From 0bc3092f8fdbc1d71bcb47291eb81e8169b68b00 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 03:23:17 +0800 Subject: [PATCH 1/8] feat(examples): the chart detail shows the rank measure of every Gekisou range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Gekisou range ranks the room by its mission's measure (combo: max combo, luck: luck points, Just: Just count). The detail lists the three measures per range (seed means, min–max) with the compared one marked, in Gekisou Live; a measure older data lacks shows as "–". ranking.js rangeMeasures, MISSION_MEASURE, MEASURES; catalog.js gekisouSkill looks up Gekisou skill names in gekisouCatalog with fallbacks, for the aptitude table to come. --- examples/songs/catalog.js | 26 +++++++++++++++ examples/songs/index.html | 5 +++ examples/songs/ranking.js | 28 ++++++++++++++++ examples/songs/songs.js | 32 +++++++++++++++++-- examples/songs/text.js | 10 ++++-- tests/unit/songs-page.test.mjs | 58 +++++++++++++++++++++++++++++++--- 6 files changed, 150 insertions(+), 9 deletions(-) diff --git a/examples/songs/catalog.js b/examples/songs/catalog.js index cb04f23..c62871b 100644 --- a/examples/songs/catalog.js +++ b/examples/songs/catalog.js @@ -84,6 +84,32 @@ export const refigure = (rows, data, scenario) => { return rows; }; +// music-data.json's `gekisouCatalog` tables by id, built once per file. +const CATALOGS = new WeakMap(); +const catalogOf = (data) => { + if (data && CATALOGS.has(data)) return CATALOGS.get(data); + const cat = (data && data.gekisouCatalog) || {}; + const out = {}; + for (const [k, list] of Object.entries(cat)) { + if (Array.isArray(list)) out[k] = new Map(list.filter((x) => x && x.id !== undefined).map((x) => [x.id, x])); + } + if (data && typeof data === "object") CATALOGS.set(data, out); + return out; +}; + +// A Gekisou skill (`table` "skills") or Gekisou support skill ("supportSkills") of `gekisouCatalog` by id: its name in +// a language (pickText: else Japanese, else any language; "#id" without one), mission and maxLevel (null when the +// catalog lacks them, as in older data). +export const gekisouSkill = (data, table, id, lang) => { + const e = (catalogOf(data)[table] || new Map()).get(id); + return { + id, + name: pickText(e && e.name, lang) || `#${id}`, + mission: e ? e.mission ?? null : null, + maxLevel: e ? e.maxLevel ?? null : null, + }; +}; + // 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 3e62f5c..634ab0b 100644 --- a/examples/songs/index.html +++ b/examples/songs/index.html @@ -399,6 +399,11 @@ .rk { display: inline-block; min-width: 32px; padding: 0 8px; border-radius: 4px; text-align: center; font-weight: 600; font-size: 12px; border: 1px solid var(--mn-border); color: var(--mn-ink-soft); } .rk-SS { color: var(--mn-orbit-light); background: var(--mn-orbit-night); border-color: var(--mn-orbit-edge); } .rk-S { color: var(--mn-accent-deep); background: var(--mn-accent-soft); border-color: color-mix(in srgb, var(--mn-accent) 40%, transparent); } + /* the rank measures of the Gekisou ranges */ + .tbl-scroll { overflow-x: auto; } + .ranks td.hi { font-weight: 600; color: var(--mn-text); } + .measures th:nth-child(2), .measures td:nth-child(2) { text-align: left; } + .measures td { white-space: nowrap; } .boot { display: flex; align-items: center; justify-content: center; gap: 12px; min-height: 60vh; color: var(--mn-text-muted); } .boot.error { color: var(--mn-rose); } diff --git a/examples/songs/ranking.js b/examples/songs/ranking.js index 075561d..5fbce94 100644 --- a/examples/songs/ranking.js +++ b/examples/songs/ranking.js @@ -38,6 +38,11 @@ // 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. +// +// Gekisou skills (the member cards' Gekisou skills and their snaps' Gekisou support skills) count in Gekisou Live's +// score with Gekisou (battleLiveScore) only, never in Free Live or in the score without Gekisou (soloScore). The page's +// decks carry none: every figure here is without them. Every Gekisou range ranks the room by its mission's measure +// (rangeMeasures). export const DIFFICULTIES = ["easy", "normal", "hard", "expert"]; export const EPS = 1e-12; @@ -79,6 +84,29 @@ 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 measure a Gekisou range ranks the room by, per mission (1 combo, 2 luck, 3 Just), and the measures the seeds +// carry per range (music-data.json deck `seeds[].ranges[j]`: maxCombo, justCount, luckPoints). +export const MISSION_MEASURE = Object.freeze({ 1: "maxCombo", 2: "luckPoints", 3: "justCount" }); +export const MEASURES = ["maxCombo", "justCount", "luckPoints"]; + +// Per Gekisou range of a chart's deck statistics (Gekisou on, no skills): its mission, the measure it ranks by (null +// for an unknown mission) and `values`, every measure as {mean, min, max} over the seeds; a measure some seed lacks +// (older data) is null. [] without seeds or for a chart unplayable with Gekisou on. +export const rangeMeasures = (deck) => { + if (!deck || deck.unplayable) return []; + const seeds = deck.seeds || []; + const stat = (i, key) => { + const v = seeds.map((s) => (s && s.ranges && s.ranges[i] ? s.ranges[i][key] : undefined)); + return v.length && v.every(Number.isFinite) ? { mean: mean(v), min: Math.min(...v), max: Math.max(...v) } : null; + }; + return (deck.ranges || []).map((r, i) => ({ + index: i, + mission: r.mission ?? null, + measure: MISSION_MEASURE[r.mission] ?? null, + values: Object.fromEntries(MEASURES.map((k) => [k, stat(i, k)])), + })); +}; + // 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); diff --git a/examples/songs/songs.js b/examples/songs/songs.js index 6f45819..c558d31 100644 --- a/examples/songs/songs.js +++ b/examples/songs/songs.js @@ -23,9 +23,9 @@ import { ticks, } from "./catalog.js"; import { - 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, + MEASURES, RANGES, RANK_MAX, SCORE_RANKS, X_MAX, chartFigures, eventDominance, formatLength, formatRanks, lengthMs, + meanSkill, modelPower, orderRates, parseRanks, perMinute, plainKind, quantile, rangeMeasures, rank, rankThreshold, + reachChance, requiredPower, scenarioData, scoreRate, weightSum, } from "./ranking.js"; import { GUIDE, UI } from "./text.js"; @@ -721,6 +721,29 @@ const main = async () => { }))); }; + // the rank measure of every Gekisou range (ranking.js rangeMeasures, no skills): Gekisou Live only; null when the + // data has none of them + const measuresBox = (r) => { + const t = u(); + const D = t.detail; + const measures = rangeMeasures(r.stats); + if (!measures.some((m) => MEASURES.some((k) => m.values[k]))) return null; + // a measure as its seed mean, with the seeds' min–max when they differ + const stat = (m) => { + if (!m) return "–"; + const v = Number.isInteger(m.mean) ? fmtInt(m.mean) : fmt(m.mean, 1); + return m.min === m.max ? v : `${v} (${fmtInt(m.min)}–${fmtInt(m.max)})`; + }; + return h("section", {}, heading("h3", "sec-head", D.measures), + h("div", { class: "tbl-scroll" }, h("table", { class: "ranks measures" }, + h("thead", {}, h("tr", {}, h("th", {}, D.mRange), h("th", {}, D.mCompared), MEASURES.map((k) => h("th", {}, D.measure[k])))), + h("tbody", {}, measures.map((m) => h("tr", {}, + h("td", {}, t.scen.range(m.index + 1, t.missions[m.mission] || null)), + h("td", {}, m.measure ? D.measure[m.measure] : "–"), + MEASURES.map((k) => h("td", { class: k === m.measure ? "num hi" : "num dim" }, stat(m.values[k])))))))), + h("p", { class: "hint" }, D.measuresHint)); + }; + // 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(); }; @@ -743,10 +766,12 @@ const main = async () => { const line = h("div", { class: "tl-scroll" }); const weights = h("section", {}); const ranks = h("div", {}); + const measures = measuresBox(r); const draw = () => { const e = eff(r); const battle = S.mode === "battle"; const solo = battle && r.weights ? freeRate(r) : null; + if (measures) measures.hidden = !battle; put(scores, h("div", { class: "tiles" }, r.weights ? tile(t.col.rate, fmt(e.rate, 3), (() => { const v = orderRates(r, skills()); @@ -795,6 +820,7 @@ const main = async () => { h("section", {}, heading("h3", "sec-head", t.detail.composition), composition(r)), weights), ranks, + measures, 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"); diff --git a/examples/songs/text.js b/examples/songs/text.js index 60d74a9..00182b3 100644 --- a/examples/songs/text.js +++ b/examples/songs/text.js @@ -66,7 +66,10 @@ export const UI = { seeds: (n) => `${n} 个种子`, unplayable: "撃奏ライブ无法游玩", unplayableHint: "Fever 超过 3 个:从反编译代码看,游戏只保留 3 个激走区间,第 4 个 Fever 开始时会出错", unplayableFree: ";自由 Live 不开激走,可以照常游玩,切到自由 Live 可看它的数值", score: "得分(当前场景)", noFigures: "得分", twoScores: "撃奏ライブ的两套分数(每点综合力)", - twoScoresHint: "撃奏ライブ同时上报两套分数:前者含激走,即本场景的数值;后者不含激走,存为歌曲最高分,约等于自由 Live 的分数", + twoScoresHint: "撃奏ライブ同时上报两套分数:前者含激走,即本场景的数值(本页的卡组不带激走技能);后者不含任何激走效果,卡上的激走技能也不计,存为歌曲最高分,约等于自由 Live 的分数", + measures: "各区间的名次指标", mRange: "区间", mCompared: "比的指标", + measure: { maxCombo: "最大连击", justCount: "Just 数", luckPoints: "幸运点数" }, + measuresHint: "撃奏ライブ的每个区间按该区间任务的指标与房间内其他人比名次:连击任务比最大连击,幸运任务比幸运点数,Just 任务比 Just 数(加粗的一格)。按本模型,理论最佳打法、不带激走技能,取种子平均,括号内为种子间的最小–最大;“–”表示数据里没有这一项。本页不模拟对手,名次由你选定。", orders: "顺序区间", sameOrder: "技能相同,顺序不影响得分", ranks: "评级门槛", rank: "评级", required: "所需分数", needPower: "所需综合力(期望)", needRange: "所需综合力(顺序区间)", chanceAt: (p) => `综合力 ${p} 达成率`, requiredRoom: (n) => `所需分数(${n} 人房间,每人)`, ranksHint: "门槛按歌定义,各难度共用;自由 Live 用单人门槛。所需综合力 = 门槛 ÷ 每点综合力的期望得分(已含准率)。", @@ -143,7 +146,10 @@ export const UI = { 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", + twoScoresHint: "Gekisou Live reports two scores: the first with Gekisou, this scenario's figure (the page's decks have no Gekisou skills); the second without any Gekisou effect, the cards' Gekisou skills included, kept as the song's best score, about the Free Live score", + measures: "Rank measures per range", mRange: "Range", mCompared: "Ranked by", + measure: { maxCombo: "Max combo", justCount: "Just count", luckPoints: "Luck points" }, + measuresHint: "Every Gekisou Live range ranks the room by its mission's measure: combo missions by the max combo, luck missions by the luck points, Just missions by the Just count (the bold cell). By this model, in the theoretical best play without Gekisou skills: seed means, with the seeds' min–max in brackets; “–” where the data lacks the measure. The page does not model opponents and takes the ranks you pick.", orders: "Order range", sameOrder: "Equal skills: the order does not matter", ranks: "Score ranks", rank: "Rank", required: "Score", needPower: "Power (expected)", needRange: "Power (order range)", chanceAt: (p) => `Chance at ${p}`, requiredRoom: (n) => `Score (room of ${n}, each)`, ranksHint: "Thresholds are the song's, shared by every difficulty; Free Live uses the solo thresholds. Power needed = threshold ÷ expected score per power (accuracy included).", diff --git a/tests/unit/songs-page.test.mjs b/tests/unit/songs-page.test.mjs index a506483..a82011f 100644 --- a/tests/unit/songs-page.test.mjs +++ b/tests/unit/songs-page.test.mjs @@ -2,12 +2,13 @@ import test from "node:test"; import assert from "node:assert/strict"; import { - 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, + DEFAULT_SCENARIO, MEASURES, MISSION_MEASURE, X_MAX, chartFigures, dominance, dominates, eventDominates, formatLength, + formatRanks, greatFactor, joinCharts, lengthMs, meanSkill, orderRates, parseRanks, perMinute, pickText, plainKind, + quantile, rangeMeasures, rank, rankPercent, rankThreshold, reachChance, requiredPower, roomThreshold, scenarioData, + scoreRate, weightSum, } from "../../examples/songs/ranking.js"; import { - chartRows, density, extent, histogram, matches, moenotesUrl, noteKinds, refigure, sortBy, ticks, + chartRows, density, extent, gekisouSkill, 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 @@ -379,6 +380,55 @@ test("Gekisou Live rates the room: every player at sqrt(5 / n) of the battle thr for (const n of [0, 1, 3, 5]) assert.equal(eventDominates(a, b, "chart", X_MAX, n), n !== 0); }); +// ---------------------------------------------------------------- Gekisou ranges and skills + +test("the rank measures per Gekisou range: the mission's measure, seed means and ranges", () => { + assert.deepEqual(MEASURES, ["maxCombo", "justCount", "luckPoints"]); + assert.deepEqual([1, 2, 3, 4].map((m) => MISSION_MEASURE[m]), ["maxCombo", "luckPoints", "justCount", undefined]); + // deck3: two seeds; range 1 has Just counts, the luck points and max combos are missing (older data) + const m = rangeMeasures(deck3()); + assert.deepEqual(m.map((x) => [x.index, x.mission, x.measure]), [[0, 1, "maxCombo"], [1, 3, "justCount"], [2, 2, "luckPoints"]]); + assert.deepEqual(m[1].values.justCount, { mean: 40, min: 40, max: 40 }); + assert.equal(m[0].values.maxCombo, null); + assert.equal(m[2].values.luckPoints, null); + const d = deck3(); + d.seeds = d.seeds.map((s, k) => ({ ...s, ranges: s.ranges.map((x, i) => ({ ...x, maxCombo: 100 + i + k, luckPoints: i === 2 ? 50 + 10 * k : 0 })) })); + const n = rangeMeasures(d); + assert.deepEqual(n[0].values.maxCombo, { mean: 100.5, min: 100, max: 101 }); + assert.deepEqual(n[2].values.luckPoints, { mean: 55, min: 50, max: 60 }); + assert.deepEqual(n[0].values.luckPoints, { mean: 0, min: 0, max: 0 }); + // an unknown mission ranks by nothing; no seeds or unplayable: nothing + assert.equal(rangeMeasures({ ...d, ranges: d.ranges.map((r) => ({ ...r, mission: 4 })) })[0].measure, null); + assert.deepEqual(rangeMeasures(null), []); + assert.deepEqual(rangeMeasures(deck3({ unplayable: "4 fevers", seeds: [] })), []); + assert.ok(rangeMeasures(deck3({ seeds: [] })).every((x) => MEASURES.every((k) => x.values[k] === null))); +}); + +test("fields of other formats (the dropped best formation deck.gekisou) leave the figures alone", () => { + const extra = { gekisou: { formation: [], objective: 1, evaluations: 1, seeds: deck3().seeds.map((s) => ({ ...s, score: s.score * 2 })) } }; + for (const sc of [null, { ranks: [2, 5, 3], just: 0.5, great: 0.25 }, { mode: "free" }]) { + assert.deepEqual(chartFigures(deck3(extra), 1, 1000, sc), chartFigures(deck3(), 1, 1000, sc)); + } + assert.deepEqual(scenarioData({ songs: [{ id: 1, charts: [{ scoreId: 1, deck: deck3(extra) }] }] }), { free: true, ranks: true, just: true }); +}); + +test("Gekisou skill names come from gekisouCatalog in the page language, with fallbacks", () => { + const data = { + gekisouCatalog: { + skills: [{ id: 7, mission: 1, maxLevel: 5, name: { ja: "連撃", en: "Combo" } }, { id: 8, mission: 3, maxLevel: 5, name: null }], + supportSkills: [{ id: 31, mission: 3, maxLevel: 5, name: { "zh-Hans": "JUST 得分" } }], + }, + }; + assert.deepEqual(gekisouSkill(data, "skills", 7, "en"), { id: 7, name: "Combo", mission: 1, maxLevel: 5 }); + assert.equal(gekisouSkill(data, "skills", 7, "ko").name, "連撃"); // else Japanese + assert.equal(gekisouSkill(data, "supportSkills", 31, "en").name, "JUST 得分"); // else any language + assert.deepEqual(gekisouSkill(data, "skills", 8, "ja"), { id: 8, name: "#8", mission: 3, maxLevel: 5 }); // no name + assert.deepEqual(gekisouSkill(data, "skills", 99, "ja"), { id: 99, name: "#99", mission: null, maxLevel: null }); + assert.deepEqual(gekisouSkill(data, "members", 7, "ja"), { id: 7, name: "#7", mission: null, maxLevel: null }); + assert.equal(gekisouSkill(songs, "skills", 7, "ja").name, "#7"); // older data: no catalog + assert.equal(gekisouSkill(null, "skills", 7, "ja").name, "#7"); +}); + // ---------------------------------------------------------------- catalog.js test("note kinds count the judged notes only", () => { From 486ed40c3791b413df5281548084227ba6b8f943 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Wed, 30 Sep 2026 03:23:17 +0800 Subject: [PATCH 2/8] docs(examples): chart data guide: how Gekisou skills work A section on the Gekisou skills: battleLiveScore only, mission-gated, the band condition of support skills, what stays exact and what is approximate with them, the skills that do nothing in the theoretical best play or only raise a rank measure, the seed mean, and the state of the checks (audited, native whole-live comparison under way). The page's figures are without Gekisou skills; the aptitude data is pending. --- README.en.md | 2 +- README.md | 2 +- examples/songs/text.js | 68 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/README.en.md b/README.en.md index efcbdcd..1b2f48a 100644 --- a/README.en.md +++ b/README.en.md @@ -86,7 +86,7 @@ Example pages. Open them with any static server at the repository root: | [examples/basic.html](examples/basic.html) | one `` | | [examples/iframe.html](examples/iframe.html) | a page holding one player, ready to embed with an `