Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions crates/ournotes-search/src/search/bonus.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//! The event-bonus arithmetic the payoff bounds share: audited native products and card-bonus sums, and the largest
//! bonus a team can reach. Every value is a nonnegative Int32 unless a function says otherwise; the audits refuse a
//! computation whose native intermediate would leave that range, so a bound never wraps into a smaller number.
use ournotes_sim::{
Error,
event::{self, EventCard},
master::EventEffectRow,
};
use std::collections::{HashMap, HashSet};

/// The refusal of a payoff computation whose native intermediates would leave the supported range.
pub(crate) fn unavailable(message: &str) -> Error {
Error::Unsupported(format!("deck payoff bound unavailable: {message}"))
}

pub(super) fn nonnegative_i32(value: i64) -> Result<i64, Error> {
if (0..=i64::from(i32::MAX)).contains(&value) {
Ok(value)
} else {
Err(unavailable("negative or wrapping native Int32 value"))
}
}

/// Audit the native order `value * (bonus + 10000) * rate / 10000`.
/// The first product must fit even when the final rate is zero.
pub(super) fn point_product(value: i64, bonus: i64, rate: i64) -> Result<i128, Error> {
nonnegative_i32(value)?;
nonnegative_i32(bonus)?;
nonnegative_i32(rate)?;
let base = nonnegative_i32(bonus.checked_add(10000).ok_or_else(|| unavailable("bonus overflow"))?)?;
let first = nonnegative_i32(value.checked_mul(base).ok_or_else(|| unavailable("first product overflow"))?)?;
let last = nonnegative_i32(first.checked_mul(rate).ok_or_else(|| unavailable("second product overflow"))?)?;
Ok(i128::from(last / 10000))
}

/// Challenge-point earnings have no event bonus or 10000-scale intermediate.
pub(super) fn challenge_product(value: i64, rate: i64) -> Result<i128, Error> {
nonnegative_i32(value)?;
nonnegative_i32(rate)?;
let product = value.checked_mul(rate).ok_or_else(|| unavailable("CP product overflow"))?;
Ok(i128::from(nonnegative_i32(product)?))
}

/// Reproduce the native card's sum of `bonus_type` effects only after proving every cast/addition safe.
pub(super) fn card_bonus(effects: &[&EventEffectRow], card: EventCard<'_>, bonus_type: i64) -> Result<i64, Error> {
let mut sum = 0i64;
for effect in effects.iter().filter(|e| e.event_bonus_type == bonus_type) {
let (hit, rank) = match card {
EventCard::Member(m) => (event::is_member_target(effect, m)?, m.rank),
EventCard::Snap(s) => (event::is_snap_target(effect, s), s.rank),
};
if hit {
let value = nonnegative_i32(event::rank_effect_value(effect, rank)?)?;
sum = nonnegative_i32(sum + value)?;
}
}
Ok(sum)
}

/// The largest event bonus of any team of five `members` (character, bonus) and up to five of the `snap` bonuses:
/// the five largest per-character maxima of the member bonuses plus the five largest Snap bonuses. A team's members
/// have distinct characters and its Snaps are distinct, so no team sums more. Every bonus is nonnegative.
pub(super) fn largest_team_bonus(members: impl IntoIterator<Item = (i64, i64)>, snap: &[i64]) -> i64 {
let mut rows = HashMap::<i64, i64>::new();
for (character, bonus) in members {
let best = rows.entry(character).or_insert(0);
*best = (*best).max(bonus);
}
let top = |mut values: Vec<i64>| {
values.sort_unstable_by(|a, b| b.cmp(a));
values.into_iter().take(5).sum::<i64>()
};
top(rows.into_values().collect()) + top(snap.to_vec())
}

