From e538c1ab38a1c6b5a4e71885725f0797e6b20fee Mon Sep 17 00:00:00 2001 From: Exmeaning <60350667+Exmeaning@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:10:11 +0000 Subject: [PATCH 1/2] feat(deck): hydrate missing live charts from moenotes-assets --- docs/deck-worker.md | 9 +- public/deck/deck-worker-core.mjs | 172 ++++- src/app/api/v1/meta/deck-charts/route.ts | 57 ++ src/components/deck/use-deck-solver.ts | 21 +- src/config/assets.ts | 1 + src/lib/deck/chart-convert.ts | 817 +++++++++++++++++++++++ src/lib/deck/haneoka/data.ts | 23 +- src/lib/deck/runtime-source.ts | 57 ++ src/lib/deck/worker-protocol.ts | 3 + src/lib/masterdata/deck-charts.ts | 248 +++++++ src/lib/masterdata/deck-master.ts | 10 +- tests/deck-live-chart-hydration.test.ts | 483 ++++++++++++++ 12 files changed, 1871 insertions(+), 30 deletions(-) create mode 100644 src/app/api/v1/meta/deck-charts/route.ts create mode 100644 src/lib/deck/chart-convert.ts create mode 100644 src/lib/masterdata/deck-charts.ts create mode 100644 tests/deck-live-chart-hydration.test.ts diff --git a/docs/deck-worker.md b/docs/deck-worker.md index f0bba3a..47a841e 100644 --- a/docs/deck-worker.md +++ b/docs/deck-worker.md @@ -86,9 +86,12 @@ against the message before using it. Then it: 2. imports the glue, a wasm-bindgen `--target web` ES module, from a `blob:` URL of its verified bytes, and calls its default export with `{ module_or_path: wasmBytes }`, so the WASM is never fetched again unverified; 3. when `masterOverlayUrl` (`/api/v1/meta/deck-master?server=…&v=…`) is present on `init`, merges the live Next.js - columnar `master` tables (`moenotes.deck-master-overlay/1`) onto the verified `deck-data.json` snapshot, recomputes - the merged dataset SHA-256, and constructs `new DeckSolver(mergedBytes, { datasetId: mergedSha256 })` (falling back - cleanly to the baseline `deckDataBytes` if the overlay endpoint is unreachable or rejected); + columnar `master` tables (`moenotes.deck-master-overlay/1`) onto the verified `deck-data.json` snapshot, hydrates any + `MasterLiveMusic` chart difficulties not yet present in `deck-data.json` via `chartsOverlayUrl` + (`/api/v1/meta/deck-charts?server=…&v=…&ids=…`, `moenotes.deck-chart-overlay/1`, converted in binary32 precision from + `moenotes-assets` `Live/MusicScore//.json`), recomputes the merged dataset SHA-256, and constructs + `new DeckSolver(mergedBytes, { datasetId: mergedSha256 })` (falling back cleanly to master-only or the baseline + `deckDataBytes` if an overlay endpoint is unreachable or rejected); 4. checks that `solver.datasetId`, the lowercase hex SHA-256 of the active deck data bytes, matches the expected hash; 5. reads `solver.capabilities()` as JSON text, when the solver has it, and returns the dataset catalogue in `ready`. diff --git a/public/deck/deck-worker-core.mjs b/public/deck/deck-worker-core.mjs index 6b026c5..c9c7899 100644 --- a/public/deck/deck-worker-core.mjs +++ b/public/deck/deck-worker-core.mjs @@ -88,6 +88,50 @@ export async function loadDeckSolverModule({ glue, wasm }) { const FILES = [['deckData', 'deck data'], ['wasm', 'engine WASM'], ['glue', 'engine glue']]; const DECK_MASTER_OVERLAY_FORMAT = 'moenotes.deck-master-overlay/1'; +const DECK_CHART_OVERLAY_FORMAT = 'moenotes.deck-chart-overlay/1'; +const CHART_OVERLAY_BATCH_SIZE = 80; + +function findMissingChartScoreIds(master, existingCharts) { + if (!record(master)) return []; + const scoreTable = master.MasterLiveMusicScore; + const musicTable = master.MasterLiveMusic; + if (!record(scoreTable) || !Array.isArray(scoreTable.columns) || !Array.isArray(scoreTable.rows)) return []; + if (!record(musicTable) || !Array.isArray(musicTable.columns) || !Array.isArray(musicTable.rows)) return []; + const scoreIdCol = scoreTable.columns.indexOf('_id'); + const fileNameCol = scoreTable.columns.indexOf('_musicScoreTextFileName'); + if (scoreIdCol < 0 || fileNameCol < 0) return []; + + const exportableScoreIds = new Set(); + for (const row of scoreTable.rows) { + if (!Array.isArray(row) || row.length !== scoreTable.columns.length) continue; + const id = row[scoreIdCol]; + const fileName = row[fileNameCol]; + if (positiveId(id) && typeof fileName === 'string' && fileName.trim().length > 0) { + exportableScoreIds.add(id); + } + } + if (exportableScoreIds.size === 0) return []; + + const existingScoreIds = new Set( + Array.isArray(existingCharts) + ? existingCharts.filter(chart => record(chart) && positiveId(chart.scoreId)).map(chart => chart.scoreId) + : [], + ); + const diffCols = DIFFICULTIES.map(([, col]) => musicTable.columns.indexOf(col)).filter(idx => idx >= 0); + if (diffCols.length === 0) return []; + + const missing = new Set(); + for (const row of musicTable.rows) { + if (!Array.isArray(row) || row.length !== musicTable.columns.length) continue; + for (const colIdx of diffCols) { + const scoreId = row[colIdx]; + if (positiveId(scoreId) && exportableScoreIds.has(scoreId) && !existingScoreIds.has(scoreId)) { + missing.add(scoreId); + } + } + } + return [...missing].sort((a, b) => a - b); +} function invalidInit(message) { if (message.protocol !== DECK_WORKER_PROTOCOL) return 'Unsupported protocol ' + String(message.protocol); @@ -97,6 +141,7 @@ function invalidInit(message) { } if (message.modelCommit !== null && (typeof message.modelCommit !== 'string' || !message.modelCommit)) return 'Invalid modelCommit'; if (message.masterOverlayUrl !== undefined && (typeof message.masterOverlayUrl !== 'string' || !message.masterOverlayUrl)) return 'Invalid masterOverlayUrl'; + if (message.chartsOverlayUrl !== undefined && (typeof message.chartsOverlayUrl !== 'string' || !message.chartsOverlayUrl)) return 'Invalid chartsOverlayUrl'; return null; } @@ -167,11 +212,22 @@ export function createDeckWorkerCore({ return value === undefined ? null : JSON.stringify(value); } - async function downloadOverlay(rawUrl) { + function resolveEndpointUrl(rawUrl, anchorUrl) { if (typeof rawUrl !== 'string' || !rawUrl) return null; - const url = rawUrl.startsWith('/') && typeof globalThis.location?.origin === 'string' && globalThis.location.origin !== 'null' - ? new URL(rawUrl, globalThis.location.origin).href - : rawUrl; + if (rawUrl.startsWith('/')) { + if (typeof anchorUrl === 'string' && /^https?:\/\//i.test(anchorUrl)) { + return new URL(rawUrl, anchorUrl).href; + } + if (typeof globalThis.location?.origin === 'string' && globalThis.location.origin !== 'null') { + return new URL(rawUrl, globalThis.location.origin).href; + } + } + return rawUrl; + } + + async function downloadOverlay(rawUrl) { + const url = resolveEndpointUrl(rawUrl, null); + if (!url) return null; try { const response = await fetch(url); if (!response.ok) return null; @@ -183,11 +239,60 @@ export function createDeckWorkerCore({ } } + async function downloadMissingCharts(rawBaseUrl, anchorUrl, missingScoreIds) { + const baseUrl = resolveEndpointUrl(rawBaseUrl, anchorUrl); + if (!baseUrl || !Array.isArray(missingScoreIds) || missingScoreIds.length === 0) { + return { charts: [], musicLengthsMs: {} }; + } + const charts = []; + const musicLengthsMs = {}; + const seen = new Set(); + for (let offset = 0; offset < missingScoreIds.length; offset += CHART_OVERLAY_BATCH_SIZE) { + const chunk = missingScoreIds.slice(offset, offset + CHART_OVERLAY_BATCH_SIZE); + const separator = baseUrl.includes('?') ? '&' : '?'; + const url = baseUrl + separator + 'ids=' + chunk.join(','); + try { + const response = await fetch(url); + if (!response.ok) continue; + const parsed = await response.json(); + if (!record(parsed) || parsed.format !== DECK_CHART_OVERLAY_FORMAT || !Array.isArray(parsed.charts)) continue; + const lengths = record(parsed.musicLengthsMs) ? parsed.musicLengthsMs : {}; + for (const chart of parsed.charts) { + if (!record(chart) || !positiveId(chart.scoreId) || seen.has(chart.scoreId)) continue; + seen.add(chart.scoreId); + charts.push(chart); + const lengthVal = lengths[String(chart.scoreId)] ?? chart.durationMs; + if (Number.isSafeInteger(lengthVal) && lengthVal > 0) { + musicLengthsMs[String(chart.scoreId)] = lengthVal; + } + } + } catch { + // Ignore chart overlay batch transport failures; unavailable songs stay filtered out of catalog.musics. + } + } + return { charts, musicLengthsMs }; + } + + async function tryCreateCandidateSolver(DeckSolver, candidateData) { + try { + const mergedBytes = new TextEncoder().encode(JSON.stringify(candidateData)); + const mergedSha256 = hex(await digest(mergedBytes.buffer)); + const candidate = new DeckSolver(mergedBytes, { datasetId: mergedSha256 }); + if (candidate && typeof candidate.datasetId === 'string' && candidate.datasetId === mergedSha256) { + return { solver: candidate, datasetId: mergedSha256, data: candidateData }; + } + candidate?.free?.(); + } catch { + // Fall back cleanly if the engine rejects overlaid tables or hydrated charts. + } + return null; + } + async function initialize(message) { const problem = invalidInit(message); if (problem) { post({ type: 'failed', code: 'protocol', message: problem }); return; } const key = JSON.stringify([message.deckDataUrl, message.deckDataSha256, message.deckDataBytes, message.wasmUrl, message.wasmSha256, - message.wasmBytes, message.glueUrl, message.glueSha256, message.glueBytes, message.modelCommit, message.masterOverlayUrl ?? null]); + message.wasmBytes, message.glueUrl, message.glueSha256, message.glueBytes, message.modelCommit, message.masterOverlayUrl ?? null, message.chartsOverlayUrl ?? null]); if (initKey !== null) { if (key !== initKey) post({ type: 'failed', code: 'protocol', message: 'This Worker is bound to another runtime' }); else post(ready ?? failure); @@ -206,24 +311,51 @@ export function createDeckWorkerCore({ try { DeckSolver = await loadSolver({ glue, wasm }); } catch (error) { throw new Failure('init', 'Engine: ' + describe(error)); } + const baseMaster = record(data.master) ? data.master : {}; + const mergedMaster = overlay ? { ...baseMaster, ...overlay.master } : baseMaster; + const baseCharts = Array.isArray(data.charts) ? data.charts : []; + const chartsOverlayUrl = message.chartsOverlayUrl ?? (record(overlay) && typeof overlay.chartsOverlayUrl === 'string' ? overlay.chartsOverlayUrl : undefined); + const missingScoreIds = chartsOverlayUrl ? findMissingChartScoreIds(mergedMaster, baseCharts) : []; + const hydrated = missingScoreIds.length > 0 + ? await downloadMissingCharts(chartsOverlayUrl, message.masterOverlayUrl, missingScoreIds) + : { charts: [], musicLengthsMs: {} }; + let created = null; let expectedDatasetId = message.deckDataSha256; let activeData = data; - if (overlay) { - try { - const mergedData = { ...data, master: { ...(record(data.master) ? data.master : {}), ...overlay.master } }; - const mergedBytes = new TextEncoder().encode(JSON.stringify(mergedData)); - const mergedSha256 = hex(await digest(mergedBytes.buffer)); - const candidate = new DeckSolver(mergedBytes, { datasetId: mergedSha256 }); - if (candidate && typeof candidate.datasetId === 'string' && candidate.datasetId === mergedSha256) { - created = candidate; - expectedDatasetId = mergedSha256; - activeData = mergedData; - } else { - candidate?.free?.(); - } - } catch { - // Fall back cleanly to the verified baseline deck-data.json if the engine rejects overlaid tables. + + if (hydrated.charts.length > 0) { + const baseProvenance = record(data.provenance) ? data.provenance : {}; + const baseReplay = record(baseProvenance.replay) ? baseProvenance.replay : {}; + const baseLengths = record(baseReplay.musicLengthsMs) ? baseReplay.musicLengthsMs : {}; + const withCharts = await tryCreateCandidateSolver(DeckSolver, { + ...data, + provenance: { + ...baseProvenance, + replay: { + ...baseReplay, + musicLengthsMs: { ...baseLengths, ...hydrated.musicLengthsMs }, + }, + }, + master: mergedMaster, + charts: [...baseCharts, ...hydrated.charts], + }); + if (withCharts) { + created = withCharts.solver; + expectedDatasetId = withCharts.datasetId; + activeData = withCharts.data; + } + } + + if (!created && overlay) { + const withMasterOnly = await tryCreateCandidateSolver(DeckSolver, { + ...data, + master: mergedMaster, + }); + if (withMasterOnly) { + created = withMasterOnly.solver; + expectedDatasetId = withMasterOnly.datasetId; + activeData = withMasterOnly.data; } } diff --git a/src/app/api/v1/meta/deck-charts/route.ts b/src/app/api/v1/meta/deck-charts/route.ts new file mode 100644 index 0000000..998577f --- /dev/null +++ b/src/app/api/v1/meta/deck-charts/route.ts @@ -0,0 +1,57 @@ +import type { NextRequest } from "next/server"; +import { buildEnv } from "@/config/build-env"; +import { isGameServer, PRIMARY_SERVER } from "@/config/servers"; +import { forceRefreshMasterData, shouldBypassMasterDataCache } from "@/lib/masterdata/build-snapshot"; +import { getDeckChartOverlay, parseScoreIdList } from "@/lib/masterdata/deck-charts"; + +export const dynamic = "force-dynamic"; + +function isPurgeAuthorized(request: NextRequest): boolean { + const expected = buildEnv("MOENOTES_CACHE_PURGE_TOKEN")?.trim(); + if (!expected) return true; + const auth = request.headers.get("authorization") ?? ""; + const bearer = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : ""; + const headerToken = request.headers.get("x-moenotes-purge-token")?.trim() ?? ""; + return bearer === expected || headerToken === expected; +} + +export async function GET(request: NextRequest) { + const rawServer = request.nextUrl.searchParams.get("server")?.trim(); + if (rawServer && !isGameServer(rawServer)) { + return Response.json( + { error: "Invalid game server" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ); + } + const server = rawServer && isGameServer(rawServer) ? rawServer : PRIMARY_SERVER; + const scoreIds = parseScoreIdList(request.nextUrl.searchParams.get("ids")); + const retryParam = request.nextUrl.searchParams.get("retry")?.trim(); + const isRetry = Boolean(retryParam && retryParam !== "0" && retryParam.toLowerCase() !== "false"); + + const requestedBypass = shouldBypassMasterDataCache({ + searchParams: request.nextUrl.searchParams, + headers: request.headers, + }); + const forceBypass = requestedBypass && isPurgeAuthorized(request); + if (forceBypass) { + await forceRefreshMasterData({ purgeTables: true }); + } + + const payload = await getDeckChartOverlay(server, scoreIds, { + ...(forceBypass ? { forceBypass: true } : {}), + ...(isRetry ? { retry: true } : {}), + }); + + const versionParam = request.nextUrl.searchParams.get("v")?.trim(); + const allResolved = scoreIds.length > 0 && payload.charts.length === scoreIds.length; + const cacheControl = !forceBypass && !isRetry && versionParam && allResolved + ? "private, max-age=60, stale-while-revalidate=300" + : "no-store, no-cache, max-age=0, must-revalidate"; + + return Response.json(payload, { + headers: { + "Cache-Control": cacheControl, + ...(forceBypass ? { "X-Moenotes-Cache-Bypass": "1" } : {}), + }, + }); +} diff --git a/src/components/deck/use-deck-solver.ts b/src/components/deck/use-deck-solver.ts index 57ac97c..5e790b4 100644 --- a/src/components/deck/use-deck-solver.ts +++ b/src/components/deck/use-deck-solver.ts @@ -1,6 +1,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { GameServer } from "@/config/servers"; -import { buildDeckMasterOverlayUrl, resolveDeckRuntime, type DeckRuntimeFailureCode, type DeckRuntimeUnavailableReason, type DeckSolverRuntime } from "@/lib/deck/runtime-source"; +import { + buildDeckChartsOverlayUrl, + buildDeckMasterOverlayUrl, + resolveDeckRuntime, + resolveLiveDeckRuntimeFallback, + type DeckRuntimeFailureCode, + type DeckRuntimeUnavailableReason, + type DeckSolverRuntime, +} from "@/lib/deck/runtime-source"; import { DeckWorkerClient } from "@/lib/deck/worker-client"; import type { DeckDataCatalog, DeckWorkerFailureCode } from "@/lib/deck/worker-protocol"; import { parseCapabilities, type DeckSolverCapabilities } from "@/lib/deck/goals"; @@ -60,16 +68,23 @@ export function useDeckSolver(server: GameServer, active: boolean) { const abort = new AbortController(); setEngine({ status: "loading" }); setJob({ status: "idle" }); setGrowthByTeam({}); void (async () => { - const resolved = await resolveDeckRuntime(server, { signal: abort.signal }).catch(error => { + let resolved = await resolveDeckRuntime(server, { signal: abort.signal }).catch(error => { if (abort.signal.aborted) return null; return { status: "failed" as const, code: "network" as const, message: error instanceof Error ? error.message : String(error) }; }); if (!resolved || abort.signal.aborted) return; + if (resolved.status !== "available") { + const fallback = await resolveLiveDeckRuntimeFallback(server, { signal: abort.signal }).catch(() => null); + if (abort.signal.aborted) return; + if (fallback) resolved = { status: "available", runtime: fallback }; + } if (resolved.status === "unavailable") { setEngine({ status: "unavailable", reason: resolved.reason }); return; } if (resolved.status === "failed") { setEngine({ status: "failed", code: resolved.code, message: resolved.message }); return; } + const localVersion = getLocalServerMasterDataVersion(server); const runtime: DeckSolverRuntime = { ...resolved.runtime, - masterOverlayUrl: buildDeckMasterOverlayUrl(server, getLocalServerMasterDataVersion(server)), + masterOverlayUrl: buildDeckMasterOverlayUrl(server, localVersion, undefined, attempt), + chartsOverlayUrl: buildDeckChartsOverlayUrl(server, localVersion, undefined, attempt), }; const ready = await worker.prepare(runtime); if (abort.signal.aborted) return; diff --git a/src/config/assets.ts b/src/config/assets.ts index 8cdf3e0..10e789a 100644 --- a/src/config/assets.ts +++ b/src/config/assets.ts @@ -55,6 +55,7 @@ export const assetConfig = { deck: { workerUrl: "/deck/deck-worker.js", masterOverlayUrl: "/api/v1/meta/deck-master", + chartsOverlayUrl: "/api/v1/meta/deck-charts", recommendEngine: process.env.NODE_ENV === "development" && process.env.PUBLIC_DECK_RECOMMEND_ENGINE !== undefined ? (String(process.env.PUBLIC_DECK_RECOMMEND_ENGINE) || null) : "/haneoka-team-engine/runtime.json", diff --git a/src/lib/deck/chart-convert.ts b/src/lib/deck/chart-convert.ts new file mode 100644 index 0000000..9901662 --- /dev/null +++ b/src/lib/deck/chart-convert.ts @@ -0,0 +1,817 @@ +/** + * Native score-to-deck-chart converter (`SsMusicScoreConverter -> MusicScoreNoteCreator`), matching Haneoka's + * `ournotes-team-builder` `runtime_chart::convert` in binary32 / .NET midpoint-to-even precision so newly exported + * `moenotes-assets` score JSON files (`Live/MusicScore//.json`) can hydrate missing `DeckChart` rows + * immediately without waiting for an offline `nnnotes` replay bundle rebuild. + */ + +export interface OverlayDeckNoteInput { + judgementAreaOffsetType: number; + lane: number; + width: number; + direction: number; + ease: number; + easeRight: number; + lines: number[]; +} + +export interface OverlayDeckChart { + scoreId: number; + durationMs: number; + notes: { + id: number[]; + op: number[]; + judgementType: number[]; + timeMs: number[]; + }; + inputs: OverlayDeckNoteInput[]; + enumeration: { + op: number[]; + judgementType: number[]; + timeMs: number[]; + }; + skillEvents: { timeMs: number[] }; + fevers: { startMs: number[]; endMs: number[] }; +} + +const f = Math.fround; +const MAX_LINES = 20; +const UNJUDGED = new Set([0, 80, 82, 100, 103, 121, 122, 123]); +const SLIDE_BEGIN_CLASS = new Set([20, 41, 61, 80, 104]); +const SLIDE_END_CLASS = new Set([22, 42, 62, 82, 105]); +const MERGEABLE = new Set([20, 22, 41, 42, 61, 62, 80, 82, 100, 101, 102, 103, 104, 105]); +const NOTE_TYPES: Readonly> = { tap: 0, flick: 1, trace: 2, long: 3, guide: 4, node: 5 }; +const FLICK_DIRS: Readonly> = { up: 0, left: 1, right: 2, down: 3 }; +const EASES: Readonly> = { linear: 0, in: 1, out: 2 }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isJudgedOp(op: number): boolean { + return !UNJUDGED.has(op); +} + +function netRound(x: number): number { + const ip = Math.trunc(x); + const frac = x - ip; + if (x >= 0) { + if (frac === 0.5) return ip & 1 ? ip + 1 : ip; + return Math.floor(x + 0.5); + } + if (frac === -0.5) return ip & 1 ? ip - 1 : ip; + return Math.trunc(x - 0.5); +} + +function netRoundInt(x: number): number { + const r = netRound(x); + return Number.isFinite(r) ? r : -(2 ** 31); +} + +function approximately(a: number, b: number): boolean { + const fa = f(a); + const fb = f(b); + const m = Math.max(Math.abs(fa), Math.abs(fb)); + const tolerance = Math.max(f(m * f(1e-6)), f(f(1.401298e-45) * f(8))); + return Math.abs(f(fb - fa)) < tolerance; +} + +function judgementTypeOf(op: number, critical: boolean): number { + if (op === 0 || op === 21) return op; + if (op === 1) return critical ? 2 : 1; + if (op === 20) return critical ? 15 : 10; + if (op === 22) return 11; + if (op === 40 || op === 41 || op === 42 || op === 102) return 5; + if (op === 60 || op === 61 || op === 63 || op === 104 || op === 105 || op === 120) return 21; + if (op === 62) return 22; + if (op === 123) return 0; + return 1; +} + +function judgementAreaOffsetTypeOf(op: number, critical: boolean): number { + if (op === 1) return critical ? 8 : 0; + if (op === 20) return critical ? 9 : 2; + if (op === 21 || op === 120 || op === 121) return 1; + if (op === 22) return 3; + if (op >= 40 && op <= 42) return 4; + if ((op >= 60 && op <= 63) || op === 80 || op === 82 || op === 104 || op === 105) return 5; + return 0; +} + +function toInt(value: unknown): number { + if (typeof value === "boolean") return Number(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) return 0; + return Number.isInteger(value) ? value : netRoundInt(value); + } + const parsed = Number.parseInt(String(value ?? "").trim(), 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +function toF32(value: unknown): number { + if (value === null || value === undefined) return 0; + if (typeof value === "boolean") return f(Number(value)); + return f(Number(value)); +} + +function toTruth(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0 && !Number.isNaN(value); + if (typeof value === "string") return value.length > 0; + return true; +} + +interface SsNote { + type: number; + t: number; + pos: number; + posAuto: boolean; + size: number; + crit: boolean; + dir: number; + easeL: number; + easeR: number; + visible: boolean; + node: SsNote[] | null; +} + +function readNote(raw: Record): SsNote { + const note: SsNote = { + type: 0, + t: 0, + pos: 0, + posAuto: false, + size: f(6), + crit: false, + dir: 0, + easeL: 0, + easeR: 0, + visible: true, + node: null, + }; + if (typeof raw.type === "string") note.type = NOTE_TYPES[raw.type] ?? 0; + if (raw.t !== undefined && raw.t !== null) note.t = toInt(raw.t); + if (raw.pos !== undefined && raw.pos !== null) { + if (raw.pos === "auto") note.posAuto = true; + else note.pos = toF32(raw.pos); + } + if (raw.size !== undefined && raw.size !== null) note.size = toF32(raw.size); + if (raw.crit !== undefined && raw.crit !== null) note.crit = toTruth(raw.crit); + if (typeof raw.dir === "string") note.dir = FLICK_DIRS[raw.dir] ?? 0; + if (raw.ease !== undefined && raw.ease !== null) { + if (Array.isArray(raw.ease)) { + note.easeL = EASES[String(raw.ease[0])] ?? 0; + note.easeR = EASES[String(raw.ease[1])] ?? 0; + } else { + note.easeL = note.easeR = EASES[String(raw.ease)] ?? 0; + } + } + if (raw.visible !== undefined && raw.visible !== null) note.visible = toTruth(raw.visible); + if (Array.isArray(raw.node)) { + note.node = raw.node.filter(isRecord).map((child) => readNote(child)); + } + return note; +} + +interface BarPos { + bar: number; + rhythm: number; + unit: number; + progress: number; +} + +interface Pos extends BarPos { + ms: number; +} + +class TickConverter { + sig: [number, number, number][] = []; + bpm: [number, number, number][] = []; + + constructor(sigs: [number, number, number][], bpms: [number, number][]) { + const s = [...sigs].sort((a, b) => a[0] - b[0]); + if (!s.length || s[0]![0] > 0) this.sig.push([0, 0, 1920]); + let bar = 0; + let previous = 0; + let perMeasure = 1920; + for (const [t, num, den] of s) { + const q = perMeasure !== 0 ? Math.trunc((t - previous) / perMeasure) : 0; + if (t - previous !== 0 && previous <= t) bar += q; + perMeasure = den !== 0 ? Math.trunc((num * 1920) / den) : 0; + this.sig.push([t, bar, perMeasure]); + previous = t; + } + const b = [...bpms].sort((a, c) => a[0] - c[0]); + if (!b.length || b[0]![0] > 0) this.bpm.push([0, 0, f(120)]); + let acc = 0; + let prevT = 0; + let prevBpm = f(120); + for (const [t, value] of b) { + if (t - prevT !== 0 && prevT <= t) acc += ((t - prevT) * 60000) / f(prevBpm * f(480)); + this.bpm.push([t, netRoundInt(acc), value]); + prevT = t; + prevBpm = value; + } + } + + private find(segments: T[], tick: number): T { + for (let i = segments.length - 1; i >= 0; i--) { + if (segments[i]![0]! <= tick) return segments[i]!; + } + return segments[0]!; + } + + timeMs(tick: number): number { + const [t0, ms0, bpm] = this.find(this.bpm, tick); + return Math.floor(((tick - t0) * 60000) / f(bpm * f(480)) + ms0); + } + + barPosition(tick: number): BarPos { + const [t0, bar0, perMeasure] = this.find(this.sig, tick); + const q = perMeasure !== 0 ? Math.trunc((tick - t0) / perMeasure) : 0; + const r = tick - t0 - q * perMeasure; + return { bar: bar0 + q, rhythm: r, unit: perMeasure, progress: f(f(r) / f(perMeasure)) }; + } +} + +const keyOf = (pos: { bar: number; progress: number }): number => f(pos.progress + f(pos.bar)); +const before = (pos: Pos, bar: number, progress: number): boolean => + pos.bar < bar || (pos.bar === bar && pos.progress < progress); +const makePosition = (tick: number, tc: TickConverter): Pos => ({ ...tc.barPosition(tick), ms: tc.timeMs(tick) }); + +function timeMsFromBar(bar: number, progress: number, bpmEvents: [number, Pos][], barEvents: [number, Pos][]): number { + const fp = f(progress); + let ref: Pos = { bar: 0, rhythm: 0, unit: 0, progress: 0, ms: 0 }; + let beats = f(4); + for (const [value, pos] of barEvents) { + if (before(pos, bar, fp)) { + beats = value; + if (pos.ms > ref.ms) ref = pos; + } + } + let bpm = f(160); + for (const [value, pos] of bpmEvents) { + if (before(pos, bar, fp)) { + bpm = value; + if (pos.ms > ref.ms) ref = pos; + } + } + const spb = f(f(beats * f(60)) / bpm); + let x = f(f(spb * f(bar - ref.bar)) + f(spb * f(fp - ref.progress))); + x = f(x * f(1000)); + return ref.ms + Math.floor(x); +} + +function barRhythm(bar: number, barEvents: [number, Pos][]): number { + let value = f(4); + for (const [beats, pos] of barEvents) { + if (bar < pos.bar) return value; + value = beats; + } + return value; +} + +function bpmAtMs(ms: number, bpmEvents: [number, Pos][]): number { + let value = f(160); + for (const [bpm, pos] of bpmEvents) { + if (pos.ms <= ms) value = bpm; + } + return value; +} + +const samePosition = (a: Pos, b: Pos): boolean => a.bar === b.bar && approximately(a.progress, b.progress); + +function ease(t: number, kind: number): number { + const ft = f(t); + if (kind === 0) return ft; + if (kind === 2) return f(ft * ft); + return f(f(f(2) - ft) * ft); +} + +const convertEase = (e: number): number => (e === 1 ? 2 : e === 2 ? 1 : 0); +const overlapKey = (t: number, pos: number, size: number): string => `${t}|${netRoundInt(pos)}|${netRoundInt(size)}`; + +interface NoteInfo extends BarPos { + lane: number; + width: number; + op: number; + slideAlong: boolean; + crit: boolean; + direction: number; + ease: number; + easeR: number; + lines: number[]; +} + +const flickDirection = (note: SsNote): number => (note.dir === 1 ? 1 : note.dir === 2 ? 2 : 0); + +function singleInfo(note: SsNote, tc: TickConverter): NoteInfo { + const bp = tc.barPosition(note.t); + const op = note.type === 1 ? 40 : note.type === 2 ? 60 : 1; + return { + ...bp, + lane: netRoundInt(f(note.pos)), + width: Math.max(1, netRoundInt(note.size)), + op, + slideAlong: false, + crit: note.crit, + direction: note.type === 1 ? flickDirection(note) : 0, + ease: 0, + easeR: 0, + lines: [], + }; +} + +function longNodeOp(nodes: SsNote[], i: number): number { + const node = nodes[i]!; + if (i === 0) return !node.visible ? 80 : node.type === 1 ? 41 : node.type === 2 ? 61 : 20; + if (i === nodes.length - 1) return !node.visible ? 82 : node.type === 1 ? 42 : node.type === 2 ? 62 : 22; + if (node.posAuto) return 21; + if (!node.visible) return 122; + return node.type === 2 ? 63 : 21; +} + +function guideNodeOp(nodes: SsNote[], i: number, guideMap: Map | null): number { + const node = nodes[i]!; + if (i === 0) { + if (guideMap && !node.posAuto) { + const single = guideMap.get(overlapKey(node.t, node.pos, node.size)); + if (single && single.type < 3) return [101, 102, 104][single.type]!; + } + return 100; + } + if (i === nodes.length - 1) return !node.visible ? 103 : node.type === 2 ? 105 : 103; + if (node.posAuto) return 63; + return !node.visible ? 122 : 63; +} + +class LineIndexAssigner { + private inUse: [number, number][] = []; + + acquire(startTick: number): number { + for (let i = this.inUse.length - 1; i >= 0; i--) { + if (this.inUse[i]![1] < startTick) this.inUse.splice(i, 1); + } + const used = new Set(this.inUse.map(([index]) => index)); + for (let i = 0; i < MAX_LINES; i++) { + if (!used.has(i)) return i; + } + throw new Error(`ss lineIndex ${MAX_LINES} startTick ${startTick}`); + } + + register(index: number, endTick: number): void { + this.inUse.push([index, endTick]); + } +} + +function lineInfos( + chains: SsNote[], + isLong: boolean, + tc: TickConverter, + out: NoteInfo[], + guideMap: Map | null, + assigner: LineIndexAssigner, +): void { + for (const chain of chains) { + const nodes = chain.node!; + const n = nodes.length; + const lineIndex = assigner.acquire(nodes[0]!.t); + const endTick = nodes[n - 1]!.t; + nodes.forEach((node, i) => { + const op = isLong ? longNodeOp(nodes, i) : guideNodeOp(nodes, i, guideMap); + let pos: number; + let size: number; + if (!node.posAuto) { + pos = node.pos; + size = node.size; + } else if (i === 0 || i >= n - 1) { + pos = nodes[0]!.pos; + size = nodes[0]!.size; + } else { + let j = i; + for (;;) { + if (j < 2) { + j = 0; + break; + } + j--; + if (!nodes[j]!.posAuto) break; + } + let k = i + 1; + while (k < n - 1 && nodes[k]!.posAuto) k++; + const a = nodes[j]!; + const b = nodes[k]!; + const span = b.t - a.t; + const progress = span < 1 ? 0 : f(f(node.t - a.t) / f(span)); + const el = ease(progress, convertEase(a.easeL)); + const er = ease(progress, convertEase(a.easeR)); + pos = f(a.pos + f(el * f(b.pos - a.pos))); + const aRight = f(a.pos + a.size); + const right = f(aRight + f(er * f(f(b.pos + b.size) - aRight))); + size = f(right - pos); + } + let directionSource = node; + if (guideMap && i === 0 && !isLong && !node.posAuto) { + directionSource = guideMap.get(overlapKey(node.t, node.pos, node.size)) ?? node; + } + out.push({ + ...tc.barPosition(node.t), + lane: netRoundInt(f(pos)), + width: Math.max(1, netRoundInt(size)), + op, + slideAlong: node.posAuto, + crit: node.crit, + direction: directionSource.type === 1 ? flickDirection(directionSource) : 0, + ease: convertEase(node.easeL), + easeR: convertEase(node.easeR), + lines: [lineIndex], + }); + }); + assigner.register(lineIndex, endTick); + } +} + +function buildNoteInfos(notes: SsNote[], tc: TickConverter): NoteInfo[] { + const guideStarts = new Set(); + for (const note of notes) { + if (note.type === 4 && note.node?.length) { + const first = note.node[0]!; + if (!first.posAuto) guideStarts.add(overlapKey(first.t, first.pos, first.size)); + } + } + const guideMap = new Map(); + const merged = new Set(); + notes.forEach((note, i) => { + if (note.type < 3) { + const key = overlapKey(note.t, note.pos, note.size); + if (guideStarts.has(key) && !guideMap.has(key)) { + guideMap.set(key, note); + merged.add(i); + } + } + }); + const out: NoteInfo[] = []; + notes.forEach((note, i) => { + if (!merged.has(i) && note.type < 3) out.push(singleInfo(note, tc)); + }); + const ordered = (type: number) => + notes + .map((note, index) => ({ note, index })) + .filter(({ note }) => note.type === type && (note.node?.length ?? 0) > 1) + .sort((a, b) => a.note.node![0]!.t - b.note.node![0]!.t || a.index - b.index) + .map(({ note }) => note); + const assigner = new LineIndexAssigner(); + lineInfos(ordered(3), true, tc, out, null, assigner); + lineInfos(ordered(4), false, tc, out, guideMap, assigner); + return out; +} + +function infoDictionary(infos: NoteInfo[]): Map> { + const dictionary = new Map>(); + for (const info of infos) { + const key = keyOf(info); + let lanes = dictionary.get(key); + if (!lanes) dictionary.set(key, (lanes = new Map())); + let list = lanes.get(info.lane); + if (!list) lanes.set(info.lane, (list = [])); + if (MERGEABLE.has(info.op) && info.lines.length) { + const hit = list.find( + (e) => + e.op === info.op && + e.width === info.width && + e.crit === info.crit && + e.direction === info.direction && + e.ease === info.ease && + e.easeR === info.easeR, + ); + if (hit) { + hit.lines.push(...info.lines); + continue; + } + } + list.push(info); + } + return dictionary; +} + +const priority = (op: number): number => ([20, 41, 61, 80, 100, 101, 102, 104].includes(op) ? 8 : op === 122 ? 9 : 10); + +interface RuntimeNote { + id: number; + pos: Pos; + op: number; + crit: boolean; + lane: number; + width: number; + direction: number; + ease: number; + easeRight: number; + lineIds: number[]; + viewNotes: RuntimeNote[]; + comboNotes: RuntimeNote[]; + endCombo: RuntimeNote[]; +} + +function lineShape(begin: RuntimeNote, points: readonly RuntimeNote[], at: Pos): readonly [number, number] { + let previous = begin; + const atKey = keyOf(at); + for (const next of points) { + const nextKey = keyOf(next.pos); + if (nextKey < atKey) { + previous = next; + continue; + } + const prevKey = keyOf(previous.pos); + const span = f(nextKey - prevKey); + const t = span > 0 ? f(f(atKey - prevKey) / span) : 0; + const left = f(previous.lane + f(ease(t, previous.ease) * f(next.lane - previous.lane))); + const rightA = f(previous.lane + previous.width); + const rightB = f(next.lane + next.width); + const right = f(rightA + f(ease(t, previous.easeRight) * f(rightB - rightA))); + return [left, f(right - left)]; + } + return [previous.lane, previous.width]; +} + +function* eighths(segStart: Pos, segEnd: Pos, bpmEvents: [number, Pos][], barEvents: [number, Pos][]): Generator { + let bar = segStart.bar; + let progress = segStart.progress; + let r = barRhythm(bar, barEvents); + let step = 1 / (r + r); + for (;;) { + progress += step; + if (progress >= 1) { + bar += 1; + progress -= 1; + r = barRhythm(bar, barEvents); + step = 1 / (r + r); + } + const fp = f(progress); + const ms = timeMsFromBar(bar, fp, bpmEvents, barEvents); + if (segEnd.ms <= ms) return; + const r2 = barRhythm(bar, barEvents); + const unit = Math.trunc(f(r2 + r2)); + yield { bar, rhythm: netRoundInt(f(fp * f(unit))), unit, progress: fp, ms }; + } +} + +/** + * Convert a decoded `Live/MusicScore//.json` object into a validated `OverlayDeckChart`. + * Throws when the input is malformed or unplayable (no judged notes, non-positive duration, or != 5 skill events). + */ +export function convertRawScoreToDeckChart(scoreId: number, root: unknown): OverlayDeckChart { + if (!Number.isSafeInteger(scoreId) || scoreId <= 0) { + throw new Error(`Invalid scoreId: ${String(scoreId)}`); + } + const score = isRecord(root) && isRecord(root.score) ? root.score : null; + if (!score) throw new Error("Missing score container"); + const events = isRecord(score.events) ? score.events : {}; + const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); + + const bpm = list(events.bpm) + .filter(isRecord) + .map((b) => [toInt(b.t), toF32(b.bpm)] as [number, number]); + const sig = list(events.sig) + .filter(isRecord) + .filter((s) => Array.isArray(s.sig) && s.sig.length >= 2) + .map((s) => { + const pair = s.sig as unknown[]; + return [toInt(s.t), toInt(pair[0]), toInt(pair[1])] as [number, number, number]; + }); + const skill = list(events.skill).map(toInt); + const fever = list(events.fever) + .filter((pair): pair is unknown[] => Array.isArray(pair) && pair.length >= 2) + .map((pair) => [toInt(pair[0]), toInt(pair[1])] as [number, number]); + const notes = list(score.notes).filter(isRecord).map((note) => readNote(note)); + + const tc = new TickConverter(sig, bpm); + const infos = buildNoteInfos(notes, tc); + const bpmEvents = [...bpm].sort((a, b) => a[0] - b[0]).map(([t, value]) => [value, makePosition(t, tc)] as [number, Pos]); + const barEvents = [...sig] + .sort((a, b) => a[0] - b[0]) + .map(([t, num, den]) => [f(f(f(num) * f(4)) / f(den)), makePosition(t, tc)] as [number, Pos]); + const fevers = [...fever].sort((a, b) => a[0] - b[0]).map(([a, b]) => [makePosition(a, tc), makePosition(b, tc)] as const); + const skillTimesMs = skill.map((tick) => makePosition(tick, tc).ms); + + if (skillTimesMs.length !== 5 || skillTimesMs.some((t) => !Number.isSafeInteger(t) || t < 0)) { + throw new Error(`Unsupported skill event schedule (${skillTimesMs.length})`); + } + + let currentId = 0; + let currentLine = 0; + let currentGuideLine = 0; + const beginByLine = new Map(); + const lineArr = new Array(MAX_LINES).fill(-1); + const guideArr = new Array(MAX_LINES).fill(-1); + + const make = (info: NoteInfo, pos: Pos, op: number, lineIds: number[]): RuntimeNote => ({ + id: currentId, + pos, + op, + crit: info.crit, + lane: info.lane, + width: info.width, + direction: info.direction, + ease: info.ease, + easeRight: info.easeR, + lineIds, + viewNotes: [], + comboNotes: [], + endCombo: [], + }); + + const endLine = (end: RuntimeNote) => { + const begins: RuntimeNote[] = []; + let ok = true; + for (const lineId of end.lineIds) { + const begin = beginByLine.get(lineId); + if (!begin) { + ok = false; + break; + } + begins.push(begin); + const lineNotes = begin.viewNotes + .filter((note) => note.lineIds.includes(lineId)) + .sort((a, b) => a.pos.bar - b.pos.bar || a.pos.progress - b.pos.progress); + lineNotes.push(end); + const mids = lineNotes.filter((note) => note !== end && isJudgedOp(note.op)).map((note) => note.pos); + const bounds = [begin.pos, ...mids, end.pos]; + const beats: Pos[] = []; + for (let s = 0; s < bounds.length - 1; s++) { + beats.push(...eighths(bounds[s]!, bounds[s + 1]!, bpmEvents, barEvents)); + } + beats.forEach((beat, k) => { + if (lineNotes.some((note) => isJudgedOp(note.op) && samePosition(note.pos, beat))) return; + const win = 15000 / bpmAtMs(beat.ms, bpmEvents); + let skip = mids.some((mid) => mid.ms - win <= beat.ms && beat.ms < mid.ms); + if (k === 0 && win > beat.ms - begin.pos.ms) skip = true; + if (k === beats.length - 1 && win > end.pos.ms - beat.ms) skip = true; + const [lane, width] = lineShape(begin, lineNotes, beat); + const combo: RuntimeNote = { + id: lineId + k * 10000 + 10000, + pos: beat, + op: skip ? 121 : 120, + crit: false, + lane, + width, + direction: 0, + ease: 0, + easeRight: 0, + lineIds: [lineId], + viewNotes: [], + comboNotes: [], + endCombo: [], + }; + if (SLIDE_BEGIN_CLASS.has(begin.op)) begin.comboNotes.push(combo); + }); + beginByLine.delete(lineId); + } + if (ok) { + for (const begin of begins) { + if (SLIDE_END_CLASS.has(end.op) && SLIDE_BEGIN_CLASS.has(begin.op)) { + end.endCombo.push(...begin.comboNotes); + } + } + } + }; + + const create = (info: NoteInfo): RuntimeNote | null => { + const ms = timeMsFromBar(info.bar, info.progress, bpmEvents, barEvents); + const pos: Pos = { bar: info.bar, rhythm: info.rhythm, unit: info.unit, progress: info.progress, ms }; + currentId++; + const op = info.op; + let note: RuntimeNote; + if (op === 1 || op === 40 || op === 60) { + note = make(info, pos, op, []); + } else if (op === 20 || op === 41 || op === 61 || op === 80) { + const ids = info.lines.map((index) => { + currentLine++; + lineArr[index] = currentLine; + return currentLine; + }); + note = make(info, pos, op, ids); + for (const id of ids) beginByLine.set(id, note); + } else if (op === 21 || op === 63) { + const id = (op === 21 ? lineArr : guideArr)[info.lines[0]!]!; + note = make(info, pos, op, [id]); + beginByLine.get(id)?.viewNotes.push(note); + } else if (op === 22 || op === 42 || op === 62 || op === 82) { + const ids = info.lines.map((index) => { + const id = lineArr[index]!; + lineArr[index] = -1; + return id; + }); + note = make(info, pos, op, ids); + endLine(note); + } else if (op === 100 || op === 101 || op === 102 || op === 104) { + const ids = info.lines.map((index) => { + const id = currentGuideLine + 10001; + currentGuideLine++; + guideArr[index] = id; + return id; + }); + note = make(info, pos, op, ids); + for (const id of ids) beginByLine.set(id, note); + } else if (op === 103 || op === 105) { + const ids = info.lines.map((index) => { + const id = guideArr[index]!; + guideArr[index] = -1; + return id; + }); + note = make(info, pos, op, ids); + endLine(note); + } else if (op === 122) { + let id = lineArr[info.lines[0]!]!; + if (id < 1) { + id = guideArr[info.lines[0]!]!; + if (id < 1) return null; + } + note = make(info, pos, op, [id]); + beginByLine.get(id)?.viewNotes.push(note); + } else { + return null; + } + return note; + }; + + const dictionary = infoDictionary(infos); + const byKey = new Map(); + for (const key of [...dictionary.keys()].sort((a, b) => a - b)) { + const flat = [...dictionary.get(key)!.values()].flat(); + flat.sort((a, b) => priority(a.op) - priority(b.op)); + for (const info of flat) { + const note = create(info); + if (!note) continue; + const noteKey = keyOf(note.pos); + const listAt = byKey.get(noteKey) ?? []; + listAt.push(note); + byKey.set(noteKey, listAt); + if (SLIDE_END_CLASS.has(note.op) && note.endCombo.length) { + for (const combo of note.endCombo) { + const comboKey = keyOf(combo.pos); + const comboList = byKey.get(comboKey) ?? []; + if (!comboList.includes(combo)) comboList.push(combo); + byKey.set(comboKey, comboList); + } + } + } + } + + const enumerationNotes = [...byKey.values()].flat(); + const positional = [...byKey.keys()].sort((a, b) => a - b).flatMap((key) => byKey.get(key)!); + const maxPosMs = positional.reduce((max, note) => (note.pos.ms > max ? note.pos.ms : max), 0); + const durationMs = Math.max(0, maxPosMs) + 1000; + const chronological = [...positional].sort((a, b) => a.pos.ms - b.pos.ms); + + let judgedCount = 0; + for (const note of chronological) { + if (isJudgedOp(note.op)) { + if (note.pos.ms < 0) throw new Error("Negative judged note timestamp"); + judgedCount++; + } + } + if (durationMs <= 0 || judgedCount === 0) { + throw new Error("Chart contains no playable judged notes"); + } + + return { + scoreId, + durationMs, + notes: { + id: chronological.map((note) => note.id), + op: chronological.map((note) => note.op), + judgementType: chronological.map((note) => judgementTypeOf(note.op, note.crit)), + timeMs: chronological.map((note) => note.pos.ms), + }, + inputs: chronological.map((note) => ({ + judgementAreaOffsetType: judgementAreaOffsetTypeOf(note.op, note.crit), + lane: note.lane, + width: note.width, + direction: note.direction, + ease: note.ease, + easeRight: note.easeRight, + lines: [...note.lineIds], + })), + enumeration: { + op: enumerationNotes.map((note) => note.op), + judgementType: enumerationNotes.map((note) => judgementTypeOf(note.op, note.crit)), + timeMs: enumerationNotes.map((note) => note.pos.ms), + }, + skillEvents: { timeMs: skillTimesMs }, + fevers: { + startMs: fevers.map(([start]) => start.ms), + endMs: fevers.map(([, end]) => end.ms), + }, + }; +} + +/** Decode plain UTF-8 or gzipped score JSON bytes and convert to `OverlayDeckChart`. */ +export async function convertRawScoreBytesToDeckChart(scoreId: number, bytes: Uint8Array): Promise { + let data = bytes; + if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) { + const stream = new Blob([bytes as BlobPart]).stream().pipeThrough(new DecompressionStream("gzip")); + data = new Uint8Array(await new Response(stream).arrayBuffer()); + } + return convertRawScoreToDeckChart(scoreId, JSON.parse(new TextDecoder().decode(data))); +} diff --git a/src/lib/deck/haneoka/data.ts b/src/lib/deck/haneoka/data.ts index 94dbdbb..e2632a9 100644 --- a/src/lib/deck/haneoka/data.ts +++ b/src/lib/deck/haneoka/data.ts @@ -1,9 +1,21 @@ /** Moenotes' verified nnnotes snapshot -> the pinned Haneoka engine document. No scoring formulas live here. */ export type Row = Record; export interface ColumnarTable { columns: string[]; rows: unknown[][] } +export interface DeckNoteInput { + judgementAreaOffsetType: number; + lane: number; + width: number; + direction: number; + ease: number; + easeRight: number; + lines: number[]; +} export interface DeckChart { scoreId: number; + durationMs?: number; notes: { id: number[]; op: number[]; judgementType: number[]; timeMs: number[] }; + inputs?: DeckNoteInput[]; + enumeration?: { op: number[]; judgementType: number[]; timeMs: number[] }; skillEvents: { timeMs: number[] }; fevers: { startMs: number[]; endMs: number[] }; } @@ -157,16 +169,21 @@ export function chartSource(data: DeckData, scoreId: number) { if (!chart) throw new Error("haneoka-chart:" + scoreId); const { notes, skillEvents, fevers } = chart; if (![notes.id, notes.op, notes.judgementType].every(values => values.length === notes.timeMs.length) - || fevers.startMs.length !== fevers.endMs.length) throw new Error("haneoka-chart-shape:" + scoreId); - const durationMs = data.provenance.replay.musicLengthsMs[String(scoreId)]; + || fevers.startMs.length !== fevers.endMs.length + || (chart.inputs !== undefined && (!Array.isArray(chart.inputs) || chart.inputs.length !== notes.timeMs.length)) + || (chart.enumeration !== undefined && (!Array.isArray(chart.enumeration.timeMs) || ![chart.enumeration.op, chart.enumeration.judgementType].every(values => Array.isArray(values) && values.length === chart.enumeration!.timeMs.length)))) + throw new Error("haneoka-chart-shape:" + scoreId); + const durationMs = data.provenance?.replay?.musicLengthsMs?.[String(scoreId)] ?? chart.durationMs; if (!Number.isSafeInteger(durationMs) || durationMs! <= 0) throw new Error("haneoka-chart-duration:" + scoreId); + const enumSrc = chart.enumeration ?? notes; return { durationMs, notes: notes.timeMs.map((timeMs, i) => ({ timeMs, operateType: notes.op[i], judgementType: notes.judgementType[i], judged: ![0, 80, 82, 100, 103, 121, 122, 123].includes(notes.op[i]!), + ...(chart.inputs?.[i] ? { input: chart.inputs[i] } : {}), })), - enumeration: notes.timeMs.map((timeMs, i) => ({ timeMs, op: notes.op[i], judgementType: notes.judgementType[i] })), + enumeration: enumSrc.timeMs.map((timeMs, i) => ({ timeMs, op: enumSrc.op[i], judgementType: enumSrc.judgementType[i] })), skillTimesMs: skillEvents.timeMs, feverMs: fevers.startMs.map((start, i) => [start, fevers.endMs[i]]), }; } diff --git a/src/lib/deck/runtime-source.ts b/src/lib/deck/runtime-source.ts index 1ca86c2..6fdc5f2 100644 --- a/src/lib/deck/runtime-source.ts +++ b/src/lib/deck/runtime-source.ts @@ -43,6 +43,8 @@ export interface DeckSolverRuntime { override: boolean; /** Optional Next.js live MasterData overlay endpoint merged over `deckData.master` in the Worker. */ masterOverlayUrl?: string; + /** Optional Next.js live chart overlay endpoint hydrating missing `deckData.charts` in the Worker. */ + chartsOverlayUrl?: string; } /** Build the version-scoped Next.js live MasterData overlay URL for a game server. */ @@ -50,10 +52,26 @@ export function buildDeckMasterOverlayUrl( server: GameServer, version?: string | null, basePath: string = assetConfig.deck.masterOverlayUrl, + retryAttempt = 0, ): string { const params = new URLSearchParams({ server }); const trimmedVersion = version?.trim(); if (trimmedVersion) params.set("v", trimmedVersion); + if (retryAttempt > 0) params.set("retry", String(retryAttempt)); + return `${basePath}?${params.toString()}`; +} + +/** Build the version-scoped Next.js live chart overlay URL for missing `MasterLiveMusicScore` entries. */ +export function buildDeckChartsOverlayUrl( + server: GameServer, + version?: string | null, + basePath: string = assetConfig.deck.chartsOverlayUrl, + retryAttempt = 0, +): string { + const params = new URLSearchParams({ server }); + const trimmedVersion = version?.trim(); + if (trimmedVersion) params.set("v", trimmedVersion); + if (retryAttempt > 0) params.set("retry", String(retryAttempt)); return `${basePath}?${params.toString()}`; } @@ -252,3 +270,42 @@ export async function resolveDeckRuntime(server: GameServer, options: DeckRuntim throw error; } } + +/** + * Synthesize a self-contained runtime backed by the site's pinned Haneoka WASM engine and an empty baseline + * `nnnotes.deck-data/1` snapshot when the external `music-data` bucket has no published replay manifest or is + * unreachable. The Worker then hydrates all MasterData tables and exported charts from the live Next.js overlays. + */ +export async function resolveLiveDeckRuntimeFallback( + server: GameServer, + options: DeckRuntimeSourceOptions = {}, +): Promise { + const deckServer = DECK_SERVER_OF[server]; + const override = options.engineOverride === undefined ? assetConfig.deck.recommendEngine || null : options.engineOverride; + if (!override) return null; + const transport: Transport = { fetch: options.fetch ?? ((url, init) => getNativeFetch()(url, init)), signal: options.signal }; + const engine = await overrideEngine(override, transport); + const region = deckServer === "jp" ? "jp" : "tw"; + const syntheticDeckBytes = new TextEncoder().encode(JSON.stringify({ + format: DECK_DATA_FORMAT, + provenance: { region, deck: { commit: "moenotes-live-overlay" }, replay: { musicLengthsMs: {} } }, + master: {}, + charts: [], + })); + const deckSha256 = await sha256Hex(syntheticDeckBytes.slice().buffer); + let binary = ""; + for (let i = 0; i < syntheticDeckBytes.length; i++) { + binary += String.fromCharCode(syntheticDeckBytes[i]!); + } + const dataUrl = `data:application/json;base64,${btoa(binary)}`; + return { + server: deckServer, + pointer: { kind: "build", url: dataUrl }, + manifest: { url: dataUrl, sha256: deckSha256 }, + deckData: { url: dataUrl, sha256: deckSha256, bytes: syntheticDeckBytes.byteLength }, + engine, + modelCommit: null, + override: true, + }; +} + diff --git a/src/lib/deck/worker-protocol.ts b/src/lib/deck/worker-protocol.ts index a7c8ecf..c8ee5ab 100644 --- a/src/lib/deck/worker-protocol.ts +++ b/src/lib/deck/worker-protocol.ts @@ -18,6 +18,8 @@ export interface DeckWorkerInit { modelCommit: string | null; /** Optional live MasterData overlay endpoint merged over `deckData.master` before solver initialization. */ masterOverlayUrl?: string; + /** Optional live chart overlay endpoint hydrating missing `MasterLiveMusicScore` entries before initialization. */ + chartsOverlayUrl?: string; } /** Page -> Worker: one recommendation. The Worker runs one at a time. */ export interface DeckWorkerRun { @@ -106,6 +108,7 @@ export function deckWorkerInit(runtime: DeckSolverRuntime): DeckWorkerInit { glueUrl: runtime.engine.js.url, glueSha256: runtime.engine.js.sha256, glueBytes: runtime.engine.js.bytes, modelCommit: runtime.modelCommit, ...(runtime.masterOverlayUrl ? { masterOverlayUrl: runtime.masterOverlayUrl } : {}), + ...(runtime.chartsOverlayUrl ? { chartsOverlayUrl: runtime.chartsOverlayUrl } : {}), }; } diff --git a/src/lib/masterdata/deck-charts.ts b/src/lib/masterdata/deck-charts.ts new file mode 100644 index 0000000..eb8a6b5 --- /dev/null +++ b/src/lib/masterdata/deck-charts.ts @@ -0,0 +1,248 @@ +import { GAME_SERVER_PROFILES, PRIMARY_SERVER, type GameServer } from "@/config/servers"; +import { serverAssetUrl } from "@/lib/assets/release"; +import { buildFetch } from "@/lib/build/fetch"; +import { convertRawScoreBytesToDeckChart, type OverlayDeckChart } from "@/lib/deck/chart-convert"; +import { + getBuildManifest, + getBuildMasterData, + getBuildMasterVersion, + isMasterDataManifestFresh, + type MasterDataReadOptions, +} from "@/lib/masterdata/build-snapshot"; +import { getChartFileUrl } from "@/lib/music/chart"; + +export const DECK_CHART_OVERLAY_FORMAT = "moenotes.deck-chart-overlay/1"; +export const MAX_DECK_CHART_IDS = 500; +const CHART_FETCH_CONCURRENCY = 6; +const MAX_CACHED_CHARTS = 1200; +const NEGATIVE_CACHE_TTL_MS = 15_000; + +export interface DeckChartOverlayPayload { + format: typeof DECK_CHART_OVERLAY_FORMAT; + server: GameServer; + region: string; + dataVersion: string; + regionVersion: string | null; + charts: OverlayDeckChart[]; + musicLengthsMs: Record; +} + +export interface DeckChartOverlayOptions extends MasterDataReadOptions { + /** Bypass negative (404/unexported) cache entries when the user explicitly clicks Retry. */ + retry?: boolean; +} + +type CachedChartEntry = + | { status: "ready"; chart: OverlayDeckChart } + | { status: "missing"; expiresAt: number }; + +interface ChartCacheState { + entries: Map; + inFlight: Map>; +} + +const chartCacheKey = Symbol.for("moenotes.masterdata.deck-chart-overlay.v1"); +const globalChartCache = globalThis as typeof globalThis & { [chartCacheKey]?: ChartCacheState }; +const cacheState = (globalChartCache[chartCacheKey] ??= { + entries: new Map(), + inFlight: new Map(), +}); + +export function resetDeckChartOverlayCacheForTests(): void { + cacheState.entries.clear(); + cacheState.inFlight.clear(); +} + +export function parseScoreIdList(raw: string | null | undefined, maxCount: number = MAX_DECK_CHART_IDS): number[] { + if (!raw) return []; + const seen = new Set(); + for (const part of raw.split(",")) { + const trimmed = part.trim(); + if (!/^[1-9]\d{0,9}$/.test(trimmed)) continue; + const id = Number(trimmed); + if (Number.isSafeInteger(id) && id > 0) { + seen.add(id); + if (seen.size >= maxCount) break; + } + } + return [...seen].sort((a, b) => a - b); +} + +function parseMasterScoreMap(raw: unknown): Map { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("Invalid MasterLiveMusicScore container"); + } + const allData = (raw as { _allData?: unknown })._allData; + if (!Array.isArray(allData)) { + throw new Error("MasterLiveMusicScore is missing _allData array"); + } + const map = new Map(); + for (const row of allData) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + const record = row as Record; + const id = record._id; + const fileName = typeof record._musicScoreTextFileName === "string" ? record._musicScoreTextFileName.trim() : ""; + if ( + typeof id === "number" && + Number.isSafeInteger(id) && + id > 0 && + fileName.length > 0 && + !/\.\.|[\x00-\x1f\\]/.test(fileName) + ) { + map.set(id, fileName); + } + } + return map; +} + +function rememberEntry(key: string, entry: CachedChartEntry): void { + if (cacheState.entries.has(key)) cacheState.entries.delete(key); + cacheState.entries.set(key, entry); + while (cacheState.entries.size > MAX_CACHED_CHARTS) { + const oldest = cacheState.entries.keys().next().value; + if (oldest === undefined) break; + cacheState.entries.delete(oldest); + } +} + +async function fetchChartBytesFromUrl(url: string, forceBypass: boolean): Promise { + for (let attempt = 1; attempt <= 2; attempt++) { + let response: Response; + try { + response = await buildFetch(url, forceBypass ? { forceBypass: true } : undefined); + } catch { + if (attempt >= 2) return null; + continue; + } + if (response.ok) { + return new Uint8Array(await response.arrayBuffer()); + } + if (response.status !== 429 && response.status < 500) { + return null; + } + } + return null; +} + +async function resolveOneChart( + server: GameServer, + scoreId: number, + chartKey: string, + options?: DeckChartOverlayOptions, +): Promise { + const forceBypass = Boolean(options?.forceBypass); + const bypassNegative = forceBypass || Boolean(options?.retry); + const assetRegion = GAME_SERVER_PROFILES[server].assetRegion; + const key = `${assetRegion}|${scoreId}|${chartKey}`; + + if (!forceBypass) { + const cached = cacheState.entries.get(key); + if (cached) { + if (cached.status === "ready") { + // Refresh LRU position on hit. + rememberEntry(key, cached); + return cached.chart; + } + if (!bypassNegative && cached.expiresAt > Date.now()) { + return null; + } + cacheState.entries.delete(key); + } + const existingFlight = cacheState.inFlight.get(key); + if (existingFlight) return existingFlight; + } + + const task = (async (): Promise => { + const baseUrl = getChartFileUrl(chartKey); + const primaryUrl = serverAssetUrl(baseUrl, server); + let bytes = await fetchChartBytesFromUrl(primaryUrl, forceBypass); + if (!bytes && server !== PRIMARY_SERVER && server !== "jp") { + const fallbackUrl = serverAssetUrl(baseUrl, PRIMARY_SERVER); + if (fallbackUrl !== primaryUrl) { + bytes = await fetchChartBytesFromUrl(fallbackUrl, forceBypass); + } + } + if (!bytes) { + rememberEntry(key, { status: "missing", expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS }); + return null; + } + try { + const converted = await convertRawScoreBytesToDeckChart(scoreId, bytes); + rememberEntry(key, { status: "ready", chart: converted }); + return converted; + } catch { + rememberEntry(key, { status: "missing", expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS }); + return null; + } + })().finally(() => { + if (cacheState.inFlight.get(key) === task) { + cacheState.inFlight.delete(key); + } + }); + + if (!forceBypass) { + cacheState.inFlight.set(key, task); + } + return task; +} + +export async function getDeckChartOverlay( + server: GameServer = PRIMARY_SERVER, + scoreIds: readonly number[], + options?: DeckChartOverlayOptions, +): Promise { + const forceBypass = Boolean(options?.forceBypass); + if (!forceBypass && !isMasterDataManifestFresh()) { + await getBuildManifest(); + } + const manifest = await getBuildManifest(options); + const regionVersion = (await getBuildMasterVersion(server, options)) ?? null; + const region = GAME_SERVER_PROFILES[server].masterdataRegion; + + const uniqueIds = [...new Set(scoreIds.filter((id) => Number.isSafeInteger(id) && id > 0))] + .sort((a, b) => a - b) + .slice(0, MAX_DECK_CHART_IDS); + + if (uniqueIds.length === 0) { + return { + format: DECK_CHART_OVERLAY_FORMAT, + server, + region, + dataVersion: manifest.dataVersion || "unknown", + regionVersion, + charts: [], + musicLengthsMs: {}, + }; + } + + const readOptions: MasterDataReadOptions | undefined = forceBypass ? { forceBypass: true } : undefined; + const scoreMap = await getBuildMasterData("MasterLiveMusicScore.json", parseMasterScoreMap, server, readOptions); + const targets = uniqueIds + .map((id) => ({ id, chartKey: scoreMap.get(id) })) + .filter((entry): entry is { id: number; chartKey: string } => typeof entry.chartKey === "string" && entry.chartKey.length > 0); + + const charts: OverlayDeckChart[] = []; + const musicLengthsMs: Record = {}; + + for (let i = 0; i < targets.length; i += CHART_FETCH_CONCURRENCY) { + const batch = targets.slice(i, i + CHART_FETCH_CONCURRENCY); + const resolved = await Promise.all( + batch.map((item) => resolveOneChart(server, item.id, item.chartKey, options)), + ); + for (const chart of resolved) { + if (!chart) continue; + charts.push(chart); + musicLengthsMs[String(chart.scoreId)] = chart.durationMs; + } + } + + return { + format: DECK_CHART_OVERLAY_FORMAT, + server, + region, + dataVersion: manifest.dataVersion || "unknown", + regionVersion, + charts, + musicLengthsMs, + }; +} diff --git a/src/lib/masterdata/deck-master.ts b/src/lib/masterdata/deck-master.ts index 59ece4f..68d8cbb 100644 --- a/src/lib/masterdata/deck-master.ts +++ b/src/lib/masterdata/deck-master.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { assetConfig } from "@/config/assets"; import { GAME_SERVER_PROFILES, PRIMARY_SERVER, type GameServer } from "@/config/servers"; import { currentMasterDataEpochSync, @@ -23,6 +24,7 @@ export interface DeckMasterOverlayPayload { dataVersion: string; regionVersion: string | null; sha256: string; + chartsOverlayUrl?: string; master: Record; } @@ -397,5 +399,11 @@ export async function getDeckMasterOverlay( cacheState.byRegion.set(region, pending); } const base = await pending; - return { server, ...base }; + const chartParams = new URLSearchParams({ server }); + if (base.regionVersion) chartParams.set("v", base.regionVersion); + return { + server, + ...base, + chartsOverlayUrl: `${assetConfig.deck.chartsOverlayUrl}?${chartParams.toString()}`, + }; } diff --git a/tests/deck-live-chart-hydration.test.ts b/tests/deck-live-chart-hydration.test.ts new file mode 100644 index 0000000..a0a0cff --- /dev/null +++ b/tests/deck-live-chart-hydration.test.ts @@ -0,0 +1,483 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path, { join } from "node:path"; +import { gzipSync } from "node:zlib"; +import { NextRequest } from "next/server"; +import { GET as getDeckChartsRoute } from "@/app/api/v1/meta/deck-charts/route"; +import { setBuildFetchTransportForTests } from "@/lib/build/fetch"; +import { convertRawScoreBytesToDeckChart, convertRawScoreToDeckChart } from "@/lib/deck/chart-convert"; +import { deckDataGap } from "@/lib/deck/data-coverage"; +import { defaultDeckGoalInput, type DeckEvent } from "@/lib/deck/goals"; +import { adaptData, chartSource, type DeckData } from "@/lib/deck/haneoka/data"; +import { adaptRequest, type RecommendationRequest } from "@/lib/deck/haneoka/request"; +import { + buildDeckChartsOverlayUrl, + buildDeckMasterOverlayUrl, + resolveLiveDeckRuntimeFallback, +} from "@/lib/deck/runtime-source"; +import { DECK_WORKER_PROTOCOL } from "@/lib/deck/worker-protocol"; +import { resetMasterDataSnapshotForTests } from "@/lib/masterdata/build-snapshot"; +import { + DECK_CHART_OVERLAY_FORMAT, + getDeckChartOverlay, + parseScoreIdList, + resetDeckChartOverlayCacheForTests, +} from "@/lib/masterdata/deck-charts"; +import { getDeckMasterOverlay } from "@/lib/masterdata/deck-master"; +import { runtimeChart } from "@/lib/music/gekiso-timing"; + +const runtimeDir = path.join(import.meta.dir, "..", "public", "deck"); +const { createDeckWorkerCore } = (await import(path.join(runtimeDir, "deck-worker-core.mjs"))) as { + createDeckWorkerCore(platform: Record): { handle(message: unknown): Promise }; +}; + +function loadSlice(): { rawBytes: Uint8Array; data: DeckData } { + const rawBytes = new Uint8Array(readFileSync(path.join(process.cwd(), "tests/fixtures/haneoka-110-slice.json"))); + const data = JSON.parse(new TextDecoder().decode(rawBytes)) as DeckData; + return { rawBytes, data }; +} + +const originalMasterDataDir = process.env.MOENOTES_MASTERDATA_DIR; +const tempDirs: string[] = []; + +afterEach(() => { + setBuildFetchTransportForTests(undefined); + resetDeckChartOverlayCacheForTests(); + if (originalMasterDataDir === undefined) { + delete process.env.MOENOTES_MASTERDATA_DIR; + } else { + process.env.MOENOTES_MASTERDATA_DIR = originalMasterDataDir; + } + resetMasterDataSnapshotForTests(); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function createSyntheticRawScore(skillTicks: number[] = [480, 960, 1920, 2880, 4320]) { + return { + score: { + events: { + bpm: [ + { t: 0, bpm: 120 }, + { t: 3840, bpm: 180 }, + ], + sig: [{ t: 0, sig: [4, 4] }], + skill: skillTicks, + fever: [[1440, 3840]], + }, + notes: [ + { type: "tap", t: 0, pos: 2, size: 4, crit: false }, + { type: "flick", t: 960, pos: 6, size: 4, crit: true, dir: "right" }, + // Long slide from t=1920 (pos=2, size=4) to t=3840 (pos=10, size=8), generating 8th-note combo ticks (op 120/121) + // whose `by_key` insertion happens when the slide end at t=3840 closes! + { + type: "long", + node: [ + { type: "tap", t: 1920, pos: 2, size: 4, crit: false, ease: "linear", visible: true }, + { type: "tap", t: 3840, pos: 10, size: 8, crit: false, ease: "linear", visible: true }, + ], + }, + { type: "trace", t: 4800, pos: 4, size: 6, crit: false }, + ], + }, + }; +} + +function writeMasterFixtureDir(): { root: string; eventId: number; oldMusicId: number } { + const root = mkdtempSync(join(tmpdir(), "moenotes-deck-charts-test-")); + tempDirs.push(root); + const { data: baseData } = loadSlice(); + const musicIdCol = baseData.master.MasterLiveMusic?.columns.indexOf("_id") ?? 0; + const eventId = 888; + const oldMusicId = (baseData.master.MasterLiveMusic?.rows?.[0]?.[musicIdCol] as number | undefined) ?? 100001; + + writeFileSync( + join(root, "manifest.json"), + JSON.stringify({ + schemaVersion: 1, + fetchedAt: "2026-10-27T00:00:00Z", + dataVersion: "test-v1", + regions: { + jp: { version: "1.2.0.10/jp-hash", assetVersion: "1.2.0", requiredTables: 2, availableTables: 2 }, + "hk-tw-mo": { version: "1.2.0.10/tw-hash", assetVersion: "1.2.0", requiredTables: 2, availableTables: 2 }, + kr: { version: "1.2.0.10/kr-hash", assetVersion: "1.2.0", requiredTables: 2, availableTables: 2 }, + }, + }), + ); + + for (const region of ["jp", "hk-tw-mo", "kr"]) { + const regionDir = join(root, region); + mkdirSync(regionDir, { recursive: true }); + for (const [tableName, columnar] of Object.entries(baseData.master)) { + const objects = columnar.rows.map((row) => + Object.fromEntries(columnar.columns.map((col, idx) => [col, row[idx]])), + ); + if (tableName === "MasterLiveMusic") { + const template = structuredClone(objects[0]!) as Record; + objects.push({ + ...template, + _id: 999, + _easyID: 9991, + _normalID: 9992, + _hardID: 9993, + _expertID: 9994, + }); + } + if (tableName === "MasterEvent") { + objects.push({ + _id: eventId, + _nameTextId: 888, + _startAt: "2026-10-01 00:00:00", + _endAt: "2026-11-30 23:59:59", + _displayEndAt: "2026-12-01 23:59:59", + _eventType: 2, + _storyChapterId: 0, + _eventItemId: 0, + _isRankingDisabled: false, + _isMusicRankingDisabled: false, + _isTotalMusicRankingDisabled: false, + _liveEventRewardGroup: 0, + _liveEventPointGroup: 0, + _challengeLiveEventRewardGroup: 0, + _challengeLiveEventPointGroup: 0, + _gekisouLiveRankingEventPointGroup: 0, + _gekisouLiveRankingEventRewardGroup: 0, + _musicId: 999, + _imageAsset: "", + _logoAsset: "", + _backgroundAsset: "", + _bannerAsset: "", + _backgroundVideoAsset: "", + _carouselHelpId: 0, + }); + } + if (tableName === "MasterChallengeMusic") { + objects.push({ + _id: 777, + _eventId: eventId, + _liveMusicId: 999, + _musicType: 1, + _gekisouMission1: 0, + _gekisouMission2: 0, + _gekisouMission3: 0, + _rankingRewardGroup: 0, + _stratAt: "2026-10-01 00:00:00", + _endAt: "2026-11-30 23:59:59", + }); + } + writeFileSync(join(regionDir, `${tableName}.json`), JSON.stringify({ _allData: objects })); + } + writeFileSync( + join(regionDir, "MasterLiveMusicScore.json"), + JSON.stringify({ + _allData: [ + { _id: 9993, _musicScoreTextFileName: "m999_hard" }, + { _id: 9994, _musicScoreTextFileName: "m999_expert" }, + { _id: 9999, _musicScoreTextFileName: "../blocked" }, + ], + }), + ); + } + return { root, eventId, oldMusicId }; +} + +describe("Raw score -> DeckChart binary32 & Haneoka runtime_chart parity", () => { + test("converts raw & gzipped scores with exact BPM timing, combo line interpolation, by_key enumeration, fevers, and 5 skills", async () => { + const raw = createSyntheticRawScore(); + const chart = convertRawScoreToDeckChart(9994, raw); + const gekisoRef = runtimeChart(raw); + + // Tick 0..3840 @ 120 BPM = 4000ms; Tick 3840..4800 (960 ticks = 2 beats) @ 180 BPM = 666ms -> maxPosMs = 4666ms -> durationMs = 5666ms. + expect(chart.scoreId).toBe(9994); + expect(chart.durationMs).toBe(5666); + expect(chart.skillEvents.timeMs).toEqual([500, 1000, 2000, 3000, 4333]); + expect(chart.fevers).toEqual({ + startMs: gekisoRef.feverMs.map(([s]) => s), + endMs: gekisoRef.feverMs.map(([, e]) => e), + }); + + // Exact 1:1 parity between `chart.enumeration` and `gekiso-timing.ts` `runtimeChart(raw).enumeration`! + expect(chart.enumeration).toEqual({ + op: gekisoRef.enumeration.map((n) => n.op), + judgementType: gekisoRef.enumeration.map((n) => n.judgementType), + timeMs: gekisoRef.enumeration.map((n) => n.timeMs), + }); + + // `notes` is sorted monotonically by `timeMs`, whereas `enumeration` has slide combo ticks appended after slide end (t=3840 -> 4000ms). + for (let i = 1; i < chart.notes.timeMs.length; i++) { + expect(chart.notes.timeMs[i]!).toBeGreaterThanOrEqual(chart.notes.timeMs[i - 1]!); + } + expect(chart.enumeration.timeMs).not.toEqual(chart.notes.timeMs); + + // Critical flick at t=960 (op=40, crit=true, dir="right"=2) -> judgementAreaOffsetType = 4, direction = 2. + const flickIdx = chart.notes.op.indexOf(40); + expect(chart.inputs[flickIdx]).toMatchObject({ + judgementAreaOffsetType: 4, + lane: 6, + width: 4, + direction: 2, + }); + + // Halfway combo tick on the long slide (t=2880 -> 3000ms, halfway between pos 2..10 & size 4..8 -> lane=6, width=6). + const midComboIdx = chart.notes.timeMs.findIndex((ms, idx) => ms === 3000 && chart.notes.op[idx] === 120); + expect(midComboIdx).toBeGreaterThanOrEqual(0); + expect(chart.inputs[midComboIdx]).toEqual({ + judgementAreaOffsetType: 1, + lane: 6, + width: 6, + direction: 0, + ease: 0, + easeRight: 0, + lines: [1], + }); + + // Gzipped asset bytes decode identically. + const gz = gzipSync(Buffer.from(JSON.stringify(raw), "utf8")); + const fromGzip = await convertRawScoreBytesToDeckChart(9994, new Uint8Array(gz)); + expect(fromGzip).toEqual(chart); + + // chartSource feeds durationMs, native inputs, and by_key enumeration into Haneoka's WASM load_source. + const syntheticDeckData: DeckData = { + format: "nnnotes.deck-data/1", + provenance: { region: "tw", deck: { commit: "test" }, replay: { musicLengthsMs: {} } }, + master: {}, + charts: [chart], + }; + const source = chartSource(syntheticDeckData, 9994); + expect(source.durationMs).toBe(5666); + expect(source.notes[midComboIdx]).toMatchObject({ + operateType: 120, + judged: true, + input: { judgementAreaOffsetType: 1, lane: 6, width: 6 }, + }); + }); + + test("rejects unplayable charts missing 5 skill markers or judged notes (matching pt.rs invariants)", () => { + const missingSkill = createSyntheticRawScore([480, 960, 1920, 2880]); + expect(() => convertRawScoreToDeckChart(9994, missingSkill)).toThrow(/Unsupported skill event schedule/); + }); +}); + +describe("/api/v1/meta/deck-charts allowlist, caching, retry & fallback", () => { + test("hydrates allowlisted scoreIds, caches hits, bypasses negative 404 cache on retry, and falls back kr -> tw", async () => { + const { root } = writeMasterFixtureDir(); + process.env.MOENOTES_MASTERDATA_DIR = root; + resetMasterDataSnapshotForTests(); + + expect(parseScoreIdList("9994, 9993, 9994, -1, abc, 0")).toEqual([9993, 9994]); + + let hardAvailable = false; + const requestedUrls: string[] = []; + const scoreJsonBytes = new TextEncoder().encode(JSON.stringify(createSyntheticRawScore())); + + setBuildFetchTransportForTests(async (input) => { + const url = String(input); + requestedUrls.push(url); + if (url.includes("/Live/MusicScore/m999_expert/m999_expert.json")) { + // Simulate KR not having uploaded the chart yet while TW (PRIMARY_SERVER) already has it. + if (url.includes("/kr/assets/")) return new Response("Not Found", { status: 404 }); + return new Response(scoreJsonBytes, { status: 200, headers: { "content-type": "application/json" } }); + } + if (url.includes("/Live/MusicScore/m999_hard/m999_hard.json") && hardAvailable) { + return new Response(scoreJsonBytes, { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("Not Found", { status: 404 }); + }); + + // 1. Partial availability: 9994 resolves, 9993 is still 404 -> Route returns Cache-Control: no-store! + const partialRes = await getDeckChartsRoute( + new NextRequest("http://localhost:3000/api/v1/meta/deck-charts?server=tw&v=1.2.0&ids=9993,9994,9999"), + ); + expect(partialRes.status).toBe(200); + expect(partialRes.headers.get("Cache-Control")).toContain("no-store"); + const partialPayload = await partialRes.json(); + expect(partialPayload.format).toBe(DECK_CHART_OVERLAY_FORMAT); + expect(partialPayload.charts.map((c: { scoreId: number }) => c.scoreId)).toEqual([9994]); + expect(partialPayload.musicLengthsMs).toEqual({ "9994": 5666 }); + // Path-traversal entry 9999 (`../blocked`) was never fetched. + expect(requestedUrls.some((u) => u.includes("blocked"))).toBe(false); + + // 2. Now m999_hard finishes uploading to moenotes-assets; Retry (`retry=1`) bypasses the negative 404 cache! + hardAvailable = true; + const withoutRetry = await getDeckChartOverlay("tw", [9993, 9994]); + expect(withoutRetry.charts.map((c) => c.scoreId)).toEqual([9994]); + + const withRetry = await getDeckChartOverlay("tw", [9993, 9994], { retry: true }); + expect(withRetry.charts.map((c) => c.scoreId)).toEqual([9993, 9994]); + + // 3. Subsequent normal versioned GET when all requested IDs resolve returns private short cache! + const fullRes = await getDeckChartsRoute( + new NextRequest("http://localhost:3000/api/v1/meta/deck-charts?server=tw&v=1.2.0&ids=9993,9994"), + ); + expect(fullRes.headers.get("Cache-Control")).toContain("max-age=60"); + + // 4. KR falls back to TW (`PRIMARY_SERVER`) on 404, whereas JP never cross-reads TW. + const krOverlay = await getDeckChartOverlay("kr", [9994]); + expect(krOverlay.charts.map((c) => c.scoreId)).toEqual([9994]); + }); +}); + +describe("End-to-end Worker missing-chart hydration & empty-baseline fallback", () => { + test("eliminates solver.dataUpdating for new Free Live & Challenge Event songs when music-data.yml lags", async () => { + const { root, eventId, oldMusicId } = writeMasterFixtureDir(); + process.env.MOENOTES_MASTERDATA_DIR = root; + resetMasterDataSnapshotForTests(); + + const rawScoreBytes = new TextEncoder().encode(JSON.stringify(createSyntheticRawScore())); + setBuildFetchTransportForTests(async (input) => { + const url = String(input); + if (url.includes("/Live/MusicScore/m999_hard/") || url.includes("/Live/MusicScore/m999_expert/")) { + return new Response(rawScoreBytes, { status: 200 }); + } + return new Response("Not Found", { status: 404 }); + }); + + const masterOverlay = await getDeckMasterOverlay("tw"); + expect(masterOverlay.chartsOverlayUrl).toContain("/api/v1/meta/deck-charts?server=tw"); + + const { rawBytes: baseBytes } = loadSlice(); + const baseHashBuf = await crypto.subtle.digest("SHA-256", baseBytes); + const baseSha256 = [...new Uint8Array(baseHashBuf)].map((b) => b.toString(16).padStart(2, "0")).join(""); + const dummyBytes = new Uint8Array([1, 2, 3, 4]); + const dummyHashBuf = await crypto.subtle.digest("SHA-256", dummyBytes); + const dummySha256 = [...new Uint8Array(dummyHashBuf)].map((b) => b.toString(16).padStart(2, "0")).join(""); + + let capturedMergedData: DeckData | null = null; + let capturedDatasetId = ""; + const events: Record[] = []; + const core = createDeckWorkerCore({ + post: (msg: Record) => events.push(msg), + fetch: async (url: string) => { + if (url === "https://data.example.invalid/deck-data.json") return new Response(baseBytes, { status: 200 }); + if (url === "https://data.example.invalid/engine.wasm" || url === "https://data.example.invalid/engine.js") { + return new Response(dummyBytes, { status: 200 }); + } + if (url.startsWith("https://data.example.invalid/api/v1/meta/deck-master")) { + return new Response(JSON.stringify(masterOverlay), { status: 200 }); + } + if (url.startsWith("https://data.example.invalid/api/v1/meta/deck-charts")) { + const parsedUrl = new URL(url); + const ids = parseScoreIdList(parsedUrl.searchParams.get("ids")); + const chartOverlay = await getDeckChartOverlay("tw", ids); + return new Response(JSON.stringify(chartOverlay), { status: 200 }); + } + return new Response("Not Found", { status: 404 }); + }, + loadSolver: async () => + class MockDeckSolver { + datasetId: string; + constructor(bytes: Uint8Array, options: { datasetId: string }) { + capturedMergedData = JSON.parse(new TextDecoder().decode(bytes)) as DeckData; + capturedDatasetId = options.datasetId; + this.datasetId = options.datasetId; + } + capabilities() { + return JSON.stringify({ scenes: ["free", "event", "challenge", "arena"] }); + } + }, + }); + + await core.handle({ + type: "init", + protocol: DECK_WORKER_PROTOCOL, + deckDataUrl: "https://data.example.invalid/deck-data.json", + deckDataSha256: baseSha256, + deckDataBytes: baseBytes.byteLength, + wasmUrl: "https://data.example.invalid/engine.wasm", + wasmSha256: dummySha256, + wasmBytes: dummyBytes.byteLength, + glueUrl: "https://data.example.invalid/engine.js", + glueSha256: dummySha256, + glueBytes: dummyBytes.byteLength, + modelCommit: null, + masterOverlayUrl: "https://data.example.invalid" + buildDeckMasterOverlayUrl("tw", "1.2.0", undefined, 1), + chartsOverlayUrl: "https://data.example.invalid" + buildDeckChartsOverlayUrl("tw", "1.2.0", undefined, 1), + }); + + expect(events).toHaveLength(1); + const ready = events[0] as { + type: string; + catalog: Parameters[2]; + }; + expect(ready.type).toBe("ready"); + const newMusic = ready.catalog?.musics.find((m) => m.id === 999); + expect(newMusic?.difficulties).toEqual(["hard", "expert"]); + + const testEvent: DeckEvent = { + id: eventId, + name: "Challenge Test Event", + startAt: "2026-10-01 00:00:00", + endAt: "2026-11-30 23:59:59", + itemId: null, + challengeMusics: [{ id: 777, musicId: 999 }], + }; + + // Both Free Live on new song 999 AND Challenge Event valuation (which inspects Challenge song 999) now pass deckDataGap! + expect( + deckDataGap( + { ...defaultDeckGoalInput("free"), musicId: 999, difficulty: "expert" }, + null, + ready.catalog, + ), + ).toBeNull(); + + expect( + deckDataGap( + { + ...defaultDeckGoalInput("eventPoints"), + venue: "freeLive", + musicId: oldMusicId, + difficulty: "expert", + challengeMusicId: 777, + rateDifficulty: "expert", + rewards: { + ...defaultDeckGoalInput("eventPoints").rewards, + challengePoints: { kind: "challengeLive" }, + }, + }, + testEvent, + ready.catalog, + ), + ).toBeNull(); + + // Verify Haneoka's data & request adapters resolve chartIndex cleanly against the hydrated dataset! + expect(capturedMergedData).not.toBeNull(); + expect(() => adaptData(capturedMergedData!, capturedDatasetId)).not.toThrow(); + const freeReq: RecommendationRequest = { + format: "ournotes-deck.recommendation-request/2", + goal: { kind: "freeLive", musicId: 999, difficulty: "expert" }, + aggregation: "expected", + k: 1, + }; + const adapted = adaptRequest(capturedMergedData!, freeReq); + expect(adapted.charts).toHaveLength(1); + expect(adapted.charts[0]?.song).toEqual({ songId: 999, difficulty: 3 }); + expect(adapted.charts[0]?.source.durationMs).toBe(5666); + expect(chartSource(capturedMergedData!, 9994).durationMs).toBe(5666); + }); + + test("resolveLiveDeckRuntimeFallback synthesizes a self-contained runtime when external music-data is unreachable", async () => { + const jsBytes = new TextEncoder().encode("export class DeckSolver {}"); + const wasmBytes = new Uint8Array([0x00, 0x61, 0x73, 0x6d]); + const fallback = await resolveLiveDeckRuntimeFallback("jp", { + engineOverride: { + model: { commit: "live-test" }, + js: { url: "https://site.example.invalid/solver.mjs" }, + wasm: { url: "https://site.example.invalid/team-engine.wasm" }, + }, + fetch: async (url) => { + if (url.endsWith("solver.mjs")) return new Response(jsBytes, { status: 200 }); + if (url.endsWith("team-engine.wasm")) return new Response(wasmBytes, { status: 200 }); + return new Response("Not Found", { status: 404 }); + }, + }); + + expect(fallback).not.toBeNull(); + expect(fallback!.server).toBe("jp"); + expect(fallback!.modelCommit).toBeNull(); + expect(fallback!.deckData.url.startsWith("data:application/json;base64,")).toBe(true); + }); +}); From 65a1e93cfda6f93ec62db67158c1db349561a1a2 Mon Sep 17 00:00:00 2001 From: Exmeaning <60350667+Exmeaning@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:15:53 +0000 Subject: [PATCH 2/2] test(deck): verify version-scoped chartsOverlayUrl from current_version.json --- tests/deck-live-chart-hydration.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/deck-live-chart-hydration.test.ts b/tests/deck-live-chart-hydration.test.ts index a0a0cff..3f754a0 100644 --- a/tests/deck-live-chart-hydration.test.ts +++ b/tests/deck-live-chart-hydration.test.ts @@ -95,7 +95,7 @@ function writeMasterFixtureDir(): { root: string; eventId: number; oldMusicId: n const oldMusicId = (baseData.master.MasterLiveMusic?.rows?.[0]?.[musicIdCol] as number | undefined) ?? 100001; writeFileSync( - join(root, "manifest.json"), + join(root, "current_version.json"), JSON.stringify({ schemaVersion: 1, fetchedAt: "2026-10-27T00:00:00Z", @@ -336,7 +336,8 @@ describe("End-to-end Worker missing-chart hydration & empty-baseline fallback", }); const masterOverlay = await getDeckMasterOverlay("tw"); - expect(masterOverlay.chartsOverlayUrl).toContain("/api/v1/meta/deck-charts?server=tw"); + expect(masterOverlay.regionVersion).toBe("1.2.0.10/tw-hash"); + expect(masterOverlay.chartsOverlayUrl).toBe("/api/v1/meta/deck-charts?server=tw&v=1.2.0.10%2Ftw-hash"); const { rawBytes: baseBytes } = loadSlice(); const baseHashBuf = await crypto.subtle.digest("SHA-256", baseBytes);