diff --git a/examples/songs/catalog.js b/examples/songs/catalog.js index 3cd75da..cb04f23 100644 --- a/examples/songs/catalog.js +++ b/examples/songs/catalog.js @@ -28,11 +28,19 @@ export const density = (chart) => { return span > 0 && n ? n / (span / 1000) : null; }; -// One row per chart of music-data.json, in song order then difficulty order, with the deck figures (ranking.js -// chartFigures) when the chart has them; `stats` is the chart's own deck statistics (null in a file made without the -// deck model). -export const chartRows = (data) => { - const eff = new Map(joinCharts(data).map((r) => [r.scoreId, r])); +// A row's deck figures (ranking.js chartFigures) from a joinCharts row, null ones without it. +const figures = (e) => ({ + base: e ? e.base : null, + baseRange: e ? e.baseRange : null, + seeds: e ? e.seeds : null, + weights: e ? e.weights : null, +}); + +// One row per chart of music-data.json, in song order then difficulty order, with the deck figures in a scenario +// (ranking.js chartFigures; default: Gekisou Live at rank 1) when the chart has them; `stats` is the chart's own deck +// statistics (null in a file made without the deck model). +export const chartRows = (data, scenario = null) => { + const eff = new Map(joinCharts(data, scenario).map((r) => [r.scoreId, r])); const out = []; for (const song of (data && data.songs) || []) { const bgm = song.bgm && song.bgm.length; @@ -60,18 +68,22 @@ export const chartRows = (data) => { density: density(chart), bgmMs: bgm ? (bgm.durationMs ?? bgm.lengthMs ?? null) : null, chartMs: chart.musicLengthMs ?? null, - base: e ? e.base : null, - baseRange: e ? e.baseRange : null, - seeds: e ? e.seeds : null, + ...figures(e), skip: chart.deck && !chart.deck.unplayable ? chart.deck.skip ?? null : null, unplayable: chart.deck ? chart.deck.unplayable ?? null : null, - weights: e ? e.weights : null, }); } } return out; }; +// Puts the deck figures of another scenario into chartRows' rows, in place (the page keeps its rows). +export const refigure = (rows, data, scenario) => { + const eff = new Map(joinCharts(data, scenario).map((r) => [r.scoreId, r])); + for (const r of rows) Object.assign(r, figures(eff.get(r.scoreId))); + return rows; +}; + // Whether a row matches a search text: any language of the title, reading, credits, or the music id. export const matches = (row, text) => { const t = String(text || "").trim().toLowerCase(); diff --git a/examples/songs/index.html b/examples/songs/index.html index 2cee9e7..3e62f5c 100644 --- a/examples/songs/index.html +++ b/examples/songs/index.html @@ -175,6 +175,8 @@ .seg button { border: 0; background: none; padding: 3px 12px; border-radius: 8px; color: var(--mn-ink-soft); font-size: 13px; font-weight: 500; } .seg button:hover { color: var(--mn-accent-deep); } .seg button[aria-selected="true"] { color: var(--mn-accent-deep); background: var(--mn-accent-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mn-accent) 30%, transparent); } + .seg button:disabled { color: var(--mn-text-muted); opacity: .5; cursor: not-allowed; } + .seg.mini button { min-width: 26px; padding: 2px 7px; font-variant-numeric: tabular-nums; } .hint { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 4px 2px 14px; color: var(--mn-text-muted); font-size: 13px; line-height: 1.75; } .hint.warn { color: var(--mn-rose); } @@ -192,6 +194,12 @@ .num-in.power { width: 136px; } .num-in::placeholder { color: var(--mn-text-muted); text-align: left; font-size: 12px; } input[type=range] { width: 140px; accent-color: var(--mn-accent); } + input[type=range]:disabled { opacity: .45; cursor: not-allowed; } + /* play scenario: ranks per range, notes on the approximations */ + .scen .field { white-space: normal; } + .rk-pick { display: inline-flex; align-items: center; gap: 6px; } + .rk-pick > small { color: var(--mn-text-muted); font-size: 12px; white-space: nowrap; } + .note { color: var(--mn-text-muted); font-size: 12px; line-height: 1.6; } .check { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--mn-ink-soft); } .check input { accent-color: var(--mn-accent); width: 16px; height: 16px; } .skills { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } @@ -359,6 +367,8 @@ .tile span { color: var(--mn-text-muted); font-size: 12px; } .tile b { font-size: 20px; font-weight: 500; letter-spacing: -.01em; font-variant-numeric: tabular-nums; } .tile small { color: var(--mn-text-muted); font-size: 11px; line-height: 1.5; } + .tile.wide { grid-column: span 2; } + .d-body .scen { margin: 0 0 4px; } .timeline { padding: 6px; border-radius: var(--cut-md); border: 1px solid var(--mn-glass-border); background: var(--mn-glass); } .timeline .lane { fill: var(--mn-text-muted); font-size: 11px; } .timeline .lane-line { stroke: var(--mn-border); stroke-dasharray: 2 4; } @@ -446,6 +456,7 @@ .d-hero { flex-direction: column; align-items: flex-start; padding: 22px 16px 16px; } .jk.xl { width: 88px; height: 88px; } .d-body { padding: 4px 16px 24px; } + .tile.wide { grid-column: 1 / -1; } .hbar { grid-template-columns: 96px 1fr 28px; } input[type=range] { width: 110px; } } diff --git a/examples/songs/ranking.js b/examples/songs/ranking.js index ac4454b..075561d 100644 --- a/examples/songs/ranking.js +++ b/examples/songs/ranking.js @@ -2,7 +2,7 @@ // statistics are ournotes-deck.chart-stats/2). // // Score model (music-data.json `deck`, checked per seed against the whole-live simulation): on the theoretical best -// play with Gekisou on (the chart's fevers with the song's missions, a solo player at rank 1), a deck of power P whose +// 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 // @@ -26,6 +26,18 @@ // (strictly somewhere). The difference is linear in c for a fixed xbar and linear in xbar for a fixed c, so the four // 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 +// 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] +// +// "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 +// bonuses recomputed on the interpolated range scores and the skill weights inside a range scaled by the same ratio. export const DIFFICULTIES = ["easy", "normal", "hard", "expert"]; export const EPS = 1e-12; @@ -56,33 +68,132 @@ export const plainKind = (data) => { const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length; -// A chart's figures from its deck statistics (`chart.deck`): `base` and `weights[k]` (performance position k's, of -// kind `kind`) as means over the seeds, `baseRange` the seeds' [min, max] base, `seeds` their number. null without -// statistics, for an unplayable chart or without the kind. -export const chartFigures = (deck, kind, power = POWER) => { - const seeds = (deck && !deck.unplayable && deck.seeds) || []; +// ---------------------------------------------------------------- play scenarios +// The Great judgement's score percent against the Perfect's (MasterLiveJudgementParameter: 80 and 100). +export const GREAT_SCORE = 0.8; +// Gekisou Live seats up to 5 players; a chart has 3 Gekisou ranges (more fevers are unplayable with Gekisou on). +export const RANK_MAX = 5; +export const RANGES = 3; +// The Just mission (music-data.json `gekisouMissions`, deck `ranges[i].mission`): Just judgements are on only there. +const JUST_MISSION = 3; +// mode "battle" | "free"; ranks[i] the rank in range i; just and great as fractions (0..1) +export const DEFAULT_SCENARIO = Object.freeze({ mode: "battle", ranks: Object.freeze([1, 1, 1]), just: 1, great: 0 }); + +// 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); + +// "r" or "r1,r2,r3" (the query's rk) as three ranks; a bad or missing value is rank 1. +export const parseRanks = (text) => { + const v = String(text ?? "").split(",").slice(0, RANGES).map((x) => clampRank(Number(x))); + return v.length === 1 ? Array(RANGES).fill(v[0]) : [...v, ...Array(RANGES - v.length).fill(1)]; +}; + +// The query form of three ranks: "" for all rank 1, "r" for one rank everywhere, else "r1,r2,r3". +export const formatRanks = (ranks) => { + const r = [...Array(RANGES).keys()].map((i) => clampRank((ranks || [])[i])); + if (r.every((x) => x === r[0])) return r[0] === 1 ? "" : String(r[0]); + return r.join(","); +}; + +// Range i's rank bonus percent at rank r (music-data.json deck `ranges[i]`: rankBonusPercents for ranks 1..5, +// rankBonusPercent for rank 1); null when the data has no such rank. +export const rankPercent = (range, r) => { + const list = range && range.rankBonusPercents; + if (Array.isArray(list) && Number.isFinite(list[r - 1])) return list[r - 1]; + return r === 1 && range && Number.isFinite(range.rankBonusPercent) ? range.rankBonusPercent : null; +}; + +// Which scenarios music-data.json can show: free (offSeeds), ranks other than 1 (rankBonusPercents and +// rangeWeights), a Just rate below 100 % (scorePerfect, with rangeWeights for the skill weights). +export const scenarioData = (data) => { + const has = { free: false, ranks: false, just: false }; + for (const song of (data && data.songs) || []) { + 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 ((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; + } + } + return has; +}; + +// One seed's no-skill score (points at the measurement power) and the position weights of `kind` in a scenario +// (see the top of the file; `ranges` is the chart's deck `ranges`); null when the seed lacks a field the scenario +// needs. Battle at rank 1 everywhere with j = 1 is the seed itself; free is an `offSeeds` entry as it is. +export const scenarioSeed = (seed, ranges, kind, scenario) => { + const sc = { ...DEFAULT_SCENARIO, ...(scenario || {}) }; + const w0 = seed && seed.weights && seed.weights[kind]; + if (!Array.isArray(w0) || !Number.isFinite(seed.score)) return null; + const g = greatFactor(sc.great); + const plain = () => ({ score: seed.score * g, weights: w0.map((v) => (v ?? 0) * g) }); + if (sc.mode === "free") return plain(); + const rs = seed.ranges || []; + 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; + if (!partial && ranks.every((r) => r === 1)) return plain(); + const rw = seed.rangeWeights && seed.rangeWeights[kind]; + if (!Array.isArray(rw)) return null; + const p1 = rs.map((_, i) => rankPercent(ranges[i], 1)); + const pr = rs.map((_, i) => rankPercent(ranges[i], ranks[i])); + if ([...p1, ...pr].some((p) => p === null) || rs.some((x) => !Number.isFinite(x.rangeScore) || !Number.isFinite(x.rankBonus))) return null; + // the all-Perfect range score: a range without Just judgements scores the same either way + const perfect = rs.map((x, i) => (Number.isFinite(x.rangeScorePerfect) ? x.rangeScorePerfect + : ranges[i] && ranges[i].mission !== JUST_MISSION ? x.rangeScore : null)); + if (partial && (!Number.isFinite(seed.scorePerfect) || perfect.some((v) => v === null))) return null; + const lerp = (p, just) => (partial ? p + j * (just - p) : just); + // the score without the rank bonuses, all Just and all Perfect (scorePerfect holds the rank-1 bonuses of its ranges) + const rest = seed.score - rs.reduce((a, x) => a + x.rankBonus, 0); + const restP = partial ? seed.scorePerfect - perfect.reduce((a, v, i) => a + Math.trunc((v * p1[i]) / 100), 0) : rest; + const rangeJ = rs.map((x, i) => lerp(perfect[i], x.rangeScore)); + const score = lerp(restP, rest) + rangeJ.reduce((a, v, i) => a + Math.trunc((v * pr[i]) / 100), 0); + const weights = w0.map((v, k) => (v ?? 0) + rs.reduce((a, x, i) => { + const d = (rw[k] && rw[k][i]) ?? 0; + const ratio = x.rangeScore > 0 ? rangeJ[i] / x.rangeScore : 1; // the Just rate's, on the range's part + return a + ((pr[i] - p1[i]) / 100) * d + (ratio - 1) * (1 + pr[i] / 100) * d; + }, 0)); + return { score: score * g, weights: weights.map((v) => v * g) }; +}; + +// 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 +// 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"); + const seeds = (deck && (free ? deck.offSeeds : !deck.unplayable && deck.seeds)) || []; if (!seeds.length || kind === null || kind === undefined) return null; - if (!seeds.every((s) => Array.isArray(s.weights && s.weights[kind]))) return null; - const bases = seeds.map((s) => s.score / power); - const n = deck.positions ?? seeds[0].weights[kind].length; + const figs = seeds.map((s) => scenarioSeed(s, deck.ranges || [], kind, scenario)); + if (figs.some((f) => !f)) return null; + const bases = figs.map((f) => f.score / power); + const n = deck.positions ?? figs[0].weights.length; return { base: mean(bases), baseRange: [Math.min(...bases), Math.max(...bases)], seeds: seeds.length, skip: deck.skip ?? null, - weights: [...Array(n).keys()].map((k) => mean(seeds.map((s) => s.weights[kind][k] ?? 0))), + weights: [...Array(n).keys()].map((k) => mean(figs.map((f) => f.weights[k] ?? 0))), }; }; -// One row per chart of music-data.json with deck figures (see chartFigures), with the song's facts; `weights[k]` is -// performance position k's. -export const joinCharts = (data) => { +// ournotes-deck's measurement power of music-data.json. +export const modelPower = (data) => (data && data.deck && data.deck.model && data.deck.model.power) || POWER; + +// One row per chart of music-data.json with deck figures in a scenario (see chartFigures), with the song's facts; +// `weights[k]` is performance position k's. +export const joinCharts = (data, scenario = null) => { const kind = plainKind(data); - const power = (data && data.deck && data.deck.model && data.deck.model.power) || POWER; + const power = modelPower(data); const out = []; for (const song of (data && data.songs) || []) { for (const chart of song.charts || []) { - const f = chartFigures(chart.deck, kind, power); + const f = chartFigures(chart.deck, kind, power, scenario); if (!f) continue; const bgm = song.bgm && song.bgm.length; out.push({ @@ -236,6 +347,10 @@ export const formatLength = (ms) => { // song choice only needs the chance of each rank and the play time; a table value is recovered from one result as // points * 10000 / ((10000 + bonus) * boostRate). // +// Free Live rates a player's score against the song's `requiredScore`. Gekisou Live rates the room: the sum of the +// scores of its n players against trunc(sqrt(5 / n) * battleRequiredScore * n). With every player scoring the same, +// one player needs trunc(sqrt(5 / n) * R_battle * n) / n, about sqrt(5 / n) * R_battle (`room` = n below; 0 for solo). +// // Event dominance (expected score): a beats b when L_a <= L_b and, for every rank r with thresholds R_a, R_b > 0, // S_a(xbar) / R_a(r) >= S_b(xbar) / R_b(r) for all 0 <= xbar <= X_MAX // (linear in xbar: both ends decide), strictly somewhere: a deck reaches every rank on a at a power no higher than on @@ -243,23 +358,29 @@ export const formatLength = (ms) => { export const SCORE_RANKS = ["D", "C", "B", "A", "S", "SS"]; -// The required score of a rank on a row's song; null when the song has no such rank. -export const rankThreshold = (row, rank) => { +// One player's share of a Gekisou Live room threshold R_battle among n players who all score the same. +export const roomThreshold = (R, n) => Math.trunc(Math.sqrt(5 / n) * R * n) / n; + +// The score one player needs for a rank on a row's song: solo (`room` 0) the song's requiredScore, in a Gekisou Live +// room of `room` players scoring the same its roomThreshold; null when the song has no such rank. +export const rankThreshold = (row, rank, room = 0) => { const hit = (row.scoreRanks || []).filter((r) => r.rank === rank).pop(); - return hit && Number.isFinite(hit.requiredScore) ? hit.requiredScore : null; + if (!hit) return null; + if (!room) return Number.isFinite(hit.requiredScore) ? hit.requiredScore : null; + return Number.isFinite(hit.battleRequiredScore) ? roomThreshold(hit.battleRequiredScore, room) : null; }; // The power at which the expected score reaches a rank's threshold; 0 for a rank needing no score, null without -// the rank or the chart's figures. `factor` scales the score (judgement accuracy). -export const requiredPower = (row, skills, rank, factor = 1) => { - const R = rankThreshold(row, rank); +// the rank or the chart's figures. `factor` scales the score (the page folds its accuracy into the figures). +export const requiredPower = (row, skills, rank, factor = 1, room = 0) => { + const R = rankThreshold(row, rank, room); if (R === null || !row.weights) return null; return R <= 0 ? 0 : R / (scoreRate(row, skills) * factor); }; // The chance over the random skill order that a deck of `power` reaches at least `rank` on the chart. -export const reachChance = (row, skills, power, rank, factor = 1) => { - const R = rankThreshold(row, rank); +export const reachChance = (row, skills, power, rank, factor = 1, room = 0) => { + const R = rankThreshold(row, rank, room); if (R === null || !row.weights) return null; if (R <= 0) return 1; const rates = orderRates(row, skills); @@ -267,13 +388,13 @@ export const reachChance = (row, skills, power, rank, factor = 1) => { }; // Whether `a` beats `b` for events (see above), strictly somewhere. -export const eventDominates = (a, b, source, xMax = X_MAX) => { +export const eventDominates = (a, b, source, xMax = X_MAX, room = 0) => { const La = lengthMs(a, source); const Lb = lengthMs(b, source); if (La === null || Lb === null || La > Lb + EPS || !a.weights || !b.weights) return false; let any = La < Lb - EPS; for (const rank of SCORE_RANKS) { - const Ra = rankThreshold(a, rank), Rb = rankThreshold(b, rank); + const Ra = rankThreshold(a, rank, room), Rb = rankThreshold(b, rank, room); if (Rb === null) continue; // b never gets the rank if (Ra === null) return false; if (Rb <= 0) { if (Ra > 0) return false; continue; } // b always gets it @@ -289,8 +410,8 @@ export const eventDominates = (a, b, source, xMax = X_MAX) => { }; // For each row the indexes of the rows that beat it for events. -export const eventDominance = (rows, source, xMax = X_MAX) => rows.map((b) => { +export const eventDominance = (rows, source, xMax = X_MAX, room = 0) => rows.map((b) => { const by = []; - rows.forEach((a, i) => { if (a !== b && eventDominates(a, b, source, xMax)) by.push(i); }); + rows.forEach((a, i) => { if (a !== b && eventDominates(a, b, source, xMax, room)) by.push(i); }); return by; }); diff --git a/examples/songs/songs.js b/examples/songs/songs.js index dc80000..6f45819 100644 --- a/examples/songs/songs.js +++ b/examples/songs/songs.js @@ -5,7 +5,13 @@ // ?v=rank|charts|guide &lang= &band= &d=[,...] &q= // &r=efficiency|event|speed|level|notes|long|short|skip &sp=density|bpmMax|bpm (ranking, speed measure) // &len=bgm|chart &oh= &x=[,...] &frontier (efficiency) -// &p= &tr= &gr= (event: rank chance) +// &gk=free &rk=[,,] (play scenario: Free Live, else Gekisou +// Live with a rank 1-5 per range; default +// Gekisou Live at rank 1 everywhere) +// &gr= &jr= (accuracy: Great share, default 0; Just +// rate in Just ranges, default 100) +// &p= &tr= &n= (event: rank chance; Gekisou Live room +// size, default 5) // &jk=off (no jackets) // &ax=
&ay=
(scatter axes) // &c= (the chart detail open) @@ -13,11 +19,13 @@ // &theme=light|dark (default: the system's) // The site root is this page's directory unless ?site= names another one. import { - DIFFICULTIES, MOENOTES, NOTE_KINDS, chartRows, extent, histogram, matches, moenotesUrl, pickText, sortBy, ticks, + DIFFICULTIES, MOENOTES, NOTE_KINDS, chartRows, extent, histogram, matches, moenotesUrl, pickText, refigure, sortBy, + ticks, } from "./catalog.js"; import { - SCORE_RANKS, X_MAX, eventDominance, formatLength, lengthMs, meanSkill, orderRates, perMinute, quantile, rank, - rankThreshold, reachChance, requiredPower, scoreRate, weightSum, + RANGES, RANK_MAX, SCORE_RANKS, X_MAX, chartFigures, eventDominance, formatLength, formatRanks, lengthMs, meanSkill, + modelPower, orderRates, parseRanks, perMinute, plainKind, quantile, rank, rankThreshold, reachChance, requiredPower, + scenarioData, scoreRate, weightSum, } from "./ranking.js"; import { GUIDE, UI } from "./text.js"; @@ -49,8 +57,6 @@ applyTheme(); dark.addEventListener("change", applyTheme); const RANKS = ["efficiency", "event", "speed", "level", "notes", "long", "short", "skip"]; const EFF_RANKS = new Set(["efficiency", "event", "skip"]); -// The Great judgement's score percent against the Perfect's (MasterLiveJudgementParameter: 80 and 100). -const GREAT_SCORE = 0.8; const AXES = ["displayLevel", "density", "bpm", "bpmMax", "notes", "bgmMs", "perMinute", "rate", "base", "skip"]; const EFF_AXES = new Set(["perMinute", "rate", "base", "skip"]); @@ -119,6 +125,11 @@ const main = async () => { const byScore = new Map(rows.map((r) => [r.scoreId, r])); const bands = new Map((songs.bands || []).map((b) => [String(b.id), b])); const hasStats = rows.some((r) => r.weights); + // what the data can show besides Gekisou Live at rank 1 (ranking.js scenarioData) + const has = scenarioData(songs); + const kind = plainKind(songs); + const power = modelPower(songs); + const pct = (v, def) => (v === null || !Number.isFinite(Number(v)) ? def : Math.min(100, Math.max(0, Math.round(Number(v))))); const S = { view: ["rank", "charts", "guide"].includes(q.get("v")) ? q.get("v") : "rank", lang: defaultLanguage(langs), @@ -133,7 +144,12 @@ const main = async () => { frontier: q.has("frontier"), power: Math.max(0, Math.round(Number(q.get("p")) || 0)), target: SCORE_RANKS.includes(q.get("tr")) ? q.get("tr") : "SS", - great: Math.min(100, Math.max(0, Number(q.get("gr")) || 0)), + // the play scenario (ranking.js chartFigures) and the accuracy + mode: q.get("gk") === "free" && has.free ? "free" : "battle", + ranks: has.ranks ? parseRanks(q.get("rk")) : Array(RANGES).fill(1), + great: pct(q.get("gr"), 0), + just: has.just ? pct(q.get("jr"), 100) : 100, + room: Math.min(RANK_MAX, Math.max(1, Math.round(Number(q.get("n")) || 5))), jackets: q.get("jk") !== "off", ax: AXES.includes(q.get("ax")) ? q.get("ax") : "displayLevel", ay: AXES.includes(q.get("ay")) ? q.get("ay") : hasStats ? "perMinute" : "density", @@ -145,7 +161,17 @@ const main = async () => { const showJackets = () => hasJackets && S.jackets; const g = () => (S.lang.startsWith("zh") ? GUIDE.zh : GUIDE.en); const skills = () => S.skills.map((x) => x / 100); - const accuracy = () => 1 - (1 - GREAT_SCORE) * (S.great / 100); + const scenario = () => ({ mode: S.mode, ranks: S.ranks, just: S.just / 100, great: S.great / 100 }); + // the rows carry the figures of the scenario chosen (the Great share folded in: the score ranks take factor 1) + const setScenario = (change) => { if (change) change(); refigure(rows, songs, scenario()); }; + setScenario(); + // the score ranks: a Gekisou Live room of S.room players who all score the same, or solo + const room = () => (S.mode === "battle" ? S.room : 0); + // Free Live's score per power at the Great share chosen (the one Gekisou Live keeps as the song's best score) + const freeRate = (r) => { + const f = chartFigures(r.stats, kind, power, { mode: "free", great: S.great / 100 }); + return f ? scoreRate(f, skills()) : null; + }; const eff = (r) => (r.weights ? { rate: scoreRate(r, skills()), perMinute: perMinute(r, skills(), S.len, S.overhead * 1000) } : { rate: null, perMinute: null }); @@ -177,7 +203,11 @@ const main = async () => { put("x", S.skills.join(","), "100,100,100,100,100"); put("p", S.power, 0); put("tr", S.target, "SS"); + put("gk", S.mode, "battle"); + put("rk", formatRanks(S.ranks), ""); put("gr", S.great, 0); + put("jr", S.just, 100); + put("n", S.room, 5); put("jk", S.jackets ? null : "off", null); put("ax", S.ax, "displayLevel"); put("ay", S.ay, hasStats ? "perMinute" : "density"); @@ -207,8 +237,10 @@ const main = async () => { h("div", { class: "song-text" }, h("div", { class: "song-title" }, h("span", { class: "t" }, title(r)), moeLink(r)), h("div", { class: "song-band" }, h("i", { class: "dot", style: `background:${bandColor(r)}` }), bandName(r)))); + // options: [value, label, disabled?] const seg = (options, value, onPick, cls = "seg") => h("div", { class: cls, role: "tablist" }, - options.map(([v, label]) => h("button", { role: "tab", "aria-selected": v === value ? "true" : "false", onclick: () => onPick(v) }, label))); + options.map(([v, label, off]) => h("button", { role: "tab", "aria-selected": v === value ? "true" : "false", disabled: Boolean(off), + onclick: () => { if (v !== value) onPick(v); } }, label))); const header = h("header", { class: "top wrap" }); const head = h("div", { class: "wrap" }); @@ -277,6 +309,46 @@ const main = async () => { const pool = () => rows.filter((r) => S.diffs.includes(r.difficulty) && (!S.band || r.bandIds.map(String).includes(S.band))); + // ------------------------------------------------------------ play scenario and accuracy + // `redo` redraws after a choice (this panel too), `live` what depends on the figures while a slider moves; `rooms` + // adds the Gekisou Live room size (score ranks), `missions` names the ranges after a song's Gekisou missions + const scenarioPanel = ({ redo, live, rooms = false, missions = null }) => { + const t = u(); + const T = t.scen; + const battle = S.mode === "battle"; + const note = (text) => h("small", { class: "note" }, text); + const modes = seg([["battle", T.battle], ["free", has.free ? T.free : `${T.free} · ${T.pending}`, !has.free]], S.mode, + (v) => { setScenario(() => { S.mode = v; }); redo(); }); + const ranks = [...Array(RANGES).keys()].map((i) => h("span", { class: "rk-pick" }, + h("small", {}, T.range(i + 1, missions ? t.missions[missions[i]] : null)), + seg([...Array(RANK_MAX).keys()].map((k) => [k + 1, String(k + 1), k > 0 && !has.ranks]), S.ranks[i], + (v) => { setScenario(() => { S.ranks = S.ranks.map((x, j) => (j === i ? v : x)); }); redo(); }, "seg mini"))); + const slider = (label, value, set, off) => { + const out = h("output", {}, off ? T.pending : `${value}%`); + const inp = h("input", { type: "range", min: 0, max: 100, step: 1, value, disabled: off, "aria-label": label }); + inp.addEventListener("input", () => { + const v = Number(inp.value); + out.textContent = `${v}%`; + setScenario(() => set(v)); + live(); + save(); + }); + return h("label", { class: "field" }, h("span", {}, label), inp, out); + }; + return h("div", { class: "panel scen glass" }, + h("div", { class: "field" }, h("span", {}, T.mode), modes, note(battle ? T.battleHint : T.freeHint)), + battle ? h("div", { class: "field" }, h("span", {}, T.ranks), ranks, note(has.ranks ? T.rankBest : `${T.rankBest} · ${T.ranksPending}`)) : null, + h("div", { class: "field" }, h("span", {}, T.accuracy), + slider(T.great, S.great, (v) => { S.great = v; }, false), + battle ? slider(T.just, S.just, (v) => { S.just = v; }, !has.just) : null, + note(battle ? T.accNote : T.accNoteFree)), + rooms && battle ? h("div", { class: "field" }, h("span", {}, T.room), + seg([...Array(RANK_MAX).keys()].map((k) => [k + 1, String(k + 1)]), S.room, (v) => { S.room = v; redo(); }, "seg mini"), + note(T.roomHint)) : null, + rooms && !battle ? note(T.soloRanks) : null); + }; + const redoMain = () => { renderMain(); save(); }; + // ------------------------------------------------------------ rankings // `live`: redraws what depends on the figures while a slider or a number changes const effPanel = (live) => { @@ -296,9 +368,6 @@ const main = async () => { const event = S.view === "rank" && S.rankBy === "event"; const power = h("input", { type: "number", class: "num-in power", min: 0, step: 1000, value: S.power || "", placeholder: t.powerHint, "aria-label": t.power }); power.addEventListener("input", () => { S.power = Math.max(0, Math.round(Number(power.value) || 0)); live(); save(); }); - const greatOut = h("output", {}, `${S.great}%`); - const great = h("input", { type: "range", min: 0, max: 100, step: 1, value: S.great, "aria-label": t.great }); - great.addEventListener("input", () => { S.great = Number(great.value); greatOut.textContent = `${S.great}%`; live(); save(); }); return h("div", { class: "panel eff glass" }, h("div", { class: "field" }, h("span", {}, t.length), seg([["bgm", t.bgm], ["chart", t.chart]], S.len, (v) => { S.len = v; renderMain(); save(); })), h("label", { class: "field" }, h("span", {}, t.overhead), oh, ohOut), @@ -306,7 +375,6 @@ const main = async () => { t.presets.map(([label, v]) => h("button", { class: "ghost", onclick: () => { S.skills = v.split(",").map(Number); renderMain(); save(); } }, label)))), event ? h("div", { class: "field" }, h("span", {}, t.target), seg(SCORE_RANKS.slice(2).reverse().map((r) => [r, r]), S.target, (v) => { S.target = v; renderMain(); save(); })) : null, event ? h("label", { class: "field" }, h("span", {}, t.power), power) : null, - event ? h("label", { class: "field" }, h("span", {}, t.great), great, greatOut) : null, S.view === "rank" ? h("label", { class: "check" }, h("input", { type: "checkbox", checked: S.frontier, onchange: (e) => { S.frontier = e.target.checked; live(); save(); } }), t.frontier) : null); }; @@ -322,7 +390,8 @@ const main = async () => { put(main, h("div", { class: "rank-head" }, tabs), h("p", { class: "hint" }, t.rankHint[S.rankBy], sub), - S.rankBy === "efficiency" || S.rankBy === "event" ? effPanel(renderTable) : null, + S.rankBy === "efficiency" || S.rankBy === "event" + ? [scenarioPanel({ redo: redoMain, live: renderTable, rooms: S.rankBy === "event" }), effPanel(renderTable)] : null, !hasStats ? h("p", { class: "hint warn" }, t.noStats) : null, tableBox); renderTable(); @@ -341,14 +410,14 @@ const main = async () => { cols.push("rate", "perMinute", "relative", "dom"); hi = "perMinute"; } else if (S.rankBy === "event") { - unsorted = list.filter((r) => r.weights && rankThreshold(r, S.target) !== null); - const dom = eventDominance(unsorted, S.len); - const f = accuracy(); + const n = room(); + unsorted = list.filter((r) => r.weights && rankThreshold(r, S.target, n) !== null); + const dom = eventDominance(unsorted, S.len, X_MAX, n); list = unsorted.map((r, i) => { const L = lengthMs(r, S.len); const perHour = L ? 3600000 / (L + S.overhead * 1000) : null; - const chance = S.power ? reachChance(r, skills(), S.power, S.target, f) : null; - return { ...r, need: requiredPower(r, skills(), S.target, f), chance, perHour, + const chance = S.power ? reachChance(r, skills(), S.power, S.target, 1, n) : null; + return { ...r, need: requiredPower(r, skills(), S.target, 1, n), chance, perHour, goal: chance === null || perHour === null ? null : chance * perHour, dominatedBy: dom[i], frontier: dom[i].length === 0 }; }); list = S.power ? sortBy(list, (r) => (r.goal === null ? null : r.goal - r.need * 1e-12)) : sortBy(list, (r) => r.need, true); @@ -527,7 +596,8 @@ const main = async () => { h("section", { class: "card glass" }, heading("h2", "sec-head", t.scatter, h("div", { class: "axes" }, pick(t.x, S.ax, (v) => { S.ax = v; }), h("button", { class: "ghost", "aria-label": t.swap, title: t.swap, onclick: () => { [S.ax, S.ay] = [S.ay, S.ax]; renderMain(); save(); } }, icon("swap")), pick(t.y, S.ay, (v) => { S.ay = v; }))), - EFF_AXES.has(S.ax) || EFF_AXES.has(S.ay) ? effPanel(() => plot.replaceChildren(scatter())) : null, + EFF_AXES.has(S.ax) || EFF_AXES.has(S.ay) + ? [scenarioPanel({ redo: redoMain, live: () => plot.replaceChildren(scatter()) }), effPanel(() => plot.replaceChildren(scatter()))] : null, plot, legend), h("div", { class: "grid2" }, h("section", { class: "card glass" }, heading("h2", "sec-head", t.levelDist), levelChart()), @@ -548,7 +618,7 @@ const main = async () => { (x.body || []).map((p) => h("p", {}, p)), x.math && x.math.length ? h("div", { class: "formula" }, x.math.map((m) => h("code", {}, m))) : null, (x.after || []).map((p) => h("p", {}, p)), - x.defs && x.defs.length ? h("dl", { class: "defs" }, x.defs.map(([term, def]) => [h("dt", {}, term), h("dd", {}, def)])) : null))))); + x.defs && x.defs.length ? h("dl", { class: "defs" }, x.defs.flatMap(([term, def]) => [h("dt", {}, term), h("dd", {}, def)])) : null))))); // the contents follow the reading: the first section in the band from under the header to 45% of the window if (guideSpy) guideSpy.disconnect(); const seen = new Set(); @@ -633,34 +703,75 @@ const main = async () => { // the song's score ranks: threshold, the power the expected score needs, and the chance at the power entered const ranksTable = (r) => { const t = u(); - const ranks = SCORE_RANKS.filter((k) => rankThreshold(r, k) !== null).reverse(); + const n = room(); + const ranks = SCORE_RANKS.filter((k) => rankThreshold(r, k, n) !== null).reverse(); if (!ranks.length || !r.weights) return null; - const f = accuracy(); const rates = orderRates(r, skills()); const spread = rates[rates.length - 1] > rates[0] + 1e-12; // equal skills: every order scores the same return h("table", { class: "ranks" }, - h("thead", {}, h("tr", {}, h("th", {}, t.detail.rank), h("th", {}, t.detail.required), h("th", {}, t.detail.needPower), + h("thead", {}, h("tr", {}, h("th", {}, t.detail.rank), h("th", {}, n ? t.detail.requiredRoom(n) : t.detail.required), h("th", {}, t.detail.needPower), spread ? h("th", {}, t.detail.needRange) : null, S.power ? h("th", {}, t.detail.chanceAt(fmtInt(S.power))) : null)), h("tbody", {}, ranks.map((k) => { - const R = rankThreshold(r, k); - const chance = S.power ? reachChance(r, skills(), S.power, k, f) : null; + const R = rankThreshold(r, k, n); + const chance = S.power ? reachChance(r, skills(), S.power, k, 1, n) : null; return h("tr", {}, h("td", {}, h("span", { class: `rk rk-${k}` }, k)), h("td", { class: "num" }, fmtInt(R)), - h("td", { class: "num" }, fmtInt(requiredPower(r, skills(), k, f))), - spread ? h("td", { class: "num dim" }, R > 0 ? `${fmtInt(R / (rates[rates.length - 1] * f))}–${fmtInt(R / (rates[0] * f))}` : "–") : null, + h("td", { class: "num" }, fmtInt(requiredPower(r, skills(), k, 1, n))), + spread ? h("td", { class: "num dim" }, R > 0 ? `${fmtInt(R / rates[rates.length - 1])}–${fmtInt(R / rates[0])}` : "–") : null, chance === null ? null : h("td", { class: "num" }, `${(100 * chance).toFixed(0)}%`)); }))); }; + // a scenario changed in the detail: the view behind it redraws when the detail closes + let behindStale = false; const openChart = (scoreId) => { S.chart = scoreId; renderDrawer(); save(); }; - const closeChart = () => { S.chart = null; renderDrawer(); save(); }; + const closeChart = () => { + S.chart = null; + renderDrawer(); + if (behindStale) { behindStale = false; renderMain(); } + save(); + }; const renderDrawer = () => { const r = S.chart && byScore.get(S.chart); if (!r) { drawer.classList.remove("open"); drawer.replaceChildren(); document.body.classList.remove("locked"); return; } const t = u(); const siblings = rows.filter((x) => x.musicId === r.musicId); - const e = eff(r); - const tile = (k, v, sub) => h("div", { class: "tile" }, h("span", {}, k), h("b", {}, v), sub ? h("small", {}, sub) : null); + const tile = (k, v, sub, cls) => h("div", { class: cls ? `tile ${cls}` : "tile" }, h("span", {}, k), h("b", {}, v), sub ? h("small", {}, sub) : null); const c = r.chart; + // the figures of the scenario chosen: redrawn in place while the detail's own scenario panel changes + const controls = h("div", {}); + const scores = h("div", {}); + const line = h("div", { class: "tl-scroll" }); + const weights = h("section", {}); + const ranks = h("div", {}); + const draw = () => { + const e = eff(r); + const battle = S.mode === "battle"; + const solo = battle && r.weights ? freeRate(r) : null; + put(scores, h("div", { class: "tiles" }, + r.weights ? tile(t.col.rate, fmt(e.rate, 3), (() => { + const v = orderRates(r, skills()); + 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.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(ranks, r.weights && r.scoreRanks.length ? h("section", {}, heading("h3", "sec-head", t.detail.ranks), ranksTable(r), + h("p", { class: "hint" }, room() ? t.detail.ranksHintRoom(S.room) : t.detail.ranksHint)) : null); + }; + const panelHere = () => (hasStats ? scenarioPanel({ + redo: () => { put(controls, panelHere()); draw(); behindStale = true; save(); }, + live: () => { draw(); behindStale = true; }, + rooms: true, + missions: r.song.gekisouMissions || null, + }) : null); + put(controls, panelHere()); + draw(); const panel = h("div", { class: "drawer", role: "dialog", "aria-modal": "true", "aria-label": title(r) }, h("div", { class: "d-hero", style: `--band:${bandColor(r)}` }, showJackets() && r.song.jacket ? h("div", { class: "d-bg", style: `background-image:url("${new URL(`jackets/${r.song.jacket}.webp`, site).href}")` }) : null, @@ -677,20 +788,13 @@ const main = async () => { tile(t.detail.notes, fmtInt(r.notes), `${t.detail.fullCombo} ${fmtInt(c.fullComboCount)}`), tile(t.detail.density, `${fmt(r.density)} N/s`, `${t.detail.span} ${formatLength((c.lastJudgedNoteMs || 0) - (c.firstNoteMs || 0))}`), tile(t.detail.bpm, String(r.bpm ?? "–"), r.bpmMin !== r.bpmMax ? `${r.bpmMin}–${r.bpmMax} · ${t.detail.bpmChanges(r.bpmChanges - 1)}` : ""), - tile(t.detail.bgm, formatLength(r.bgmMs), `${t.detail.musicLength} ${formatLength(r.chartMs)}`), - r.weights ? tile(t.col.rate, fmt(e.rate, 3), (() => { - const v = orderRates(r, skills()); - 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.unplayable ? tile(t.detail.unplayable, "–", t.detail.unplayableHint) : null, - r.weights ? tile(t.col.perMinute, fmt(e.perMinute, 3), `${t.length} ${t[S.len]} + ${S.overhead} s`) : null), - heading("h3", "sec-head", t.detail.timeline), h("div", { class: "tl-scroll" }, timeline(r)), + tile(t.detail.bgm, formatLength(r.bgmMs), `${t.detail.musicLength} ${formatLength(r.chartMs)}`)), + hasStats ? heading("h3", "sec-head", t.detail.score) : null, controls, scores, + heading("h3", "sec-head", t.detail.timeline), line, h("div", { class: "grid2" }, h("section", {}, heading("h3", "sec-head", t.detail.composition), composition(r)), - r.weights ? h("section", {}, heading("h3", "sec-head", t.detail.weights), weightsChart(r), h("p", { class: "hint" }, t.detail.weightsHint)) : null), - r.weights && r.scoreRanks.length ? h("section", {}, heading("h3", "sec-head", t.detail.ranks), ranksTable(r), h("p", { class: "hint" }, t.detail.ranksHint)) : null, + weights), + ranks, h("p", { class: "ids" }, `${t.detail.musicId} ${r.musicId} · ${t.detail.scoreId} ${r.scoreId} · ${t.detail.musicType} ${r.song.musicType}`))); drawer.replaceChildren(panel); drawer.classList.add("open"); @@ -707,12 +811,18 @@ const main = async () => { const renderFoot = () => { const p = songs.provenance || {}; const m = p.master || {}; + const c = p.client || {}; const d = p.deck || null; const deckLink = d && d.source && d.commit ? h("a", { href: `${d.source}/tree/${d.commit}`, target: "_blank", rel: "noopener" }, `${d.name || "deck"} ${d.commit.slice(0, 7)}`) : null; - put(foot, h("footer", { class: "foot" }, h("span", {}, u().source(p.region ?? "?", m.version ?? "?"), + // the data is the same on every server as far as we know; the region and versions it was taken from go in the hint + const client = c.versionName ? `${c.versionName}${c.versionCode ? ` (${c.versionCode})` : ""}` : "?"; + const master = m.version ?? "?"; + put(foot, h("footer", { class: "foot" }, h("span", {}, + h("span", { title: u().sourceHint(p.region ?? "?", master, client) }, u().source(/^[0-9a-f]{32}$/i.test(master) ? master.slice(0, 8) : master)), deckLink ? [` · ${u().deckModel} `, deckLink] : null), + h("span", {}, u().caveat), moeBase ? h("span", {}, h("a", { href: moeBase, target: "_blank", rel: "noopener" }, "moenotes", icon("out")), ` · ${u().moenotesHint}`) : null)); }; const renderAll = () => { renderHeader(); renderHead(); renderFilters(); renderMain(); renderFoot(); renderDrawer(); save(); }; diff --git a/examples/songs/text.js b/examples/songs/text.js index 999862a..3be4c14 100644 --- a/examples/songs/text.js +++ b/examples/songs/text.js @@ -9,7 +9,7 @@ export const UI = { band: "乐队", difficulty: "难度", heads: { rank: "谱面排行", charts: "谱面图表" }, lead: { - rank: (songs, charts) => `${songs} 首歌、${charts} 张谱面的可计算事实:效率、活动评级、速度、等级与时长。点任意一行看谱面详情;作词作曲、音频和谱面预览见 moenotes。`, + rank: (songs, charts) => `${songs} 首歌、${charts} 张谱面的效率、活动评级、速度、等级与时长。得分数值按所选游玩场景计算:默认撃奏ライブ、三个区间都是第 1 名(最佳情况),也可切到不开激走的自由 Live,见说明。点任意一行看谱面详情;作词作曲、音频和谱面预览见 moenotes。`, charts: (songs, charts) => `${songs} 首歌、${charts} 张谱面的分布。散点图的两轴可任选,点任意一点看谱面详情。`, }, empty: "没有符合条件的谱面", emptyHint: "当前的乐队、难度、搜索词或“只看前沿”把所有谱面都排除了。", clear: "清除筛选", @@ -31,7 +31,15 @@ export const UI = { length: "时长", bgm: "BGM", chart: "谱面", overhead: "每局额外耗时", skills: "技能加成 %", meanSkill: (v) => `平均 ${v}%`, presets: [["全 150", "150,150,150,150,150"], ["全 100", "100,100,100,100,100"], ["无技能", "0,0,0,0,0"]], - target: "目标评级", power: "综合力", powerHint: "不填则只看所需综合力", great: "Great 比例", + target: "目标评级", power: "综合力", powerHint: "不填则只看所需综合力", + scen: { + mode: "场景", battle: "撃奏ライブ", free: "自由 Live", battleHint: "多人(最多 5 人),激走开启", freeHint: "单人,激走关闭", + ranks: "名次", range: (i, m) => `区间 ${i}${m ? ` · ${m}` : ""}`, rankBest: "名次 1 为最佳情况", ranksPending: "名次 2–5 数据待更新", + accuracy: "准率", great: "Great 比例", just: "Just 率", + accNote: "近似,不含断连;Great 比例作用于所有音符,Just 率只作用于 Just 任务区间", accNoteFree: "近似,不含断连;Great 比例作用于所有音符", + pending: "数据待更新", room: "房间人数", + roomHint: "按全员得分之和评级;假设全员与你同分,你的所需分数 = √(5/n) × 撃奏ライブ门槛", soloRanks: "自由 Live 用单人评级门槛", + }, frontier: "只看前沿", col: { rank: "#", song: "曲目", level: "等级", time: "时长", bpm: "BPM", notes: "Notes", density: "N/s", rate: "分/综合力", perMinute: "分/综合力/分钟", relative: "相对", dom: "支配", skip: "跳过系数", base: "基础系数", @@ -54,15 +62,21 @@ export const UI = { notes: "Notes", fullCombo: "全连数", density: "密度", bpm: "BPM", bpmChanges: (n) => `${n} 次变速`, span: "音符区间", musicLength: "谱面长度", timeline: "时间轴", fever: "Fever", skill: "技能", mission: "激走任务", composition: "音符构成", weights: "技能位权重", - weightsHint: "第 k 位成员带一个 +100% 普通加分技能时整局多得的分数 ÷ 综合力(整局模拟:窗口、帧、连击、Just 与激走排名加成都算在内),取种子平均;百分比为占 W 的份额。哪位成员落在哪个位置每局随机,所以期望只用到 W。", - seeds: (n) => `${n} 个种子`, unplayable: "无法游玩", unplayableHint: "Fever 超过 3 个:游戏只保留 3 个激走区间,第 4 个 Fever 开始时出错", + weightsHint: "第 k 位成员带一个 +100% 普通加分技能时整局多得的分数 ÷ 综合力(整局模拟:窗口、帧、连击都算在内;撃奏ライブ另含 Just 与所选名次的排名加成),取种子平均,并按准率缩放;百分比为占 W 的份额。哪位成员落在哪个位置每局随机,所以期望只用到 W。", + seeds: (n) => `${n} 个种子`, unplayable: "撃奏ライブ无法游玩", unplayableHint: "Fever 超过 3 个:从反编译代码看,游戏只保留 3 个激走区间,第 4 个 Fever 开始时会出错", + unplayableFree: ";自由 Live 不开激走,可以照常游玩,切到自由 Live 可看它的数值", + score: "得分(当前场景)", noFigures: "得分", twoScores: "撃奏ライブ的两套分数(每点综合力)", + twoScoresHint: "撃奏ライブ同时上报两套分数:前者含激走,即本场景的数值;后者不含激走,存为歌曲最高分,约等于自由 Live 的分数", orders: "顺序区间", sameOrder: "技能相同,顺序不影响得分", ranks: "评级门槛", rank: "评级", required: "所需分数", needPower: "所需综合力(期望)", - needRange: "所需综合力(顺序区间)", chanceAt: (p) => `综合力 ${p} 达成率`, - ranksHint: "门槛按歌定义,各难度共用。所需综合力 = 门槛 ÷ (每点综合力得分 × Great 比例系数)。", + needRange: "所需综合力(顺序区间)", chanceAt: (p) => `综合力 ${p} 达成率`, requiredRoom: (n) => `所需分数(${n} 人房间,每人)`, + ranksHint: "门槛按歌定义,各难度共用;自由 Live 用单人门槛。所需综合力 = 门槛 ÷ 每点综合力的期望得分(已含准率)。", + ranksHintRoom: (n) => `门槛按歌定义,各难度共用。撃奏ライブ按房间全员得分之和评级,这里假设房间 ${n} 人、全员与你同分:你的所需分数 = √(5/${n}) × 撃奏ライブ门槛。所需综合力 = 所需分数 ÷ 每点综合力的期望得分(已含准率)。`, efficiency: "效率(当前设置)", close: "关闭", }, kinds: { tap: "点击", flick: "划键", slide: "长条", trace: "追踪", combo: "连击节点" }, - loading: "读取中…", error: "错误", source: (r, v) => `数据:${r} 区服 · master ${v}`, deckModel: "得分模型", + loading: "读取中…", error: "错误", source: (v) => `数据:国际服 / 日服 · 版本 ${v}`, + sourceHint: (r, m, c) => `提取自 ${r} 区服 · master ${m} · 客户端 ${c}`, deckModel: "得分模型", + caveat: "数值以游戏反编译为来源,尽量准确但不保证正确;与其它资料有出入时请以它们为准", }, en: { app: "Chart data", views: { rank: "Rankings", charts: "Charts", guide: "Guide" }, @@ -71,7 +85,7 @@ export const UI = { band: "Band", difficulty: "Difficulty", heads: { rank: "Chart rankings", charts: "Chart figures" }, lead: { - rank: (songs, charts) => `Computable facts of ${songs} songs and ${charts} charts: efficiency, event ranks, speed, level and length. Open any row for the chart's details; credits, audio and chart previews are on moenotes.`, + rank: (songs, charts) => `Efficiency, event ranks, speed, level and length of ${songs} songs and ${charts} charts. Score figures follow the play scenario chosen: by default Gekisou Live at rank 1 in all three ranges (the best case), or Free Live without Gekisou; see the guide. Open any row for the chart's details; credits, audio and chart previews are on moenotes.`, charts: (songs, charts) => `How ${songs} songs and ${charts} charts spread. Pick any two figures for the scatter; open any point for the chart's details.`, }, empty: "No chart matches", emptyHint: "The band, difficulties, search or “frontier only” leave out every chart.", clear: "Clear filters", @@ -93,7 +107,17 @@ export const UI = { length: "Length", bgm: "BGM", chart: "Chart", overhead: "Overhead per play", skills: "Skill score up %", meanSkill: (v) => `mean ${v}%`, presets: [["All 150", "150,150,150,150,150"], ["All 100", "100,100,100,100,100"], ["None", "0,0,0,0,0"]], - target: "Target rank", power: "Power", powerHint: "empty: required power only", great: "Great share", + target: "Target rank", power: "Power", powerHint: "empty: required power only", + scen: { + mode: "Scenario", battle: "Gekisou Live", free: "Free Live", battleHint: "multiplayer (up to 5), Gekisou on", freeHint: "solo, Gekisou off", + ranks: "Rank", range: (i, m) => `range ${i}${m ? ` · ${m}` : ""}`, rankBest: "rank 1 is the best case", ranksPending: "ranks 2–5: data pending", + accuracy: "Accuracy", great: "Great share", just: "Just rate", + accNote: "approximate, no combo breaks; the Great share applies to every note, the Just rate to Just mission ranges only", + accNoteFree: "approximate, no combo breaks; the Great share applies to every note", + pending: "data pending", room: "Players", + roomHint: "the room's summed score is rated; with every player scoring the same as you, you need √(5/n) × the Gekisou Live threshold", + soloRanks: "Free Live uses the solo rank thresholds", + }, frontier: "Frontier only", col: { rank: "#", song: "Song", level: "Level", time: "Time", bpm: "BPM", notes: "Notes", density: "N/s", rate: "Score/power", perMinute: "Score/power/min", relative: "Rel.", dom: "Beaten", skip: "Skip", base: "Base", @@ -115,15 +139,21 @@ export const UI = { musicType: "Song type", bgm: "BGM", musicId: "Music id", scoreId: "Chart id", notes: "Notes", fullCombo: "Full combo", density: "Density", bpm: "BPM", 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, combo, Just and the Gekisou rank bonuses included), averaged over the seeds; and its share of W. Which member lands on which position is drawn every live, so the expectation only uses W.", - seeds: (n) => `${n} seeds`, unplayable: "Unplayable", unplayableHint: "More than 3 fevers: the game keeps three Gekisou ranges and fails when a fourth fever starts", + 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.", + 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)", + twoScoresHint: "Gekisou Live reports two scores: the first with Gekisou, this scenario's figure; the second without, kept as the song's best score, about the Free Live score", 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}`, - ranksHint: "Thresholds are the song's, shared by every difficulty. Power needed = threshold ÷ (score per power × Great factor).", + 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).", + ranksHintRoom: (n) => `Thresholds are the song's, shared by every difficulty. Gekisou Live rates the room's summed score; here a room of ${n} players who all score the same as you: you need √(5/${n}) × the Gekisou Live threshold. Power needed = that score ÷ expected score per power (accuracy included).`, efficiency: "Efficiency (current settings)", close: "Close", }, kinds: { tap: "Tap", flick: "Flick", slide: "Slide", trace: "Trace", combo: "Combo tick" }, - loading: "Loading…", error: "error", source: (r, v) => `Data: region ${r} · master ${v}`, deckModel: "score model", + loading: "Loading…", error: "error", source: (v) => `Data: Global / Japan · version ${v}`, + sourceHint: (r, m, c) => `extracted from region ${r} · master ${m} · client ${c}`, deckModel: "score model", + caveat: "Figures sourced from the decompiled game client: we aim for accuracy but cannot guarantee it; where other sources disagree, trust them", }, }; @@ -131,32 +161,60 @@ export const UI = { export const GUIDE = { zh: { title: "定义、推导与成立条件", - lead: "本页的得分数值来自 music-data.json 的 deck 统计:ournotes-deck 按客户端的得分代码(反编译确认)对每张谱面做整局模拟,激走开启,并逐个种子用真实技能值的卡组核对。" - + "下文给出每个量的定义、由它推出的结论以及结论成立的条件;模型没有覆盖的机制单列一节,逐项说明它们会怎样改变结论、目前怎样处理。", + lead: "本页的数值以游戏客户端的反编译代码为来源:ournotes-deck 按反编译得到的得分逻辑对每张谱面做整局模拟(激走开启与关闭各一次),结果就是 music-data.json 的 deck 统计。" + + "我们尽量保证正确,但对反编译代码的理解、模型的简化和数据版本都可能出错,所以不能保证。如果本页和其它站点、工具或实机结果有出入,多半是我们这边有误,请以它们为准。" + + "下文给出每个量的定义、推导和成立条件;模型没有覆盖的机制单列一节。", sections: [ { title: "得分模型", body: [ - "理论最佳打法:激走开启,每个判定音符都在准确时刻命中,激走 Just 任务区间内为 Just、其它为 Perfect,全连,生命不归零;Fever 区间与歌曲的激走任务一起构成激走区间,单人游玩排名第 1,每个完成的区间都拿到排名加成。" - + "整局按客户端的帧、得分帧(40 ms)、连击与激走连击系数、技能的发动与结束帧逐步模拟(ournotes-deck live::full)。本页的卡组模型是普通加分技能:效果 2000、持续 5 秒、作用于全体、无条件,卡组得分为", + "本页的得分数值按所选的游玩场景计算(第 2 节)。默认是撃奏ライブ(最多 5 人的多人模式)的最佳情况:激走开启,Fever 区间与歌曲的激走任务一起构成激走区间,三个区间都拿第 1 名的排名加成。另一个场景是自由 Live:从反编译代码看,自由 Live 和挑战 Live 不开激走(单人激走只有调试开关,发布版没有入口),按本模型单人游玩的得分会低很多。" + + "两个场景都按理论最佳打法模拟:每个判定音符都在准确时刻命中,全连,生命不归零;撃奏ライブ的激走 Just 任务区间内为 Just,其它音符为 Perfect。准率的近似见第 6 节。" + + "整局按反编译代码中的帧、得分帧(40 ms)、连击与激走连击系数、技能的发动与结束帧逐步模拟(ournotes-deck live::full)。本页的卡组模型是普通加分技能:效果 2000、持续 5 秒、作用于全体、无条件,卡组得分为", ], math: [ "S = P · ( base + Σ_k x_{π(k)} · w_k )", "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 张全部通过。", - "激走的影响很大:区间排名加成(MasterLiveGekisouRankingScoreBonus,单人最高 250%)按区间得分计算,Expert 谱面的 base 中有 36%–69%(中位 53%)来自它;技能落在激走区间内时,排名加成随之放大,所以区间内的技能位权重明显更高。", + "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%)。" - + "游戏的种子规律未知,所以种子平均不是游戏内的真实期望。", + + "游戏的种子规律未知,所以种子平均未必等于游戏内的真实期望。", + ], + }, + { + title: "游玩场景与名次", + body: [ + "撃奏ライブ中,每个激走区间按该区间的任务指标(连击、幸运点数或 Just 数)与房间内的其他玩家比名次,同值同名次;区间完成时,再加上该区间得分的一个百分比作为排名加成(MasterLiveGekisouRankingScoreBonus,按任务模式和名次查表,名次越靠后越少)。" + + "页面上三个区间的名次可以分别选 1–5,默认都是 1,即最佳情况。名次不重新模拟,而是由名次 1 的模拟线性推出:", + ], + math: [ + "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 倍)。", + "自由 Live 场景的数值来自另一次激走关闭的整局模拟,不是从撃奏ライブ减去加成得到的:关闭激走时也没有 Just、激走连击系数和幸运冲刺。关闭激走时没有幸运抽签,本页用到的技能也没有概率条件,所以每张谱面只有一个种子;" + + "Fever 超过 3 个、撃奏ライブ无法游玩的谱面,在自由 Live 场景照常计算。", + "从反编译代码看,撃奏ライブ同时上报两套分数:一套含全部激走效果(Just、幸运、激走连击和排名加成),本页撃奏ライブ场景的数值对应这一套;另一套不含激走效果,存为歌曲的最高分,按本模型约等于自由 Live 的分数。谱面详情里两个数字都列出。" + + "服务器怎样使用这两套分数(例如计算活动积分),客户端代码里看不到。", ], }, { title: "技能顺序是随机变量", body: [ - "客户端在每局开始时构造 MemberDataContainer:先把技能顺序置为 0…n−1,再用 MemberShuffle 随机流做 Fisher–Yates 洗牌;种子取自客户端时钟,单人重试沿用同一种子(顺序也不变)。" - + "第 e 个技能事件触发洗牌后第 e 位成员的技能,Snap 技能的列表按同一顺序构建。因此 π 在 5! = 120 种排列上均匀分布,卡组里的站位不是可选变量。", + "从反编译代码看,客户端在每局开始时构造 MemberDataContainer:先把技能顺序置为 0…n−1,再用 MemberShuffle 随机流做 Fisher–Yates 洗牌;种子取自客户端时钟,单人重试沿用同一种子(顺序也不变)。" + + "第 e 个技能事件触发洗牌后第 e 位成员的技能,Snap 技能的列表按同一顺序构建。因此本页把 π 当作在 5! = 120 种排列上均匀分布,卡组里的站位不是可选变量。", ], math: [ "E[S] / P = base + x̄ · W, x̄ = (1/n) Σ_i x_i, W = Σ_k W_k", @@ -164,7 +222,7 @@ export const GUIDE = { ], after: [ "推论一:期望只取决于技能平均值 x̄,与技能在成员间如何分配、谁在第几位无关。“最强技能放在权重最高的位置”只是 120 种排列中最好的一种,是上界,不是期望。", - "推论二:离散度等于技能离散度与位置权重离散度的乘积;5 个技能相同时,每一局得分都相同。以技能 [140, 100, 60, 30, 0]% 为例,在全部 Expert 谱面上,最好与最差排列相差中位 15.4%、最大 29.2%,最佳排列比期望高中位 7.6%;激走区间让位置权重差距拉大,顺序的影响远大于不计激走时。" + "推论二:离散度等于技能离散度与位置权重离散度的乘积;5 个技能相同时,每一局得分都相同。按本模型,以技能 [140, 100, 60, 30, 0]% 为例,在全部 Expert 谱面上,最好与最差排列相差中位 15.4%、最大 29.2%,最佳排列比期望高中位 7.6%;激走区间让位置权重差距拉大,顺序的影响远大于不计激走时。" + "页面给出期望、120 种排列的区间和 P10。", ], }, @@ -179,8 +237,8 @@ export const GUIDE = { after: [ "固定 x̄ 时,两边之差乘以 (L_a + c)(L_b + c) 后对 c 是线性的;固定 c 时对 x̄ 也是线性的。所以只需检查四个角点:S_a ≥ S_b 与 S_a/L_a ≥ S_b/L_b 在 x̄ = 0 和 x̄ = x_max 处成立。" + "x_max = 150%,取自主数据中单个技能的最大值(MasterLiveSkillEffect,满级)。不设上界时,一些 Easy 谱面只能靠不存在的 500% 以上技能值留在前沿上。", - "前沿(不被任何谱面支配的集合)是与参数无关的结论:无论 x̄ 和 c 取何值,最优谱面一定在前沿上。给定参数时的名次只是其中一种排序。" - + "现有数据中,两种 L 下前沿都只有 340 张里的 2 张。", + "在本模型内,前沿(不被任何谱面支配的集合)与 x̄ 和 c 无关:无论它们取何值,最优谱面都在前沿上;给定参数时的名次只是其中一种排序。" + + "换游玩场景、名次或 Just 率会改变各谱面的数值,前沿也可能随之变化;Great 比例对所有谱面是同一个系数,不改变前沿。现有数据中,撃奏ライブ名次 1 时两种 L 下前沿都只有 340 张里的 2 张。", "比较的前提是同一卡组在两首歌上综合力相同。卡牌的曲种加成(musicType)和标签加成会使 P 随歌曲变化,这时应比较 P_a S_a 与 P_b S_b。", ], }, @@ -188,8 +246,9 @@ export const GUIDE = { title: "评级与活动", body: [ "评级门槛按歌定义:MasterLiveMusic._liveScoreRankGroup 指向 MasterLiveScoreRank 的一组行,同一首歌的所有难度共用;得分达到的最高门槛就是评级。" - + "协力的门槛为 trunc(√(5/n) · R_battle · n)(n 为未断线人数),E 计作 D。", - "客户端的活动积分计算如下,其中 v 由(活动,评级)查表,加成来自卡组,倍率在消耗 k 个 boost 时为 5k、不消耗时为 1:", + + "自由 Live 用单人门槛 R(_requiredScore)。撃奏ライブ用另一组门槛 R_battle(_battleLiveRequiredScore),按房间内全员得分之和评级:总分对比 trunc(√(5/n) · R_battle · n)(n 为未断线人数),E 计作 D。" + + "页面在撃奏ライブ场景下假设房间 n 人(默认 5,可选 1–5)且全员与你同分,于是你的所需分数约为 √(5/n) · R_battle;队友得分高于或低于你时,你实际需要的分数会随之变低或变高。", + "反编译代码中的活动积分计算如下,其中 v 由(活动,评级)查表,加成来自卡组,倍率在消耗 k 个 boost 时为 5k、不消耗时为 1:", ], math: [ "积分 = trunc( (10000 + 加成) · 倍率 · v(活动, 评级) / 10000 )", @@ -200,18 +259,25 @@ export const GUIDE = { "歌曲只通过评级进入积分,所以每个 boost 换到的积分与选哪首歌无关;歌曲的差别只在于各评级的达成概率和一局的时长。" + "活动支配只要求 v 随评级不减,不需要知道积分表:满足上式时,任何卡组达成任何评级在 a 上所需的综合力都不高于 b,而 a 不更长。", "积分表可以反推,不必等主数据:一局的加成和倍率已知,所以 v = 积分 × 10000 / ((10000 + 加成) × 倍率),截断带来的误差小于 1/倍率;每个评级一条样本就能唯一确定 v。", - "达成率是在 120 种技能顺序上 P · f · S_π ≥ R 成立的比例,f 为下节的判定系数;所需综合力取期望,区间取最好与最差排列。" - + "技能全 140% 时,Expert 达到 SS 所需综合力从 27 万到 128 万,相差 4.7 倍。SS 门槛对本模型的得分容量做过原点的最小二乘拟合,相对残差约 30%:门槛不是按容量定的,所以活动选曲必须逐曲计算,不能拿效率排行代替。" + "达成率是在 120 种技能顺序上 P · S_π ≥ R 成立的比例(S 已含第 6 节的准率近似);所需综合力取期望,区间取最好与最差排列。" + + "按本模型,技能全 140%、撃奏ライブ名次 1、5 人房间时,Expert 达到 SS 所需综合力从 27 万到 205 万,相差 7.5 倍。SS 门槛对本模型的得分容量做过原点的最小二乘拟合,中位相对残差约 32%:门槛看来不是按容量定的,所以活动选曲最好逐曲计算,不宜直接拿效率排行代替。" + "85 首歌里有 20 首的 Hard 比 Expert 更容易拿到 SS。", ], }, { - title: "判定精度", - body: ["判定系数:Perfect 100%,Great 80%,Good 50%,Bad 与 Miss 为 0 且断连;Just 为 230%,只在激走的 Just 任务区间内可能出现。"], - math: ["E[S | Great 比例 q] = (1 − 0.2 q) · S"], + title: "判定与准率", + body: ["判定系数:Perfect 100%,Great 80%,Good 50%,Bad 与 Miss 为 0 且断连;Just 为 230%,只在撃奏ライブ的 Just 任务区间内可能出现。页面上的两个准率滑块都是近似,都不含断连:"], + math: [ + "g(q) = 1 − 0.2 q", + "X(j) = X_P + j · (X_J − X_P), X ∈ { 不含名次加成的整局得分 N, 区间得分 RS_i }", + "S ≈ g(q) · [ N(j) + Σ_i trunc( RS_i(j) · p_i(r_i) / 100 ) ]", + "w_k ≈ g(q) · [ w_r[k] + Σ_i ( ρ_i − 1 ) · ( 1 + p_i(r_i) / 100 ) · u_i[k] ], ρ_i = RS_i(j) / RS_i", + ], after: [ - "近似:若只出现 Perfect 与 Great(不断连),且每个音符为 Great 的概率 q 与位置无关,那么普通音符与排名加成都按 (1 − 0.2 q) 缩放,页面“Great 比例”就是把这个系数乘进所需综合力和达成率。" - + "Just 区间内的音符不是这样:一个 Great 从 230% 降到 80%,按比例损失远大于 20%,所以 Just 区间多的谱面上这个系数偏乐观,效率排序也可能随 q 变化。", + "Great 比例 q 作用于所有音符:若只出现 Perfect 与 Great(不断连),且每个音符为 Great 的概率 q 与位置无关,那么普通音符与排名加成都按 g(q) 缩放,页面把这个系数乘进所有得分数值(效率、所需综合力、达成率和详情)。" + + "Just 区间内的音符不完全是这样:一个 Great 从 230% 降到 80%,按比例损失远大于 20%,所以 Just 区间多的谱面上这个系数偏乐观。", + "Just 率 j 只作用于撃奏ライブ的 Just 任务区间:deck 另做一次 Just 区间内全打 Perfect 的无技能模拟(X_P),页面在它与全 Just 的模拟(X_J)之间按 j 线性插值,名次加成按插值后的区间得分重算(第 2 节的截断公式),再乘 g(q)。" + + "技能位权重在各区间内的部分按同一比例 ρ_i 缩放,这也是近似。j = 100%、q = 0 时回到第 1、2 节的数值;自由 Live 没有 Just,只有 Great 比例。", "断连不满足上述条件:连击系数和激走连击系数取决于当前连击数,连击任务区间还会因断连失败,断在不同位置损失不同(deck 统计尚未导出每张谱面的断连损失分布)。" + "判定转换技能(12006、13005)把 Great 转为 Perfect,相当于降低 q;回复和护盾只在生命归零之后才影响得分(此后每个音符 × 0.3)。", ], @@ -221,10 +287,12 @@ export const GUIDE = { defs: [ ["其它技能类型", "music-data.json 的 deck.kinds 列出主数据里所有与因子线性相关的加分类别(效果 2000、2002、2004、2005,按持续时间、目标和条件区分),每张谱面都给出了各类别的位置权重;" + "本页只用普通加分这一类。判定加分(2004,目标判定 41/46)和带条件的 2000(条件组 14、15)权重不同,需要按具体卡组逐类求和;累计加分(2001、2003)、判定转换、激走技能与 Snap 技能不是线性的,只能整局模拟。"], - ["多人游玩", "排名加成按区间内的名次给出,本页按单人(第 1 名)计算;协力时名次与其他玩家有关,排名加成通常更低。"], + ["撃奏ライブ的对手", "名次由房间里其他玩家在各区间的任务指标决定。本页不模拟对手,而是让你直接选名次(名次 1 为最佳情况);同值时加成表按哪一种名次查尚未完全核实,直接选名次时不受影响。房间不满 5 人时服务器是否补位,客户端代码里看不到。游戏没有协力模式。"], ["Snap 技能", "跟随所属成员一起被洗牌;效果多由条件触发(成员乐队、生命、判定数等),取决于具体卡组,本页不计入。条件在整局都满足的加分,相当于在 base 上乘 (1 + y)。"], ["概率发动(条件 4011)", "若发动概率 p 与其它量独立,它的期望贡献等于把该技能值换成 p · x,可以直接填进技能输入;方差会比表中给出的更大。"], ["实机一局耗时", "进入结算的时刻、加载时间都依赖设备和网络,由每局额外耗时 c 表示;支配关系对所有 c ≥ 0 成立。"], + ["核对范围", "deck 的各项规则是对照反编译得到的客户端代码核对的(在模拟器里运行游戏自己的函数,比对输出),还没有拿实机成绩逐局核对。" + + "判定窗口类技能(4000–4003、13001)尚未接入整局模拟,另有少数激走条件只有合成测试;本页只用普通加分技能,不受这两项影响。游戏更新或热更新补丁也可能改变这些规则。"], ], }, { @@ -233,41 +301,69 @@ export const GUIDE = { ["等级", "显示等级(_musicScoreDisplayLevel,可带小数);得分使用整数等级(_musicScoreLevel)。"], ["Notes", "判定音符数,也就是全连连击数;不含隐藏音符、导引终点和不判定的长条节点。音符构成按 NoteOperateType 归类:点击 {1, 101}、划键 {40, 41, 42, 102}、长条 {20, 21, 22}、追踪 {60–63, 104, 105}、连击节点 {120}。"], ["主 BPM、密度", "主 BPM 是第一个到最后一个判定音符之间持续时间最长的 BPM;密度 = 判定音符数 ÷ 这段区间的秒数。"], - ["基础系数、W、跳过系数", "base 与 W 定义见第 1、2 节(base 含激走区间排名加成)。跳过系数是跳过时每点综合力的得分(全部按 Great、连击为 0、无技能),与时长无关。"], - ["常见排行的口径问题", "用 Expert 的末音时间当所有难度的时长、把额外耗时固定为某个值不作说明、得分用未公开的卡组或不说明是否计入激走,这三种做法都会让名次随隐藏参数变化,无法复现。" - + "本页把所有参数放在界面上,并用支配关系给出与参数无关的结论。"], + ["基础系数、W、跳过系数", "base 与 W 定义见第 1、3 节:撃奏ライブ场景下 base 含所选名次的激走区间排名加成,自由 Live 场景没有;两者都按准率缩放。跳过系数是跳过时每点综合力的得分(全部按 Great、连击为 0、无技能),与时长无关,本页不随场景变化。"], + ["与其它资料不一致时", "本页的数值来自我们对反编译代码的理解,没有官方资料可以对照,可能有错;和其它站点、工具或实机结果不一致时,请优先相信它们。" + + "口径不同(时长的取法、游玩场景与名次、准率、卡组设定)也会带来差别,可以先把页面上的参数调成对方的设定再比较。"], ], }, ], }, en: { title: "Definitions, derivations and conditions", - lead: "The score figures on this page are music-data.json's deck statistics: ournotes-deck simulates every chart's whole live after the client's score code (decompiled), Gekisou on, and checks each seed against a deck of real skill values. " - + "Below: each quantity's definition, what follows from it and under which conditions; the mechanisms the model leaves out have their own section, with how each would change the results and how it is handled now.", + lead: "The figures on this page are sourced from the game client's decompiled code: ournotes-deck simulates every chart's whole live (once with Gekisou on, once off) after the score logic read from it, and the results are music-data.json's deck statistics. " + + "We aim for them to be correct but cannot guarantee it: our reading of the decompiled code, the model's simplifications and the data version can all be wrong. Where this page disagrees with other sites, tools or real plays, the mistake is most likely ours; trust them. " + + "Below: each quantity's definition, derivation and conditions; the mechanisms the model leaves out have their own section.", sections: [ { title: "Score model", body: [ - "Theoretical best play: Gekisou on, every judged note hit at its exact time, Just inside the Gekisou Just-count ranges and Perfect elsewhere, a full combo, life never at zero; the fevers with the song's Gekisou missions make the Gekisou ranges, and a solo player takes rank 1, so every completed range adds its rank bonus. " - + "The live is simulated frame by frame after the client (ournotes-deck live::full): score frames (40 ms), combo and Gekisou combo factors, the skills' execute and finish frames. The page's deck model is the plain score-up skill: effect 2000, 5 s, the whole deck, no condition. A deck scores", + "The page's score figures follow the play scenario chosen (section 2). The default is the best case of Gekisou Live (撃奏ライブ, the multiplayer mode of up to 5 players): Gekisou on, the fevers with the song's Gekisou missions make the Gekisou ranges, and all three ranges take the rank-1 bonus. The other scenario is Free Live: by the decompiled code, Free Live and Challenge Live play without Gekisou (solo Gekisou is a debug switch with no way in in the release), and by this model solo scores are much lower. " + + "Both are simulated in theoretical best play: every judged note hit at its exact time, a full combo, life never at zero; in Gekisou Live Just inside the Gekisou Just mission ranges and Perfect elsewhere. Section 6 has the accuracy approximations. " + + "The live is simulated frame by frame after the decompiled code (ournotes-deck live::full): frames, score frames (40 ms), combo and Gekisou combo factors, the skills' execute and finish frames. The page's deck model is the plain score-up skill: effect 2000, 5 s, the whole deck, no condition. A deck scores", ], math: [ "S = P · ( base + Σ_k x_{π(k)} · w_k )", "base = score / P₀, w_k = (score with a factor-1 skill at position k − score) / P₀, P₀ = 300000", ], after: [ - "score is the exact no-skill score (rank bonuses included), 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.", - "Gekisou weighs a lot: the range rank bonus (MasterLiveGekisouRankingScoreBonus, up to 250 % solo) is a share of the range's score, and makes 36–69 % (median 53 %) of an Expert chart's base; a skill inside a Gekisou range raises the rank bonus too, so the positions inside ranges weigh much more.", - "Luck 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 is not the game's own expectation.", + "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.", + "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.", + ], + }, + { + title: "Play scenarios and ranks", + body: [ + "In Gekisou Live every Gekisou range ranks the players of the room by that range's mission measure (combo, luck points or Just count), equal values sharing a rank; a completed range adds a percentage of its own score as the rank bonus (MasterLiveGekisouRankingScoreBonus, looked up by mission mode and rank, smaller for lower ranks). " + + "The page takes a rank 1–5 for each of the three ranges, rank 1 everywhere by default, the best case. The ranks are not simulated again but follow from the rank-1 simulation linearly:", + ], + math: [ + "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).", + "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 and the rank bonuses), which the page's Gekisou Live figures stand for; the other without any Gekisou effect, 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.", ], }, { title: "The skill order is random", body: [ - "At the start of every live the client builds MemberDataContainer: the skill order is set to 0…n−1 and Fisher–Yates shuffled with the MemberShuffle random stream, seeded from the client clock (a solo retry keeps the seed, and the order). " - + "Skill event e fires the member at position e of the shuffled order, and the snap skill lists follow the same order. So π is uniform over the 5! = 120 orders; the deck's slot order is not a choice.", + "From the decompiled code: at the start of every live the client builds MemberDataContainer; the skill order is set to 0…n−1 and Fisher–Yates shuffled with the MemberShuffle random stream, seeded from the client clock (a solo retry keeps the seed, and the order). " + + "Skill event e fires the member at position e of the shuffled order, and the snap skill lists follow the same order. So the page takes π as uniform over the 5! = 120 orders; the deck's slot order is not a choice.", ], math: [ "E[S] / P = base + x̄ · W, x̄ = (1/n) Σ_i x_i, W = Σ_k W_k", @@ -275,7 +371,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. 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. " + "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.", ], }, @@ -290,8 +386,8 @@ export const GUIDE = { after: [ "For a fixed x̄ the difference times (L_a + c)(L_b + c) is linear in c, and for a fixed c it is linear in x̄, so four corners decide: S_a ≥ S_b and S_a/L_a ≥ S_b/L_b at x̄ = 0 and at x̄ = x_max. " + "x_max = 150 %, the largest single skill value in the master data (MasterLiveSkillEffect, level 5). Without a bound, some Easy charts stay on the frontier only for skill values above 500 %, which do not exist.", - "The frontier (the charts nothing beats) holds whatever the parameters: for any x̄ and c the best chart is on it; a ranking for given parameters is one ordering of it. " - + "On the current data the frontier holds 2 of the 340 charts for either L.", + "Within this model the frontier (the charts nothing beats) does not depend on x̄ and c: for any of them the best chart is on it; a ranking for given parameters is one ordering of it. " + + "Another play scenario, rank or Just rate changes every chart's figures, and the frontier may change with them; the Great share is one factor for every chart and leaves it alone. On the current data, Gekisou Live at rank 1, the frontier holds 2 of the 340 charts for either L.", "The comparison assumes the deck has the same power on both songs. Card song-type (musicType) and tag bonuses make P depend on the song; then compare P_a S_a with P_b S_b.", ], }, @@ -299,8 +395,9 @@ export const GUIDE = { title: "Score ranks and events", body: [ "Rank thresholds are per song: MasterLiveMusic._liveScoreRankGroup selects rows of MasterLiveScoreRank, shared by every difficulty; the rank is the highest threshold the score reaches. " - + "Co-op thresholds are trunc(√(5/n) · R_battle · n) for n connected players, E counting as D.", - "The client computes event points as below; v comes from the (event, rank) table, the bonus from the deck, and the rate is 5k for k boosts spent, 1 for none:", + + "Free Live uses the solo threshold R (_requiredScore). Gekisou Live has thresholds of its own, R_battle (_battleLiveRequiredScore), and rates the sum of every player's score in the room against trunc(√(5/n) · R_battle · n) for n connected players, E counting as D. " + + "In Gekisou Live the page takes a room of n players (5 by default, 1–5) who all score the same as you, so you need about √(5/n) · R_battle; teammates scoring above or below you lower or raise what you actually need.", + "In the decompiled code event points are computed as below; v comes from the (event, rank) table, the bonus from the deck, and the rate is 5k for k boosts spent, 1 for none:", ], math: [ "points = trunc( (10000 + bonus) · rate · v(event, rank) / 10000 )", @@ -311,18 +408,25 @@ export const GUIDE = { "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 · f · S_π ≥ R, f the judgement factor of the next section; the power needed uses the expectation, its range the best and the worst order. " - + "With every skill at 140 %, SS on Expert needs 0.27 to 1.28 million power, a factor 4.7. A least-squares fit through the origin of the SS thresholds against this model's score capacity leaves 30 % relative residuals: thresholds are not set by capacity, so event choices must be computed song by song, not read off the efficiency ranking. " + "The chance is the share of the 120 skill orders with P · S_π ≥ R (S with the accuracy approximation of section 6); 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.", ], }, { - title: "Judgement accuracy", - body: ["Judgement factors: Perfect 100 %, Great 80 %, Good 50 %, Bad and Miss 0 and a combo break; Just 230 %, only possible inside Gekisou Just-count ranges."], - math: ["E[S | Great share q] = (1 − 0.2 q) · S"], + title: "Judgements and accuracy", + body: ["Judgement factors: Perfect 100 %, Great 80 %, Good 50 %, Bad and Miss 0 and a combo break; Just 230 %, only possible inside the Just mission ranges of Gekisou Live. The page's two accuracy sliders are both approximations, and neither has combo breaks:"], + math: [ + "g(q) = 1 − 0.2 q", + "X(j) = X_P + j · (X_J − X_P), X ∈ { the whole-live score without rank bonuses N, the range scores RS_i }", + "S ≈ g(q) · [ N(j) + Σ_i trunc( RS_i(j) · p_i(r_i) / 100 ) ]", + "w_k ≈ g(q) · [ w_r[k] + Σ_i ( ρ_i − 1 ) · ( 1 + p_i(r_i) / 100 ) · u_i[k] ], ρ_i = RS_i(j) / RS_i", + ], after: [ - "Approximation: with Perfects and Greats only (no break) and a Great chance q that does not depend on the note, plain notes and rank bonuses scale by (1 − 0.2 q); the page's Great share applies that factor to the power needed and the chance. " - + "Notes inside Just ranges do not: a Great there falls from 230 % to 80 %, far more than 20 %, so the factor is optimistic on charts with much Just range, and the efficiency order may change with q.", + "The Great share q applies to every note: with Perfects and Greats only (no break) and a Great chance q that does not depend on the note, plain notes and rank bonuses scale by g(q), and the page applies that factor to every score figure (efficiency, the power needed, the chance and the details). " + + "Notes inside Just ranges do not quite: a Great there falls from 230 % to 80 %, far more than 20 %, so the factor is optimistic on charts with much Just range.", + "The Just rate j applies to the Just mission ranges of Gekisou Live only: deck runs the live once more with Perfects instead of Justs in those ranges and no skills (X_P), the page interpolates linearly by j between it and the all-Just run (X_J), recomputes the rank bonuses on the interpolated range scores (the floor formula of section 2) and applies g(q). " + + "The skill position weights are scaled by the same ratio ρ_i inside every range, again an approximation. At j = 100 % and q = 0 the figures are those of sections 1 and 2; Free Live has no Just, only the Great share.", "Breaks do not meet the condition: the combo and Gekisou combo factors depend on the running combo and a break fails a combo mission range, so a break costs differently at different notes (the deck statistics do not export a chart's break-cost distribution yet). " + "Judgement conversion (12006, 13005) turns Greats into Perfects, lowering q; recovery and guard skills change the score only once life reaches zero (every note × 0.3 after that).", ], @@ -332,10 +436,12 @@ export const GUIDE = { defs: [ ["Other skill types", "music-data.json's deck.kinds lists every score-up kind of the master data whose score is linear in the factor (effects 2000, 2002, 2004, 2005, told apart by duration, targets and conditions), with each chart's position weights per kind; " + "the page uses the plain score-up kind only. Judgement score up (2004, target judgements 41/46) and conditional 2000s (condition groups 14, 15) weigh differently and need a deck's own kinds summed; cumulative score up (2001, 2003), judgement conversion, Gekisou skills and snap skills are not linear and need the simulation itself."], - ["Multiplayer", "The rank bonus follows the rank within the range; the page takes a solo player (rank 1). In co-op the rank depends on the other players and the bonus is usually lower."], + ["Gekisou Live opponents", "The ranks depend on the other players' mission measures in every range. The page does not model opponents and lets you pick the ranks instead (rank 1 is the best case); which kind of rank the bonus table takes on equal values is not fully checked, and picking the ranks directly does not depend on it. Whether the server fills a room of fewer than 5 is not in the client code. The game has no co-op mode."], ["Snap skills", "Shuffled with their member; mostly conditional (the member's band, life, judgement counts), so deck-specific and left out here. A score up whose condition holds all live multiplies base by (1 + y)."], ["Probability skills (condition 4011)", "With an activation chance p independent of the rest, the expected contribution is that of the value p · x, which can be entered as the skill value; the spread is larger than shown."], ["Real play time", "When the results start and how long loading takes depend on the device and the network; the overhead c stands for them, and dominance holds for every c ≥ 0."], + ["Scope of the checks", "deck's rules are checked against the decompiled client code (the game's own functions run in an emulator and their outputs compared), not yet against real play results live by live. " + + "Judgement window skills (4000–4003, 13001) are not in the whole-live simulation yet, and a few Gekisou conditions only have synthetic tests; the page uses the plain score-up skill only and is affected by neither. A game update or hotfix patch may also change these rules."], ], }, { @@ -344,9 +450,9 @@ export const GUIDE = { ["Level", "The display level (_musicScoreDisplayLevel, with decimals); the score uses the whole level (_musicScoreLevel)."], ["Notes", "Judged notes, the combo of a full combo; hidden notes, guide ends and unjudged slide ticks are left out. By NoteOperateType: tap {1, 101}, flick {40, 41, 42, 102}, slide {20, 21, 22}, trace {60–63, 104, 105}, combo tick {120}."], ["Main BPM, density", "The main BPM holds longest from the first to the last judged note; density = judged notes ÷ that span in seconds."], - ["Base, W, skip", "Base and W as in sections 1 and 2 (base includes the Gekisou range rank bonuses). Skip: score per point of power of a skipped live (every note Great, combo 0, no skills), independent of the length."], - ["Common ranking pitfalls", "Using the Expert chart's last note as every difficulty's length, a fixed unstated overhead, or a score from an unstated deck or with Gekisou unstated all make the order depend on hidden parameters and impossible to reproduce. " - + "This page puts every parameter on screen and states the parameter-free results through dominance."], + ["Base, W, skip", "Base and W as in sections 1 and 3: in Gekisou Live base includes the range rank bonuses of the ranks chosen, in Free Live it has none; both scale with the accuracy. Skip: score per point of power of a skipped live (every note Great, combo 0, no skills), independent of the length, and the same in every scenario on this page."], + ["Where other sources disagree", "The figures come from our reading of the decompiled code, with no official data to check them against, and may be wrong; where other sites, tools or real plays disagree, trust them first. " + + "Different conventions (how the length is taken, the play scenario and ranks, the accuracy, the deck) also make differences; set the page's parameters to theirs before comparing."], ], }, ], diff --git a/tests/unit/songs-page.test.mjs b/tests/unit/songs-page.test.mjs index 6e7c996..a506483 100644 --- a/tests/unit/songs-page.test.mjs +++ b/tests/unit/songs-page.test.mjs @@ -2,11 +2,12 @@ import test from "node:test"; import assert from "node:assert/strict"; import { - X_MAX, chartFigures, dominance, dominates, eventDominates, formatLength, joinCharts, lengthMs, meanSkill, orderRates, - perMinute, pickText, plainKind, quantile, rank, rankThreshold, reachChance, requiredPower, scoreRate, weightSum, + DEFAULT_SCENARIO, X_MAX, chartFigures, dominance, dominates, eventDominates, formatLength, formatRanks, greatFactor, + joinCharts, lengthMs, meanSkill, orderRates, parseRanks, perMinute, pickText, plainKind, quantile, rank, rankPercent, + rankThreshold, reachChance, requiredPower, roomThreshold, scenarioData, scoreRate, weightSum, } from "../../examples/songs/ranking.js"; import { - chartRows, density, extent, histogram, matches, moenotesUrl, noteKinds, sortBy, ticks, + chartRows, density, extent, histogram, matches, moenotesUrl, noteKinds, refigure, sortBy, ticks, } from "../../examples/songs/catalog.js"; // music-data.json: kind 0 is a judgement score up, kind 1 the plain score up the page models @@ -208,6 +209,176 @@ test("rank marks the frontier and sorts", () => { assert.equal(dominates(a, { ...a, scoreId: 9 }, "chart"), false); }); +// ---------------------------------------------------------------- play scenarios + +// chartFigures before the scenarios: the seeds' own score and weights +const statusQuo = (deck, kind, power) => { + const seeds = (deck && !deck.unplayable && deck.seeds) || []; + if (!seeds.length || !seeds.every((s) => Array.isArray(s.weights && s.weights[kind]))) return null; + const bases = seeds.map((s) => s.score / power); + const n = deck.positions ?? seeds[0].weights[kind].length; + const avg = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length; + return { base: avg(bases), baseRange: [Math.min(...bases), Math.max(...bases)], seeds: seeds.length, skip: deck.skip ?? null, + weights: [...Array(n).keys()].map((k) => avg(seeds.map((s) => s.weights[kind][k] ?? 0))) }; +}; + +// three ranges: combo, Just, luck; rank 1 bonus = trunc(rangeScore * p(1) / 100); the no-bonus score 20000 all Just, +// 19198 all Perfect (range 1 scores 2003 all Just, 1201 all Perfect) +const RANGES3 = [ + { index: 0, mission: 1, rankBonusPercent: 250, rankBonusPercents: [250, 200, 160, 130, 100] }, + { index: 1, mission: 3, rankBonusPercent: 250, rankBonusPercents: [250, 200, 160, 130, 100] }, + { index: 2, mission: 2, rankBonusPercent: 370, rankBonusPercents: [370, 300, 230, 160, 100] }, +]; +const W1 = [0.1, 0.2, 0.3, 0.4, 0.5]; +const RW1 = [[0.01, 0.02, 0], [0, 0.03, 0.01], [0.02, 0, 0.04], [0, 0, 0], [0.05, 0.01, 0.02]]; // [k][i] +const battleSeed = (extra = {}) => ({ + seed: 0, + score: 20000 + 2502 + 5007 + 5575, + scorePerfect: 19198 + 2502 + 3002 + 5575, + weights: [[9, 9, 9, 9, 9], W1], + rangeWeights: [RW1.map((w) => w.map(() => 1)), RW1], + ranges: [ + { rangeScore: 1001, rankBonus: 2502, justCount: 0 }, + { rangeScore: 2003, rankBonus: 5007, rangeScorePerfect: 1201, justCount: 40 }, + { rangeScore: 1507, rankBonus: 5575, justCount: 0 }, + ], + ...extra, +}); +const deck3 = (extra = {}) => ({ + positions: 5, skip: 2, unplayable: null, ranges: RANGES3, + seeds: [battleSeed(), battleSeed({ seed: 1, score: battleSeed().score + 300, scorePerfect: battleSeed().scorePerfect + 300 })], + offSeeds: [{ seed: 0, score: 9000, weights: [[1, 1, 1, 1, 1], [0.05, 0.06, 0.07, 0.08, 0.09]], check: null }], + ...extra, +}); +const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-9, `${msg}: ${a} vs ${b}`); +const nearAll = (a, b, msg) => { assert.equal(a.length, b.length, msg); a.forEach((v, i) => near(v, b[i], `${msg}[${i}]`)); }; + +test("rank 1 everywhere, all Just and no Great is the seeds themselves, value for value", () => { + const decks = [deck3(), songs.songs[0].charts[1].deck, songs.songs[1].charts[0].deck, songs.songs[1].charts[1].deck, null]; + for (const d of decks) { + for (const sc of [undefined, null, DEFAULT_SCENARIO, { mode: "battle", ranks: [1, 1, 1], just: 1, great: 0 }, { mode: "battle" }]) { + assert.deepEqual(chartFigures(d, 1, 1000, sc), statusQuo(d, 1, 1000)); + } + } + assert.deepEqual(joinCharts(songs, DEFAULT_SCENARIO), joinCharts(songs)); + // the rows' figures too, and back after another scenario + const rows = chartRows(songs); + const before = structuredClone(rows.map(({ song, chart, stats, ...r }) => r)); + refigure(rows, songs, { mode: "battle", ranks: [5, 5, 5], great: 0.5 }); + assert.notDeepEqual(rows.find((r) => r.scoreId === 13).base, before.find((r) => r.scoreId === 13).base); + refigure(rows, songs, DEFAULT_SCENARIO); + assert.deepEqual(rows.map(({ song, chart, stats, ...r }) => r), before); + assert.equal(greatFactor(0), 1); + near(greatFactor(0.25), 0.95, "g(0.25)"); + assert.equal(greatFactor(2), greatFactor(1)); +}); + +test("the linear rank formula against the hand computation", () => { + const d = deck3({ seeds: [battleSeed()] }); + // ranks 2, 5, 3: percents 200, 100, 230; bonuses trunc(1001 * 2) = 2002, trunc(2003 * 1) = 2003, trunc(1507 * 2.3) = 3466 + const f = chartFigures(d, 1, 1000, { mode: "battle", ranks: [2, 5, 3] }); + near(f.base, (20000 + 2002 + 2003 + 3466) / 1000, "base"); + // w[k] + (-0.5 rw[k][0] - 1.5 rw[k][1] - 1.4 rw[k][2]) + nearAll(f.weights, [0.1 - 0.005 - 0.03, 0.2 - 0.045 - 0.014, 0.3 - 0.01 - 0.056, 0.4, 0.5 - 0.025 - 0.015 - 0.028], "weights"); + assert.equal(rankPercent(RANGES3[2], 3), 230); + assert.equal(rankPercent({ rankBonusPercent: 250 }, 1), 250); // older data: rank 1 only + assert.equal(rankPercent({ rankBonusPercent: 250 }, 2), null); + // the rank bonus is floored on the range score: 1001 * 160 / 100 = 1601.6 + near(chartFigures(d, 1, 1000, { ranks: [3, 1, 1] }).base, (20000 + 1601 + 5007 + 5575) / 1000, "floor"); + // a single rank moves only its own range + const g = chartFigures(d, 1, 1000, { ranks: [1, 1, 5] }); + near(g.base, (20000 + 2502 + 5007 + 1507) / 1000, "rank 5 in range 2"); + nearAll(g.weights, W1.map((w, k) => w + ((100 - 370) / 100) * RW1[k][2]), "rank 5 weights"); + // the mean over the seeds + const two = chartFigures(deck3(), 1, 1000, { ranks: [2, 5, 3] }); + near(two.base, f.base + 0.15, "two seeds"); + assert.deepEqual(two.baseRange.map((x) => +x.toFixed(9)), [f.base, f.base + 0.3].map((x) => +x.toFixed(9))); + // without rangeWeights or rankBonusPercents the other ranks have no figures; rank 1 still has + const bare = deck3({ seeds: [battleSeed({ rangeWeights: undefined })] }); + assert.equal(chartFigures(bare, 1, 1000, { ranks: [2, 1, 1] }), null); + assert.deepEqual(chartFigures(bare, 1, 1000, { ranks: [1, 1, 1] }), statusQuo(bare, 1, 1000)); + assert.equal(chartFigures(deck3({ ranges: RANGES3.map(({ rankBonusPercents, ...r }) => r) }), 1, 1000, { ranks: [2, 2, 2] }), null); +}); + +test("the Just rate interpolates to the all-Perfect run, then the Great share scales", () => { + const d = deck3({ seeds: [battleSeed()] }); + // j = 0: all Perfect at rank 1 is scorePerfect itself + near(chartFigures(d, 1, 1000, { just: 0 }).base, (19198 + 2502 + 3002 + 5575) / 1000, "all Perfect"); + // j = 0.5, q = 0.25, ranks 2, 5, 3: no-bonus score 19198 + 0.5 * 802 = 19599; range 1 scores 1201 + 0.5 * 802 = 1602 + const f = chartFigures(d, 1, 1000, { ranks: [2, 5, 3], just: 0.5, great: 0.25 }); + near(f.base, ((19599 + 2002 + 1602 + 3466) * 0.95) / 1000, "base"); + // the weights of range 1 scale by 1602 / 2003, with its rank bonus (100 %) on top + const rho = 1602 / 2003; + const wr = [0.065, 0.141, 0.234, 0.4, 0.432]; + nearAll(f.weights, wr.map((w, k) => (w + (rho - 1) * 2 * RW1[k][1]) * 0.95), "weights"); + // the Great share alone + const q = chartFigures(d, 1, 1000, { great: 0.5 }); + near(q.base, (battleSeed().score * 0.9) / 1000, "Great base"); + nearAll(q.weights, W1.map((w) => w * 0.9), "Great weights"); + // without scorePerfect, or a Just range without its Perfect score, no figures below 100 % + assert.equal(chartFigures(deck3({ seeds: [battleSeed({ scorePerfect: undefined })] }), 1, 1000, { just: 0.5 }), null); + const noJustPerfect = battleSeed(); + noJustPerfect.ranges = noJustPerfect.ranges.map(({ rangeScorePerfect, ...x }) => x); + assert.equal(chartFigures(deck3({ seeds: [noJustPerfect] }), 1, 1000, { just: 0.5 }), null); +}); + +test("Free Live reads offSeeds, also on charts unplayable with Gekisou", () => { + const d = deck3(); + const f = chartFigures(d, 1, 1000, { mode: "free" }); + near(f.base, 9, "base"); + assert.equal(f.seeds, 1); + assert.deepEqual(f.weights, [0.05, 0.06, 0.07, 0.08, 0.09]); + // ranks and the Just rate do not apply; the Great share does + assert.deepEqual(chartFigures(d, 1, 1000, { mode: "free", ranks: [5, 5, 5], just: 0 }), f); + near(chartFigures(d, 1, 1000, { mode: "free", great: 1 }).base, 7.2, "Great"); + const four = deck3({ unplayable: "4 fevers", seeds: [] }); + assert.equal(chartFigures(four, 1, 1000), null); + near(chartFigures(four, 1, 1000, { mode: "free" }).base, 9, "unplayable"); + assert.equal(chartFigures(deck3({ offSeeds: undefined }), 1, 1000, { mode: "free" }), null); + // a kind the Gekisou-off run could not model is null there + assert.equal(chartFigures(deck3({ offSeeds: [{ seed: 0, score: 9000, weights: [[1, 1, 1, 1, 1], null] }] }), 1, 1000, { mode: "free" }), null); +}); + +test("what the data can show, the ranks in the query", () => { + const data = (d) => ({ songs: [{ id: 1, charts: [{ scoreId: 1, deck: d }] }] }); + assert.deepEqual(scenarioData(data(deck3())), { free: true, ranks: true, just: true }); + assert.deepEqual(scenarioData(songs), { free: false, ranks: false, just: false }); // the current data + assert.deepEqual(scenarioData(data(deck3({ offSeeds: [], ranges: RANGES3.map(({ rankBonusPercents, ...r }) => r) }))), + { free: false, ranks: false, just: true }); + assert.deepEqual(parseRanks(null), [1, 1, 1]); + assert.deepEqual(parseRanks("5"), [5, 5, 5]); + assert.deepEqual(parseRanks("2,5,3"), [2, 5, 3]); + assert.deepEqual(parseRanks("2,x"), [2, 1, 1]); + assert.deepEqual(parseRanks("9,0,4,2"), [1, 1, 4]); + assert.equal(formatRanks([1, 1, 1]), ""); + assert.equal(formatRanks([5, 5, 5]), "5"); + assert.equal(formatRanks([2, 5, 3]), "2,5,3"); + for (const t of ["", "4", "1,2,3", "3,3,1"]) assert.equal(formatRanks(parseRanks(t)), t); +}); + +test("Gekisou Live rates the room: every player at sqrt(5 / n) of the battle threshold", () => { + assert.equal(roomThreshold(1000, 5), 1000); + assert.equal(roomThreshold(1000, 1), 2236); // trunc(sqrt(5) * 1000) + assert.equal(roomThreshold(1000, 2), 1581); // trunc(sqrt(2.5) * 2000) / 2 = 3162 / 2 + assert.equal(roomThreshold(0, 3), 0); + const ranks = [{ rank: "D", requiredScore: 0, battleRequiredScore: 0 }, { rank: "SS", requiredScore: 800, battleRequiredScore: 1000 }]; + const a = { scoreId: 1, base: 4, chartMs: 100000, bgmMs: null, weights: [0.25, 0.25, 0.25, 0.25, 0], scoreRanks: ranks }; + assert.equal(rankThreshold(a, "SS"), 800); // solo: requiredScore + assert.equal(rankThreshold(a, "SS", 5), 1000); + assert.equal(rankThreshold(a, "SS", 1), 2236); + assert.equal(rankThreshold(a, "D", 2), 0); + assert.equal(rankThreshold({ scoreRanks: [{ rank: "SS", requiredScore: 800 }] }, "SS", 5), null); + const x = [1, 1, 1, 1, 1]; // 4 + 1 per power + near(requiredPower(a, x, "SS"), 160, "solo power"); + near(requiredPower(a, x, "SS", 1, 5), 200, "room of 5"); + near(requiredPower(a, x, "SS", 1, 1), 447.2, "room of 1"); + assert.equal(reachChance(a, x, 199, "SS", 1, 5), 0); + assert.equal(reachChance(a, x, 200, "SS", 1, 5), 1); + // solo compares requiredScore (equal here), a room battleRequiredScore (a lower on a) + const b = { ...a, scoreId: 2, scoreRanks: [ranks[0], { rank: "SS", requiredScore: 800, battleRequiredScore: 1100 }] }; + for (const n of [0, 1, 3, 5]) assert.equal(eventDominates(a, b, "chart", X_MAX, n), n !== 0); +}); + // ---------------------------------------------------------------- catalog.js test("note kinds count the judged notes only", () => {