/// The largest sum of `count` completed characters' bonuses, each a row keyed by character: the required characters
/// plus the largest optional rows. Callers pass the rows of the characters a prefix can still use.
pub(super) fn sum_rows(rows: &HashMap<i64, i64>, required: &HashSet<i64>, count: usize) -> i64 {
let fixed: i64 = required.iter().map(|c| rows[c]).sum();
let mut optional: Vec<_> = rows.iter().filter(|(c, _)| !required.contains(c)).map(|(_, &v)| v).collect();
optional.sort_unstable_by(|a, b| b.cmp(a));
fixed + optional.iter().take(count - required.len()).sum::<i64>()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn native_intermediate_wrapping_is_refused_even_at_zero_rate() {
assert!(point_product(i64::from(i32::MAX), 0, 0).is_err());
assert!(point_product(1, i64::from(i32::MAX), 0).is_err());
assert!(point_product(1, 0, i64::from(i32::MAX)).is_err());
assert!(point_product(-1, 0, 1).is_err());
assert_eq!(point_product(100, 20000, 0).unwrap(), 0);
assert_eq!(challenge_product(i64::from(i32::MAX), 1).unwrap(), i128::from(i32::MAX));
assert_eq!(challenge_product(i64::from(i32::MAX), 0).unwrap(), 0);
assert!(challenge_product(i64::from(i32::MAX), 2).is_err());
}

#[test]
fn effect_casts_and_each_addition_must_fit_before_native_wrapping() {
let member =
event::EventMember { id: 1, character_id: 1, band_id: Some(1), card_type: 1, tags: vec![], rank: 1 };
let mut effect = EventEffectRow {
event_bonus_type: event::EVENT_POINT,
resource_type_constraint: 2,
rank1_effect_value: i64::from(i32::MAX),
..Default::default()
};
let card = EventCard::Member(&member);
assert_eq!(card_bonus(&[&effect], card, event::EVENT_POINT).unwrap(), i64::from(i32::MAX));
assert_eq!(card_bonus(&[&effect], card, event::EVENT_ITEM).unwrap(), 0, "another bonus type is not read");
assert!(card_bonus(&[&effect, &effect], card, event::EVENT_POINT).is_err());
effect.rank1_effect_value = 1i64 << 32;
assert!(card_bonus(&[&effect], card, event::EVENT_POINT).is_err());
effect.rank1_effect_value = -1;
assert!(card_bonus(&[&effect], card, event::EVENT_POINT).is_err());
}

/// Every team of five distinct-character members and at most five distinct Snaps, by enumeration.
fn enumerated(members: &[(i64, i64)], snaps: &[i64]) -> Option<i64> {
let n = members.len();
let mut best_members = None;
for mask in 0u32..1 << n {
if mask.count_ones() != 5 {
continue;
}
let chosen: Vec<_> = (0..n).filter(|&i| mask & (1 << i) != 0).map(|i| members[i]).collect();
let mut characters: Vec<_> = chosen.iter().map(|&(c, _)| c).collect();
characters.sort_unstable();
characters.dedup();
if characters.len() == 5 {
let sum = chosen.iter().map(|&(_, b)| b).sum::<i64>();
best_members = Some(best_members.map_or(sum, |b: i64| b.max(sum)));
}
}
let mut best_snaps = 0;
for mask in 0u32..1 << snaps.len() {
if mask.count_ones() <= 5 {
let sum = (0..snaps.len()).filter(|&j| mask & (1 << j) != 0).map(|j| snaps[j]).sum::<i64>();
best_snaps = best_snaps.max(sum);
}
}
best_members.map(|m| m + best_snaps)
}

#[test]
fn largest_team_bonus_equals_the_best_enumerated_team() {
let mut x = 0x2545_f491_4f6c_dd1du64;
let mut next = |n: u64| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x % n
};
let mut checked = 0;
for _ in 0..400 {
let n = 5 + next(8) as usize;
let characters = 3 + next(6) as i64;
let members: Vec<_> = (0..n).map(|_| (next(characters as u64) as i64, next(4) as i64 * 2500)).collect();
let snaps: Vec<_> = (0..next(9)).map(|_| next(5) as i64 * 700).collect();
let Some(expected) = enumerated(&members, &snaps) else { continue };
assert_eq!(largest_team_bonus(members.iter().copied(), &snaps), expected, "{members:?} {snaps:?}");
checked += 1;
}
assert!(checked > 100);
}
}
130 changes: 130 additions & 0 deletions crates/ournotes-search/src/search/caps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! Largest payoff each term of a metric can pay, used to fold a combined metric and to bound its terms.

use super::{Pool, SearchRequest};
use crate::domain::CandidateDomain;
use crate::search::bonus::{card_bonus, point_product};
use crate::types::Metric;
use ournotes_sim::Error;
use ournotes_sim::scenario::EventPayoffInput;

/// At least the payoff of one terminal result of any team of the domain under a term of a combined metric: one for
/// a score target met or missed, the threshold of a capped score, the largest row of an event reward table, else
/// the largest Int32.
pub(crate) fn term_cap(
pool: &Pool,
request: &SearchRequest,
domain: &CandidateDomain,
metric: &Metric,
input: Option<&EventPayoffInput>,
) -> i128 {
match metric {
Metric::ScoreAtLeast { .. } | Metric::ScoreAndLifeAtLeast { .. } => 1,
Metric::CappedScore { threshold } => i128::from(*threshold),
_ => event_cap(pool, request, domain, metric, input).unwrap_or(i128::from(i32::MAX)),
}
}

