Phase 2: domain layer with tests

Vite + React + TypeScript + Vitest. No router, state library or UI kit.

The 159-row combo table is extracted from reference/index.html by
scripts/extract-reference-data.mjs rather than retyped: it balances
brackets past string literals to slice out the pure-data declarations and
evaluates them in a vm with no DOM. raw.ts and labels.ts are generated.
The extracted count matches the 159 the Phase 1 app reported on device.

Entry.id keeps the reference's format verbatim (shift>p1@S#FIX VEL),
built from the unsplit context string, so drill progress already on the
phone migrates instead of being orphaned.

The domain is pure where the reference was not: boxes go in and new
boxes come out, and the random source is injected. That is what made
card selection testable.

Two bugs found by doing so:

- pickCard returned the card just answered when random() gave exactly 0.
  A zero-weight card does not move the accumulator, so "r <= 0" matched
  it. The reference has this; at one draw in 2^53 it is invisible in use,
  but a pinned random source hits it every time. Fixed to "r < 0".
- boxDistribution incremented through an index that
  noUncheckedIndexedAccess types as possibly undefined.

65 tests. Several assert numbers observed on the device: 159 entries,
sample-mode layers of 3 / 35 / 2, a 16-card SHIFT+pads deck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 00:53:04 +03:00
parent 9e897a26ea
commit b32c38019e
15 changed files with 4461 additions and 5 deletions

View File

@@ -0,0 +1,83 @@
// Extracts the combo table and label maps from reference/index.html into TypeScript.
//
// The reference is the data spec, so the 159 entries are never retyped by hand:
// this lifts the literals straight out of it and re-runs whenever it changes.
// It reads only pure-data declarations, evaluated in a vm with no DOM.
import { readFileSync, writeFileSync } from "node:fs";
import vm from "node:vm";
const SRC = "reference/index.html";
const src = readFileSync(SRC, "utf8");
// Balance brackets from a declaration's first opener, skipping string literals so
// that braces or quotes inside descriptions cannot end the scan early.
function literalAfter(name) {
const at = src.indexOf(`const ${name}=`);
if (at < 0) throw new Error(`declaration not found: ${name}`);
let i = src.indexOf("=", at) + 1;
while (/\s/.test(src[i])) i++;
const start = i;
let depth = 0, quote = null;
for (; i < src.length; i++) {
const c = src[i];
if (quote) {
if (c === "\\") { i++; continue; }
if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'" || c === "`") { quote = c; continue; }
if (c === "{" || c === "[" || c === "(") depth++;
else if (c === "}" || c === "]" || c === ")") {
depth--;
if (depth === 0) return src.slice(start, i + 1);
}
}
throw new Error(`unbalanced literal: ${name}`);
}
const NAMES = ["L", "PL", "GROUPS", "GNAME", "NOLATCH", "MODORDER", "RAW", "CTX", "CTXHINT"];
const ctx = vm.createContext({});
for (const n of NAMES) vm.runInContext(`var ${n} = ${literalAfter(n)};`, ctx);
// The reference adds the 16 pad labels in a loop right after declaring L.
vm.runInContext(`for (let i = 1; i <= 16; i++) L["p" + i] = "PAD " + i;`, ctx);
const g = (n) => vm.runInContext(n, ctx);
const header = (what) =>
`// GENERATED from ${SRC} by scripts/extract-reference-data.mjs — do not edit by hand.\n` +
`// ${what}\n\n`;
const j = (v) => JSON.stringify(v, null, 2);
writeFileSync(
"src/domain/raw.ts",
header("The combo table, exactly as the reference declares it.") +
`import type { RawEntry } from "./types";\n\nexport const RAW: RawEntry[] = ${j(g("RAW"))};\n`
);
writeFileSync(
"src/domain/labels.ts",
header("Control labels, groupings and context names.") +
`import type { ControlId, ContextCode, GroupId } from "./types";\n\n` +
`/** Full name printed on each control. */\n` +
`export const L: Record<ControlId, string> = ${j(g("L"))};\n\n` +
`/** Shorter labels used on the panel where the full one will not fit. */\n` +
`export const PL: Partial<Record<ControlId, string>> = ${j(g("PL"))};\n\n` +
`/** A target may name a whole group of controls rather than one. */\n` +
`export const GROUPS: Record<GroupId, ControlId[]> = ${j(g("GROUPS"))};\n\n` +
`export const GNAME: Record<GroupId, string> = ${j(g("GNAME"))};\n\n` +
`/** Controls that never latch when held: the knobs and the pads. */\n` +
`export const NOLATCH: ControlId[] = ${j([...g("NOLATCH")])};\n\n` +
`/** Canonical modifier order, so a layer key is stable however it was pressed. */\n` +
`export const MODORDER: ControlId[] = ${j(g("MODORDER"))};\n\n` +
`export const CTX: Record<ContextCode, string> = ${j(g("CTX"))};\n\n` +
`export const CTXHINT: Record<ContextCode, string> = ${j(g("CTXHINT"))};\n`
);
const raw = g("RAW");
console.log(`RAW entries ${raw.length}`);
console.log(`control labels ${Object.keys(g("L")).length}`);
console.log(`contexts ${Object.keys(g("CTX")).join(", ")}`);
console.log(`groups ${Object.keys(g("GROUPS")).join(", ")}`);
console.log(`entries with how ${raw.filter((r) => r.length > 7).length}`);
console.log(`group targets ${raw.filter((r) => typeof r[1] === "string" && g("GROUPS")[r[1]]).length}`);
console.log(`array targets ${raw.filter((r) => Array.isArray(r[1])).length}`);