From d4abb52ff75059a13e99a88fd6812ec1bdaa5dd2 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 00:41:57 +0800 Subject: [PATCH] feat: chart-stats scenarios: Gekisou off, every rank, the Perfect play - offSeeds: every chart is also measured with Gekisou off, as a solo live (Free Live, Challenge Live) plays: the theoretical best play without Gekisou (every note Perfect at its time), seed 0, the no-skill score, the weights and a check deck on its own generator. A kind whose conditions read the Gekisou state has null weights there; a chart with more than three fevers is measured too. - Ranks: every range carries rankBonusPercents (ranks 1..5 of the song's mission pattern) and every seed rangeWeights[kind][position][range], the range score each measurement run gains, so that a Gekisou live at any ranks follows linearly: the no-skill score exactly (score - sum rankBonus + sum trunc(rangeScore * p(r) / 100)), the weights within two points per range. rankCheck plays the seed's check deck at random ranks through the explicit rank confirmations. A kind whose conditions read the confirmed rank (7012) has no range weights, nor has a chart where a range's bonus can fall inside another range's score frames. - scorePerfect and rangeScorePerfect: one more no-skill play per seed, every Just judged Perfect, for a Just rate between the two. - The Gekisou on numbers and their check decks are unchanged; model gains ranks, perfect and off. - Tests: random ranks against the rank confirmation path (no-skill score exact, a unit weight within two points per range, real decks within the bound), offSeeds against the per-order model, offSeeds of a chart with more than three fevers, kinds on the confirmed rank and on the Gekisou state, the Perfect play. Co-Authored-By: Claude Opus 5.5 --- README.en.md | 36 ++- README.md | 25 +- src/bin/ournotes-deck.rs | 6 +- src/chartstats.rs | 609 ++++++++++++++++++++++++++++++++------- tests/chartstats.rs | 317 +++++++++++++++++++- 5 files changed, 869 insertions(+), 124 deletions(-) diff --git a/README.en.md b/README.en.md index 083a26f..98e646d 100644 --- a/README.en.md +++ b/README.en.md @@ -134,15 +134,33 @@ Chart statistics: ournotes-deck chart-stats --data deck-data.json [--seeds 8] -o chart-stats.json ``` -measures every chart's deck-independent numbers on the whole-live simulation with Gekisou on and a solo rank 1 -(`ournotes-deck.chart-stats/2`). The play is the theoretical best play with Gekisou: every note judged at its time, -Just inside the Just-count ranges and Perfect elsewhere. Per seed: the exact no-skill score and the Gekisou ranges' -results, and for every score-up kind of the master (2000 / 2002 / 2004 / 2005 rows grouped by type, duration, -targets and conditions, see `kinds`) at every performance position the score gained at factor 1 per unit of deck -power (`weights[kind][k]`). A deck scores about `P × (score / power + Σ factor_k × weights[kind_k][k])`; every seed -checks this on a random deck of the master's own values at another power and fails beyond the flooring bound. -Charts with a luck range are given on the first N published seeds (`--seeds`, default 8), which is not a native -expectation; a chart with more than three fevers, where the game fails when the fourth starts, is `unplayable`. +measures every chart's deck-independent numbers on the whole-live simulation (`ournotes-deck.chart-stats/2`) in +two scenarios: Gekisou on (`seeds`, as a Battle Live plays) and Gekisou off (`offSeeds`, as a solo live such as Free +Live or Challenge Live plays). + +With Gekisou on, the play is the theoretical best play with Gekisou: every note judged at its time, Just inside the +Just-count ranges and Perfect elsewhere, rank 1 in every range. Per seed: the exact no-skill score and the Gekisou +ranges' results, and for every score-up kind of the master (2000 / 2002 / 2004 / 2005 rows grouped by type, +duration, targets and conditions, see `kinds`) at every performance position the score gained at factor 1 per unit +of deck power (`weights[kind][k]`). A deck scores about `P × (score / power + Σ factor_k × weights[kind_k][k])`; +every seed checks this on a random deck of the master's own values at another power and fails beyond the flooring +bound. Charts with a luck range are given on the first N published seeds (`--seeds`, default 8), which is not a +native expectation; a chart with more than three fevers, where the game fails when the fourth starts, is +`unplayable` (it plays with Gekisou off). + +Other ranks need no further play: a rank bonus is `trunc(rangeScore × percent / 100)`, a fixed score in the frame of +the range's end that changes no factor and no note score, so at rank r_i in range i the no-skill score is exactly +`score − Σ rankBonus_i + Σ trunc(rangeScore_i × rankBonusPercents_i[r_i − 1] / 100)` and a weight is +`weights[kind][k] + Σ (rankBonusPercents_i[r_i − 1] − rankBonusPercents_i[0]) / 100 × rangeWeights[kind][k][i]` +(`rangeWeights`: the range score the effect gains per unit of deck power). Every seed also plays its check deck at +random ranks through explicit rank confirmations (`rankCheck`). A kind whose conditions read the confirmed rank +(7012) has no `rangeWeights`, nor has a chart where a rank bonus can fall inside another range's score frames. Every +seed also gives the no-skill score and range scores of the same play with every Just judged Perfect +(`scorePerfect`, `rangeScorePerfect`). + +With Gekisou off, the play is the theoretical best play (every note Perfect at its time), seed 0, without Just, luck, +Gekisou combo or rank bonus; `score`, `weights` and a check as above. A kind whose conditions read the Gekisou state +cannot play without Gekisou and has null weights. ## Tests diff --git a/README.md b/README.md index ea5e235..4a24692 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,25 @@ ournotes-deck live --data deck-data.json --roster box.json --score SCORE_ID --e ournotes-deck chart-stats --data deck-data.json [--seeds 8] -o chart-stats.json ``` -在整场模拟上(激走开启,单人名次 1)实测每张谱面与卡组无关的量(`ournotes-deck.chart-stats/2`)。打法为激走理论最佳: -每个音符按准点判定,Just 任务区间内为 Just,其余为 Perfect。每个种子给出:无技能的精确得分与各激走区间结果; -master 中每种加分效果(2000 / 2002 / 2004 / 2005,按类型、时长、目标、条件分组,见 `kinds`)在每个演出位上 -因子为 1 时的得分增量除以综合力(`weights[kind][k]`)。卡组得分约为 `P × (score / power + Σ factor_k × weights[kind_k][k])`, -每个种子都用 master 真实数值的随机卡组在另一综合力下实跑校验,偏差超出取整上限即报错。有幸运区间的谱面按前 N 个 -发布种子给出(`--seeds`,默认 8),这不是原生期望;超过三段 fever 的谱面游戏会在第四段开始时出错,记为 `unplayable`。 +在整场模拟上实测每张谱面与卡组无关的量(`ournotes-deck.chart-stats/2`),分两种场景:激走开启(`seeds`,撃奏ライブ +的打法)与激走关闭(`offSeeds`,自由 Live、挑战 Live 等单人 Live 的打法)。 + +激走开启时打法为激走理论最佳:每个音符按准点判定,Just 任务区间内为 Just,其余为 Perfect,每个区间取名次 1。每个 +种子给出:无技能的精确得分与各激走区间结果;master 中每种加分效果(2000 / 2002 / 2004 / 2005,按类型、时长、目标、 +条件分组,见 `kinds`)在每个演出位上因子为 1 时的得分增量除以综合力(`weights[kind][k]`)。卡组得分约为 +`P × (score / power + Σ factor_k × weights[kind_k][k])`,每个种子都用 master 真实数值的随机卡组在另一综合力下实跑校验, +偏差超出取整上限即报错。有幸运区间的谱面按前 N 个发布种子给出(`--seeds`,默认 8),这不是原生期望;超过三段 fever +的谱面游戏会在第四段开始时出错,记为 `unplayable`(激走关闭时照常可玩)。 + +其他名次不重跑:名次加成为 `trunc(区间得分 × 百分比 / 100)`,记在区间结束帧的固定分上,不改因子也不改音符得分,所以 +区间 i 取名次 r_i 时无技能得分精确为 `score − Σ rankBonus_i + Σ trunc(rangeScore_i × rankBonusPercents_i[r_i − 1] / 100)`, +权重为 `weights[kind][k] + Σ (rankBonusPercents_i[r_i − 1] − rankBonusPercents_i[0]) / 100 × rangeWeights[kind][k][i]` +(`rangeWeights` 是该效果带来的区间得分增量除以综合力)。每个种子另用同一校验卡组在随机名次下走显式名次确认实跑校验 +(`rankCheck`)。条件读取确认名次(7012)的效果种类没有 `rangeWeights`,名次加成可能落进另一区间得分帧的谱面整张没有。 +每个种子还给出把 Just 全部改判 Perfect 的同一打法的无技能得分与各区间得分(`scorePerfect`、`rangeScorePerfect`)。 + +激走关闭时打法为理论最佳(每个音符准点 Perfect),种子 0,没有 Just、幸运、激走连击和名次加成;给出同形的 `score`、 +`weights` 与校验。条件读取激走状态的效果种类在激走关闭时无法演出,其权重为 null。 ## 测试 diff --git a/src/bin/ournotes-deck.rs b/src/bin/ournotes-deck.rs index a774afb..ceadc73 100644 --- a/src/bin/ournotes-deck.rs +++ b/src/bin/ournotes-deck.rs @@ -30,9 +30,9 @@ conditional items also require --resource-type ID --resource-id ID and context.e scenario options: --scenario free|mission|battle|arena|challenge --scenario-music ID --context FILE --scenario-music is the special row ID for arena/challenge; --score always denotes the base chart. --context uses explicit powerSnapshot.eventIds and separate resultClock normalized DateTime ticks. -chart-stats measures every chart on the whole-live simulation with Gekisou on (ournotes-deck.chart-stats/2): the -no-skill score and the weight of every score-up kind at every position, per seed (--seeds N for charts with a luck -range, default 8). +chart-stats measures every chart on the whole-live simulation (ournotes-deck.chart-stats/2): the no-skill score and +the weight of every score-up kind at every position, with Gekisou on per seed (--seeds N for charts with a luck range, +default 8; rank 1, range weights for the other ranks, the Perfect play's scores) and with Gekisou off (offSeeds). common options: -k N (default 10), --leader ID, --include ID[,ID...], --exclude ID[,ID...], --exclude-snaps ID[,ID...], --no-snaps, --time-limit-ms N"; diff --git a/src/chartstats.rs b/src/chartstats.rs index 7cb5bfa..e105f8f 100644 --- a/src/chartstats.rs +++ b/src/chartstats.rs @@ -1,14 +1,31 @@ //! Chart statistics: what a chart contributes to the live score whatever the deck, measured on the whole-live -//! simulation ([`crate::live::full`]) with Gekisou on, as the game plays every live. +//! simulation ([`crate::live::full`]) in two scenarios: with Gekisou on (`seeds`), as a Gekisou live (Battle Live) +//! plays, and with Gekisou off (`off_seeds`), as a solo live (Free Live, Challenge Live) plays. //! -//! The play is the theoretical best play of a live with Gekisou ([`JudgementStream::theoretical_best_gekisou`]): every -//! judged note at its exact time, Just inside the Just-count ranges (where the game enables the Just judgement) and -//! Perfect elsewhere, the frame delta times of the default schedule. The Gekisou ranges are the chart's fevers with the -//! song's missions ([`Scenario::Free`]); a solo player takes rank 1, so every completed range adds its rank bonus. -//! Luck ranges draw lottery results (and luck rushes, which raise the note score) from the play's random seed, so -//! everything is given per seed of a seed set: one seed when no range is a luck range (the play then draws nothing), -//! else the first [`published_seeds`]. The seed set is not the game's seed law (unknown), so a mean over it is not a -//! native expectation. +//! With Gekisou on, the play is the theoretical best play of a live with Gekisou +//! ([`JudgementStream::theoretical_best_gekisou`]): every judged note at its exact time, Just inside the Just-count +//! ranges (where the game enables the Just judgement) and Perfect elsewhere, the frame delta times of the default +//! schedule. The Gekisou ranges are the chart's fevers with the song's missions ([`Scenario::Free`]); the player takes +//! rank 1 in every range, so every completed range adds its rank 1 bonus. Luck ranges draw lottery results (and luck +//! rushes, which raise the note score) from the play's random seed, so everything is given per seed of a seed set: one +//! seed when no range is a luck range (the play then draws nothing), else the first [`published_seeds`]. The seed set +//! is not the game's seed law (unknown), so a mean over it is not a native expectation. +//! +//! Other ranks follow without playing again. A range's rank bonus is `trunc(rangeScore * percent / 100)`, added as a +//! fixed score in the frame of the range's end; it changes no factor and no note score, and a range's score (its end +//! score minus its start score) holds an earlier range's bonus at both ends, so it does not depend on the ranks. At +//! ranks `r_i` the no-skill score is then exact ([`SeedStats::score_at_ranks`]) and every weight moves by the range +//! weights ([`SeedStats::weights_at_ranks`]). The ranks reach the skills only through the confirmed-rank condition +//! 7012: a kind whose condition groups have it gets no range weights, and a chart where a range's bonus falls inside +//! another range's score frames gets none at all. +//! +//! Each seed also plays the no-skill live on the Perfect play: the same play with every Just judged Perfect instead +//! (Just is enabled only inside the Just-count ranges, so nothing else changes), for a Just rate between the two. +//! +//! With Gekisou off, the play is the theoretical best play of a live without Gekisou +//! ([`JudgementStream::theoretical_best`]): every judged note Perfect at its exact time, seed [`OFF_SEED`]; there is +//! no Just, luck, Gekisou combo or rank bonus, and a chart with more than three fevers plays as any other. A kind +//! whose conditions read the Gekisou state cannot play there and has no weights. //! //! Numbers, per seed, at the measurement power [`POWER`]: //! - `score`: the exact no-skill score, rank bonuses included; @@ -17,11 +34,14 @@ //! by a deck whose position-`k` member has one such effect at a factor of 1 (value 10000), divided by //! [`POWER`]. The skill runs through the simulation's own updaters, conditions, frames and appliers, so the //! weight carries every rule of the game: its execute and finish frames, the 40 ms score frames, the combo and -//! Gekisou combo factors, Just scores, luck rushes and the rank bonuses of the ranges it overlaps. +//! Gekisou combo factors, Just scores, luck rushes and the rank bonuses of the ranges it overlaps; +//! - with Gekisou on, per range `i` also `range_weights[kind][k][i]`: the range score that effect gains, divided by +//! [`POWER`]. //! -//! A deck of these kinds scores, up to the floors, `P * (score / POWER + sum_k factor_k * weights[kind_k][k])` with +//! A deck of these kinds scores, up to the floors, `P * (score / power + sum_k factor_k * weights[kind_k][k])` with //! `factor` the effect's factor as the applier converts it ([`kind_factor`]). [`SeedStats::check`] plays a random -//! deck of real master values at another power and bounds the deviation; a chart whose deviation exceeds the bound +//! deck of real master values at another power and bounds the deviation, [`SeedStats::rank_check`] the same deck at +//! random ranks and [`OffSeedStats::check`] a random deck with Gekisou off; a chart whose deviation exceeds a bound //! fails. Effects of other types (cumulative score 2001 / 2003, life, judgement conversion, Gekisou skills, snap //! skills) are not linear in the chart alone: a deck's score comes from the simulation itself. @@ -31,7 +51,7 @@ use crate::data::{DataChart, DeckData}; use crate::error::Error; use crate::live::full::{self, GekisouSetup, LiveNote, LiveParams, LivePlay, Performer}; use crate::live::model::{JudgementStream, JustRule}; -use crate::live::score::{ComboTable, LiveScoreSettings}; +use crate::live::score::{ComboTable, LiveScoreSettings, get_frame}; use crate::live::seeds::published_seeds; use crate::live::skill::{judgement_factor_mill, note_factor_mill}; use crate::live::skip::{Chart, SkipEvaluator, judgement_note_total_count}; @@ -60,12 +80,24 @@ pub const DIFFICULTIES: [&str; 4] = ["easy", "normal", "hard", "expert"]; /// Gekisou mission of the luck ranges. const MISSION_LUCK: i64 = 2; -/// `NoteSimulateJudgement` of a Just. +/// `NoteSimulateJudgement` of a Just and of a Perfect. const SIMULATE_JUST: i32 = 6; +const SIMULATE_PERFECT: i32 = 5; /// Live skill id of the measurement skill of kind `i`: `KIND_SKILL_BASE - i`, below every real id. const KIND_SKILL_BASE: i64 = -1_000_000; /// Live skill id of the check deck's skill at position `k`: `CHECK_SKILL_BASE - k`. const CHECK_SKILL_BASE: i64 = -2_000_000; +/// The seed of the Gekisou off play. +pub const OFF_SEED: i32 = 0; +/// The ranks of a range: 1..=RANKS. +pub const RANKS: usize = 5; +/// Skill condition type of the previous frame's confirmed rank. +const CONDITION_CONFIRMED_RANK: i64 = 7012; +/// Start states of the generators of the check decks (Gekisou on, Gekisou off) and of the rank checks' ranks, each +/// xored with the score id. +const CHECK_SALT: u64 = 0x9e37_79b9_7f4a_7c15; +const OFF_CHECK_SALT: u64 = 0x6f66_665f_6368_6563; +const RANK_CHECK_SALT: u64 = 0x7261_6e6b_5f63_6865; /// A score-up kind: the fields of a live skill effect row that shape its score, the value aside. #[derive(Clone, Debug, PartialEq, Serialize)] @@ -168,6 +200,43 @@ pub fn kinds(master: &Master) -> Vec { out } +/// Whether a condition group has a condition of a type. +fn group_has(master: &Master, group: i64, condition_type: i64) -> bool { + group != 0 + && master.skill_condition_sets.iter().filter(|s| s.group == group).any(|s| { + s.condition_ids + .iter() + .any(|&c| master.skill_condition(c).is_some_and(|c| c.condition_type == condition_type)) + }) +} + +impl Kind { + /// Whether the kind's condition, release or reset group reads the previous frame's confirmed rank (condition + /// 7012). + pub fn reads_rank(&self, master: &Master) -> bool { + [ + self.skill_condition_group, + self.skill_release_condition_group, + self.effect_execute_limit_reset_condition_group, + ] + .iter() + .any(|&g| group_has(master, g, CONDITION_CONFIRMED_RANK)) + } +} + +/// Whether a range's rank bonus can fall inside another range's score: the bonus of range `i` is a fixed score in +/// the 40 ms frame of its end, and range `j`'s score is the score at the frame of its end minus the score at the +/// frame of its start, so the bonus is in it when `frame(start_j) < frame(end_i) <= frame(end_j)`. +fn bonus_inside_a_range(fevers: &[(i32, i32)]) -> bool { + fevers.iter().enumerate().any(|(i, &(_, end_i))| { + let f = get_frame(end_i); + fevers + .iter() + .enumerate() + .any(|(j, &(start_j, end_j))| i != j && get_frame(start_j) < f && f <= get_frame(end_j)) + }) +} + /// The factor of an effect of a kind at a value, as its applier converts it: 2000 `floor(value / 10000f * 1e5)`, /// 2005 `floor(value / -10000f * 1e5)`, 2002 / 2004 the same quotient rounded half to even; divided by 1e5. pub fn kind_factor(effect_type: i64, value: i64) -> f64 { @@ -188,8 +257,20 @@ pub struct RangeInfo { pub mission: i64, pub start_ms: i32, pub end_ms: i32, - /// The solo (rank 1) rank bonus percentage of the song's mission pattern. + /// The rank 1 bonus percentage of the song's mission pattern (`rank_bonus_percents[0]`). pub rank_bonus_percent: i64, + /// The rank bonus percentages of ranks 1..=5 of the song's mission pattern. + pub rank_bonus_percents: [i64; RANKS], +} + +impl RangeInfo { + /// The rank bonus percentage at a rank (1..=5). + pub fn percent(&self, rank: i32) -> Result { + usize::try_from(rank.wrapping_sub(1)) + .ok() + .and_then(|r| self.rank_bonus_percents.get(r).copied()) + .ok_or_else(|| Error::Input(format!("rank {rank} is not in 1..={RANKS}"))) + } } /// A range on one seed's no-skill play. @@ -203,6 +284,8 @@ pub struct RangeResult { pub just_count: i32, /// Lottery results drawn: Miss, Hit, Super Hit, Critical. pub lot_results: [i32; 4], + /// The range score on the Perfect play (every Just judged Perfect). + pub range_score_perfect: i32, } /// The check deck of a seed: a random deck of real master values at [`CHECK_POWER`]. @@ -216,7 +299,18 @@ pub struct Check { pub bound: f64, } -/// One seed's measurements at [`POWER`]. +/// The check deck of a seed played at random ranks. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RankCheck { + /// The rank of each range (1..=5). + pub ranks: Vec, + pub exact: i32, + pub predicted: f64, + pub bound: f64, +} + +/// One seed's measurements at [`POWER`], Gekisou on. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct SeedStats { @@ -227,6 +321,79 @@ pub struct SeedStats { /// `weights[kind][position]`: score gained per unit of deck power and of the effect's factor. pub weights: Vec>, pub check: Check, + /// The exact no-skill score on the Perfect play (every Just judged Perfect), its rank 1 bonuses included. + pub score_perfect: i32, + /// `range_weights[kind][position][range]`: range score gained per unit of deck power and of the effect's + /// factor. `None` when a range's bonus can fall inside another range's score, a kind's `None` when its + /// conditions read the confirmed rank: the ranks do not follow linearly there. + pub range_weights: Option>>>>, + /// The check deck at random ranks; `None` without ranges, without range weights or when the check deck has a + /// kind without them. + pub rank_check: Option, +} + +impl SeedStats { + fn check_ranks(&self, ranges: &[RangeInfo], ranks: &[i32]) -> Result<(), Error> { + if ranks.len() != ranges.len() || ranges.len() != self.ranges.len() { + return Err(Error::Input(format!("{} ranks for {} ranges", ranks.len(), self.ranges.len()))); + } + Ok(()) + } + + /// The exact no-skill score at [`POWER`] when range `i` takes rank `ranks[i]` (1..=5): + /// `score - sum_i rankBonus_i + sum_i trunc(rangeScore_i * percent_i(ranks[i]) / 100)`. It holds when + /// `range_weights` is not `None`. + pub fn score_at_ranks(&self, ranges: &[RangeInfo], ranks: &[i32]) -> Result { + self.check_ranks(ranges, ranks)?; + let mut score = self.score as i64; + for ((r, info), &rank) in self.ranges.iter().zip(ranges).zip(ranks) { + score += r.range_score as i64 * info.percent(rank)? / 100 - r.rank_bonus as i64; + } + Ok(score as i32) + } + + /// `weights[kind][position]` when range `i` takes rank `ranks[i]` (1..=5): `weights[kind][k] + sum_i + /// (percent_i(ranks[i]) - percent_i(1)) / 100 * range_weights[kind][k][i]`; `None` without range weights, a + /// kind's `None` without its own. + pub fn weights_at_ranks( + &self, + ranges: &[RangeInfo], + ranks: &[i32], + ) -> Result>>>, Error> { + self.check_ranks(ranges, ranks)?; + let Some(rw) = &self.range_weights else { return Ok(None) }; + let mut d = Vec::with_capacity(ranges.len()); + for (info, &rank) in ranges.iter().zip(ranks) { + d.push((info.percent(rank)? - info.percent(1)?) as f64 / 100.0); + } + Ok(Some( + self.weights + .iter() + .zip(rw) + .map(|(w, rw)| { + rw.as_ref().map(|rw| { + w.iter() + .zip(rw) + .map(|(&w, r)| w + r.iter().zip(&d).map(|(&r, &d)| d * r).sum::()) + .collect() + }) + }) + .collect(), + )) + } +} + +/// The measurements at [`POWER`] of a seed with Gekisou off. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OffSeedStats { + pub seed: i32, + /// The exact no-skill score. + pub score: i32, + /// `weights[kind][position]`: score gained per unit of deck power and of the effect's factor; a kind's `None` + /// when its conditions read the Gekisou state, which a live without Gekisou does not have. + pub weights: Vec>>, + pub check: Check, } /// The statistics of one chart. @@ -255,8 +422,12 @@ pub struct ChartStats { pub ranges: Vec, /// Notes judged Just on the play. pub just_notes: i32, - /// Measurements per seed; empty when the game cannot play the chart ([`ChartStats::unplayable`]). + /// Measurements per seed with Gekisou on; empty when the game cannot play the chart with Gekisou + /// ([`ChartStats::unplayable`]). pub seeds: Vec, + /// Measurements with Gekisou off, one seed ([`OFF_SEED`]); every chart has them. + pub off_seeds: Vec, + /// Why the game cannot play the chart with Gekisou (Gekisou off plays it). #[serde(skip_serializing_if = "Option::is_none")] pub unplayable: Option, } @@ -284,36 +455,124 @@ impl Rng { } } +/// Gekisou of a play: the setup, the play's frame delta times and its Perfect play (every Just judged Perfect). +struct Gekisou { + setup: GekisouSetup, + dt: Vec, + perfect: LivePlay, +} + /// The simulation inputs of a chart's play. struct Live<'m> { master: &'m Master, /// The master with one measurement skill per kind at [`UNIT_VALUE`]. - measure: Master, - notes: Vec, - events: Vec<(i32, i32)>, + measure: &'m Master, + notes: &'m [LiveNote], + events: &'m [(i32, i32)], params: LiveParams, - setup: GekisouSetup, + /// `None`: Gekisou off. + gekisou: Option, play: LivePlay, - dt: Vec, positions: usize, } +/// A check deck: `(kind, value)` per position, and its effect rows `(live skill id, kind, value)`. +type CheckDeck<'k> = (Vec>, Vec<(i64, &'k Kind, i64)>); + +/// A random check deck of real master values of the kinds `usable` (indices into `kinds`): each position has no +/// skill with probability 1/4, else a kind and one of its values. +fn check_deck<'k>(kinds: &'k [Kind], usable: &[usize], positions: usize, rng: &mut Rng) -> CheckDeck<'k> { + let mut deck = vec![None; positions]; + let mut rows = Vec::new(); + if !usable.is_empty() { + for (k, slot) in deck.iter_mut().enumerate() { + if rng.below(4) == 0 { + continue; + } + let ki = usable[rng.below(usable.len())]; + let value = kinds[ki].values[rng.below(kinds[ki].values.len())]; + *slot = Some((ki, value)); + rows.push((CHECK_SKILL_BASE - k as i64, &kinds[ki], value)); + } + } + (deck, rows) +} + +/// The live skill of each position of a check deck. +fn check_skills(deck: &[Option<(usize, i64)>]) -> Vec> { + deck.iter().enumerate().map(|(k, d)| d.map(|_| CHECK_SKILL_BASE - k as i64)).collect() +} + +/// A check: the exact score of a check deck, the prediction and the bound. +struct Checked { + exact: i32, + predicted: f64, + bound: f64, +} + +impl Checked { + fn within(self, what: impl FnOnce() -> String) -> Result { + if (self.exact as f64 - self.predicted).abs() > self.bound { + return Err(Error::Domain(format!( + "{}: the check deck scores {}, the chart statistics predict {:.1} (bound {:.1})", + what(), + self.exact, + self.predicted, + self.bound + ))); + } + Ok(self) + } +} + impl Live<'_> { - /// Plays the live with one live skill per position (`None`: no skill) on a master, at a power and seed. + /// Plays the live with one live skill per position (`None`: no skill) on a master, at a power and seed; with + /// `ranks`, range `i` takes the confirmed rank and bonus percentage `ranks[i]` (Gekisou on only). fn run( &self, master: &Master, skills: &[Option], power: i32, seed: i32, + ranks: Option<&[(i32, i64)]>, + ) -> Result<(i32, Vec), Error> { + self.run_play(&self.play, master, skills, power, seed, ranks) + } + + /// [`Live::run`] on another play of the same frames. + fn run_play( + &self, + play: &LivePlay, + master: &Master, + skills: &[Option], + power: i32, + seed: i32, + ranks: Option<&[(i32, i64)]>, ) -> Result<(i32, Vec), Error> { let deck: Vec = skills.iter().map(|s| Performer { live_skill: s.map(|id| (id, 1)), ..Default::default() }).collect(); let params = LiveParams { total_power: power, ..self.params }; - let mut lm = full::LiveModel::new_gekisou(master, &deck, &self.notes, &self.events, params, &self.setup)?; - let mut play = self.play.clone(); + let mut play = play.clone(); play.base_seed = seed; - let score = lm.run_timed(&play, &self.dt)?; + let Some(g) = &self.gekisou else { + if ranks.is_some() { + return Err(Error::Input("ranks without Gekisou".into())); + } + let mut lm = full::LiveModel::new(master, &deck, self.notes, self.events, params)?; + return Ok((lm.run(&play)?, Vec::new())); + }; + let mut lm = match ranks { + None => full::LiveModel::new_gekisou(master, &deck, self.notes, self.events, params, &g.setup)?, + Some(ranks) => { + let mut lm = + full::LiveModel::new_gekisou_external(master, &deck, self.notes, self.events, params, &g.setup)?; + for (i, &(rank, percent)) in ranks.iter().enumerate() { + lm.queue_gekisou_rank_confirmation(i, rank, percent)?; + } + lm + } + }; + let score = lm.run_timed(&play, &g.dt)?; Ok((score, lm.gekisou_ranges())) } @@ -327,69 +586,160 @@ impl Live<'_> { m } - fn seed_stats(&self, kinds: &[Kind], seed: i32, rng: &mut Rng, judged: i32) -> Result { + /// Plays a check deck (its skills on `master`) at [`CHECK_POWER`] and predicts its score, `CHECK_POWER * (base + + /// sum_k factor_k * weight(kind_k, k))`. The bound: one point per judged note and per range bonus (`floors` + /// counts them) in each run, where the measured weights carry the floors of two runs, scaled by the power ratio + /// and the factors; `slack` more points per unit of factor at [`POWER`]; the binary32 chain a few ulps. + #[allow(clippy::too_many_arguments)] + fn check( + &self, + kinds: &[Kind], + master: &Master, + deck: &[Option<(usize, i64)>], + seed: i32, + ranks: Option<&[(i32, i64)]>, + base: f64, + weight: impl Fn(usize, usize) -> f64, + floors: f64, + slack: f64, + ) -> Result { + let (exact, _) = self.run(master, &check_skills(deck), CHECK_POWER, seed, ranks)?; + let scale = CHECK_POWER as f64 / POWER as f64; + let mut per_power = base; + let mut gain = 0f64; + for (k, d) in deck.iter().enumerate() { + if let Some((ki, value)) = *d { + let x = kind_factor(kinds[ki].effect_type, value); + per_power += x * weight(ki, k); + gain += x.abs(); + } + } + let predicted = CHECK_POWER as f64 * per_power; + let floors = floors * (1.0 + scale * (1.0 + 2.0 * gain)); + let bound = floors + slack * scale * gain + 4e-6 * predicted.abs(); + Ok(Checked { exact, predicted, bound }) + } + + /// The measurements of one seed with Gekisou on; `infos` are the chart's ranges, `linear` whether the ranks + /// follow linearly on the chart, `reads_rank[kind]` whether a kind reads the confirmed rank. + #[allow(clippy::too_many_arguments)] + fn seed_stats( + &self, + kinds: &[Kind], + infos: &[RangeInfo], + linear: bool, + reads_rank: &[bool], + seed: i32, + rng: &mut Rng, + rank_rng: &mut Rng, + judged: i32, + ) -> Result { let none = vec![None; self.positions]; - let (score, gk) = self.run(self.master, &none, POWER, seed)?; - let ranges = gk + let (score, gk) = self.run(self.master, &none, POWER, seed, None)?; + let perfect = &self.gekisou.as_ref().ok_or_else(|| Error::Input("seed stats without Gekisou".into()))?.perfect; + let (score_perfect, gk_perfect) = self.run_play(perfect, self.master, &none, POWER, seed, None)?; + if gk_perfect.len() != gk.len() { + return Err(Error::Game("the Perfect play has other ranges".into())); + } + let ranges: Vec = gk .iter() - .map(|r| RangeResult { + .zip(&gk_perfect) + .map(|(r, p)| RangeResult { range_score: r.end_score.wrapping_sub(r.start_score), rank_bonus: r.rank_bonus.unwrap_or(0), max_combo: r.max_combo, just_count: r.just_count, lot_results: r.lot_results, + range_score_perfect: p.end_score.wrapping_sub(p.start_score), }) .collect(); let mut weights = vec![vec![0f64; self.positions]; kinds.len()]; + let mut range_weights = vec![vec![vec![0f64; ranges.len()]; self.positions]; kinds.len()]; for (ki, kind) in kinds.iter().enumerate() { let unit = kind_factor(kind.effect_type, UNIT_VALUE); for k in 0..self.positions { let mut skills = none.clone(); skills[k] = Some(KIND_SKILL_BASE - ki as i64); - let (s, _) = self.run(&self.measure, &skills, POWER, seed)?; + let (s, gk) = self.run(self.measure, &skills, POWER, seed, None)?; weights[ki][k] = (s as f64 - score as f64) / (POWER as f64 * unit); + for ((w, r), r0) in range_weights[ki][k].iter_mut().zip(&gk).zip(&ranges) { + let rs = r.end_score.wrapping_sub(r.start_score); + *w = (rs as f64 - r0.range_score as f64) / (POWER as f64 * unit); + } } } + let range_weights: Option>>>> = + linear.then(|| range_weights.into_iter().zip(reads_rank).map(|(w, &r)| (!r).then_some(w)).collect()); // the check deck: a random kind and master value at each position, at another power - let mut deck = vec![None; self.positions]; - let mut rows = Vec::new(); - if !kinds.is_empty() { - for (k, slot) in deck.iter_mut().enumerate() { - if rng.below(4) == 0 { - continue; + let usable: Vec = (0..kinds.len()).collect(); + let (deck, rows) = check_deck(kinds, &usable, self.positions, rng); + let master = Self::master_with(self.master, &rows); + let floors = judged as f64 + MAX_GEKISOU_FEVERS as f64; + let c = self + .check(kinds, &master, &deck, seed, None, score as f64 / POWER as f64, |ki, k| weights[ki][k], floors, 0.0)? + .within(|| format!("seed {seed}"))?; + let check = Check { deck, exact: c.exact, predicted: c.predicted, bound: c.bound }; + let mut stats = + SeedStats { seed, score, ranges, weights, check, score_perfect, range_weights, rank_check: None }; + + // the same deck at random ranks, through the explicit rank confirmations + let Some(rw) = &stats.range_weights else { return Ok(stats) }; + if stats.ranges.is_empty() || stats.check.deck.iter().flatten().any(|&(ki, _)| rw[ki].is_none()) { + return Ok(stats); + } + let ranks: Vec = infos.iter().map(|_| 1 + rank_rng.below(RANKS) as i32).collect(); + let confirmations: Vec<(i32, i64)> = + ranks.iter().zip(infos).map(|(&r, info)| Ok((r, info.percent(r)?))).collect::>()?; + let base = stats.score_at_ranks(infos, &ranks)? as f64 / POWER as f64; + let w = stats.weights_at_ranks(infos, &ranks)?.ok_or_else(|| Error::Game("no range weights".into()))?; + // each range moves a weight by the difference of two floored bonuses from its rank 1 value: under 2 points + let slack = 2.0 * infos.len() as f64; + let deck = stats.check.deck.clone(); + let weight = |ki: usize, k: usize| w[ki].as_ref().map_or(f64::NAN, |w| w[k]); + let c = self + .check(kinds, &master, &deck, seed, Some(&confirmations), base, weight, floors, slack)? + .within(|| format!("seed {seed} at ranks {ranks:?}"))?; + stats.rank_check = Some(RankCheck { ranks, exact: c.exact, predicted: c.predicted, bound: c.bound }); + Ok(stats) + } + + /// The measurements of one seed with Gekisou off. + fn off_seed_stats(&self, kinds: &[Kind], seed: i32, rng: &mut Rng, judged: i32) -> Result { + let none = vec![None; self.positions]; + let (score, _) = self.run(self.master, &none, POWER, seed, None)?; + let mut weights: Vec>> = Vec::with_capacity(kinds.len()); + 'kinds: for (ki, kind) in kinds.iter().enumerate() { + let unit = kind_factor(kind.effect_type, UNIT_VALUE); + let mut w = Vec::with_capacity(self.positions); + for k in 0..self.positions { + let mut skills = none.clone(); + skills[k] = Some(KIND_SKILL_BASE - ki as i64); + match self.run(self.measure, &skills, POWER, seed, None) { + Ok((s, _)) => w.push((s as f64 - score as f64) / (POWER as f64 * unit)), + // a condition that reads the Gekisou state: the kind cannot play without Gekisou + Err(Error::Unsupported(_)) => { + weights.push(None); + continue 'kinds; + } + Err(e) => return Err(e), } - let ki = rng.below(kinds.len()); - let value = kinds[ki].values[rng.below(kinds[ki].values.len())]; - *slot = Some((ki, value)); - rows.push((CHECK_SKILL_BASE - k as i64, &kinds[ki], value)); } + weights.push(Some(w)); } + let usable: Vec = (0..kinds.len()).filter(|&ki| weights[ki].is_some()).collect(); + let (deck, rows) = check_deck(kinds, &usable, self.positions, rng); let master = Self::master_with(self.master, &rows); - let skills: Vec> = - deck.iter().enumerate().map(|(k, d)| d.map(|_| CHECK_SKILL_BASE - k as i64)).collect(); - let (exact, _) = self.run(&master, &skills, CHECK_POWER, seed)?; - let scale = CHECK_POWER as f64 / POWER as f64; - let mut per_power = score as f64 / POWER as f64; - let mut gain = 0f64; - for (k, d) in deck.iter().enumerate() { - if let Some((ki, value)) = *d { - let x = kind_factor(kinds[ki].effect_type, value); - per_power += x * weights[ki][k]; - gain += x.abs(); - } - } - let predicted = CHECK_POWER as f64 * per_power; - // floors: one point per judged note and per range bonus in each run; the measured weights carry the - // floors of two runs, scaled by the power ratio and the factors; the binary32 chain a few ulps - let floors = (judged as f64 + MAX_GEKISOU_FEVERS as f64) * (1.0 + scale * (1.0 + 2.0 * gain)); - let bound = floors + 4e-6 * predicted.abs(); - if (exact as f64 - predicted).abs() > bound { - return Err(Error::Domain(format!( - "seed {seed}: the check deck scores {exact}, the chart statistics predict {predicted:.1} (bound {bound:.1})" - ))); - } - Ok(SeedStats { seed, score, ranges, weights, check: Check { deck, exact, predicted, bound } }) + let weight = |ki: usize, k: usize| weights[ki].as_ref().map_or(f64::NAN, |w| w[k]); + let c = self + .check(kinds, &master, &deck, seed, None, score as f64 / POWER as f64, weight, judged as f64, 0.0)? + .within(|| format!("Gekisou off, seed {seed}"))?; + Ok(OffSeedStats { + seed, + score, + weights, + check: Check { deck, exact: c.exact, predicted: c.predicted, bound: c.bound }, + }) } } @@ -422,12 +772,16 @@ pub fn chart_stats(master: &Master, chart: &DataChart, kinds: &[Kind], seeds: us .iter() .take(MAX_GEKISOU_FEVERS) .enumerate() - .map(|(i, &(start_ms, end_ms))| RangeInfo { - index: i, - mission: resolved.gekisou_missions[i.min(2)], - start_ms, - end_ms, - rank_bonus_percent: factors.get(i).map_or(0, |f| f[0]), + .map(|(i, &(start_ms, end_ms))| { + let percents = factors.get(i).copied().unwrap_or([0; RANKS]); + RangeInfo { + index: i, + mission: resolved.gekisou_missions[i.min(2)], + start_ms, + end_ms, + rank_bonus_percent: percents[0], + rank_bonus_percents: percents, + } }) .collect(); let events: Vec<(i32, i32)> = c.skill_events.iter().map(|e| (e.index, e.time_ms)).collect(); @@ -449,8 +803,47 @@ pub fn chart_stats(master: &Master, chart: &DataChart, kinds: &[Kind], seeds: us ranges, just_notes: 0, seeds: Vec::new(), + off_seeds: Vec::new(), unplayable: None, }; + let notes: Vec = c + .notes + .iter() + .zip(&chart.judgement_types) + .map(|(n, &jt)| LiveNote { + note_id: n.id, + time_ms: n.time_ms, + note_operate_type: n.note_type, + judgement_type: jt, + }) + .collect(); + let unit: Vec<(i64, &Kind, i64)> = + kinds.iter().enumerate().map(|(i, k)| (KIND_SKILL_BASE - i as i64, k, UNIT_VALUE)).collect(); + let measure = Live::master_with(master, &unit); + let params = LiveParams { + skill_target_music_type: resolved.skill_target_music_type, + total_power: POWER, + music_level: level, + converted_note_count: c.converted_note_count, + music_length_ms, + score_music_length_ms: None, + assist_factor: 1.0, + }; + + // Gekisou off: every chart, whatever its fevers + let off = Live { + master, + measure: &measure, + notes: ¬es, + events: &events, + params, + gekisou: None, + play: JudgementStream::theoretical_best(&c).to_live_play()?, + positions, + }; + let mut off_rng = Rng(OFF_CHECK_SALT ^ chart.score_id as u64); + out.off_seeds.push(off.off_seed_stats(kinds, OFF_SEED, &mut off_rng, judged)?); + if chart.fevers.len() > MAX_GEKISOU_FEVERS { out.unplayable = Some(format!( "{} fevers: the game fails when the fourth fever starts (GekisouController.BeforeUpdate 0x55d4838)", @@ -461,42 +854,32 @@ pub fn chart_stats(master: &Master, chart: &DataChart, kinds: &[Kind], seeds: us let rule = JustRule::new(master, &setup)?; let stream = JudgementStream::theoretical_best_gekisou(&c, &chart.judgement_types, &rule)?; out.just_notes = stream.judged.iter().filter(|r| r[2] == SIMULATE_JUST).count() as i32; - let unit: Vec<(i64, &Kind, i64)> = - kinds.iter().enumerate().map(|(i, k)| (KIND_SKILL_BASE - i as i64, k, UNIT_VALUE)).collect(); + let mut perfect = stream.clone(); + for r in &mut perfect.judged { + if r[2] == SIMULATE_JUST { + r[2] = SIMULATE_PERFECT; + } + } let live = Live { master, - measure: Live::master_with(master, &unit), - notes: c - .notes - .iter() - .zip(&chart.judgement_types) - .map(|(n, &jt)| LiveNote { - note_id: n.id, - time_ms: n.time_ms, - note_operate_type: n.note_type, - judgement_type: jt, - }) - .collect(), - events, - params: LiveParams { - skill_target_music_type: resolved.skill_target_music_type, - total_power: POWER, - music_level: level, - converted_note_count: c.converted_note_count, - music_length_ms, - score_music_length_ms: None, - assist_factor: 1.0, - }, - dt: stream.delta_times()?, + measure: &measure, + notes: ¬es, + events: &events, + params, + gekisou: Some(Gekisou { setup, dt: stream.delta_times()?, perfect: perfect.to_live_play()? }), play: stream.to_live_play()?, - setup, positions, }; - let luck = live.setup.missions.iter().take(live.setup.fevers.len()).any(|&m| m == MISSION_LUCK); + let setup = &live.gekisou.as_ref().expect("Gekisou on").setup; + let luck = setup.missions.iter().take(setup.fevers.len()).any(|&m| m == MISSION_LUCK); let seed_list = if luck { published_seeds(seeds.max(1)) } else { vec![0] }; - let mut rng = Rng(0x9e37_79b9_7f4a_7c15 ^ chart.score_id as u64); + let linear = !bonus_inside_a_range(&chart.fevers); + let reads_rank: Vec = kinds.iter().map(|k| k.reads_rank(master)).collect(); + let mut rng = Rng(CHECK_SALT ^ chart.score_id as u64); + let mut rank_rng = Rng(RANK_CHECK_SALT ^ chart.score_id as u64); for seed in seed_list { - out.seeds.push(live.seed_stats(kinds, seed, &mut rng, judged)?); + let s = live.seed_stats(kinds, &out.ranges, linear, &reads_rank, seed, &mut rng, &mut rank_rng, judged)?; + out.seeds.push(s); } Ok(out) } @@ -535,8 +918,34 @@ pub fn document(data: &DeckData, seeds: Option) -> Result Value { @@ -65,9 +66,34 @@ fn chart_json_fevers(score_id: i64, n: i32, rng: &mut common::Rng, fevers: &[(i3 ) } -fn document(charts: Vec, full_combo: i64) -> Value { +/// The synthetic master; with `extra`, also live skill 4 (2000 on the confirmed rank, condition 7012) and 5 (2000 +/// on a Gekisou combo, condition 7005), one level each. +fn document_with(charts: Vec, full_combo: i64, extra: bool) -> Value { let mut rng = common::Rng::new(7); - let s = common::synth(&mut rng, 12, 4); + let mut s = common::synth(&mut rng, 12, 4); + if extra { + let mut push = |table: &str, rows: Value| { + let t = s.tables.iter_mut().find(|(n, _)| n == table).unwrap(); + t.1.as_array_mut().unwrap().extend(rows.as_array().unwrap().iter().cloned()); + }; + push( + "MasterSkillCondition", + json!([{"_id": 20, "_conditionType": 7012, "_conditionValues": [1], "_isPositive": true, "_conditionTargetIDs": []}, + {"_id": 21, "_conditionType": 7005, "_conditionValues": [1], "_isPositive": true, "_conditionTargetIDs": []}]), + ); + push( + "MasterSkillConditionSet", + json!([{"_id": 20, "_group": 20, "_conditionIds": [20]}, {"_id": 21, "_group": 21, "_conditionIds": [21]}]), + ); + push("MasterLiveSkill", json!([{"_id": 4, "_skillCategories": [1]}, {"_id": 5, "_skillCategories": [1]}])); + push( + "MasterLiveSkillEffect", + json!([{"_id": 1000, "_liveSkillID": 4, "_level": 1, "_skillConditionGroup": 20, "_skillTargetIDs": [], + "_skillEffectType": 2000, "_activationTimeSecond": 5.0, "_effectValue": 3000}, + {"_id": 1001, "_liveSkillID": 5, "_level": 1, "_skillConditionGroup": 21, "_skillTargetIDs": [], + "_skillEffectType": 2000, "_activationTimeSecond": 5.0, "_effectValue": 3000}]), + ); + } let mut master = serde_json::Map::new(); for (name, rows) in &s.tables { master.insert(name.clone(), columns(rows)); @@ -144,11 +170,15 @@ fn data(n: i32) -> (DeckData, i32) { } fn data_fevers(n: i32, fevers: &[(i32, i32)]) -> (DeckData, i32) { + data_with(n, fevers, false) +} + +fn data_with(n: i32, fevers: &[(i32, i32)], extra: bool) -> (DeckData, i32) { let mut rng = common::Rng::new(11); let (chart, last) = chart_json_fevers(1004, n, &mut rng, fevers); let ops = chart["notes"]["op"].as_array().unwrap(); let judged = ops.iter().filter(|o| ![0, 80, 82, 100, 103, 121, 122, 123].contains(&o.as_i64().unwrap())).count(); - (DeckData::from_json(&document(vec![chart], judged as i64).to_string()).unwrap(), last) + (DeckData::from_json(&document_with(vec![chart], judged as i64, extra).to_string()).unwrap(), last) } const FEVERS: [(i32, i32); 3] = [(8000, 16000), (24000, 32000), (42000, 50000)]; @@ -197,6 +227,8 @@ fn measured_weights_predict_whole_live_simulations() { for (i, r) in s.ranges.iter().enumerate() { assert_eq!((r.index, r.mission, r.start_ms, r.end_ms), (i, i as i64 + 1, FEVERS[i].0, FEVERS[i].1)); assert_eq!(r.rank_bonus_percent, 10 * (i as i64 + 1)); + // the synthetic table has rank 1 rows only + assert_eq!(r.rank_bonus_percents, [10 * (i as i64 + 1), 0, 0, 0, 0]); } for seed in &s.seeds { assert_eq!(seed.ranges.len(), 3); @@ -210,6 +242,12 @@ fn measured_weights_predict_whole_live_simulations() { assert_eq!((seed.ranges[0].just_count, seed.ranges[1].just_count), (0, 0)); assert_eq!(seed.ranges[2].just_count, s.just_notes); assert!(s.just_notes > 0); + // the Perfect play: the ranges before the Just-count range play the same, the Just-count range scores less + for r in &seed.ranges[..2] { + assert_eq!(r.range_score_perfect, r.range_score); + } + assert!(seed.ranges[2].range_score_perfect < seed.ranges[2].range_score); + assert!(seed.score_perfect < seed.score); assert!((seed.check.exact as f64 - seed.check.predicted).abs() <= seed.check.bound); assert_eq!(seed.weights.len(), kinds.len()); // an unconditioned score-up raises the score wherever its position fires @@ -285,16 +323,39 @@ fn a_chart_without_luck_ranges_has_one_seed() { assert_eq!(s.seeds.len(), 1); assert_eq!(s.seeds[0].seed, 0); assert_eq!(s.just_notes, 0); + // no Just: the Perfect play is the play; no ranges: nothing to rank + assert_eq!(s.seeds[0].score_perfect, s.seeds[0].score); + let rw = s.seeds[0].range_weights.as_ref().unwrap(); + assert!(rw.iter().all(|k| k.as_ref().unwrap().iter().all(|p| p.is_empty()))); + assert!(s.seeds[0].rank_check.is_none()); + assert_eq!(s.seeds[0].score_at_ranks(&s.ranges, &[]).unwrap(), s.seeds[0].score); + // Gekisou off without fevers: the same live without a Gekisou controller + assert_eq!(s.off_seeds.len(), 1); + assert_eq!(s.off_seeds[0].score, s.seeds[0].score); + for (on, off) in s.seeds[0].weights.iter().zip(&s.off_seeds[0].weights) { + assert_eq!(Some(on), off.as_ref()); + } } #[test] fn more_than_three_fevers_cannot_be_played() { let fevers = [(8000, 10000), (20000, 22000), (30000, 32000), (40000, 42000)]; let (d, _) = data_fevers(300, &fevers); - let s = chartstats::chart_stats(&d.master, &d.charts[0], &chartstats::kinds(&d.master), 2).unwrap(); + let kinds = chartstats::kinds(&d.master); + let s = chartstats::chart_stats(&d.master, &d.charts[0], &kinds, 2).unwrap(); assert!(s.seeds.is_empty()); assert_eq!(s.ranges.len(), 3); assert!(s.unplayable.as_deref().unwrap().contains("fourth fever")); + // without Gekisou the game plays it + let off = &s.off_seeds[..]; + assert_eq!(off.len(), 1); + assert!(off[0].score > 0 && off[0].weights.len() == kinds.len()); + assert!(off[0].weights.iter().all(|w| w.as_ref().is_some_and(|w| w.len() == s.positions))); + assert!((off[0].check.exact as f64 - off[0].check.predicted).abs() <= off[0].check.bound); + let chart = d.chart(1004).unwrap(); + let play = Play::theoretical_best(&d.master, &chart).unwrap(); + let model = LiveModel::new(&d.master, 24, &chart, &play).unwrap(); + assert_eq!(model.score(POWER, &[]), off[0].score); } #[test] @@ -320,11 +381,24 @@ fn document_lists_kinds_and_charts() { assert_eq!(v["source"]["region"], "test"); assert_eq!(v["model"]["power"], POWER); assert_eq!(v["kinds"].as_array().unwrap().len(), 4); + for key in ["ranks", "perfect", "off"] { + assert!(v["model"][key].as_str().is_some_and(|s| !s.contains(" ")), "{key}"); + } let c = &v["charts"][0]; assert_eq!(c["scoreId"], 1004); assert_eq!(c["events"].as_array().unwrap().len(), 5); + assert_eq!(c["ranges"][0]["rankBonusPercents"].as_array().unwrap().len(), 5); assert_eq!(c["seeds"].as_array().unwrap().len(), 2); - assert_eq!(c["seeds"][0]["weights"].as_array().unwrap().len(), 4); + let seed = &c["seeds"][0]; + assert_eq!(seed["weights"].as_array().unwrap().len(), 4); + assert!(seed["scorePerfect"].is_i64() && seed["ranges"][2]["rangeScorePerfect"].is_i64()); + // rangeWeights[kind][position][range] + let rw = seed["rangeWeights"].as_array().unwrap(); + assert_eq!((rw.len(), rw[0].as_array().unwrap().len(), rw[0][0].as_array().unwrap().len()), (4, 5, 3)); + assert_eq!(seed["rankCheck"]["ranks"].as_array().unwrap().len(), 3); + let off = c["offSeeds"].as_array().unwrap(); + assert_eq!(off.len(), 1); + assert_eq!(off[0].as_object().unwrap().keys().collect::>(), ["check", "score", "seed", "weights"]); assert!(c.get("unplayable").is_none()); assert!(chartstats::document(&d, Some(0)).is_err()); } @@ -376,3 +450,234 @@ fn per_order_model_matches_the_whole_live_simulation_with_live_skills() { } assert_eq!(compared, 40); } + +/// A Gekisou live with explicit ranks: the chart's notes, events, setup and default play. +struct Ranked { + notes: Vec, + events: Vec<(i32, i32)>, + setup: GekisouSetup, + play: ournotes_deck::live::full::LivePlay, + dt: Vec, + converted: i32, + length: i32, +} + +impl Ranked { + fn new(d: &DeckData) -> Ranked { + let chart = d.chart(1004).unwrap(); + let setup = GekisouSetup { fevers: FEVERS.to_vec(), missions: vec![1, 2, 3] }; + let rule = JustRule::new(&d.master, &setup).unwrap(); + let stream = JudgementStream::theoretical_best_gekisou(&chart, &d.charts[0].judgement_types, &rule).unwrap(); + Ranked { + notes: notes_of(d), + events: chart.skill_events.iter().map(|e| (e.index, e.time_ms)).collect(), + setup, + play: stream.to_live_play().unwrap(), + dt: stream.delta_times().unwrap(), + converted: chart.converted_note_count, + length: chart.last_timing_note_ms + 1000, + } + } + + /// The score of a deck at a power and seed, range `i` confirmed at rank `ranks[i]` with its percentage. + fn run(&self, master: &Master, perf: &[Option<(i64, i64)>], power: i32, seed: i32, ranks: &[(i32, i64)]) -> i32 { + let deck: Vec = perf.iter().map(|&p| Performer { live_skill: p, ..Default::default() }).collect(); + let params = LiveParams { + skill_target_music_type: 1, + total_power: power, + music_level: 24, + converted_note_count: self.converted, + music_length_ms: self.length, + score_music_length_ms: None, + assist_factor: 1.0, + }; + let mut lm = + full::LiveModel::new_gekisou_external(master, &deck, &self.notes, &self.events, params, &self.setup) + .unwrap(); + for (i, &(rank, pct)) in ranks.iter().enumerate() { + lm.queue_gekisou_rank_confirmation(i, rank, pct).unwrap(); + } + let mut play = self.play.clone(); + play.base_seed = seed; + lm.run_timed(&play, &self.dt).unwrap() + } +} + +/// The kind of a live skill effect row. +fn kind_of(kinds: &[chartstats::Kind], row: &ournotes_deck::master::LiveSkillEffectRow) -> usize { + kinds + .iter() + .position(|k| { + k.effect_type == row.skill_effect_type + && k.activation_time_second == row.activation_time_second + && k.skill_target_ids == row.skill_target_ids + && k.skill_condition_group == row.skill_condition_group + }) + .unwrap() +} + +/// Rank bonus percentages for every rank of every range, so that each rank moves the score. +fn rank_table(d: &mut DeckData) { + d.master.gekisou_ranking_score_bonuses.clear(); + let mut id = 1; + for count in 1..=3 { + for rank in 1..=5 { + d.master.gekisou_ranking_score_bonuses.push(ournotes_deck::master::GekisouRankingBonusRow { + id, + mission_pattern: 2, + rank, + count, + score_bonus_percent: [30, 22, 15, 9, 4][rank as usize - 1] + 5 * count, + }); + id += 1; + } + } +} + +/// At random ranks the no-skill score from the statistics is exact, a unit effect's weight is within two points per +/// range and real decks at another power are within the bound, against plays through the explicit rank +/// confirmations. +#[test] +fn ranks_follow_linearly_on_the_rank_confirmation_path() { + let (mut d, _) = data_fevers(700, &FEVERS); + rank_table(&mut d); + let kinds = chartstats::kinds(&d.master); + let s = chartstats::chart_stats(&d.master, &d.charts[0], &kinds, 3).unwrap(); + for (i, r) in s.ranges.iter().enumerate() { + assert_eq!(r.rank_bonus_percents[0], r.rank_bonus_percent); + assert_eq!(r.rank_bonus_percents[4], 4 + 5 * (i as i64 + 1)); + } + let live = Ranked::new(&d); + // kind 0 (2000 for 5 s, no condition) at factor 1 as live skill 99 + let mut unit = d.master.clone(); + let mut row = d.master.live_skill_effects.iter().find(|r| r.live_skill_id == 1 && r.level == 1).unwrap().clone(); + assert_eq!(kind_of(&kinds, &row), 0); + (row.id, row.live_skill_id, row.effect_value) = (100_000, 99, 10000); + unit.live_skill_effects.push(row); + let none = vec![None; 5]; + let mut rng = common::Rng::new(21); + let mut checked = 0; + for seed in &s.seeds { + let rc = seed.rank_check.as_ref().expect("a rank check"); + assert!((rc.exact as f64 - rc.predicted).abs() <= rc.bound, "{rc:?}"); + assert!(rc.ranks.iter().all(|r| (1..=5).contains(r))); + // rank 1 everywhere: the statistics themselves + assert_eq!(seed.score_at_ranks(&s.ranges, &[1, 1, 1]).unwrap(), seed.score); + let w1 = seed.weights_at_ranks(&s.ranges, &[1, 1, 1]).unwrap().unwrap(); + assert_eq!(w1, seed.weights.iter().cloned().map(Some).collect::>()); + assert!( + seed.score_at_ranks(&s.ranges, &[1, 1]).is_err() && seed.score_at_ranks(&s.ranges, &[1, 6, 1]).is_err() + ); + for _ in 0..4 { + let ranks: Vec = (0..3).map(|_| rng.range(1, 5) as i32).collect(); + let confirmed: Vec<(i32, i64)> = + ranks.iter().zip(&s.ranges).map(|(&r, info)| (r, info.percent(r).unwrap())).collect(); + let exact0 = live.run(&d.master, &none, POWER, seed.seed, &confirmed); + assert_eq!(exact0, seed.score_at_ranks(&s.ranges, &ranks).unwrap(), "ranks {ranks:?}"); + let w = seed.weights_at_ranks(&s.ranges, &ranks).unwrap().unwrap(); + for k in 0..5 { + let mut perf = none.clone(); + perf[k] = Some((99, 1)); + let exact = live.run(&unit, &perf, POWER, seed.seed, &confirmed) as f64; + let predicted = exact0 as f64 + POWER as f64 * w[0].as_ref().unwrap()[k]; + assert!((exact - predicted).abs() < 6.0 + 1e-6, "ranks {ranks:?} k {k}: {exact} {predicted}"); + } + // real decks of the master's skills 1 (2000) and 2 (2004) at another power + let power = rng.range(100_000, 900_000) as i32; + let perf: Vec> = (0..5) + .map(|_| if rng.below(5) == 0 { None } else { Some((rng.range(1, 2), rng.range(1, 5))) }) + .collect(); + let mut predicted = exact0 as f64 / POWER as f64; + let mut gain = 0.0; + for (k, p) in perf.iter().enumerate() { + if let Some((id, lv)) = *p { + let row = + d.master.live_skill_effects.iter().find(|r| r.live_skill_id == id && r.level == lv).unwrap(); + let x = chartstats::kind_factor(row.skill_effect_type, row.effect_value); + predicted += x * w[kind_of(&kinds, row)].as_ref().unwrap()[k]; + gain += x; + } + } + let exact = live.run(&d.master, &perf, power, seed.seed, &confirmed) as f64; + let p = power as f64 * predicted; + let scale = power as f64 / POWER as f64; + let bound = + (s.judged_notes as f64 + 3.0) * (1.0 + scale * (1.0 + 2.0 * gain)) + 6.0 * scale * gain + 4e-6 * p; + assert!( + (exact - p).abs() <= bound, + "ranks {ranks:?} power {power} deck {perf:?}: exact {exact} predicted {p}" + ); + checked += 1; + } + } + assert_eq!(checked, 12); + // the lower ranks score less + let seed = &s.seeds[0]; + assert!(seed.score_at_ranks(&s.ranges, &[5, 5, 5]).unwrap() < seed.score); +} + +/// Gekisou off: the no-skill score is the per-order model's, and decks of the master's skills (live-conditioned +/// ones included) predicted from the off weights are within the flooring bound of the per-order model. +#[test] +fn off_seeds_match_the_per_order_model() { + let (d, _) = data_fevers(700, &FEVERS); + let kinds = chartstats::kinds(&d.master); + let s = chartstats::chart_stats(&d.master, &d.charts[0], &kinds, 2).unwrap(); + let off = &s.off_seeds[0]; + assert_eq!((s.off_seeds.len(), off.seed), (1, chartstats::OFF_SEED)); + assert!(off.weights.iter().all(|w| w.as_ref().is_some_and(|w| w.len() == 5))); + assert!((off.check.exact as f64 - off.check.predicted).abs() <= off.check.bound); + // no Just, no rank bonus: less than Gekisou on + assert!(s.seeds.iter().all(|x| off.score < x.score)); + let chart = d.chart(1004).unwrap(); + let play = Play::theoretical_best(&d.master, &chart).unwrap(); + let model = LiveModel::new(&d.master, 24, &chart, &play).unwrap(); + assert_eq!(model.score(POWER, &[]), off.score); + let mut rng = common::Rng::new(13); + for _ in 0..40 { + let power = rng.range(50_000, 900_000) as i32; + let perf: Vec<(i64, i64)> = + (0..5).map(|_| if rng.below(5) == 0 { (0, 0) } else { (rng.range(1, 3), rng.range(1, 5)) }).collect(); + let exact = model.score(power, &model.commands(&d.master, &perf).unwrap()) as f64; + let mut predicted = off.score as f64 / POWER as f64; + let mut gain = 0.0; + for (k, &(id, lv)) in perf.iter().enumerate() { + for row in d.master.live_skill_effects.iter().filter(|r| r.live_skill_id == id && r.level == lv) { + let x = chartstats::kind_factor(row.skill_effect_type, row.effect_value); + predicted += x * off.weights[kind_of(&kinds, row)].as_ref().unwrap()[k]; + gain += x; + } + } + let p = power as f64 * predicted; + let scale = power as f64 / POWER as f64; + let bound = s.judged_notes as f64 * (1.0 + scale * (1.0 + 2.0 * gain)) + 4e-6 * p; + assert!((exact - p).abs() <= bound, "power {power} deck {perf:?}: exact {exact} predicted {p}"); + } +} + +/// A kind on the confirmed rank has no range weights (the other kinds keep theirs); a kind on the Gekisou state has +/// no weights with Gekisou off and stays out of that check deck. +#[test] +fn kinds_on_the_rank_or_the_gekisou_state() { + let (d, _) = data_with(500, &FEVERS, true); + let kinds = chartstats::kinds(&d.master); + assert_eq!(kinds.len(), 6); + assert_eq!((kinds[4].skill_condition_group, kinds[5].skill_condition_group), (20, 21)); + let reads: Vec = kinds.iter().map(|k| k.reads_rank(&d.master)).collect(); + assert_eq!(reads, [false, false, false, false, true, false]); + let s = chartstats::chart_stats(&d.master, &d.charts[0], &kinds, 2).unwrap(); + for seed in &s.seeds { + let rw = seed.range_weights.as_ref().unwrap(); + assert!(rw[4].is_none() && rw.iter().enumerate().all(|(i, w)| i == 4 || w.is_some())); + assert!((seed.check.exact as f64 - seed.check.predicted).abs() <= seed.check.bound); + let w = seed.weights_at_ranks(&s.ranges, &[2, 3, 4]).unwrap().unwrap(); + assert!(w[4].is_none() && w[5].is_some()); + if seed.check.deck.iter().flatten().any(|&(ki, _)| ki == 4) { + assert!(seed.rank_check.is_none()); + } + } + let off = &s.off_seeds[0]; + assert!(off.weights[5].is_none() && off.weights.iter().enumerate().all(|(i, w)| i == 5 || w.is_some())); + assert!(off.check.deck.iter().flatten().all(|&(ki, _)| ki != 5)); + assert!((off.check.exact as f64 - off.check.predicted).abs() <= off.check.bound); +}