/// The largest row of an event metric's reward table on the request's result route, played or skipped, at the
/// route's rate and the largest event bonus of a team of the domain. None for another metric and for a product
/// outside Int32.
fn event_cap(
pool: &Pool,
request: &SearchRequest,
domain: &CandidateDomain,
metric: &Metric,
input: Option<&EventPayoffInput>,
) -> Option<i128> {
use ournotes_sim::event::{self, EventCard, EventResultRoute};

let event_id = metric.event()?;
let master = pool.master;
let q = request.objective.context()?.event_request(master, input?, event_id).ok()?;
let ev = master.event(event_id)?;
let challenge =
matches!(q.route, EventResultRoute::ChallengePlayed { .. } | EventResultRoute::ChallengeSkip { .. });
let consumed = i64::from(q.consumed_count);
let rates =
if challenge { event::challenge_point_bonus(master, consumed) } else { event::boost_bonus(master, consumed) }
.ok()?;
let (value, rate, effect) = match *metric {
Metric::ClientEventPoints { .. } => {
let (rows, group) = if challenge {
(&master.challenge_live_event_points, ev.challenge_live_event_point_group)
} else {
(&master.live_event_points, ev.live_event_point_group)
};
(
rows.iter().filter(|row| row.group == group).map(|row| row.value).max(),
rates[4],
Some(event::EVENT_POINT),
)
}
Metric::ClientChallengePoints { .. } => {
(master.live_challenge_points.iter().map(|row| row.value).max(), rates[4], None)
}
Metric::RankedEventItems { resource_type, resource_id, .. } => {
let (rows, group) = if challenge {
(&master.challenge_live_event_rewards, ev.challenge_live_event_reward_group)
} else {
(&master.live_event_rewards, ev.live_event_reward_group)
};
let counts = rows
.iter()
.filter(|row| {
row.event_group == group && row.resource_type == resource_type && row.resource_id == resource_id
})
.map(|row| row.resource_count);
(counts.max(), rates[0], Some(event::EVENT_ITEM))
}
_ => return None,
};
let bonus = match effect {
Some(kind) => {
let effects = event::event_effects(master, event_id);
let members = domain
.members()
.iter()
.map(|&m| {
let card = ournotes_sim::bonus::event_member(master, &pool.members[m]);
Ok((pool.members[m].character_id, card_bonus(&effects, EventCard::Member(&card), kind)?))
})
.collect::<Result<Vec<_>, Error>>()
.ok()?;
let snaps = domain
.snaps()
.iter()
.map(|&s| card_bonus(&effects, EventCard::Snap(&ournotes_sim::bonus::event_snap(&pool.snaps[s])), kind))
.collect::<Result<Vec<_>, Error>>()
.ok()?;
crate::search::bonus::largest_team_bonus(members, &snaps)
}
None => 0,
};
point_product(value.unwrap_or(0).max(0), bonus, rate).ok()
}

/// Whether no negative local score has a native result: the local score -1 has no rank (a solo score below every
/// threshold is a game error), or its room total takes NONE and `paid` holds for no NONE reward. Ranks walk the
/// thresholds a score or total reaches, so every smaller score takes no higher rank, and NONE is the least. Settlement
/// then reports a Game error for every negative score, and every team with a result scores at least zero in every
/// play, whatever its factor margin. False when the request maps no local score to a total.
pub(crate) fn negative_scores_unpaid(
pool: &Pool,
context: &ournotes_sim::scenario::ResolvedContext,
input: &EventPayoffInput,
paid: impl Fn(i64) -> bool,
) -> bool {
use ournotes_sim::event;
use ournotes_sim::scenario::Scenario;
let Some(music) = pool.master.live_music(context.resolved.live_music_id) else { return false };
let group = music.live_score_rank_group;
if !matches!(context.scenario, Scenario::Battle(_) | Scenario::Arena(_)) {
return event::score_rank(pool.master, group, -1).is_err();
}
input.multiplayer_score_policy.as_ref().and_then(|policy| policy.total_and_count(-1).ok()).is_some_and(
|(total, players)| {
event::battle_score_rank(pool.master, group, i64::from(total), players) == event::RANK_NONE
&& !paid(event::RANK_NONE)
},
)
}
15 changes: 4 additions & 11 deletions crates/ournotes-search/src/search/certified_payoff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use super::{Pool, SearchRequest};
use crate::types::Metric;
use ournotes_sim::Error;
use ournotes_sim::event;
use ournotes_sim::scenario::{EventPayoffInput, MultiplayerScorePolicy, ResolvedContext, Scenario, item_payoff};
use ournotes_sim::scenario::{EventPayoffInput, MultiplayerScorePolicy, ResolvedContext, Scenario};

