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:
83
scripts/extract-reference-data.mjs
Normal file
83
scripts/extract-reference-data.mjs
Normal 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}`);
|
||||
Reference in New Issue
Block a user