From 946f25c1b50ca1cbb8209ca766056a1a2f6c3aca Mon Sep 17 00:00:00 2001 From: nichinichisou0609 <128159543+nichinichisou0609@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:58:41 +0800 Subject: [PATCH] feat(songs): support nominal chart expectations --- examples/songs/catalog.js | 1 + examples/songs/expectation.js | 52 +++++++ examples/songs/ranking.js | 128 ++++++++++++--- examples/songs/songs.js | 50 +++--- examples/songs/text.js | 64 +++++--- tests/fixtures/chart-stats-v3.json | 1 + tests/unit/songs-expectation.test.mjs | 216 ++++++++++++++++++++++++++ 7 files changed, 448 insertions(+), 64 deletions(-) create mode 100644 examples/songs/expectation.js create mode 100644 tests/fixtures/chart-stats-v3.json create mode 100644 tests/unit/songs-expectation.test.mjs diff --git a/examples/songs/catalog.js b/examples/songs/catalog.js index f8c010b..a93ea1f 100644 --- a/examples/songs/catalog.js +++ b/examples/songs/catalog.js @@ -33,6 +33,7 @@ const figures = (e) => ({ base: e ? e.base : null, baseRange: e ? e.baseRange : null, seeds: e ? e.seeds : null, + nominal: e?.nominal === true, weights: e ? e.weights : null, }); diff --git a/examples/songs/expectation.js b/examples/songs/expectation.js new file mode 100644 index 0000000..fb32ae4 --- /dev/null +++ b/examples/songs/expectation.js @@ -0,0 +1,52 @@ +// Nominal expectation figures: centers and numerical enclosures, independent of replay seeds. +import { DEFAULT_SCENARIO as BEST_BATTLE, clampRank, greatFactor, rankPercent } from "./ranking.js"; +const finite = (v) => typeof v === "number" && Number.isFinite(v); +export const isEstimate = (v) => Array.isArray(v) && v.length === 2 && finite(v[0]) && finite(v[1]) && v[1] >= 0 && finite(v[0] - v[1]) && finite(v[0] + v[1]); +// A broken new-format row must never fall back to the legacy seed mean. +export const hasNominalExpectation = (deck) => Boolean(deck && typeof deck === "object" + && ("expectation" in deck || "replaySeeds" in deck)); +export function scenarioExpectation(expected, ranges, kind, scenario) { + const sc = { ...BEST_BATTLE, ...scenario ?? {} }; + const row = expected?.weights?.[kind]; + if (!expected || sc.mode === "free" || !Array.isArray(ranges) || ranges.some((r) => !r || typeof r !== "object") + || !Array.isArray(expected.ranges) + || expected.ranges.length !== ranges.length || !isEstimate(expected.score) + || !Array.isArray(row) || !row.every(isEstimate)) + return null; + const just = finite(sc.just) ? Math.max(0, Math.min(1, sc.just)) : 1; + const great = greatFactor(sc.great); + const ranks = ranges.map((_, i) => clampRank(sc.ranks?.[i])); + const rank1 = ranks.every((r) => r === 1); + if (rank1 && just === 1) + return { + score: expected.score[0] * great, + weights: row.map((v) => v[0] * great), + scoreBounds: great === 1 ? [expected.score[0] - expected.score[1], expected.score[0] + expected.score[1]] : null + }; + if (!isEstimate(expected.scorePerfect)) + return null; + const rw = expected.rangeWeights?.[kind]; + if (!Array.isArray(rw) || rw.length !== row.length || !rw.every((r) => Array.isArray(r) && r.length === ranges.length && r.every(isEstimate))) + return null; + const p1 = ranges.map((r) => rankPercent(r, 1)); + const pr = ranges.map((r, i) => rankPercent(r, ranks[i])); + if (p1.some((p) => p === null) || pr.some((p) => p === null)) + return null; + if (!expected.ranges.every((r) => r && isEstimate(r.rangeScore) && isEstimate(r.rangeScorePerfect) && isEstimate(r.rankBonus) && isEstimate(r.rankBonusPerfect))) + return null; + const lerp = (perfect, best) => perfect + just * (best - perfect); + let score = lerp(expected.scorePerfect[0], expected.score[0]); + const weights = row.map((v) => v[0]); + expected.ranges.forEach((r, i) => { + const rangeScore = lerp(r.rangeScorePerfect[0], r.rangeScore[0]); + if (ranks[i] !== 1) + score += rangeScore * pr[i] / 100 - lerp(r.rankBonusPerfect[0], r.rankBonus[0]); + const ratio = r.rangeScore[0] > 0 ? rangeScore / r.rangeScore[0] : 1; + weights.forEach((_, k) => { + weights[k] += ((pr[i] - p1[i]) / 100 + (ratio - 1) * (1 + pr[i] / 100)) * rw[k][i][0]; + }); + }); + if (!finite(score) || !weights.every(finite)) + return null; + return { score: score * great, weights: weights.map((w) => w * great), scoreBounds: null }; +} diff --git a/examples/songs/ranking.js b/examples/songs/ranking.js index 0016ca1..b47c913 100644 --- a/examples/songs/ranking.js +++ b/examples/songs/ranking.js @@ -1,7 +1,7 @@ -// Chart efficiency ranking: pure functions over a site's music-data.json (nnnotes.music-data/1, whose deck -// statistics are ournotes-deck.chart-stats/2). +// Chart efficiency ranking: pure functions over a site's music-data.json. New chart-stats/3 data uses nominal +// expectations with numerical enclosures. Legacy music-data/1 seed statistics remain readable. // -// Score model (music-data.json `deck`, checked per seed against the whole-live simulation): on the theoretical best +// Score model (music-data.json `deck`, checked against the complete expectation or legacy whole-live samples): on the theoretical best // play with Gekisou on (the chart's fevers with the song's missions, rank 1 in every range), a deck of power P whose // live skills are plain score-up skills (effect type 2000 for 5 s on the whole deck, no targets or conditions) raising // the note score by x_1 .. x_n scores @@ -10,9 +10,10 @@ // // where `base` (the no-skill score per unit of power, rank bonuses included) and the skill event weights `w_k` (the // score a factor-1 plain skill at performance position k adds, per unit of power) are the chart's own and pi is the -// skill order of the live. Luck ranges draw from the play's random seed, so the deck statistics are per seed of a -// seed set; the page takes the mean over those seeds, which is not the game's own expectation (its seed law is -// unknown). The client draws pi at the start of every live: `MemberDataContainer` fills the skill order with 0..n-1 +// skill order of the live. Chart-stats/3 integrates the model's independent nominal lottery and skill probabilities; +// replaySeeds are examples only and never enter that expectation. Legacy statistics instead average their seed set, +// which need not follow the game's unknown seed law. The client draws pi at the start of every live: +// `MemberDataContainer` fills the skill order with 0..n-1 // and Fisher-Yates shuffles it with the MemberShuffle random stream, seeded from the client clock (a solo retry keeps // the seed, so the order). pi is uniform over the n! orders, and so // @@ -27,13 +28,16 @@ // corners xbar in {0, X_MAX}, c in {0, infinity} decide it: S_a >= S_b and S_a / L_a >= S_b / L_b at both ends. // The deck power must be the same on both charts: song type and tag bonuses change a deck's power per song. // -// Play scenarios (see scenarioSeed): "battle" is Gekisou Live (撃奏ライブ, up to 5 players, Gekisou on) with a rank +// Legacy play scenarios (see scenarioSeed): "battle" is Gekisou Live (撃奏ライブ, up to 5 players, Gekisou on) with a rank // r_i in 1..5 per Gekisou range; the seeds are its rank-1 simulations, and the other ranks follow from them linearly // (the rank bonus trunc(rangeScore * p / 100) is added at the range's end and changes nothing else): // // base_r = (score - sum_i rankBonus_i + sum_i trunc(rangeScore_i * p_i(r_i) / 100)) / power // w_r[k] = w[k] + sum_i (p_i(r_i) - p_i(1)) / 100 * rangeWeights[k][i] // +// Nominal scenarios (see scenarioExpectation) retain the measured expected bonus in every rank-1 range. Other +// ranks substitute the target percentage times the expected range score as a linear approximation: truncating +// an expectation is not an expected truncated score. Only untransformed figures carry the exported enclosure. // "free" is Free Live (solo, Gekisou off), its own simulation (`offSeeds`). Two accuracy approximations, without // combo breaks: a Great share q scales every score by 1 - 0.2 q; a Just rate j (battle only) interpolates between the // all-Just seeds and the all-Perfect run of the Just ranges (`scorePerfect`, `rangeScorePerfect`), with the rank @@ -44,6 +48,8 @@ // decks carry none: every figure here is without them. Every Gekisou range ranks the room by its mission's measure // (rangeMeasures). +import { hasNominalExpectation, isEstimate, scenarioExpectation } from "./expectation.js"; + export const DIFFICULTIES = ["easy", "normal", "hard", "expert"]; export const EPS = 1e-12; // The largest score-up value of one live skill (MasterLiveSkillEffect, types 2000 / 2004 at level 5: 15000 = 150 %). @@ -90,19 +96,24 @@ export const MISSION_MEASURE = Object.freeze({ 1: "maxCombo", 2: "luckPoints", 3 export const MEASURES = ["maxCombo", "justCount", "luckPoints"]; // Per Gekisou range of a chart's deck statistics (Gekisou on, no skills): its mission, the measure it ranks by (null -// for an unknown mission) and `values`, every measure as {mean, min, max} over the seeds; a measure some seed lacks -// (older data) is null. [] for a chart unplayable with Gekisou on. +// for an unknown mission) and `values`, every measure as {mean, min, max}. Nominal data uses its center and exported +// enclosure, legacy data its seed mean and min–max. Missing measures are null; an unplayable chart returns []. export const rangeMeasures = (deck) => { if (!deck || deck.unplayable) return []; const seeds = deck.seeds || []; const stat = (i, key) => { + if (hasNominalExpectation(deck)) { + const v = deck.expectation?.ranges?.[i]?.[key]; + if (isEstimate(v)) return { mean: v[0], min: v[0] - v[1], max: v[0] + v[1] }; + return Number.isFinite(v) ? { mean: v, min: v, max: v } : null; + } const v = seeds.map((s) => (s && s.ranges && s.ranges[i] ? s.ranges[i][key] : undefined)); return v.length && v.every(Number.isFinite) ? { mean: mean(v), min: Math.min(...v), max: Math.max(...v) } : null; }; - return (deck.ranges || []).map((r, i) => ({ + return (Array.isArray(deck.ranges) ? deck.ranges : []).map((r, i) => ({ index: i, - mission: r.mission ?? null, - measure: MISSION_MEASURE[r.mission] ?? null, + mission: r?.mission ?? null, + measure: MISSION_MEASURE[r?.mission] ?? null, values: Object.fromEntries(MEASURES.map((k) => [k, stat(i, k)])), })); }; @@ -110,7 +121,7 @@ export const rangeMeasures = (deck) => { // The score factor of a Great share q (0..1) over every note: 1 - 0.2 q. export const greatFactor = (q) => 1 - (1 - GREAT_SCORE) * (Number.isFinite(q) ? Math.min(1, Math.max(0, q)) : 0); -const clampRank = (r) => (Number.isInteger(r) && r >= 1 && r <= RANK_MAX ? r : 1); +export const clampRank = (r) => (Number.isInteger(r) && r >= 1 && r <= RANK_MAX ? r : 1); // "r" or "r1,r2,r3" (the query's rk) as three ranks; a bad or missing value is rank 1. export const parseRanks = (text) => { @@ -143,8 +154,24 @@ export const scenarioData = (data) => { for (const chart of song.charts || []) { const d = chart.deck; if (!d) continue; - const seeds = d.seeds || []; if ((d.offSeeds || []).length) has.free = true; + if (hasNominalExpectation(d)) { + if (d.unplayable) continue; + const e = d.expectation; + const ranges = d.ranges || []; + const kinds = Array.isArray(e?.weights) ? e.weights : []; + const usable = kinds.map((_, kind) => chartFigures(d, kind)).some(Boolean); + if (usable) { + if (ranges.length && ranges.every((r) => Array.isArray(r.rankBonusPercents) + && r.rankBonusPercents.length >= RANK_MAX + && r.rankBonusPercents.slice(0, RANK_MAX).every(Number.isFinite)) + && kinds.some((_, kind) => chartFigures(d, kind, POWER, { ranks: ranges.map(() => RANK_MAX) }))) has.ranks = true; + if (kinds.some((_, kind) => chartFigures(d, kind, POWER, { just: 0 }))) has.just = true; + if (shapes && chartVariants(d).length) has.aptitude = true; + } + continue; + } + const seeds = d.seeds || []; if ((d.ranges || []).length && d.ranges.every((r) => Array.isArray(r.rankBonusPercents) && r.rankBonusPercents.length >= RANK_MAX) && seeds.some((s) => s.rangeWeights)) has.ranks = true; if (seeds.some((s) => Number.isFinite(s.scorePerfect) && s.rangeWeights)) has.just = true; @@ -193,11 +220,28 @@ export const scenarioSeed = (seed, ranges, kind, scenario) => { }; // A chart's figures from its deck statistics (`chart.deck`) in a scenario (default: battle, rank 1, all Just, no -// Great): `base` and `weights[k]` (performance position k's, of kind `kind`) as means over the seeds (`offSeeds` in -// free), `baseRange` the seeds' [min, max] base, `seeds` their number. null without statistics, for a chart +// Great): `base` and `weights[k]` (performance position k's, of kind `kind`). Nominal figures have nominal=true, +// seeds=0 and baseRange enclosing only an untransformed expectation (null after scenario adjustments). Legacy +// figures average the seeds (`offSeeds` in free), with baseRange their min–max and seeds their count. null without statistics, for a chart // unplayable with Gekisou on (battle), without the kind or without the scenario's fields. export const chartFigures = (deck, kind, power = POWER, scenario = null) => { const free = Boolean(scenario && scenario.mode === "free"); + if (!free && hasNominalExpectation(deck)) { + if (deck.unplayable || !Number.isInteger(kind) || kind < 0 || !Number.isFinite(power) || power <= 0) return null; + const f = scenarioExpectation(deck.expectation, deck.ranges || [], kind, scenario); + if (!f || (deck.positions !== undefined && f.weights.length !== deck.positions)) return null; + const base = f.score / power; + const baseRange = f.scoreBounds ? f.scoreBounds.map((x) => x / power) : null; + if (!Number.isFinite(base) || (baseRange && !baseRange.every(Number.isFinite))) return null; + return { + base, + baseRange, + seeds: 0, + nominal: true, + skip: deck.skip ?? null, + weights: f.weights, + }; + } const seeds = (deck && (free ? deck.offSeeds : !deck.unplayable && deck.seeds)) || []; if (!seeds.length || kind === null || kind === undefined) return null; const figs = seeds.map((s) => scenarioSeed(s, deck.ranges || [], kind, scenario)); @@ -218,8 +262,9 @@ export const chartFigures = (deck, kind, power = POWER, scenario = null) => { // `deck.gekisouAptitude.shapes`, the chart's `deck.gekisouAptitude.variants`). A shape is the class of member Gekisou // skills (at their highest level) or snap Gekisou support skills (at the level of the highest rank) with the same score // effects; a support skill with a band condition has two variants, the condition met (bandMatch true) or not (false). -// Every figure of a variant is a gain Δ over deck.seeds (same seeds, same play, rank 1) as [mean, standard error] over -// its own seeds; a deterministic variant ran one seed, with se 0. For a scenario (SCHEMA 1.6, P₀ the measurement power): +// Chart-stats/3 expresses gains as [center, numerical enclosure half-width], subtracting no-skill expectations under +// the same nominal probability model. Legacy figures subtract the same seed and use [mean, standard error]; a +// deterministic legacy variant ran one seed, with SE 0. For a legacy scenario (P₀ the measurement power): // rank 1 everywhere: Δscore = score (Just play; scorePerfect in the Perfect play) // ranks r: Δscore = tail + Σ_i rangeScore_i · (1 + p_i(r_i) / 100) (±1 point per range) // plain skill cross: Δw[k](r) = weights[k] + Σ_i (p_i(r_i) − p_i(1)) / 100 · rangeWeights[k][i] @@ -231,6 +276,8 @@ export const chartFigures = (deck, kind, power = POWER, scenario = null) => { // P · (Δscore / P₀ + x̄ · Σ_k Δw[k]): aptitudeFigures has the shape of chartFigures, so scoreRate applies. Free Live has // no Gekisou: no aptitude. The gains of several skills do not add up (combo boosts saturate, rush support and luck // gauge skills reinforce each other, 13005 conversion may change other skills’ triggers): one skill at a time. +// Nominal rank changes require rangeWeights (the declared linear domain) and replace only changed ranges' measured +// expected rank bonuses. Their original numerical radius is not a standard error and is never assigned to a transform. const CACHE = new WeakMap(); const cached = (data, key, make) => { @@ -257,20 +304,26 @@ const mOf = (x) => (Array.isArray(x) && Number.isFinite(x[0]) ? x[0] : null); const seOf = (x) => (Array.isArray(x) && Number.isFinite(x[1]) ? x[1] : 0); // A variant's gain in a scenario (see above; `ranges` the chart's deck ranges, default power P₀ 300000): `base` the -// score gain per unit of power, `baseSe` its raw standard error only (null after transformations), +// score gain per unit of power, `baseSe` its legacy raw standard error only (null after transformations and for +// nominal data), `baseRadius` the nominal raw enclosure half-width only (null after transformations), // `weights[k]` the gain of position k's plain skill weight (null when the data has no cross terms), `crossAtRank1` true // when the cross terms stay at rank 1 (no rangeWeights) while the ranks are not, and the variant's seed facts. null in // Free Live or without the scenario's fields. -export const aptitudeFigures = (variant, ranges, power = POWER, scenario = null) => { +export const aptitudeFigures = (variant, ranges, power = POWER, scenario = null, nominal = false) => { const sc = { ...DEFAULT_SCENARIO, ...(scenario || {}) }; if (!variant || sc.mode === "free" || mOf(variant.score) === null) return null; const rs = Array.isArray(variant.ranges) ? variant.ranges : []; + if (nominal && (!Number.isFinite(power) || power <= 0 || !isEstimate(variant.score) + || !Array.isArray(variant.ranges) || !Array.isArray(ranges) || rs.length !== ranges.length + || rs.some((r) => !r || typeof r !== "object") + || (variant.weights != null && (!Array.isArray(variant.weights) || !variant.weights.every(isEstimate))))) return null; const ranks = rs.map((_, i) => clampRank((sc.ranks || [])[i])); const j = Number.isFinite(sc.just) ? Math.min(1, Math.max(0, sc.just)) : 1; const partial = j < 1; const lerp = (p, just) => (partial ? p + j * (just - p) : just); const g = greatFactor(sc.great); const pick = (x, xp) => { // a Just-play figure at the Just rate + if (nominal && (!isEstimate(x) || (partial && !isEstimate(xp)))) return null; const a = mOf(x); if (a === null) return null; if (!partial) return a; @@ -278,9 +331,24 @@ export const aptitudeFigures = (variant, ranges, power = POWER, scenario = null) return b === null ? null : lerp(b, a); }; const rank1 = ranks.every((r) => r === 1); + if (nominal && !rank1 && !Array.isArray(variant.rangeWeights)) return null; + if (nominal && variant.rangeWeights != null && (!Array.isArray(variant.weights) + || !Array.isArray(variant.rangeWeights) || variant.rangeWeights.length !== variant.weights.length + || !variant.rangeWeights.every((w) => Array.isArray(w) && w.length === rs.length && w.every(isEstimate)))) return null; let score; if (rank1) { score = pick(variant.score, variant.scorePerfect); + } else if (nominal) { + score = pick(variant.score, variant.scorePerfect); + if (score === null) return null; + for (let i = 0; i < rs.length; i++) { + if (ranks[i] === 1) continue; + const p = rankPercent(ranges[i], ranks[i]); + const rangeScore = pick(rs[i].rangeScore, rs[i].rangeScorePerfect); + const rankBonus = pick(rs[i].rankBonus, rs[i].rankBonusPerfect); + if (p === null || rangeScore === null || rankBonus === null) return null; + score += rangeScore * p / 100 - rankBonus; + } } else { const tail = pick(variant.tail, variant.tailPerfect); const pr = rs.map((_, i) => rankPercent(ranges[i], ranks[i])); @@ -289,20 +357,28 @@ export const aptitudeFigures = (variant, ranges, power = POWER, scenario = null) if (tail === null || [...pr, ...p1].some((p) => p === null) || rsj.some((v) => v === null)) return null; score = tail + rsj.reduce((a, v, i) => a + v * (1 + pr[i] / 100), 0); } - if (score === null) return null; + if (score === null || (nominal && !Number.isFinite(score))) return null; let weights = null; let crossAtRank1 = false; if (!partial && Array.isArray(variant.weights)) { const rw = variant.rangeWeights; crossAtRank1 = !rank1 && !Array.isArray(rw); + if (nominal && !rank1 && rs.some((_, i) => rankPercent(ranges[i], 1) === null + || rankPercent(ranges[i], ranks[i]) === null)) return null; const shift = rs.map((_, i) => (rank1 || !Array.isArray(rw) ? 0 : (rankPercent(ranges[i], ranks[i]) - rankPercent(ranges[i], 1)) / 100)); weights = variant.weights.map((w, k) => ((mOf(w) ?? 0) + shift.reduce((a, d, i) => a + (d ? d * (mOf(rw[k] && rw[k][i]) ?? 0) : 0), 0)) * g); } + if (nominal && weights && !weights.every(Number.isFinite)) return null; + const raw = rank1 && !partial && sc.great === 0; + const base = (score * g) / power; + const baseRadius = nominal && raw ? variant.score[1] / power : null; + if (nominal && (!Number.isFinite(base) || (baseRadius !== null && !Number.isFinite(baseRadius)))) return null; return { - base: (score * g) / power, + base, // Only the original sampled statistic has an SE; aggregate transformed means have no covariance data. - baseSe: rank1 && !partial && sc.great === 0 ? seOf(variant.score) / power : null, + baseSe: !nominal && raw ? seOf(variant.score) / power : null, + ...(nominal ? { nominal: true, baseRadius } : {}), weights, missingPerfectCross: partial, crossAtRank1, @@ -322,7 +398,11 @@ export const aptitudeRate = (fig, skills) => { // Only the raw all-Just, rank-1, no-Great, no-ordinary-skill gain has an exported SE. // No SE is assigned to transformed or combined results: joint samples/covariances are absent. -export const aptitudeSe = (fig, skills) => (fig && !skillValues(skills).some((x) => x > 0) ? fig.baseSe : null); +export const aptitudeSe = (fig, skills) => (fig && !fig.nominal && !skillValues(skills).some((x) => x > 0) ? fig.baseSe : null); + +// The nominal enclosure is exported for the raw score gain only. It does not quantify model error, seed spread, +// or the uncertainty of a changed rank, accuracy, or combined ordinary-skill result. +export const aptitudeRadius = (fig, skills) => (fig?.nominal && !skillValues(skills).some((x) => x > 0) ? fig.baseRadius : null); // A variant that adds no score: "measures" when it still moves a range measure (or converts judgements), "none" when // it changes nothing in the theoretical best play; null for a variant with a score gain or cross term. diff --git a/examples/songs/songs.js b/examples/songs/songs.js index 4b38cb1..7c4fc52 100644 --- a/examples/songs/songs.js +++ b/examples/songs/songs.js @@ -20,10 +20,11 @@ import { ticks, moenotesJacketUrl, } from "./catalog.js"; import { - MEASURES, RANGES, RANK_MAX, SCORE_RANKS, X_MAX, aptitudeFigures, aptitudeRate, aptitudeSe, aptitudeShapes, chartVariants, chartFigures, eventDominance, formatLength, formatRanks, lengthMs, + MEASURES, RANGES, RANK_MAX, SCORE_RANKS, X_MAX, aptitudeFigures, aptitudeRadius, aptitudeRate, aptitudeSe, aptitudeShapes, chartVariants, chartFigures, eventDominance, formatLength, formatRanks, lengthMs, meanSkill, modelPower, orderRates, parseRanks, perMinute, plainKind, quantile, rangeMeasures, rank, rankThreshold, reachChance, requiredPower, scenarioData, scoreRate, weightSum, zeroGain, } from "./ranking.js"; +import { hasNominalExpectation, isEstimate } from "./expectation.js"; import { GUIDE, UI, REPLAY_UI } from "./text.js"; import { axisGoal, paretoPoints } from "./pareto.js"; import { mountReplayPanel } from "./replay-panel.js"; @@ -87,6 +88,8 @@ const heading = (tag, cls, title, ...tail) => h("div", { class: cls }, icon("sta h("span", { class: "track", "aria-hidden": "true" }), tail.flat().filter(Boolean).length ? h("div", { class: "tail" }, tail) : null); const fmt = (v, d = 2) => (v === null || v === undefined || !Number.isFinite(v) ? "–" : v.toFixed(d)); const fmtInt = (v) => (Number.isFinite(v) ? Math.round(v).toLocaleString() : "–"); +// Keep the numerical endpoints visible even when a narrow enclosure would round to zero at display precision. +const fmtBounds = (lo, hi) => `[${lo}, ${hi}]`; const modelText = (text) => { const name = text.includes("ournotes-deck") ? "ournotes-deck" : "Rust", index = text.indexOf(name); return index < 0 ? text : [text.slice(0,index), h("a",{class:"model-link",href:"https://github.com/empty-sekai/ournotes-deck",target:"_blank",rel:"noopener",title:"ournotes-deck (GitHub)"},name),text.slice(index+name.length)]; @@ -740,13 +743,14 @@ const main = async () => { const measuresBox = (r) => { const t = u(); const D = t.detail; + const nominal = hasNominalExpectation(r.stats); const measures = rangeMeasures(r.stats); if (!measures.some((m) => MEASURES.some((k) => m.values[k]))) return null; - // a measure as its seed mean, with the seeds' min–max when they differ + // A nominal enclosure and a legacy seed range are different quantities. const stat = (m) => { if (!m) return "–"; const v = Number.isInteger(m.mean) ? fmtInt(m.mean) : fmt(m.mean, 1); - return m.min === m.max ? v : `${v} (${fmtInt(m.min)}–${fmtInt(m.max)})`; + return m.min === m.max ? v : nominal ? `${v} ${fmtBounds(m.min, m.max)}` : `${v} (${fmtInt(m.min)}–${fmtInt(m.max)})`; }; return h("section", {}, heading("h3", "sec-head", D.measures), h("div", { class: "tbl-scroll" }, h("table", { class: "ranks measures" }, @@ -755,13 +759,14 @@ const main = async () => { h("td", {}, t.scen.range(m.index + 1, t.missions[m.mission] || null)), h("td", {}, m.measure ? D.measure[m.measure] : "–"), MEASURES.map((k) => h("td", { class: k === m.measure ? "num hi" : "num dim" }, stat(m.values[k])))))))), - h("p", { class: "hint" }, D.measuresHint)); + h("p", { class: "hint" }, nominal ? D.nominalMeasuresHint : D.measuresHint)); }; // Aptitude only describes taking one skill; it never changes the baseline rows used by rankings. // Build names and raw measure tables once. Slider updates only replace the numerical cells. const aptitudeBox = (r, changed) => { const A = u().aptitude, D = u().detail; + const nominal = hasNominalExpectation(r.stats); const element = h("section", { class: "aptitude" }, heading("h3", "sec-head", [A.title, " ", betaBadge("aptitude-beta-note")]), h("p", { id: "aptitude-beta-note", class: "hint beta-note" }, u().betaNote)); @@ -772,8 +777,12 @@ const main = async () => { return { element, update() {} }; } const shapes = aptitudeShapes(songs); - const pair = (x) => Array.isArray(x) && Number.isFinite(x[0]) - ? `${fmt(x[0], Number.isInteger(x[0]) ? 0 : 2)}${x[1] > 0 ? ` ± ${fmt(x[1], 2)}` : ""}` : "–"; + const pair = (x) => { + if (nominal) return isEstimate(x) ? `${fmt(x[0], Number.isInteger(x[0]) ? 0 : 2)}${x[1] > 0 + ? ` ${fmtBounds(x[0] - x[1], x[0] + x[1])}` : ""}` : "–"; + return Array.isArray(x) && Number.isFinite(x[0]) + ? `${fmt(x[0], Number.isInteger(x[0]) ? 0 : 2)}${x[1] > 0 ? ` ± ${fmt(x[1], 2)}` : ""}` : "–"; + }; const input = h("input", { type: "number", class: "num-in power", min: 0, step: 1000, value: S.power || "", "aria-label": A.power }); input.addEventListener("input", () => { S.power = Math.max(0, Math.round(Number(input.value) || 0)); changed(); }); const xs = S.skills.map((x, k) => { @@ -786,7 +795,7 @@ const main = async () => { h("div", { class: "panel" }, h("label", { class: "field" }, A.power, input), h("small", { class: "note" }, A.defaultPower(fmtInt(power))), h("div", { class: "field" }, u().skills, xs)), - h("p", { class: "hint" }, A.accuracy), h("p", { class: "hint" }, A.se)); + h("p", { class: "hint" }, A.accuracy), h("p", { class: "hint" }, nominal ? A.intervalNote : A.se)); const items = chartVariants(r.stats).map((v) => { const shape = shapes.get(v.shape); const names = shapeSkills(songs, shape, S.lang); @@ -805,35 +814,38 @@ const main = async () => { h("td", {}, shape ? A[shape.source] || shape.source : "–"), h("td", { title: shapeBands(songs, shape, S.lang).join(" / ") }, v.bandMatch === true ? A.match : v.bandMatch === false ? A.mismatch : A.noBand), gain, ratio, - h("td", {}, `${v.seeds ?? "–"} / ${v.crossSeeds ?? "–"}`, - !v.deterministic ? h("small", {}, A.rawScore, ": ", pair(v.score)) : null, - v.seTargetMet === false ? h("small", { class: "warn" }, A.warning) : null, - v.deterministic ? h("small", {}, A.deterministic) : null, + h("td", {}, nominal ? A.nominal : `${v.seeds ?? "–"} / ${v.crossSeeds ?? "–"}`, + nominal ? h("small", {}, A.interval) : !v.deterministic ? h("small", {}, A.rawScore, ": ", pair(v.score)) : null, + !nominal && v.seTargetMet === false ? h("small", { class: "warn" }, A.warning) : null, + !nominal && v.deterministic ? h("small", {}, A.deterministic) : null, zero ? h("small", {}, zero === "none" ? A.zero : A.onlyMeasures) : null)); return { v, gain, ratio, hint, row }; }); if (!items.length) element.append(h("p", { class: "hint" }, A.empty)); else element.append(h("div", { class: "tbl-scroll" }, h("table", { class: "ranks apt-table" }, - h("thead", {}, h("tr", {}, [A.skill, A.source, A.band, A.gain, A.ratio, A.seeds].map((x) => h("th", {}, x)))), + h("thead", {}, h("tr", {}, [A.skill, A.source, A.band, A.gain, A.ratio, nominal ? A.method : A.seeds].map((x) => h("th", {}, x)))), h("tbody", {}, items.map((x) => x.row))))); const factors = data.factors || []; const keys = ["judgedNotes", "justNotes", "perfectNotes", "tailNotes", "comboAtStart", "lotteries"]; if (factors.length) element.append(heading("h3", "sec-head", A.factors), h("div", { class: "tbl-scroll" }, h("table", { class: "ranks measures factors" }, h("thead", {}, h("tr", {}, h("th", {}, D.mRange), keys.map((k) => h("th", {}, A[k])))), h("tbody", {}, factors.map((x, i) => h("tr", {}, h("td", {}, String(i + 1)), keys.map((k) => h("td", {}, k === "lotteries" ? pair(x[k]) : fmtInt(x[k])))))))), - h("p", { class: "hint" }, A.factorNote)); + h("p", { class: "hint" }, nominal ? A.nominalFactorNote : A.factorNote)); return { element, update() { const sc = scenario(); const P = S.power || power; for (const { v, gain, ratio, hint } of items) { // A missing/mismatched plain kind must not be mistaken for measured zero cross terms. const usable = header.plainKind === kind && kind !== null ? v : { ...v, weights: null, rangeWeights: null }; - const f = aptitudeFigures(usable, r.stats.ranges || [], power, sc); + const f = aptitudeFigures(usable, r.stats.ranges || [], power, sc, nominal); const rate = aptitudeRate(f, skills()); const se = aptitudeSe(f, skills()); - put(gain, rate === null ? "–" : `${fmt(P * rate, 1)}${se === null ? "" : ` ± ${fmt(P * se, 1)}`}`); + const radius = aptitudeRadius(f, skills()); + put(gain, rate === null ? "–" : `${fmt(P * rate, 1)}${radius === null + ? se === null ? "" : ` ± ${fmt(P * se, 1)}` : ` ${fmtBounds(P * (rate - radius), P * (rate + radius))}`}`); put(ratio, rate !== null && r.base > 0 ? `${fmt(100 * rate / r.base, 2)}%` : "–"); - put(hint, rate === null ? [f && f.missingPerfectCross ? A.missingPerfect : A.missingCross, + put(hint, rate === null ? [nominal && !f ? (!Array.isArray(usable.rangeWeights) && sc.ranks.some((rank) => rank !== 1) + ? A.missingRank : u().scen.pending) : f && f.missingPerfectCross ? A.missingPerfect : A.missingCross, f ? ` ${A.baseOnly}: ${fmt(P * f.base, 1)}` : ""] : f.crossAtRank1 ? A.rank1 : ""); } } }; @@ -883,14 +895,16 @@ const main = async () => { return v[v.length - 1] > v[0] + 1e-12 ? `${t.detail.orders} ${fmt(v[0], 3)}–${fmt(v[v.length - 1], 3)} · P10 ${fmt(quantile(v, 0.1), 3)}` : t.detail.sameOrder; })()) : null, r.weights ? tile(t.col.base, fmt(r.base, 3), `W ${fmt(weightSum(r), 3)} · ${t.col.skip} ${fmt(r.skip, 3)}` - + (r.seeds > 1 ? ` · ${t.detail.seeds(r.seeds)} ${fmt(r.baseRange[0], 3)}–${fmt(r.baseRange[1], 3)}` : "")) : null, + + (r.nominal ? ` · ${t.detail.nominal}${r.baseRange ? ` · ${t.detail.interval} ${fmtBounds(...r.baseRange)}` : ` · ${t.approximateShort}`}` + : r.seeds > 1 ? ` · ${t.detail.seeds(r.seeds)} ${fmt(r.baseRange[0], 3)}–${fmt(r.baseRange[1], 3)}` : "")) : null, r.weights ? tile(t.col.perMinute, fmt(e.perMinute, 3), `${t.length} ${t[S.len]} + ${S.overhead} s`) : null, battle && r.weights ? tile(t.detail.twoScores, `${fmt(e.rate, 3)} / ${solo === null ? t.scen.pending : fmt(solo, 3)}`, t.detail.twoScoresHint, "wide") : null, battle && r.unplayable ? tile(t.detail.unplayable, "–", `${t.detail.unplayableHint}${has.free ? t.detail.unplayableFree : ""}`) : null, !r.weights && !(battle && r.unplayable) && r.stats ? tile(t.detail.noFigures, "–", t.scen.pending) : null)); put(line, timeline(r)); - put(weights, r.weights ? [heading("h3", "sec-head", t.detail.weights), weightsChart(r), h("p", { class: "hint" }, t.detail.weightsHint)] : null); + put(weights, r.weights ? [heading("h3", "sec-head", t.detail.weights), weightsChart(r), + h("p", { class: "hint" }, r.nominal ? t.detail.nominalWeightsHint : t.detail.weightsHint)] : null); put(ranks, r.weights && r.scoreRanks.length ? h("section", {}, heading("h3", "sec-head", t.detail.ranks), h("div", { class: "tbl-scroll" }, ranksTable(r)), h("p", { class: "hint" }, room() ? t.detail.ranksHintRoom(S.room) : t.detail.ranksHint)) : null); }; diff --git a/examples/songs/text.js b/examples/songs/text.js index 5022b5e..6a10f49 100644 --- a/examples/songs/text.js +++ b/examples/songs/text.js @@ -49,6 +49,7 @@ export const UI = { empty: "没有适用于本谱任务的技能变体。", skill: "技能 / 等级", source: "来源", member: "成员", support: "支援", band: "乐队条件", match: "匹配", mismatch: "不匹配", noBand: "无条件", unknown: "未知形状", gain: "Δ得分", ratio: "占无技能得分", seeds: "种子 / 交叉项", changes: "区间指标变化", converted: "转换数 Δ", + method: "计算方法", nominal: "标称概率期望", interval: "计算包围区间", more: (n) => `同形状的其他 ${n} 个技能`, warning: "标准误未达目标", deterministic: "确定性检验通过", zero: "理论最佳打法下无增量", onlyMeasures: "只改变指标", power: "换算综合力", defaultPower: (p) => `未填综合力时按测量综合力 ${p} 显示`, @@ -56,12 +57,15 @@ export const UI = { accuracy: "Just 率插值、Great 比例缩放都是近似。Just < 100% 且普通技能非零时,缺少 Perfect 交叉权重,不提供完整增量。", missingPerfect: "缺少 Perfect 交叉权重;完整增量不可用。", baseOnly: "无普通技能增量(近似)", rawScore: "原始无普通技能 Δ分(P₀、全 Just、第 1 名)", rawPerfect: "原始全 Perfect Δ分(测量综合力)", se: "± 仅附在原始采样统计量上,表示标准误,不是置信区间。名次、准率或普通技能改变后的合成结果没有协方差,不附 ±。展开区间指标可看原始 Δscore ± 标准误;未达目标针对原始 Δscore,调整参数不会重测。", + intervalNote: "方括号是原始期望值的计算包围区间,反映数值计算及导出精度;它不是标准误、置信区间或模型误差。使用模型中的独立标称概率确定性计算,重放种子不参与期望。改变名次、准率或普通技能后的近似结果不附原始区间。", + missingRank: "当前名次缺少已验证的区间交叉权重,无法换算;可恢复全部第 1 名查看原始期望。", rank1: "此项缺少区间交叉权重,交叉项仍按名次 1,不能精确跟随当前名次。", missingCross: "没有可用的普通技能交叉项;将普通技能倍率设为 0 可看基础增量。", raw: "以下指标及转换数是全 Just、无 Great 的原始增量,不随准率滑块变化;名次由你指定,指标增量不直接换算成更高名次。", factors: "谱面因子(不带技能)", judgedNotes: "区间判定数", justNotes: "Just 音符", perfectNotes: "Just 区间 Perfect 音符", tailNotes: "尾部音符", comboAtStart: "进入时连击", lotteries: "抽签次数", factorNote: "区间计数为 Start 帧之后至 End 帧;尾部为 End 至 Complete,受技能影响但不计入区间分。抽签次数取基线种子均值 ± 标准误。", + nominalFactorNote: "区间计数为 Start 帧之后至 End 帧;尾部为 End 至 Complete,受技能影响但不计入区间分。抽签次数为无技能基线的标称概率期望,方括号为计算包围区间。", }, allColumns: "全部列", allColumnsHint: "可左右滚动对比", frontier: "只看前沿", col: { @@ -97,6 +101,8 @@ export const UI = { span: "音符区间", musicLength: "谱面长度", timeline: "时间轴", fever: "Fever", skill: "技能", mission: "激走任务", composition: "音符构成", weights: "技能位权重", weightsHint: "第 k 位成员带一个 +100% 普通加分技能时整局多得的分数 ÷ 综合力(整局模拟:窗口、帧、连击都算在内;撃奏ライブ另含 Just 与所选名次的排名加成),取种子平均,并按准率缩放;百分比为占 W 的份额。哪位成员落在哪个位置每局随机,所以期望只用到 W。", + nominalWeightsHint: "第 k 位成员带一个 +100% 普通加分技能时整局增加的期望分数 ÷ 综合力,按模型中的独立标称概率计算,包含窗口、帧、连击、Just 与所选名次的排名加成;名次与准率调整为近似。百分比为占 W 的份额。技能顺序每局随机,所以顺序平均只用到 W;重放种子不参与此期望。", + nominal: "标称概率期望", interval: "计算包围区间", seeds: (n) => `${n} 个种子`, unplayable: "撃奏ライブ无法游玩", unplayableHint: "Fever 超过 3 个:从反编译代码看,游戏只保留 3 个激走区间,第 4 个 Fever 开始时会出错", unplayableFree: ";自由 Live 不开激走,可以照常游玩,切到自由 Live 可看它的数值", score: "得分(当前场景)", noFigures: "得分", twoScores: "撃奏ライブ的两套分数(每点综合力)", @@ -104,6 +110,7 @@ export const UI = { measures: "各区间的名次指标", mRange: "区间", mCompared: "比的指标", measure: { maxCombo: "最大连击", justCount: "Just 数", luckPoints: "幸运点数" }, measuresHint: "撃奏ライブ的每个区间按该区间任务的指标与房间内其他人比名次:连击任务比最大连击,幸运任务比幸运点数,Just 任务比 Just 数(加粗的一格)。按本模型,理论最佳打法、不带激走技能,取种子平均,括号内为种子间的最小–最大;“–”表示数据里没有这一项。本页不模拟对手,名次由你选定。", + nominalMeasuresHint: "各区间按最大连击、幸运点数或 Just 数比拼名次(加粗的一格)。数据采用理论最佳打法、不带激走技能,按模型中的独立标称概率计算;方括号内为期望值的计算包围区间,不是种子间的波动范围。“–”表示数据缺失。本页不模拟对手,名次由你选定。", orders: "顺序区间", sameOrder: "技能相同,顺序不影响得分", ranks: "评级门槛", rank: "评级", required: "所需分数", needPower: "所需综合力(期望)", needRange: "所需综合力(顺序区间)", chanceAt: (p) => `综合力 ${p} 达成率`, requiredRoom: (n) => `所需分数(${n} 人房间,每人)`, ranksHint: "门槛按歌定义,各难度共用;自由 Live 用单人门槛。所需综合力 = 门槛 ÷ 每点综合力的期望得分(已含准率)。", @@ -164,6 +171,7 @@ export const UI = { empty: "No skill variants apply to this chart's missions.", skill: "Skill / level", source: "Source", member: "Member", support: "Support", band: "Band condition", match: "matched", mismatch: "unmatched", noBand: "none", unknown: "Unknown shape", gain: "Score Δ", ratio: "Share of no-skill score", seeds: "Seeds / cross terms", changes: "Range measure changes", converted: "Conversions Δ", + method: "Method", nominal: "Nominal expectation", interval: "Numerical enclosure", more: (n) => `${n} other skills of this shape`, warning: "SE target not met", deterministic: "Passed determinism test", zero: "No gain in best play", onlyMeasures: "Measures only", power: "Power for conversion", defaultPower: (p) => `Empty power uses the measurement power ${p}`, @@ -171,12 +179,15 @@ export const UI = { accuracy: "Just interpolation and Great scaling are approximate. Below 100% Just with nonzero ordinary skills, Perfect cross weights are missing: no complete gain is provided.", missingPerfect: "Perfect cross weights missing; complete gain unavailable.", baseOnly: "No-ordinary-skill gain (approximate)", rawScore: "Raw no-ordinary-skill score Δ (P₀, all Just, rank 1)", rawPerfect: "Raw all-Perfect score Δ (measurement power)", se: "± labels raw sampled statistics only: standard error, not a confidence interval. Combined results after ranks, accuracy or ordinary skills change have no covariance data and no ±. Expand range measures for raw score gain ± SE. The target flag concerns original score gain; changing settings does not resample it.", + intervalNote: "Square brackets enclose the original expectation and reflect numerical calculation and export precision. They are not a standard error, confidence interval or model error. The calculation deterministically integrates the model's independent nominal probabilities; replay seeds do not enter the expectation. Approximations after ranks, accuracy or ordinary skills change carry no original enclosure.", + missingRank: "Validated range cross weights are unavailable at these ranks. Restore rank 1 everywhere to see the original expectation.", rank1: "No range cross weights: cross terms stay at rank 1, not exactly at the chosen ranks.", missingCross: "Ordinary-skill cross terms unavailable; set ordinary skills to 0 to see the base gain.", raw: "Measures and conversions below are raw all-Just, no-Great gains, unchanged by accuracy sliders. Ranks are chosen by you; higher measures do not directly become higher ranks.", factors: "Chart factors (no skills)", judgedNotes: "Range judged notes", justNotes: "Just notes", perfectNotes: "Perfects in Just ranges", tailNotes: "Tail notes", comboAtStart: "Entry combo", lotteries: "Lotteries", factorNote: "Range counts cover after Start through End; the tail covers after End through Complete, affected by skills but outside the range score. Lottery counts are the baseline seed mean ± standard error.", + nominalFactorNote: "Range counts cover after Start through End; the tail covers after End through Complete, affected by skills but outside the range score. Lottery counts are the no-skill nominal expectation; square brackets give its numerical enclosure.", }, allColumns: "All columns", allColumnsHint: "Scroll sideways to compare", frontier: "Frontier only", col: { @@ -211,6 +222,8 @@ export const UI = { bpmChanges: (n) => `${n} changes`, span: "Notes span", musicLength: "Chart length", timeline: "Timeline", fever: "Fever", skill: "Skill", mission: "Gekisou mission", composition: "Notes by kind", weights: "Skill position weights", weightsHint: "The score a +100 % plain score-up skill on the member at position k adds over the whole live, per point of power (whole-live simulation: windows, frames and combo included; in Gekisou Live also Just and the rank bonuses of the ranks chosen), averaged over the seeds and scaled by the accuracy; and its share of W. Which member lands on which position is drawn every live, so the expectation only uses W.", + nominalWeightsHint: "The expected score a +100% plain score-up skill at position k adds over the whole live, per point of power, under the model's independent nominal probabilities. This includes windows, frames, combo, Just and the chosen rank bonuses; rank and accuracy adjustments are approximate. Percentages show each position's share of W. Skill order is drawn every live, so its average uses W; replay seeds do not enter this expectation.", + nominal: "Nominal expectation", interval: "Numerical enclosure", seeds: (n) => `${n} seeds`, unplayable: "Unplayable in Gekisou Live", unplayableHint: "More than 3 fevers: by the decompiled code the game keeps three Gekisou ranges and fails when a fourth fever starts", unplayableFree: "; Free Live has no Gekisou and plays it as usual: switch to Free Live for its figures", score: "Score (current scenario)", noFigures: "Score", twoScores: "Gekisou Live's two scores (per point of power)", @@ -218,6 +231,7 @@ export const UI = { measures: "Rank measures per range", mRange: "Range", mCompared: "Ranked by", measure: { maxCombo: "Max combo", justCount: "Just count", luckPoints: "Luck points" }, measuresHint: "Every Gekisou Live range ranks the room by its mission's measure: combo missions by the max combo, luck missions by the luck points, Just missions by the Just count (the bold cell). By this model, in the theoretical best play without Gekisou skills: seed means, with the seeds' min–max in brackets; “–” where the data lacks the measure. The page does not model opponents and takes the ranks you pick.", + nominalMeasuresHint: "Each range ranks players by maximum combo, Luck points or Just count (the bold cell). Values use ideal play without Gekisou skills and the model's independent nominal probabilities. Square brackets enclose the calculated expectation, not the spread between replay seeds; “–” marks missing data. The page does not model opponents and takes the ranks you pick.", orders: "Order range", sameOrder: "Equal skills: the order does not matter", ranks: "Score ranks", rank: "Rank", required: "Score", needPower: "Power (expected)", needRange: "Power (order range)", chanceAt: (p) => `Chance at ${p}`, requiredRoom: (n) => `Score (room of ${n}, each)`, ranksHint: "Thresholds are the song's, shared by every difficulty; Free Live uses the solo thresholds. Power needed = threshold ÷ expected score per power (accuracy included).", @@ -340,7 +354,7 @@ export const REPLAY_UI = { export const GUIDE = { "zh": { "title": "定义、推导与成立条件", - "lead": "排行与适性展示理论最佳打法的统计参考;谱面详情还可逐音符计算给定输入的一局。两者使用同一套 ournotes-deck Rust 模型,原生逐帧核对的案例见下表。", + "lead": "排行与适性展示理论最佳打法的统计参考;谱面详情还可逐音符计算给定输入的一局。新 chart-stats/3 使用标称概率期望与计算包围区间,旧 music-data/1 的采样统计仍可读取。两者使用同一套 ournotes-deck Rust 模型,原生逐帧核对的案例见下表。", "sections": [ { "title": "得分模型", @@ -352,10 +366,11 @@ export const GUIDE = { "base = score / P₀, w_k = (位置 k 带因子 1 的技能时的得分 − score) / P₀, P₀ = 300000" ], "after": [ - "score 是模型算出的无技能得分(撃奏ライブ场景含区间排名加成),w_k 是第 k 位成员带一个因子 1(技能值 10000,即 +100%)的普通加分技能时整局多得的分数;两者都除以测量综合力 P₀。技能值换算成因子时有取整(⌊值/10000 × 10⁵⌋/10⁵),各音符得分也有取整,所以上式在取整范围内成立:deck 对每个种子用一副真实技能值的随机卡组、在另一个综合力(1000003)下整局重算,偏差超过上界的谱面直接报错;现有 340 张全部通过。这项核对只说明上式与模拟自洽,不能说明模拟与游戏一致。", + "score 是模型算出的无技能得分(撃奏ライブ场景含区间排名加成),w_k 是第 k 位成员带一个因子 1(技能值 10000,即 +100%)的普通加分技能时整局多得的分数;两者都除以测量综合力 P₀。技能值换算成因子时有取整(⌊值/10000 × 10⁵⌋/10⁵),各音符得分也有取整,所以上式在取整范围内成立:旧格式的 deck 对每个种子用一副真实技能值的随机卡组、在另一个综合力(1000003)下整局重算,偏差超过上界的谱面直接报错;该旧快照的 340 张全部通过。这项核对只说明上式与模拟自洽,不能说明模拟与游戏一致。", "每首歌有三个激走任务(连击、幸运或 Just,按歌定义,各难度共用),依次对应谱面的三个 Fever 区间。Just 判定只在 Just 任务区间内开启,所以三个任务里没有 Just 的歌(85 首中 41 首:21 首全是连击、20 首全是幸运)整局都没有 Just;三个任务全是 Just 的有 27 首,另有 17 首连击、幸运、Just 各一个。", "按本模型,激走的影响很大:与激走关闭相比,Expert 谱面的无技能得分高 1.6–4.7 倍(中位 2.4 倍)。主要来源是区间排名加成(MasterLiveGekisouRankingScoreBonus):每个完成的区间再加上该区间得分的一个百分比,按名次查表,默认按第 1 名计;三个激走任务相同的歌(85 首中 68 首)为 250%,三个任务各不相同的(17 首)为 370%,多人游玩时即使是第 5 名也有 100%。按第 1 名计,Expert 谱面的 base 中有 36%–69%(中位 53%)来自它。其次是 Just 任务区间内的 Just 判定(230%,Perfect 为 100%):区间长、Just 音符多的谱面倍数最高,4.7 倍的那张三个 Just 区间共约 52 秒、233 个 Just 音符;连击任务区间的激走连击系数在没有激走技能时影响很小(约 1%)。技能落在激走区间内时,排名加成随之放大,所以区间内的技能位权重明显更高。", - "幸运任务区间的抽签与幸运冲刺取自本局的随机种子。含幸运区间的谱面(340 张中 148 张)给出前 8 个公开种子的结果,本页取种子平均,并在详情里给出种子间 base 的区间(中位相差 1.3%,最大 4.2%)。游戏的种子规律未知,所以种子平均未必等于游戏内的真实期望。" + "数据格式决定统计含义。chart-stats/3 的 expectation 按模型中的独立标称抽签与技能概率确定性计算,每项 [中心值, 向外包围半径] 给出计算期望的区间,包含数值计算及导出精度;不是标准误、置信区间或实机误差。replaySeeds 仅是可重放的单局样本,改变其数量或内容不会改变期望。旧 music-data/1 的 seeds 则取样本平均,详情区间表示样本最小–最大。游戏的种子规律未知,两种参考都不承诺等于游戏中的真实分布。", + "本节及后续涉及 85 首歌、340 张谱面的数值举例来自旧采样数据快照,不代表当前曲目总数或新期望值的重新测量。该旧快照有 148 张含幸运区间,使用前 8 个公开种子;base 的样本范围中位相差 1.3%、最大 4.2%。新格式的公式核对改用完整期望及取整上界,重放样本数量不参与核对值的期望计算。" ] }, { @@ -365,11 +380,12 @@ export const GUIDE = { "单局 JSON 可给出固定 Solo 名次,由完整引擎结算;外部排名输入则明确给出确认帧、区间、名次与加成百分比,采用客户端的帧快照计分。两种方式不同。本页不模拟对手、网络延迟或服务器裁定。" ], "math": [ - "base_r = ( score − Σ_i B_i + Σ_i trunc( RS_i · p_i(r_i) / 100 ) ) / P₀", + "chart-stats/3: base_r ≈ ( E[score] + Σ_{i: r_i ≠ 1} ( E[RS_i] · p_i(r_i) / 100 − E[B_i] ) ) / P₀", + "旧单种子: base_r = ( score − Σ_i B_i + Σ_i trunc( RS_i · p_i(r_i) / 100 ) ) / P₀", "w_r[k] = w[k] + Σ_i ( p_i(r_i) − p_i(1) ) / 100 · u_i[k]" ], "after": [ - "RS_i 是区间 i 的得分,p_i(r) 是区间 i 在名次 r 时的加成百分比,B_i = trunc(RS_i · p_i(1) / 100) 是名次 1 的加成(现有数据的每个种子、每个区间都满足这个等式),u_i[k] 是位置 k 的技能让区间 i 多得的分数 ÷ P₀。依据:从反编译代码看,加成在区间结束时一次性记入,不改变之后音符的得分系数;名次只影响一种技能条件(7012),现有主数据里没有技能用到它。所以按本模型,换名次只改变加成这一项,w_r 的误差来自技能加成的取整,小于 3/P₀。deck 另用随机名次整局实跑核对了这组线性式,现有数据全部落在误差上界内。按本模型,三个区间全是第 1 名时的无技能得分是全是第 5 名时的 1.25–1.65 倍(340 张谱面,中位 1.46 倍)。", + "RS_i 是区间得分,p_i(r) 是该名次的加成百分比,B_i 是第 1 名的实际结算加成,u_i[k] 是位置 k 的技能让该区间增加的分数 ÷ P₀。旧格式先逐种子计算截断加成再平均。新格式保留所有仍为第 1 名区间的已计算 E[B_i],仅替换改变名次的区间;不能用 trunc(E[RS_i] · p/100) 冒充 E[trunc(RS_i · p/100)]。页面按实数乘积作线性参考,不把原始包围区间贴在变换结果上。只有数据声明的线性范围可换算权重;激走适性缺少 rangeWeights 时,不提供非第 1 名的结果。原始固定名次及取整界限由数据生成器的完整模型核对。旧快照中三个区间全第 1 名的无技能分数为全第 5 名的 1.25–1.65 倍(340 张谱面,中位 1.46 倍)。", "自由 Live 场景的数值来自另一次激走关闭的整局模拟,不是从撃奏ライブ减去加成得到的:关闭激走时也没有 Just、激走连击系数和幸运冲刺。关闭激走时没有幸运抽签,本页用到的技能也没有概率条件,所以每张谱面只有一个种子;Fever 超过 3 个、撃奏ライブ无法游玩的谱面,在自由 Live 场景照常计算。", "从反编译代码看,撃奏ライブ同时上报两套分数:一套含全部激走效果(Just、幸运、激走连击、排名加成和激走技能),本页撃奏ライブ场景的数值对应这一套(暂不含激走技能,第 7 节);另一套不含任何激走效果(激走技能也不计),存为歌曲的最高分,按本模型约等于自由 Live 的分数。谱面详情里两个数字都列出。服务器怎样使用这两套分数(例如计算活动积分),客户端代码里看不到。" ] @@ -385,7 +401,7 @@ export const GUIDE = { ], "after": [ "推论一:期望只取决于技能平均值 x̄,与技能在成员间如何分配、谁在第几位无关。“最强技能放在权重最高的位置”只是 120 种排列中最好的一种,是上界,不是期望。", - "推论二:离散度等于技能离散度与位置权重离散度的乘积;5 个技能相同时,每一局得分都相同。按本模型,以技能 [140, 100, 60, 30, 0]% 为例,在全部 Expert 谱面上,最好与最差排列相差中位 15.4%、最大 29.2%,最佳排列比期望高中位 7.6%;激走区间让位置权重差距拉大,顺序的影响远大于不计激走时。页面给出期望、120 种排列的区间和 P10。", + "推论二:顺序离散度等于技能离散度与位置权重离散度的乘积;5 个技能相同时,排列不改变这里的参考得分,单局仍可能有幸运等随机波动。旧快照以技能 [140, 100, 60, 30, 0]% 为例,在全部 Expert 谱面上,最好与最差排列相差中位 15.4%、最大 29.2%,最佳排列比平均高中位 7.6%。页面的区间和 P10 仅描述 120 种顺序的参考值;新格式每种顺序已经对标称概率取期望,不代表所有单局结果的分布。", "单局计算不取 120 种顺序的平均。JSON 明确列出成员、skillOrder 与 seed;技能事件依次映射到该顺序,成员属性与配对支援仍随成员记录。相同数据与输入可以重放同一局。" ] }, @@ -418,7 +434,7 @@ export const GUIDE = { "after": [ "歌曲只通过评级进入积分,所以每个 boost 换到的积分与选哪首歌无关;歌曲的差别只在于各评级的达成概率和一局的时长。活动支配只要求 v 随评级不减,不需要知道积分表:满足上式时,任何卡组达成任何评级在 a 上所需的综合力都不高于 b,而 a 不更长。", "积分表可以反推,不必等主数据:一局的加成和倍率已知,所以 v = 积分 × 10000 / ((10000 + 加成) × 倍率),截断带来的误差小于 1/倍率;每个评级一条样本就能唯一确定 v。", - "达成率是在 120 种技能顺序上 P · S_π ≥ R 成立的比例(S 为所选场景的理论最佳基线);所需综合力取期望,区间取最好与最差排列。按本模型,技能全 140%、撃奏ライブ名次 1、5 人房间时,Expert 达到 SS 所需综合力从 27 万到 205 万,相差 7.5 倍。SS 门槛对本模型的得分容量做过原点的最小二乘拟合,中位相对残差约 32%:门槛看来不是按容量定的,所以活动选曲最好逐曲计算,不宜直接拿效率排行代替。85 首歌里有 20 首的 Hard 比 Expert 更容易拿到 SS。" + "达成率是在 120 种技能顺序上 P · S_π ≥ R 成立的比例(S 为所选场景的理论最佳参考值),不包含单局幸运波动,也不由数值包围区间推断概率;所需综合力取期望,区间取最好与最差排列。按本模型,技能全 140%、撃奏ライブ名次 1、5 人房间时,Expert 达到 SS 所需综合力从 27 万到 205 万,相差 7.5 倍。SS 门槛对本模型的得分容量做过原点的最小二乘拟合,中位相对残差约 32%:门槛看来不是按容量定的,所以活动选曲最好逐曲计算,不宜直接拿效率排行代替。85 首歌里有 20 首的 Hard 比 Expert 更容易拿到 SS。" ] }, { @@ -442,18 +458,19 @@ export const GUIDE = { "建模了什么。成员卡带激走技能,与它配对的支援卡带激走支援技能。从反编译代码看,它们只在激走开启时建立,只加进含激走的那套分数(battleLiveScore),不进不含激走的那套(soloScore),自由 Live 里也没有。每个技能属于一种激走任务(连击、幸运或 Just),只在同任务的区间里触发,与演奏位置和洗牌无关;支援技能可以带乐队条件(条件 5000),看配对的成员是否属于指定乐队。效果按类型有激走连击加成、幸运槽倍率、加幸运点、加满槽、Just 数加成与累计 Just、幸运冲刺期间的得分加成、每 10 连击或每个 Just 的加分,以及连击保护、Great 转 Perfect、转 Just、放宽 Just 判定等,ournotes-deck 在整局模拟里逐帧计算这些效果。", "名次与判定。固定名次的统计参考使用第 2 节的区间加成公式;实际单局按给定判定流运行。判定转换、每个 Just 加分与累计 Just 可能改变后续状态,不能用平均准率缩放这些效果。", "理论最佳基线不能体现所有技能的收益。连击保护与判定转换需要对应的断连或判定输入,窗口放宽需要原始判定元数据;只提高任务指标的技能可能帮助真实对局取得更高名次,但固定名次参考不会自动改变名次。可用单局 JSON 核对具体输入下的效果。", - "种子。幸运区间的抽签取自本局的随机种子,幸运类激走技能的效果也随种子变化,所以带激走技能时幸运区间的种子间差别可能更大。页面取所给种子的平均,这不是游戏里的期望,游戏的种子规律未知。演奏位置每局洗牌,但激走技能与位置无关;洗牌只改变单个种子里概率判定的抽取次序,不改变期望。", + "概率与重放。chart-stats/3 对模型中的独立标称抽签和技能概率计算期望;replaySeeds 用于单局对照,不作为期望的样本。旧 music-data/1 仍读取所给种子的平均与标准误。实际游戏的种子规律未知;单局 JSON 则始终按明确种子重放。演奏位置每局洗牌,单个种子的概率抽取顺序可能随之变化。", "原生覆盖按场景记录在验证表中。普通技能和不带卡上激走技能的更新链已有逐帧证据;这不代表每种卡上激走技能及所有组合都已验证。未核对的效果和条件仍是未核对。", "适性只测单个技能。每个成员卡激走技能取最高等级,每个支援卡激走支援技能取最高突破时的等级;计分参数相同的技能合并成形状,乐队条件分匹配与不匹配。成员技能由一名演奏者单独携带;支援技能搭配同任务、无效果的合成空激走技能,统一测量,不借用真卡。数据缺失时显示“数据待更新”,不会当作零。排行与图表的默认基线仍不带卡上激走技能。", - "适性增量由同一随机种子下带与不带单个技能的整局相减得到。理论最佳、名次 1 使用 score 增量均值;普通技能倍率与名次调整使用已导出的权重和区间增量,是受取整与线性假设限制的参考。具体技能与混合判定的一局使用第 6 节的完整引擎。", + "新格式的适性增量为带与不带单个技能的完整标称期望之差;旧格式则对同种子整局差值取平均。理论最佳、名次 1 直接使用已导出的 score 增量,保留排名加成的期望。只在数据具有已验证的区间交叉权重时调整名次,且仅替换改变名次区间的加成;普通技能倍率、名次和准率调整是受取整与线性假设限制的参考。具体技能与混合判定的一局使用第 6 节的完整引擎。", "多个技能的增量不能相加:激走连击系数会饱和,幸运冲刺支援与幸运槽技能存在交互,判定转换也可能改变其它效果的触发;这些不能用单技能增量拼出整副编成。适性是单个技能的响应,不是组卡结果。谱面因子列出区间判定音符、Just 音符、Just 区间只能 Perfect 的音符、End 至 Complete 的尾部音符、开始时连击及无技能抽签次数,用来解释差异。", - "每项 [均值, 标准误] 的标准误是样本标准差除以种子数的平方根,不是模型误差。先检查随机依赖,再做四种子探测;四次相等不能单独证明确定性。随机变体从 32 个种子逐批加倍,护栏为 65536;理论最佳与全 Perfect 两端的 score 增量都须达到目标:增量绝对值的 1% 与同种子无技能基线的 0.1% 中较宽者。可能只满足基线目标,不表示增量达到 1%。正式产物要求两端都达标,护栏内未收敛就拒绝生成。交叉项最多取前 64 个种子,缺少协方差,不能当作独立误差合成。", + "chart-stats/3 的每项 [中心值, 包围半径] 来自确定性概率计算,原始分数、增量、交叉权重和抽签次数都使用这套含义。它没有采样标准误、种子收敛标记或采样预算。页面只在未改变名次、准率与普通技能的原始无普通技能增量上展示包围区间;换算后的均值不冒用原区间。", + "旧采样格式才使用 [均值, 标准误],其中标准误为样本标准差除以种子数平方根。旧生成器先检查随机依赖与四种子探测,随机变体从 32 逐批加倍至 65536,要求理论最佳和全 Perfect 两端达到增量绝对值 1% 与基线 0.1% 中较宽的目标;交叉项最多 64 个种子。旧 SE 不是模型误差,缺少协方差时也不能当作独立误差合成。这些采样规则不用于新格式。", "适性没有全 Perfect 打法的普通技能交叉权重。缺少对应权重的统计调整只能作有限精度参考;任意判定与多技能组合须交给单局计算,不能拼接端点增量。" ], "defs": [ [ "概率发动(条件 4011)", - "模型按技能随机流抽取 float32 值,再与发动门槛比较。当前主数据的条件 4011 用于幸运激走成员技能 11003 与支援技能 11005。两组真实卡与支援的原生整局核对已通过,包含发动、未发动及 5% 概率成功分支,结果见下表。给定种子的单局结果由完整模型计算;种子平均与标准误用于描述适性。普通技能输入表示无条件加分,不能用 p × 技能值替代概率效果。" + "单局模型按技能随机流抽取 float32 值,再与发动门槛比较。已核对的主数据中,条件 4011 用于幸运激走成员技能 11003 与支援技能 11005;两组真实卡与支援的原生整局核对包含发动、未发动及 5% 概率成功分支,见下表。给定种子的单局由完整模型重放;新适性按独立标称概率求期望,旧适性使用种子平均与标准误。普通技能输入表示无条件加分,不能用 p × 技能值替代概率效果。" ] ] }, @@ -622,7 +639,7 @@ export const GUIDE = { }, "en": { "title": "Definitions, derivations and conditions", - "lead": "Rankings and aptitude show statistical references for theoretical best play. Chart details can also calculate one play from per-note inputs. Both use the same ournotes-deck Rust model; the native frame checks are listed below.", + "lead": "Rankings and aptitude show statistical references for theoretical best play. Chart details can also calculate one play from per-note inputs. New chart-stats/3 data uses nominal expectations and numerical enclosures; legacy music-data/1 samples remain readable. Both use the same ournotes-deck Rust model; the native frame checks are listed below.", "sections": [ { "title": "Score model", @@ -634,10 +651,11 @@ export const GUIDE = { "base = score / P₀, w_k = (score with a factor-1 skill at position k − score) / P₀, P₀ = 300000" ], "after": [ - "score is the model's no-skill score (in Gekisou Live with the range rank bonuses), w_k the score a factor-1 plain skill (value 10000, +100 %) on the member at position k adds over the whole live; both per point of the measurement power P₀. The value becomes a factor with a floor (⌊value/10000 × 10⁵⌋/10⁵) and every note score is floored, so the formula holds up to the floors: for every seed deck plays a random deck of real skill values at another power (1000003) through the whole live, and a chart whose deviation exceeds the bound fails. All 340 current charts pass. This check only shows that the formula agrees with the simulation, not that the simulation agrees with the game.", + "score is the model's no-skill score (in Gekisou Live with the range rank bonuses), w_k the score a factor-1 plain skill (value 10000, +100 %) on the member at position k adds over the whole live; both per point of the measurement power P₀. The value becomes a factor with a floor (⌊value/10000 × 10⁵⌋/10⁵) and every note score is floored, so the formula holds up to the floors: in the legacy format, for every seed deck plays a random deck of real skill values at another power (1000003) through the whole live, and a chart whose deviation exceeds the bound fails. All 340 charts in that legacy snapshot pass. This check only shows that the formula agrees with the simulation, not that the simulation agrees with the game.", "Every song has three Gekisou missions (combo, luck or Just, set per song and shared by every difficulty), one for each of the chart's three fevers in order. Just judgements are only on inside Just mission ranges, so the songs without a Just mission (41 of 85: 21 all combo, 20 all luck) have no Just all live; 27 songs have three Just missions, and 17 have one each of combo, luck and Just.", "By this model Gekisou weighs a lot: against Gekisou off, an Expert chart's no-skill score is 1.6–4.7 times higher (median 2.4). Most of it is the range rank bonus (MasterLiveGekisouRankingScoreBonus): every completed range adds a percentage of its own score, looked up by rank, rank 1 by default; that is 250 % on the songs whose three missions are the same (68 of 85) and 370 % on those whose three missions all differ (17), and in multiplayer even rank 5 gets 100 %. At rank 1 it makes 36–69 % (median 53 %) of an Expert chart's base. Next come the Just judgements inside Just mission ranges (230 %, against 100 % for a Perfect): charts with long ranges and many Just notes gain the most, and the 4.7 times chart has three Just ranges of about 52 s in all with 233 Just notes; the Gekisou combo factor of combo mission ranges matters little without Gekisou skills (about 1 %). A skill inside a Gekisou range raises the rank bonus too, so the positions inside ranges weigh much more.", - "Luck mission ranges draw their lottery and luck rushes from the live's random seed. Charts with a luck range (148 of 340) come with the first 8 published seeds; the page takes the mean over the seeds and shows the seeds' base range in the details (1.3 % apart at the median, 4.2 % at most). The game's seed law is unknown, so the seed mean need not equal the game's own expectation." + "The data format determines the statistics. chart-stats/3 deterministically integrates the model's independent nominal lottery and skill probabilities. Each expectation estimate is [center, outward enclosure half-width], accounting for numerical calculation and export precision, not a standard error, confidence interval or device error. replaySeeds are individual examples only: their count and contents do not change the expectation. Legacy music-data/1 instead averages seeds and displays their min–max. The game's seed law is unknown, so neither reference promises the game's actual distribution.", + "Numerical examples mentioning 85 songs and 340 charts in this guide come from a legacy sampled snapshot, not the current catalog count or a new nominal measurement. Of those charts, 148 had Luck ranges and used the first 8 published seeds; their base ranges differed by 1.3% at the median and 4.2% at most. New formula checks use complete expectations and flooring bounds; replay sample count does not enter those expectations." ] }, { @@ -647,11 +665,12 @@ export const GUIDE = { "A single-play JSON can specify fixed Solo ranks and let the complete engine settle them. External ranking instead supplies the confirmation frame, range, group rank and bonus percentage, using client frame snapshots. These are different inputs. The page does not simulate opponents, network delays or server decisions." ], "math": [ - "base_r = ( score − Σ_i B_i + Σ_i trunc( RS_i · p_i(r_i) / 100 ) ) / P₀", + "chart-stats/3: base_r ≈ ( E[score] + Σ_{i: r_i ≠ 1} ( E[RS_i] · p_i(r_i) / 100 − E[B_i] ) ) / P₀", + "Legacy single seed: base_r = ( score − Σ_i B_i + Σ_i trunc( RS_i · p_i(r_i) / 100 ) ) / P₀", "w_r[k] = w[k] + Σ_i ( p_i(r_i) − p_i(1) ) / 100 · u_i[k]" ], "after": [ - "RS_i is range i's score, p_i(r) range i's bonus percent at rank r, B_i = trunc(RS_i · p_i(1) / 100) the rank-1 bonus (every range of every seed of the current data meets this), and u_i[k] the score the skill at position k adds to range i, per P₀. Why: by the decompiled code the bonus is added once at the range's end and leaves the score factors of the notes after it alone, and the rank reaches one skill condition only (7012), which no skill of the current master data uses. So by this model a rank changes the bonus term only; w_r is off by the skill floors, less than 3/P₀. deck also runs whole lives at random ranks against these formulas, and the current data stays within the error bound. By this model the no-skill score at rank 1 in all three ranges is 1.25–1.65 times that at rank 5 (340 charts, median 1.46).", + "RS_i is a range score, p_i(r) its bonus percent at rank r, B_i its settled rank-1 bonus, and u_i[k] the extra range score from position k's skill per P₀. Legacy data truncates each seed's bonus before averaging. New data retains the calculated E[B_i] wherever rank 1 is kept and replaces only changed ranges. trunc(E[RS_i] · p/100) must not stand in for E[trunc(RS_i · p/100)]. The page uses a real-valued linear approximation and assigns it no original numerical enclosure. Weights can be adjusted only in the declared linear domain; nominal aptitude without rangeWeights is unavailable at other ranks. The generator checks original fixed-rank results and flooring bounds with the complete model. In the old snapshot, rank 1 everywhere scored 1.25–1.65 times rank 5 (340 charts, median 1.46).", "The Free Live figures come from a separate whole-live simulation with Gekisou off, not from Gekisou Live minus the bonuses: without Gekisou there are no Just judgements, Gekisou combo factor or luck rushes either. Without Gekisou there is no luck lottery, and the page's skill has no probability condition, so every chart has one seed; charts with more than 3 fevers, unplayable in Gekisou Live, are computed as usual in Free Live.", "By the decompiled code, Gekisou Live reports two scores: one with every Gekisou effect (Just, luck, the Gekisou combo, the rank bonuses and the Gekisou skills), which the page's Gekisou Live figures stand for (without the Gekisou skills for now, section 7); the other without any Gekisou effect (Gekisou skills included), kept as the song's best score, by this model about the Free Live score. The chart details show both. How the server uses the two (for event points, say) is not in the client code." ] @@ -667,7 +686,7 @@ export const GUIDE = { ], "after": [ "First: the expectation depends on the mean skill value only, not on how the values are spread over members or who stands where. The strongest skill on the heaviest position is the best of the 120 orders, an upper bound, not the expectation.", - "Second: the spread is the product of the skills' spread and the position weights' spread; five equal skills score the same every live. By this model, with skills [140, 100, 60, 30, 0] % over every Expert chart, the best and the worst order differ by 15.4 % at the median and 29.2 % at most, and the best order is 7.6 % (median) above the expectation; the Gekisou ranges pull the position weights apart, so the order matters far more than without Gekisou. The page shows the expectation, the range over the 120 orders and P10.", + "Second: order spread is the product of the skills' spread and the position weights' spread. With five equal skills, order leaves this reference score unchanged; a play can still vary with Luck and other randomness. In the old Expert snapshot, skills [140, 100, 60, 30, 0]% gave a best–worst difference of 15.4% at the median and 29.2% at most, with the best order 7.6% above the mean at the median. The page's range and P10 describe only the 120 reference order values. In the new format each order already averages nominal probabilities; this is not the distribution of all individual plays.", "A single play does not average the 120 orders. Its JSON specifies performers, skillOrder and seed. Chart skill events map to that order; member attributes and paired supports remain with their performer record. The same data and inputs replay the same play." ] }, @@ -700,7 +719,7 @@ export const GUIDE = { "after": [ "The song enters the points only through the rank, so the points a boost buys do not depend on the song; songs differ only in their rank chances and their length. Event dominance needs no point table, only v non-decreasing in the rank: when it holds, any deck needs no more power on a than on b for any rank, and a is no longer.", "The table can be recovered without the master data: bonus and rate of a live are known, so v = points × 10000 / ((10000 + bonus) × rate) with a truncation error below 1/rate; one result per rank determines v.", - "The chance is the share of the 120 skill orders with P · S_π ≥ R (S from the selected theoretical-best baseline); the power needed uses the expectation, its range the best and the worst order. By this model, with every skill at 140 %, in Gekisou Live at rank 1 in a room of 5, SS on Expert needs 0.27 to 2.05 million power, a factor 7.5. A least-squares fit through the origin of the SS thresholds against this model's score capacity leaves a median relative residual of about 32 %: the thresholds do not seem to be set by capacity, so event choices are best computed song by song rather than read straight off the efficiency ranking. For 20 of the 85 songs SS is easier on Hard than on Expert." + "The chance is the share of the 120 skill orders with P · S_π ≥ R (S from the selected theoretical-best reference). It excludes individual-play Luck variation, and a numerical enclosure does not determine a probability; the power needed uses the expectation, its range the best and the worst order. By this model, with every skill at 140 %, in Gekisou Live at rank 1 in a room of 5, SS on Expert needs 0.27 to 2.05 million power, a factor 7.5. A least-squares fit through the origin of the SS thresholds against this model's score capacity leaves a median relative residual of about 32 %: the thresholds do not seem to be set by capacity, so event choices are best computed song by song rather than read straight off the efficiency ranking. For 20 of the 85 songs SS is easier on Hard than on Expert." ] }, { @@ -724,18 +743,19 @@ export const GUIDE = { "What is modelled. Member cards carry Gekisou skills, and the support cards paired with them Gekisou support skills. By the decompiled code they are only set up with Gekisou on and only add to the score with Gekisou (battleLiveScore), never to the one without (soloScore), and Free Live has none of them. Every skill belongs to one Gekisou mission (combo, luck or Just) and only triggers in the ranges of that mission, independent of the performance position and the shuffle; a support skill can carry a band condition (condition 5000) on whether its paired member is in a given band. By type the effects are the Gekisou combo bonus, the luck gauge multiplier, luck points, a full gauge, the Just count bonus and cumulative Just, a score up during luck rushes, points per 10 combo or per Just, and combo protection, Great to Perfect, conversion to Just, a looser Just judgement and a few more; ournotes-deck computes them frame by frame in the whole-live simulation.", "Ranks and judgements. Fixed-rank statistical references use the range bonus formula in section 2; a single play runs its declared judgement stream. Conversion, per-Just scoring and cumulative Just can change later state, so an average accuracy multiplier does not calculate these effects.", "A theoretical-best baseline cannot show every skill’s benefit. Protection and conversion need the relevant breaks or grades, and wider windows need original result metadata. A skill that raises mission measures may improve a real placement, but a fixed-rank reference does not change its placement automatically. Single-play JSON can check a concrete input.", - "Seeds. Luck ranges draw from the live's random seed, and so do the luck Gekisou skills' effects, so with Gekisou skills the luck ranges may spread wider between seeds. The page takes the mean over the seeds given, which is not the game's own expectation; the game's seed law is unknown. The performance positions are shuffled every live, but Gekisou skills do not depend on the position; the shuffle only reorders a seed's probability draws and leaves the expectation alone.", + "Probabilities and replay. chart-stats/3 integrates the model's independent nominal lottery and skill probabilities. replaySeeds are comparisons of individual plays, not expectation samples. Legacy music-data/1 still reads the supplied seed means and standard errors. The game's seed law is unknown; single-play JSON always replays an explicit seed. Shuffling performance positions can change the draw order of one seeded play.", "Native coverage is now documented by case in the validation table. Ordinary-skill and no-card-Gekisou update chains have frame comparisons; this does not certify every card Gekisou skill or every combination. Unchecked effects and conditions remain unchecked.", "Aptitude measures one skill at a time. Member Gekisou skills use their highest level, support skills their highest limit-break level. Equal scoring parameters share a shape; band conditions have matched and unmatched variants. One performer carries a member skill alone; a support skill uses a synthetic empty Gekisou skill of its mission, not a real card. Missing data is pending, not zero. The ranking and chart baseline still carries no Gekisou skills.", - "Aptitude subtracts complete runs with and without one skill on the same seed. The theoretical-best rank-1 value uses the mean score increment. Ordinary skill and rank adjustments use exported weights and range increments, as references limited by rounding and linear assumptions. A concrete mix of skills and judgements uses the complete engine in section 6.", + "New aptitude subtracts complete nominal expectations with and without one skill. Legacy aptitude averages differences from matched seeds. The theoretical-best rank-1 value uses the exported score increment and retains the expected rank bonus. Ranks change only when validated range cross weights are available, replacing bonuses in changed ranges only. Ordinary skill, rank and accuracy adjustments remain references limited by flooring and linear assumptions. A concrete mix of skills and judgements uses the complete engine in section 6.", "Gains of several skills cannot be added: the Gekisou combo factor saturates, rush supports interact with luck-gauge skills, and judgement conversion can change other effects; single-skill increments do not reconstruct a whole formation. Aptitude is a single-skill response, not a formation result. Chart factors give judged notes, Just notes, notes that can only be Perfect in Just ranges, tail notes from End to Complete, starting combo and no-skill lotteries to help explain differences.", - "Each [mean, standard error] uses sample standard deviation divided by the square root of the seed count; SE is not model error. Random dependencies are checked before the four-seed probe, and four equal results alone do not prove determinism. Random variants double from 32 seeds up to a guard of 65536. Both theoretical-best and all-Perfect score increments must meet the larger of 1% of the absolute increment and 0.1% of the same-seed no-skill baseline. Meeting the baseline target need not mean 1% relative precision. A formal artifact requires both endpoints to pass; failure to converge within the guard rejects generation. Cross terms use at most the first 64 seeds; covariance is unavailable, so errors cannot be combined as independent.", + "Every chart-stats/3 [center, enclosure half-width] comes from deterministic probability calculations. Raw scores, gains, cross weights and lottery counts share that meaning. There is no sampling standard error, seed convergence flag or sampling budget. The page attaches an enclosure only to the original no-ordinary-skill gain, without rank or accuracy changes; transformed centers do not inherit it.", + "Only legacy sampled data uses [mean, standard error], with sample standard deviation divided by the square root of the seed count. Its generator checked random dependencies and four-seed probes, doubled random variants from 32 up to 65536, and required both best and Perfect endpoints to meet the larger of 1% of the gain or 0.1% of the baseline. Cross terms used at most 64 seeds. Legacy SE is not model error, and absent covariance prevents combining errors as independent. Those sampling rules do not apply to the new format.", "All-Perfect ordinary-skill cross weights are unavailable. Statistical adjustments without corresponding weights remain limited-precision references; arbitrary judgements and multiple skills use single-play calculation, rather than combined endpoint increments." ], "defs": [ [ "Probability activation (condition 4011)", - "The model compares a float32 draw from the skill random stream with the activation threshold. In the current master, condition 4011 is used by luck Gekisou member skill 11003 and support skill 11005. Two native whole-live checks with real cards and supports passed, covering activation, rejection and a successful 5% branch; see the table below. The full model calculates one live for a given seed; seed means and standard errors describe aptitude. Ordinary skill inputs are unconditional score up: p × value cannot substitute for probability effects." + "The single-play model compares a float32 draw from the skill random stream with the activation threshold. In the checked master, condition 4011 is used by Luck member skill 11003 and support skill 11005. Two native whole-live checks with real cards and supports cover activation, rejection and a successful 5% branch; see the table. The full model replays a given seed; new aptitude integrates independent nominal probabilities, while legacy aptitude uses seed means and standard errors. Ordinary skill inputs are unconditional score up: p × value cannot substitute for probability effects." ] ] }, diff --git a/tests/fixtures/chart-stats-v3.json b/tests/fixtures/chart-stats-v3.json new file mode 100644 index 0000000..ad12018 --- /dev/null +++ b/tests/fixtures/chart-stats-v3.json @@ -0,0 +1 @@ +{"charts":[{"convertedNoteCount":84,"difficulty":"easy","events":[[0,19555],[1,48888],[2,77333],[3,63111],[4,34666]],"expectation":{"check":{"bound":6285.590357382665,"deck":[[2,5000],[1,15000],[1,8000],[0,8000],[3,14000]],"expected":[8226795.0,0.001],"predicted":[8226357.679,0.001],"ranks":null},"rangeWeights":[[[[0.166736666667,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.09816,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.2001,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]]],[[[0.166736666667,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.09816,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.2001,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]]],[[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]]],[[[0.166736666667,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.09816,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.2001,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]]]],"ranges":[{"justCount":2,"lotResults":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"luckPoints":[0.0,0.0],"maxCombo":19,"rangeScore":[73072.0,0.001],"rangeScorePerfect":[58934.0,0.001],"rankBonus":[270366.0,0.0],"rankBonusPerfect":[218055.0,0.0]},{"justCount":0,"lotResults":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"luckPoints":[0.0,0.0],"maxCombo":11,"rangeScore":[52273.0,0.001],"rangeScorePerfect":[52273.0,0.001],"rankBonus":[193410.0,0.0],"rankBonusPerfect":[193410.0,0.0]},{"justCount":0,"lotResults":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"luckPoints":[0.0,0.0],"maxCombo":15,"rangeScore":[115650.0,0.001],"rangeScorePerfect":[115650.0,0.001],"rankBonus":[427905.0,0.0],"rankBonusPerfect":[427905.0,0.0]}],"rankCheck":{"bound":6494.896540299357,"deck":[[2,5000],[1,15000],[1,8000],[0,8000],[3,14000]],"expected":[7303120.0,0.001],"predicted":[7302745.908,0.001],"ranks":[4,1,2]},"score":[1888157.0,0.001],"scorePerfect":[1807709.0,0.001],"weights":[[[0.866216666667,1e-12],[0.499036666667,1e-12],[0.94047,1e-12],[0.306203333333,1e-12],[0.133273333333,1e-12]],[[0.866216666667,1e-12],[0.499036666667,1e-12],[0.94047,1e-12],[0.306203333333,1e-12],[0.133273333333,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.866216666667,1e-12],[0.499036666667,1e-12],[0.94047,1e-12],[0.306203333333,1e-12],[0.133273333333,1e-12]]]},"gekisouAptitude":{"factors":[{"comboAtStart":6,"judgedNotes":18,"justNotes":1,"lotteries":[0.0,0.0],"perfectNotes":17,"tailNotes":0},{"comboAtStart":56,"judgedNotes":10,"justNotes":0,"lotteries":[0.0,0.0],"perfectNotes":0,"tailNotes":0},{"comboAtStart":130,"judgedNotes":15,"justNotes":0,"lotteries":[0.0,0.0],"perfectNotes":0,"tailNotes":1}],"variants":[{"bandMatch":null,"check":{"bound":4739.492703503674,"deck":[[0,8500],[0,8000],[0,8500],[0,9000],null],"expected":[6870060.0,0.001],"predicted":[6869770.876,0.001],"ranks":[3,5,3]},"converted":[0.0,0.0],"rangeWeights":[[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]]],"ranges":[{"justCount":[2.0,0.0],"luckPoints":[0.0,0.0],"maxCombo":[0.0,0.0],"rangeScore":[0.0,0.001],"rangeScorePerfect":[0.0,0.001],"rankBonus":[0.0,0.0],"rankBonusPerfect":[0.0,0.0]},{"justCount":[0.0,0.0],"luckPoints":[0.0,0.0],"maxCombo":[0.0,0.0],"rangeScore":[0.0,0.001],"rangeScorePerfect":[0.0,0.001],"rankBonus":[0.0,0.0],"rankBonusPerfect":[0.0,0.0]},{"justCount":[0.0,0.0],"luckPoints":[0.0,0.0],"maxCombo":[0.0,0.0],"rangeScore":[0.0,0.001],"rangeScorePerfect":[0.0,0.001],"rankBonus":[0.0,0.0],"rankBonusPerfect":[0.0,0.0]}],"score":[0.0,0.001],"scorePerfect":[0.0,0.001],"shape":0,"tail":[0.0,0.001],"tailPerfect":[0.0,0.001],"weights":[[0.0,1e-12],[-0.0,1e-12],[0.0,1e-12],[-0.0,1e-12],[-0.0,1e-12]]},{"bandMatch":null,"check":{"bound":2932.987743818321,"deck":[[0,7500],[0,1000],[0,7000],null,[0,3000]],"expected":[7328529.318,0.451],"predicted":[7328211.988,8.551],"ranks":[1,5,1]},"converted":[0.0,0.0],"rangeWeights":[[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]],[[0.0,1e-12],[0.0,1e-12],[0.0,1e-12]]],"ranges":[{"justCount":[0.0,0.0],"luckPoints":[0.0,0.0],"maxCombo":[0.0,0.0],"rangeScore":[0.0,0.001],"rangeScorePerfect":[0.0,0.001],"rankBonus":[0.0,0.0],"rankBonusPerfect":[0.0,0.0]},{"justCount":[0.0,0.0],"luckPoints":[7.7,0.001],"maxCombo":[0.0,0.0],"rangeScore":[508.108,0.001],"rangeScorePerfect":[508.108,0.001],"rankBonus":[1879.649,0.451],"rankBonusPerfect":[1879.649,0.451]},{"justCount":[0.0,0.0],"luckPoints":[0.0,0.0],"maxCombo":[0.0,0.0],"rangeScore":[0.0,0.001],"rangeScorePerfect":[0.0,0.001],"rankBonus":[0.0,0.0],"rankBonusPerfect":[0.0,0.0]}],"score":[2387.757,0.451],"scorePerfect":[2387.757,0.451],"shape":7,"tail":[-0.0,0.901],"tailPerfect":[-0.0,0.901],"weights":[[-0.0,3.000001e-06],[2e-06,3.000001e-06],[0.0,3.000001e-06],[-0.0,3.000001e-06],[-0.0,3.000001e-06]]}]},"judgedNotes":163,"justNotes":2,"lastNoteMs":91554,"level":6,"missions":[3,2,1],"musicId":100076,"musicLengthMs":92554,"offSeeds":[{"check":{"bound":4198.579624035885,"deck":[[2,8000],null,[1,6000],[3,8000],[0,10000]],"exact":3725424,"predicted":3725223.8423046665},"score":968075,"seed":0,"weights":[[0.15550333333333333,0.13584666666666667,0.2001,0.3062033333333333,0.13327333333333333],[0.15550333333333333,0.13584666666666667,0.2001,0.3062033333333333,0.13327333333333333],[0.0,0.0,0.0,0.0,0.0],[0.15550333333333333,0.13584666666666667,0.2001,0.3062033333333333,0.13327333333333333]]}],"positions":5,"ranges":[{"endMs":26666,"index":0,"mission":3,"rankBonusPercent":370,"rankBonusPercents":[370,290,250,170,170],"startMs":19555},{"endMs":56000,"index":1,"mission":2,"rankBonusPercent":370,"rankBonusPercents":[370,290,250,170,170],"startMs":48888},{"endMs":84444,"index":2,"mission":1,"rankBonusPercent":370,"rankBonusPercents":[370,290,250,170,170],"startMs":77333}],"replaySeeds":[-70152769,-452351740,156766337,-1696681451,-1283484205,-895566507,322491774,2099122494],"scoreId":10007600,"skip":2.153571450444202}],"format":"ournotes-deck.chart-stats/3","gekisouAptitude":{"host":"each shape alone on one performer, other positions empty; support skills paired with an effect-free member Gekisou skill of the same mission; member-target conditions measured both ways","law":"independent nominal lottery and skill probabilities","plainKind":0,"shapes":[{"bandCondition":false,"effects":[{"activationTimeSecond":0.0,"condition":[],"cumulative":null,"effectExecuteLimitCount":0,"effectLimitCount":0,"effectType":13000,"effectValue":1,"maxEffectValue":0,"release":[],"reset":[],"skillTargetIds":[],"trigger":[[{"positive":true,"targetIds":[57],"type":7020,"values":[]}]],"triggerType":2}],"id":0,"mission":3,"skills":[{"bandIds":null,"id":1,"level":5,"memberTargetIds":null}],"source":"member"},{"bandCondition":false,"effects":[{"activationTimeSecond":0.0,"condition":[],"cumulative":null,"effectExecuteLimitCount":0,"effectLimitCount":0,"effectType":11001,"effectValue":20000,"maxEffectValue":0,"release":[],"reset":[],"skillTargetIds":[],"trigger":[[{"positive":true,"targetIds":[56],"type":7020,"values":[]}]],"triggerType":2}],"id":7,"mission":2,"skills":[{"bandIds":null,"id":8,"level":5,"memberTargetIds":null}],"source":"member"}]},"kinds":[{"activationTimeSecond":5.0,"durationMs":5000,"effectExecuteLimitCount":0,"effectExecuteLimitResetConditionGroup":0,"effectLimitCount":0,"effectType":2000,"id":0,"rows":20,"skillConditionGroup":0,"skillReleaseConditionGroup":0,"skillTargetIds":[],"values":[1000,2000,3000,4000,5000,5500,6000,6500,7000,7500,8000,8500,9000,10000,11500,13000]},{"activationTimeSecond":5.0,"durationMs":5000,"effectExecuteLimitCount":0,"effectExecuteLimitResetConditionGroup":0,"effectLimitCount":0,"effectType":2004,"id":1,"rows":10,"skillConditionGroup":0,"skillReleaseConditionGroup":0,"skillTargetIds":[41,46],"values":[6000,7000,8000,9000,10000,11000,12000,15000]},{"activationTimeSecond":5.0,"durationMs":5000,"effectExecuteLimitCount":0,"effectExecuteLimitResetConditionGroup":0,"effectLimitCount":0,"effectType":2000,"id":2,"rows":10,"skillConditionGroup":15,"skillReleaseConditionGroup":0,"skillTargetIds":[],"values":[3000,4000,5000,6000,7000,8000,9000,12000]},{"activationTimeSecond":5.0,"durationMs":5000,"effectExecuteLimitCount":0,"effectExecuteLimitResetConditionGroup":0,"effectLimitCount":0,"effectType":2000,"id":3,"rows":10,"skillConditionGroup":14,"skillReleaseConditionGroup":0,"skillTargetIds":[],"values":[5000,6000,7000,8000,9000,10000,11000,14000]}],"model":{"checkPower":1000003,"engine":"whole-live simulation (live::full), Gekisou on, solo rank 1","expectation":"independent nominal lottery and skill probabilities; estimates as [center, outward interval half-width]","gekisouAptitude":"every Gekisou skill shape of the chart's missions measured alone at solo rank 1 under independent nominal lottery and skill probabilities; expected increments with minus without as [center, outward interval half-width] on the best and Perfect plays; tail = score - sum(rangeScore + rankBonus); plain-kind weights are expected cross terms at rank 1; rangeWeights require linear ranges and a shape independent of confirmed rank; each variant checked against a full expectation of a random plain deck at checkPower within the flooring bound, using fixed ranks in that linear domain and rank 1 elsewhere; indicators use deterministic judgement counters and expected lottery points; partial Just rates interpolate the no-live-skill increments, while plain-skill cross weights use the best play","off":"offSeeds: Gekisou off (no Just, luck, Gekisou combo or rank bonus), theoretical best play (every note Perfect at its time), seed 0, every chart; the same formula on its score and weights, a kind whose conditions read the Gekisou state has null weights; checked","perfect":"scorePerfect, rangeScorePerfect and rankBonusPerfect: expected whole-live score, range scores and rank-1 bonuses on the same play with every Just judged Perfect","play":"theoretical best: exact note times, Just inside Just-count ranges, Perfect elsewhere","power":300000,"ranks":"fixed ranks r_i (1..5): retain the measured expected rank-1 bonus at rank 1; other ranks subtract that expectation and enclose the target bonus with binary32 and truncation bounds. In the linear range domain, weights[kind][k] becomes weights[kind][k] + sum_i (rankBonusPercents_i[r_i - 1] - rankBonusPercents_i[0]) / 100 * rangeWeights[kind][k][i]; rankCheck compares a full fixed-rank expectation against its prediction interval","replaySeeds":"one replay seed without a luck range, else the requested published seeds","score":"P * (score / power + sum_k factor_k * weights[kind_k][k]) within the flooring bound; checked against a full expectation","unitValue":10000},"source":{"exporter":{"chartFormat":"nnnotes.live-score/1","name":"nnnotes","version":"0.1.4"},"format":"nnnotes.deck-data/1","master":{"source":"api","version":"3cf4870f2c70d67432fe444b4dee743a"},"region":"tw"}} diff --git a/tests/unit/songs-expectation.test.mjs b/tests/unit/songs-expectation.test.mjs new file mode 100644 index 0000000..ed61e39 --- /dev/null +++ b/tests/unit/songs-expectation.test.mjs @@ -0,0 +1,216 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { + aptitudeFigures, aptitudeRadius, aptitudeRate, aptitudeSe, chartFigures, modelPower, + plainKind, rangeMeasures, scenarioData, +} from "../../examples/songs/ranking.js"; +import { hasNominalExpectation, isEstimate, scenarioExpectation } from "../../examples/songs/expectation.js"; +import { chartRows, refigure } from "../../examples/songs/catalog.js"; +import { GUIDE, UI } from "../../examples/songs/text.js"; + +// Unmodified exported rows for the mixed Just/Luck/Combo chart 10007600 and two aptitude variants. +// Source: native chart-stats/3 at bca2fdde8cf45f440243d1c408889135bc538c23, master version in the fixture. +// This fixture preserves the real nested estimate format; the arithmetic edge cases below are deliberately synthetic. +const native = JSON.parse(readFileSync(new URL("../fixtures/chart-stats-v3.json", import.meta.url), "utf8")); +const nativeChart = native.charts[0]; +const { charts: _, ...header } = native; +const nativeData = { + format: "nnnotes.music-data/2", deck: header, + songs: [{ id: nativeChart.musicId, charts: [{ ...nativeChart, deck: nativeChart }] }], +}; +const near = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-11, `${actual} != ${expected}`); +const range = { mission: 2, rankBonusPercent: 250, rankBonusPercents: [250, 190, 160, 100, 50] }; +const ranges = [range, { ...range, mission: 3 }]; +const estimate = { + score: [100.75, 0.125], scorePerfect: [80.5, 0.25], + ranges: [ + { rangeScore: [20.25, 0.01], rangeScorePerfect: [15.25, 0.01], rankBonus: [49.6, 0.01], + rankBonusPerfect: [37.3, 0.01], maxCombo: 8, justCount: 0, luckPoints: [3.25, 0.125] }, + { rangeScore: [11.875, 0.01], rangeScorePerfect: [6.125, 0.01], rankBonus: [29.2, 0.01], + rankBonusPerfect: [14.2, 0.01], maxCombo: 9, justCount: 4, luckPoints: [0, 0] }, + ], + weights: [[[0.5, 1e-10], [0.25, 1e-10]]], + rangeWeights: [[[[0.1, 1e-10], [0.05, 1e-10]], [[0.05, 1e-10], [0.025, 1e-10]]]], +}; +const deck = { + positions: 2, skip: 0.1, ranges, expectation: estimate, replaySeeds: [11, 22], + offSeeds: [{ seed: 0, score: 70, weights: [[0.4, 0.2]] }], +}; +const sample = { seed: 9, score: 999, scorePerfect: 888, weights: [[9, 9]], + rangeWeights: [[[9, 9], [9, 9]]], ranges: [{ luckPoints: 999 }, { luckPoints: 999 }] }; + +test("a real chart-stats/3 export feeds the catalog and scenario availability", () => { + assert.equal(native.format, "ournotes-deck.chart-stats/3"); + assert.deepEqual(nativeChart.missions, [3, 2, 1]); + const kind = plainKind(nativeData), power = modelPower(nativeData); + assert.equal(kind, 0); + const f = chartFigures(nativeChart, kind, power); + assert.equal(f.base, nativeChart.expectation.score[0] / power); + assert.deepEqual(f.weights, nativeChart.expectation.weights[kind].map(([center]) => center)); + assert.equal(f.nominal, true); + assert.equal(f.seeds, 0); + assert.deepEqual(f.baseRange, nativeChart.expectation.score.map((_, i) => + (nativeChart.expectation.score[0] + (i ? 1 : -1) * nativeChart.expectation.score[1]) / power)); + assert.deepEqual(scenarioData(nativeData), { free: true, ranks: true, just: true, aptitude: true }); + const rows = chartRows(nativeData); + assert.equal(rows[0].base, f.base); + assert.equal(rows[0].nominal, true); + refigure(rows, nativeData, { ranks: [2, 1, 1] }); + assert.equal(rows[0].baseRange, null, "a transformed value has no raw numerical enclosure"); + refigure(rows, nativeData, { mode: "free" }); + assert.equal(rows[0].nominal, false, "scenario changes clear nominal figure metadata"); + assert.equal(rows[0].base, nativeChart.offSeeds[0].score / power); +}); + +test("real replay seeds and stale legacy seed rows never alter nominal expectations", () => { + const f = chartFigures(nativeChart, 0); + const measures = rangeMeasures(nativeChart); + for (const replaySeeds of [[], [0], [42, -1, 99, 12345]]) { + const changed = { ...nativeChart, replaySeeds, seeds: [sample] }; + assert.deepEqual(chartFigures(changed, 0), f); + assert.deepEqual(rangeMeasures(changed), measures); + } + const lucky = nativeChart.expectation.ranges[1].luckPoints; + assert.deepEqual(measures[1].values.luckPoints, + { mean: lucky[0], min: lucky[0] - lucky[1], max: lucky[0] + lucky[1] }); + assert.ok(nativeChart.replaySeeds.every(Number.isInteger)); +}); + +test("nominal rank 1 retains the exported bonus and its original enclosure", () => { + const f = chartFigures(deck, 0, 10); + assert.equal(f.base, 10.075); + assert.deepEqual(f.baseRange, [10.0625, 10.0875]); + assert.deepEqual(f.weights, [0.5, 0.25]); + assert.equal(scenarioExpectation(estimate, ranges, 0, null).score, 100.75); + assert.notEqual(estimate.ranges[0].rankBonus[0], Math.trunc(estimate.ranges[0].rangeScore[0] * 2.5)); +}); + +test("nominal rank substitution uses the real product, preserving untouched rank-1 bonuses", () => { + const f = scenarioExpectation(estimate, ranges, 0, { ranks: [1, 2] }); + near(f.score, 100.75 - 29.2 + 11.875 * 1.9); + assert.notEqual(f.score, 100.75 - 29.2 + Math.trunc(11.875 * 1.9)); + near(f.weights[0], 0.5 + (1.9 - 2.5) * 0.05); + near(f.weights[1], 0.25 + (1.9 - 2.5) * 0.025); + assert.equal(f.scoreBounds, null); +}); + +test("Perfect and partial Just retain the measured rank-1 endpoint bonuses", () => { + assert.equal(scenarioExpectation(estimate, ranges, 0, { just: 0 }).score, 80.5); + assert.equal(scenarioExpectation(estimate, ranges, 0, { just: 0.5 }).score, 90.625); + near(scenarioExpectation(estimate, ranges, 0, { ranks: [1, 2], just: 0 }).score, + 80.5 - 14.2 + 6.125 * 1.9); + near(scenarioExpectation(estimate, ranges, 0, { ranks: [1, 2], just: 0.5 }).score, + 90.625 - (29.2 + 14.2) / 2 + (11.875 + 6.125) / 2 * 1.9); + const great = scenarioExpectation(estimate, ranges, 0, { great: 0.5 }); + near(great.score, 100.75 * 0.9); + assert.equal(great.scoreBounds, null); + assert.equal(chartFigures(deck, 0, 10, { mode: "free" }).base, 7); + const unplayable = { ...deck, unplayable: "four ranges", expectation: null }; + assert.equal(chartFigures(unplayable, 0), null); + assert.equal(chartFigures(unplayable, 0, 10, { mode: "free" }).base, 7); +}); + +test("nominal range measures use numerical enclosures or exact counters", () => { + assert.deepEqual(rangeMeasures(deck)[0].values, { + maxCombo: { mean: 8, min: 8, max: 8 }, justCount: { mean: 0, min: 0, max: 0 }, + luckPoints: { mean: 3.25, min: 3.125, max: 3.375 }, + }); + const missing = { ...deck, expectation: null, seeds: [sample] }; + assert.ok(rangeMeasures(missing).every((r) => Object.values(r.values).every((v) => v === null))); +}); + +test("missing, malformed or incomplete nominal statistics never fall back to legacy seeds", () => { + const stale = { ...deck, expectation: null, seeds: [sample] }; + const { expectation: _, ...missing } = stale; + assert.equal(hasNominalExpectation(missing), true); + assert.equal(chartFigures(stale, 0), null); + assert.equal(chartFigures(missing, 0), null); + for (const score of [[100, -1], [NaN, 0], [100, Infinity], [100], [Number.MAX_VALUE, Number.MAX_VALUE]]) { + assert.equal(isEstimate(score), false); + assert.equal(chartFigures({ ...stale, expectation: { ...estimate, score } }, 0), null); + } + for (const bad of [ + { ...estimate, ranges: undefined }, { ...estimate, ranges: [] }, + { ...estimate, weights: [[0.5, 0.25]] }, { ...estimate, weights: [[[0.5, -1], [0.25, 0]]] }, + ]) assert.equal(chartFigures({ ...stale, expectation: bad }, 0), null); + for (const bad of [ + { ...estimate, rangeWeights: null }, { ...estimate, rangeWeights: [[]] }, + { ...estimate, rangeWeights: [[[[0.1, 0]], [[0.05, 0]]]] }, + { ...estimate, ranges: [null, estimate.ranges[1]] }, + ]) assert.equal(chartFigures({ ...stale, expectation: bad }, 0, 10, { ranks: [2, 1] }), null); + assert.equal(chartFigures({ ...deck, positions: 3 }, 0), null); + assert.equal(chartFigures({ ...deck, ranges: [null, range] }, 0), null); + for (const power of [0, -1, NaN, Infinity, Number.MIN_VALUE]) assert.equal(chartFigures(deck, 0, power), null); + const d = { ...nativeData, songs: [{ charts: [{ deck: { ...stale, + gekisouAptitude: nativeChart.gekisouAptitude } }] }] }; + assert.deepEqual(scenarioData(d), { free: true, ranks: false, just: false, aptitude: false }); +}); + +const variant = { + shape: 0, score: [100, 0.125], scorePerfect: [80, 0.25], + ranges: [0, 1].map(() => ({ rangeScore: [10.25, 0.01], rangeScorePerfect: [8.25, 0.01], + rankBonus: [24.8, 0.01], rankBonusPerfect: [19.8, 0.01] })), + weights: [[0.1, 0.001]], rangeWeights: [[[0.01, 0.0001], [0.02, 0.0001]]], +}; + +test("real aptitude radius remains a numerical radius, never a sampling SE", () => { + const v = nativeChart.gekisouAptitude.variants.find((v) => v.shape === 7); + assert.deepEqual(v.score, [2387.757, 0.451]); + const f = aptitudeFigures(v, nativeChart.ranges, 300000, null, true); + assert.equal(f.base, v.score[0] / 300000); + assert.equal(f.baseSe, null); + assert.equal(f.baseRadius, v.score[1] / 300000); + assert.equal(aptitudeRadius(f, [0, 0, 0, 0, 0]), 0.451 / 300000); + assert.equal(aptitudeSe(f, [0, 0, 0, 0, 0]), null); + assert.equal(aptitudeRadius(f, [1, 0, 0, 0, 0]), null); + assert.equal(f.seeds, null); +}); + +test("nominal aptitude replaces changed ranks only and does not truncate the mean", () => { + const f = aptitudeFigures(variant, ranges, 10, { ranks: [1, 2] }, true); + near(f.base, (100 + 10.25 * 1.9 - 24.8) / 10); + near(f.weights[0], 0.1 + (1.9 - 2.5) * 0.02); + assert.equal(f.baseRadius, null); + assert.equal(aptitudeRadius(f, [0]), null); + assert.equal(aptitudeSe(f, [0]), null); + const just = aptitudeFigures(variant, ranges, 10, { ranks: [1, 2], just: 0.5 }, true); + near(just.base, (90 + 9.25 * 1.9 - 22.3) / 10); + assert.equal(just.missingPerfectCross, true); + assert.equal(aptitudeRate(just, [1]), null); + assert.equal(aptitudeRadius(just, [0]), null); + const raw = aptitudeFigures(variant, ranges, 10, null, true); + assert.equal(aptitudeRadius(raw, [0]), 0.0125); + assert.equal(aptitudeFigures(variant, ranges, 10, { just: 0.5 }, true).base, 9); + assert.equal(aptitudeFigures(variant, ranges, 10, { great: 0.5 }, true).baseRadius, null); +}); + +test("nominal aptitude rejects unsupported ranks and malformed response dimensions", () => { + const nonlinear = { ...variant, rangeWeights: null }; + assert.ok(aptitudeFigures(nonlinear, ranges, 10, null, true), "rank 1 does not require a linear range response"); + assert.equal(aptitudeFigures(nonlinear, ranges, 10, { ranks: [1, 2] }, true), null); + assert.equal(aptitudeFigures({ ...nonlinear, weights: null }, ranges, 10, { ranks: [1, 2] }, true), null); + for (const bad of [ + { ...variant, score: [100, -1] }, { ...variant, ranges: [] }, + { ...variant, weights: [[0.1, -1]] }, { ...variant, rangeWeights: [] }, + { ...variant, rangeWeights: [[[0.01, 0]]] }, + ]) assert.equal(aptitudeFigures(bad, ranges, 10, null, true), null); + const badBonus = { ...variant, ranges: [variant.ranges[0], { ...variant.ranges[1], rankBonus: null }] }; + assert.equal(aptitudeFigures(badBonus, ranges, 10, { ranks: [1, 2] }, true), null); + assert.equal(aptitudeFigures(variant, ranges, 0, null, true), null); + assert.equal(aptitudeFigures(variant, ranges, 10, { mode: "free" }, true), null); +}); + +test("Chinese and English distinguish nominal intervals, legacy SE and replay samples", () => { + for (const lang of ["zh", "en"]) { + const A = UI[lang].aptitude, D = UI[lang].detail; + for (const key of ["method", "nominal", "interval", "intervalNote", "missingRank", "nominalFactorNote"]) + assert.ok(A[key]?.length, `${lang}.aptitude.${key}`); + for (const key of ["nominal", "interval", "nominalWeightsHint", "nominalMeasuresHint"]) + assert.ok(D[key]?.length, `${lang}.detail.${key}`); + const guide = JSON.stringify(GUIDE[lang]); + for (const term of ["chart-stats/3", "music-data/1", "replaySeeds"]) assert.ok(guide.includes(term), `${lang}: ${term}`); + } + assert.match(UI.en.aptitude.intervalNote, /not a standard error/); + assert.match(UI.zh.aptitude.intervalNote, /不是标准误/); +});