fn invalid(message: &str) -> Error {
Error::Domain(format!("certified payoff: {message}"))
Expand Down Expand Up @@ -65,15 +65,8 @@ pub(crate) fn payoff_map(
pool.check_deck(&deck)?;
let cuts = rank_cuts(pool, context, input, support)?;
let value_at = |score| match *metric {
Metric::ClientEventPoints { event_id } => {
Ok(i128::from(context.preview_event_points(pool, &deck, input, event_id, score)?.points_for(event_id)))
}
Metric::ClientChallengePoints { event_id } => Ok(i128::from(
context.preview_event_points(pool, &deck, input, event_id, score)?.challenge_points_for(event_id),
)),
Metric::RankedEventItems { event_id, resource_type, resource_id } => {
let preview = context.preview_event_items(pool, &deck, input, event_id, score)?;
item_payoff(&preview, event_id, resource_type, resource_id)
Metric::ClientEventPoints { .. } | Metric::ClientChallengePoints { .. } | Metric::RankedEventItems { .. } => {
crate::search::reward::payoff_of(pool, request, metric, Some(input), physical, score, power, None)
}
_ => Err(invalid("non-event metric reached native settlement")),
};
Expand Down Expand Up @@ -225,7 +218,7 @@ mod tests {
use super::*;
use crate::search::{Constraints, Objective, PlayInput};
use ournotes_sim::live::model::JudgementStream;
use ournotes_sim::scenario::ContextInput;
use ournotes_sim::scenario::{ContextInput, item_payoff};
use serde_json::{Value, json};

fn fixture() -> Synth {
Expand Down
59 changes: 59 additions & 0 deletions crates/ournotes-search/src/search/curve.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//! The shape of a score-dependent payoff: how far one order's payoff steps with its score, and the score cap the
//! step is read at.
use crate::search::bonus::unavailable;
use crate::search::reward::points_payoff;
use ournotes_sim::Error;

/// One step of a played Live's per-order payoff, from its lowest score on.
#[derive(Clone, Copy, Debug)]
pub(crate) enum Step {
/// Event or challenge points `multiplier * (bonus + 10000) / 10000`, the multiplier being the rate times the
/// largest reward value at or below the step's score (challenge points read no card bonus).
Points(i64),
/// A score target: reached (1) or not (0).
Target(bool),
}

/// The per-order payoff of a played Live steps with the order's score. Every order of a deck with power `p`
/// scores at most `ceil(p * global * (1 + eps))` (the global coefficient of the joint envelope with its margin, see
/// `JointBounds::score_steps`), so the step of that cap bounds each of its orders.
#[derive(Clone, Debug)]
pub(crate) struct ScoreSteps {
global: f64,
eps: f64,
/// (lowest score, step) by ascending score, each step at least the previous one.
steps: Vec<(i128, Step)>,
}

impl ScoreSteps {
pub(crate) fn new(global: f64, eps: f64, steps: Vec<(i128, Step)>) -> Self {
Self { global, eps, steps }
}

/// The score cap of every order of a deck with power at most `power`.
pub(crate) fn score_cap(&self, power: i64) -> i128 {
((power as f64) * self.global * (1.0 + self.eps)).ceil() as i128
}

pub(crate) fn value(&self, bonus: i64, power: i64) -> Result<i128, Error> {
let cap = self.score_cap(power);
let at = self.steps.partition_point(|&(score, _)| score <= cap);
// The joint bound's compilation proved the native products nonwrapping up to the largest bonus.
match at.checked_sub(1).map(|i| self.steps[i].1) {
None => Err(unavailable("score cap below the lowest step")),
Some(Step::Points(multiplier)) => Ok(points_payoff(bonus, multiplier)),
Some(Step::Target(reached)) => Ok(i128::from(reached)),
}
}

/// The largest multiplier of any step; it only orders the members a search visits.
pub(crate) fn largest_step(&self) -> f64 {
self.steps.iter().fold(0.0, |best, &(_, step)| match step {
Step::Points(multiplier) => best.max(multiplier as f64),
Step::Target(_) => best,
})
}
}

/// The steps of one term of a combined metric, with its weight and its member and Snap bonuses.
pub(crate) type TermSteps<'a> = (i128, &'a [i64], &'a [i64], ScoreSteps);
Loading
Loading