@alpaca-software/40kdc-data 0.4.0 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=fix-corner-ruin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fix-corner-ruin.d.ts","sourceRoot":"","sources":["../src/fix-corner-ruin.ts"],"names":[],"mappings":""}
@@ -0,0 +1,160 @@
1
+ /**
2
+ * One-shot correction (refuses to re-run once applied) of the `corner-ruin-left` /
3
+ * `corner-ruin-right` catalog templates: the long leg is 7″ but the physical
4
+ * piece is 6.5″. Shrinks the leg in `data/core/terrain-templates.json` and
5
+ * compensates every layout piece referencing those templates in
6
+ * `data/core/terrain-layouts.json` so the resolved outer corner stays exactly
7
+ * where it was placed.
8
+ *
9
+ * Pieces are centroid-anchored (`board = position + R·M·(v − c)`), so changing
10
+ * the footprint moves the centroid `c` and would shift the whole piece. Keeping
11
+ * any unchanged vertex fixed requires `position += R·M·(c_new − c_old)` — the
12
+ * same correction in board space and in a parent area's centroid-local frame
13
+ * (the area transform is rigid and applied after).
14
+ *
15
+ * Pieces with an inline baked `footprint` (the migrated gw-11e-crucible corner
16
+ * segments, which carry the template id only as provenance) are intentionally
17
+ * untouched: the template edit cannot affect them.
18
+ *
19
+ * Verifies by resolving every layout before and after: unchanged footprint
20
+ * vertices must not move, the two shortened leg vertices must move by exactly
21
+ * 0.5″, and every piece not referencing the templates must be bit-identical.
22
+ *
23
+ * Usage: `npx tsx src/fix-corner-ruin.ts` (run from `tools/`).
24
+ */
25
+ import { readFileSync, writeFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { resolveLayout, polygonCentroid, footprintVertices, } from "./terrain/resolve.js";
28
+ const REPO_ROOT = join(new URL("../..", import.meta.url).pathname);
29
+ const CATALOG_PATH = join(REPO_ROOT, "data", "core", "terrain-templates.json");
30
+ const LAYOUTS_PATH = join(REPO_ROOT, "data", "core", "terrain-layouts.json");
31
+ const OLD_LEG = 7;
32
+ const NEW_LEG = 6.5;
33
+ const TARGETS = new Set(["corner-ruin-left", "corner-ruin-right"]);
34
+ /** Resolver output is rounded to 1e-4; position rounding adds ≤5e-5 per axis. */
35
+ const TOL = 2e-4;
36
+ // Mirror → rotate, matching the resolver's orientation math (y-down, CW).
37
+ function applyMirror(v, m) {
38
+ if (m === "horizontal")
39
+ return { x: -v.x, y: v.y };
40
+ if (m === "vertical")
41
+ return { x: v.x, y: -v.y };
42
+ return v;
43
+ }
44
+ function rotateCw(v, deg) {
45
+ const r = (deg * Math.PI) / 180;
46
+ const c = Math.cos(r);
47
+ const s = Math.sin(r);
48
+ return { x: c * v.x - s * v.y, y: s * v.x + c * v.y };
49
+ }
50
+ const round4 = (n) => Math.round(n * 1e4) / 1e4;
51
+ function main() {
52
+ const catalog = JSON.parse(readFileSync(CATALOG_PATH, "utf8"));
53
+ const layouts = JSON.parse(readFileSync(LAYOUTS_PATH, "utf8"));
54
+ // ---- snapshot ground truth with the OLD catalog --------------------------
55
+ const before = new Map(layouts.map((l) => [l.id, resolveLayout(l, catalog)]));
56
+ // ---- patch the two templates, recording centroid deltas ------------------
57
+ const deltas = new Map();
58
+ for (const t of catalog) {
59
+ if (!TARGETS.has(t.id))
60
+ continue;
61
+ if (t.footprint.type !== "polygon")
62
+ throw new Error(`${t.id}: expected polygon footprint`);
63
+ const oldVerts = footprintVertices(t.footprint);
64
+ const movedIdx = [];
65
+ t.footprint.points.forEach((p, i) => {
66
+ if (p.y === OLD_LEG) {
67
+ p.y = NEW_LEG;
68
+ movedIdx.push(i);
69
+ }
70
+ });
71
+ if (movedIdx.length !== 2) {
72
+ throw new Error(`${t.id}: expected exactly 2 leg vertices at y=${OLD_LEG}, found ${movedIdx.length}`);
73
+ }
74
+ const cOld = polygonCentroid(oldVerts);
75
+ const cNew = polygonCentroid(footprintVertices(t.footprint));
76
+ const delta = { x: cNew.x - cOld.x, y: cNew.y - cOld.y };
77
+ deltas.set(t.id, { delta, movedIdx });
78
+ console.log(`${t.id}: centroid (${round4(cOld.x)}, ${round4(cOld.y)}) → (${round4(cNew.x)}, ${round4(cNew.y)})`);
79
+ }
80
+ if (deltas.size !== 2)
81
+ throw new Error(`expected both templates in catalog, found ${deltas.size}`);
82
+ const touched = [];
83
+ let skippedInline = 0;
84
+ for (const layout of layouts) {
85
+ for (const piece of layout.pieces ?? []) {
86
+ if (!piece.template || !TARGETS.has(piece.template))
87
+ continue;
88
+ if (piece.footprint) {
89
+ skippedInline++; // baked geometry: template id is provenance only
90
+ continue;
91
+ }
92
+ const { delta, movedIdx } = deltas.get(piece.template);
93
+ const d = rotateCw(applyMirror(delta, piece.mirror ?? "none"), piece.rotation_degrees ?? 0);
94
+ piece.position = { x: round4(piece.position.x + d.x), y: round4(piece.position.y + d.y) };
95
+ touched.push({
96
+ layoutId: layout.id,
97
+ pieceId: piece.id ?? "<anon>",
98
+ template: piece.template,
99
+ movedIdx,
100
+ });
101
+ }
102
+ }
103
+ // ---- verify against the snapshot ------------------------------------------
104
+ const after = new Map(layouts.map((l) => [l.id, resolveLayout(l, catalog)]));
105
+ const touchedKey = new Set(touched.map((t) => `${t.layoutId}/${t.pieceId}`));
106
+ let failures = 0;
107
+ console.log("\nlayout/piece fixed-corner Δ leg Δ");
108
+ for (const t of touched) {
109
+ const b = before.get(t.layoutId).find((r) => r.id === t.pieceId);
110
+ const a = after.get(t.layoutId).find((r) => r.id === t.pieceId);
111
+ if (!b || !a || b.vertices.length !== a.vertices.length) {
112
+ failures++;
113
+ console.error(` ✗ ${t.layoutId}/${t.pieceId}: piece missing or vertex count changed`);
114
+ continue;
115
+ }
116
+ let fixedMax = 0;
117
+ let legMin = Infinity;
118
+ let legMax = 0;
119
+ b.vertices.forEach((bv, i) => {
120
+ const av = a.vertices[i];
121
+ const moved = Math.hypot(av.x - bv.x, av.y - bv.y);
122
+ if (t.movedIdx.includes(i)) {
123
+ legMin = Math.min(legMin, moved);
124
+ legMax = Math.max(legMax, moved);
125
+ }
126
+ else {
127
+ fixedMax = Math.max(fixedMax, moved);
128
+ }
129
+ });
130
+ const ok = fixedMax <= TOL && Math.abs(legMin - 0.5) <= TOL && Math.abs(legMax - 0.5) <= TOL;
131
+ if (!ok)
132
+ failures++;
133
+ console.log(` ${ok ? "✓" : "✗"} ${`${t.layoutId}/${t.pieceId}`.padEnd(52)} ${fixedMax.toExponential(1).padStart(8)} ${legMin.toFixed(4)}–${legMax.toFixed(4)}`);
134
+ }
135
+ // Everything NOT touched must be bit-identical.
136
+ for (const layout of layouts) {
137
+ const b = before.get(layout.id);
138
+ const a = after.get(layout.id);
139
+ for (let i = 0; i < b.length; i++) {
140
+ const key = `${layout.id}/${b[i].id ?? ""}`;
141
+ if (touchedKey.has(key))
142
+ continue;
143
+ const same = b[i].vertices.length === a[i].vertices.length &&
144
+ b[i].vertices.every((v, j) => v.x === a[i].vertices[j].x && v.y === a[i].vertices[j].y);
145
+ if (!same) {
146
+ failures++;
147
+ console.error(` ✗ ${key}: untouched piece geometry changed`);
148
+ }
149
+ }
150
+ }
151
+ if (failures > 0) {
152
+ console.error(`\nFIX FAILED: ${failures} verification failures; files NOT written.`);
153
+ process.exit(1);
154
+ }
155
+ writeFileSync(CATALOG_PATH, `${JSON.stringify(catalog, null, 2)}\n`);
156
+ writeFileSync(LAYOUTS_PATH, `${JSON.stringify(layouts, null, 2)}\n`);
157
+ console.log(`\n✓ ${touched.length} placements compensated (${skippedInline} inline-baked pieces left untouched). Wrote both data files.`);
158
+ }
159
+ main();
160
+ //# sourceMappingURL=fix-corner-ruin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fix-corner-ruin.js","sourceRoot":"","sources":["../src/fix-corner-ruin.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * One-shot correction (refuses to re-run once applied) of the `corner-ruin-left` /\n * `corner-ruin-right` catalog templates: the long leg is 7″ but the physical\n * piece is 6.5″. Shrinks the leg in `data/core/terrain-templates.json` and\n * compensates every layout piece referencing those templates in\n * `data/core/terrain-layouts.json` so the resolved outer corner stays exactly\n * where it was placed.\n *\n * Pieces are centroid-anchored (`board = position + R·M·(v − c)`), so changing\n * the footprint moves the centroid `c` and would shift the whole piece. Keeping\n * any unchanged vertex fixed requires `position += R·M·(c_new − c_old)` — the\n * same correction in board space and in a parent area's centroid-local frame\n * (the area transform is rigid and applied after).\n *\n * Pieces with an inline baked `footprint` (the migrated gw-11e-crucible corner\n * segments, which carry the template id only as provenance) are intentionally\n * untouched: the template edit cannot affect them.\n *\n * Verifies by resolving every layout before and after: unchanged footprint\n * vertices must not move, the two shortened leg vertices must move by exactly\n * 0.5″, and every piece not referencing the templates must be bit-identical.\n *\n * Usage: `npx tsx src/fix-corner-ruin.ts` (run from `tools/`).\n */\nimport { readFileSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\nimport {\n resolveLayout,\n polygonCentroid,\n footprintVertices,\n type Vec2,\n type Mirror,\n type TerrainTemplate,\n type TerrainLayout,\n} from \"./terrain/resolve.js\";\n\nconst REPO_ROOT = join(new URL(\"../..\", import.meta.url).pathname);\nconst CATALOG_PATH = join(REPO_ROOT, \"data\", \"core\", \"terrain-templates.json\");\nconst LAYOUTS_PATH = join(REPO_ROOT, \"data\", \"core\", \"terrain-layouts.json\");\n\nconst OLD_LEG = 7;\nconst NEW_LEG = 6.5;\nconst TARGETS = new Set([\"corner-ruin-left\", \"corner-ruin-right\"]);\n/** Resolver output is rounded to 1e-4; position rounding adds ≤5e-5 per axis. */\nconst TOL = 2e-4;\n\n// Mirror → rotate, matching the resolver's orientation math (y-down, CW).\nfunction applyMirror(v: Vec2, m: Mirror): Vec2 {\n if (m === \"horizontal\") return { x: -v.x, y: v.y };\n if (m === \"vertical\") return { x: v.x, y: -v.y };\n return v;\n}\nfunction rotateCw(v: Vec2, deg: number): Vec2 {\n const r = (deg * Math.PI) / 180;\n const c = Math.cos(r);\n const s = Math.sin(r);\n return { x: c * v.x - s * v.y, y: s * v.x + c * v.y };\n}\nconst round4 = (n: number): number => Math.round(n * 1e4) / 1e4;\n\nfunction main(): void {\n const catalog = JSON.parse(readFileSync(CATALOG_PATH, \"utf8\")) as TerrainTemplate[];\n const layouts = JSON.parse(readFileSync(LAYOUTS_PATH, \"utf8\")) as TerrainLayout[];\n\n // ---- snapshot ground truth with the OLD catalog --------------------------\n const before = new Map(layouts.map((l) => [l.id, resolveLayout(l, catalog)]));\n\n // ---- patch the two templates, recording centroid deltas ------------------\n const deltas = new Map<string, { delta: Vec2; movedIdx: number[] }>();\n for (const t of catalog) {\n if (!TARGETS.has(t.id)) continue;\n if (t.footprint.type !== \"polygon\") throw new Error(`${t.id}: expected polygon footprint`);\n const oldVerts = footprintVertices(t.footprint);\n const movedIdx: number[] = [];\n t.footprint.points.forEach((p, i) => {\n if (p.y === OLD_LEG) {\n p.y = NEW_LEG;\n movedIdx.push(i);\n }\n });\n if (movedIdx.length !== 2) {\n throw new Error(`${t.id}: expected exactly 2 leg vertices at y=${OLD_LEG}, found ${movedIdx.length}`);\n }\n const cOld = polygonCentroid(oldVerts);\n const cNew = polygonCentroid(footprintVertices(t.footprint));\n const delta = { x: cNew.x - cOld.x, y: cNew.y - cOld.y };\n deltas.set(t.id, { delta, movedIdx });\n console.log(\n `${t.id}: centroid (${round4(cOld.x)}, ${round4(cOld.y)}) → (${round4(cNew.x)}, ${round4(cNew.y)})`,\n );\n }\n if (deltas.size !== 2) throw new Error(`expected both templates in catalog, found ${deltas.size}`);\n\n // ---- compensate every template-referencing placement ---------------------\n interface Touched {\n layoutId: string;\n pieceId: string;\n template: string;\n movedIdx: number[];\n }\n const touched: Touched[] = [];\n let skippedInline = 0;\n for (const layout of layouts) {\n for (const piece of layout.pieces ?? []) {\n if (!piece.template || !TARGETS.has(piece.template)) continue;\n if (piece.footprint) {\n skippedInline++; // baked geometry: template id is provenance only\n continue;\n }\n const { delta, movedIdx } = deltas.get(piece.template)!;\n const d = rotateCw(applyMirror(delta, piece.mirror ?? \"none\"), piece.rotation_degrees ?? 0);\n piece.position = { x: round4(piece.position.x + d.x), y: round4(piece.position.y + d.y) };\n touched.push({\n layoutId: layout.id,\n pieceId: piece.id ?? \"<anon>\",\n template: piece.template,\n movedIdx,\n });\n }\n }\n\n // ---- verify against the snapshot ------------------------------------------\n const after = new Map(layouts.map((l) => [l.id, resolveLayout(l, catalog)]));\n const touchedKey = new Set(touched.map((t) => `${t.layoutId}/${t.pieceId}`));\n let failures = 0;\n\n console.log(\"\\nlayout/piece fixed-corner Δ leg Δ\");\n for (const t of touched) {\n const b = before.get(t.layoutId)!.find((r) => r.id === t.pieceId);\n const a = after.get(t.layoutId)!.find((r) => r.id === t.pieceId);\n if (!b || !a || b.vertices.length !== a.vertices.length) {\n failures++;\n console.error(` ✗ ${t.layoutId}/${t.pieceId}: piece missing or vertex count changed`);\n continue;\n }\n let fixedMax = 0;\n let legMin = Infinity;\n let legMax = 0;\n b.vertices.forEach((bv, i) => {\n const av = a.vertices[i];\n const moved = Math.hypot(av.x - bv.x, av.y - bv.y);\n if (t.movedIdx.includes(i)) {\n legMin = Math.min(legMin, moved);\n legMax = Math.max(legMax, moved);\n } else {\n fixedMax = Math.max(fixedMax, moved);\n }\n });\n const ok = fixedMax <= TOL && Math.abs(legMin - 0.5) <= TOL && Math.abs(legMax - 0.5) <= TOL;\n if (!ok) failures++;\n console.log(\n ` ${ok ? \"✓\" : \"✗\"} ${`${t.layoutId}/${t.pieceId}`.padEnd(52)} ${fixedMax.toExponential(1).padStart(8)} ${legMin.toFixed(4)}–${legMax.toFixed(4)}`,\n );\n }\n\n // Everything NOT touched must be bit-identical.\n for (const layout of layouts) {\n const b = before.get(layout.id)!;\n const a = after.get(layout.id)!;\n for (let i = 0; i < b.length; i++) {\n const key = `${layout.id}/${b[i].id ?? \"\"}`;\n if (touchedKey.has(key)) continue;\n const same =\n b[i].vertices.length === a[i].vertices.length &&\n b[i].vertices.every((v, j) => v.x === a[i].vertices[j].x && v.y === a[i].vertices[j].y);\n if (!same) {\n failures++;\n console.error(` ✗ ${key}: untouched piece geometry changed`);\n }\n }\n }\n\n if (failures > 0) {\n console.error(`\\nFIX FAILED: ${failures} verification failures; files NOT written.`);\n process.exit(1);\n }\n\n writeFileSync(CATALOG_PATH, `${JSON.stringify(catalog, null, 2)}\\n`);\n writeFileSync(LAYOUTS_PATH, `${JSON.stringify(layouts, null, 2)}\\n`);\n console.log(\n `\\n✓ ${touched.length} placements compensated (${skippedInline} inline-baked pieces left untouched). Wrote both data files.`,\n );\n}\n\nmain();\n"]}
@@ -25,6 +25,8 @@ import { fileURLToPath } from "node:url";
25
25
  import { Dataset } from "./data/dataset.js";
26
26
  import { normalizeName } from "./data/normalize.js";
27
27
  import { describeScoringCard } from "./translate/index.js";
28
+ import { awardsOf } from "./scoring/index.js";
29
+ import { createRunnerState, dispatch } from "./runner.js";
28
30
  import { exportRoster } from "./export/index.js";
29
31
  import { importRoster, REGISTERED_ADAPTERS } from "./import/import-roster.js";
30
32
  import { selectAdapter } from "./import/adapter.js";
@@ -369,6 +371,154 @@ function genScoringTranslation() {
369
371
  writeJson(join(CONFORMANCE, "scoring-translation", "cases.json"), cases);
370
372
  console.log(`scoring-translation/cases.json: ${cases.length} cases`);
371
373
  }
374
+ /**
375
+ * Scoring-engine corpus: pin the pure VP arithmetic of the scoring engine
376
+ * (`tools/src/scoring/` — the oracle) so the Rust `wh40kdc::scoring` port
377
+ * reproduces it. Three ops, each case `{ name, op, args, expected }`:
378
+ *
379
+ * - `score_event` — per card and approach, assert every award matching the
380
+ * approach (by its full-`awards`-array index). Pins `scoreAward`, `scoreTurn`
381
+ * (exclusive-group "highest only", `vp_per × count` clamped to `per_max`,
382
+ * cumulative sums), `scoreCap` (tactical 5 vs fixed `vp_max`/uncapped), and
383
+ * `scoreSecondaryEvent`; primary cards also carry a `roundCap` to pin
384
+ * `scorePrimaryEvent`. `cap: null` means uncapped (Infinity has no JSON form).
385
+ * - `score_state` — replay scenarios over a `PlayerGame`, pinning the per-round
386
+ * cap (15), per-game primary cap (45), grand-total cap (100), score+discard,
387
+ * and undo.
388
+ * - `wtc_result` — the 20-point band mapping across its boundaries.
389
+ *
390
+ * Goldens are produced by driving the TS runner's own `dispatch`, so the corpus
391
+ * and the runner agree by construction; the cross-impl contract is the Rust
392
+ * runner reproducing them. Integers are compared exactly (no tolerance).
393
+ */
394
+ function genScoring() {
395
+ const ds = Dataset.embedded();
396
+ mkdirSync(join(CONFORMANCE, "scoring"), { recursive: true });
397
+ // One initialized runner state, reused across cases (the ops don't mutate it).
398
+ const specVersion = Number.parseInt(readFileSync(join(CONFORMANCE, "SPEC_VERSION"), "utf8").trim(), 10);
399
+ const state = createRunnerState();
400
+ const init = dispatch(state, {
401
+ op: "init",
402
+ args: { spec_version: specVersion, locale: "C", tz: "UTC", seed: 0 },
403
+ });
404
+ if (!init.ok)
405
+ throw new Error(`gen scoring: init failed: ${JSON.stringify(init)}`);
406
+ const run = (op, args) => {
407
+ const r = dispatch(state, { op, args });
408
+ if (!r.ok)
409
+ throw new Error(`gen scoring: ${op} failed: ${JSON.stringify(r)} for ${JSON.stringify(args)}`);
410
+ return r.value;
411
+ };
412
+ const cases = [];
413
+ // score_event: every mission card, both approaches. Assert the approach's
414
+ // awards by their full-array index; count vp_per awards to their per_max
415
+ // (else 2) so the cap logic actually bites.
416
+ const cards = ds.missionCards.all
417
+ .slice()
418
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
419
+ for (const card of cards) {
420
+ for (const approach of ["fixed", "tactical"]) {
421
+ const asserted = awardsOf(card)
422
+ .map((aw, index) => ({ aw, index }))
423
+ .filter(({ aw }) => aw.mode == null || aw.mode === approach)
424
+ .map(({ aw, index }) => aw.vp_per != null ? { index, count: aw.per_max ?? 2 } : { index });
425
+ const args = { cardId: card.id, approach, asserted };
426
+ if (card.card_type === "primary")
427
+ args.roundCap = 15;
428
+ cases.push({
429
+ name: `score_event/${card.id}/${approach}`,
430
+ op: "score_event",
431
+ args,
432
+ expected: run("score_event", args),
433
+ });
434
+ }
435
+ }
436
+ // score_state: hand-authored replay scenarios. Card ids are real deck/mission
437
+ // cards; expected state is whatever the engine produces.
438
+ const stateScenarios = [
439
+ {
440
+ name: "primary-round-and-game-caps",
441
+ args: {
442
+ approach: "tactical",
443
+ ops: [
444
+ { kind: "set-primary", round: 1, vp: 30, roundCap: 15, gameCap: 45 },
445
+ { kind: "set-primary", round: 2, vp: 30, roundCap: 15, gameCap: 45 },
446
+ { kind: "set-primary", round: 3, vp: 30, roundCap: 15, gameCap: 45 },
447
+ { kind: "set-primary", round: 4, vp: 30, roundCap: 15, gameCap: 45 },
448
+ ],
449
+ },
450
+ },
451
+ {
452
+ // The full primary path: a card's raw round total, clamped to the round
453
+ // cap on store, then cleared back to 0 by a set-primary 0.
454
+ name: "score-primary-then-clear",
455
+ args: {
456
+ approach: "tactical",
457
+ ops: [
458
+ {
459
+ kind: "score-primary",
460
+ cardId: "ground-control",
461
+ round: 2,
462
+ asserted: awardsOf(ds.missionCards.get("ground-control")).map((aw, index) => aw.vp_per != null ? { index, count: aw.per_max ?? 3 } : { index }),
463
+ roundCap: 15,
464
+ gameCap: 45,
465
+ },
466
+ { kind: "set-primary", round: 3, vp: 99, roundCap: 15, gameCap: 45 },
467
+ { kind: "set-primary", round: 2, vp: 0, roundCap: 15, gameCap: 45 },
468
+ ],
469
+ },
470
+ },
471
+ {
472
+ name: "secondary-score-and-undo",
473
+ args: {
474
+ approach: "tactical",
475
+ ops: [
476
+ { kind: "draw", cardId: "no-prisoners" },
477
+ { kind: "score-secondary", cardId: "no-prisoners", round: 2, asserted: [{ index: 0, count: 3 }] },
478
+ { kind: "remove-score", index: 0 },
479
+ ],
480
+ },
481
+ },
482
+ {
483
+ // Uncapped set-primary (no caps) overshoots so the 100 grand-total cap bites.
484
+ name: "grand-total-cap-at-100",
485
+ args: {
486
+ approach: "tactical",
487
+ ops: [
488
+ { kind: "set-primary", round: 1, vp: 30 },
489
+ { kind: "set-primary", round: 2, vp: 30 },
490
+ { kind: "set-primary", round: 3, vp: 30 },
491
+ { kind: "set-primary", round: 4, vp: 30 },
492
+ { kind: "set-primary", round: 5, vp: 30 },
493
+ { kind: "draw", cardId: "no-prisoners" },
494
+ { kind: "score-secondary", cardId: "no-prisoners", round: 5, asserted: [{ index: 0, count: 99 }] },
495
+ ],
496
+ },
497
+ },
498
+ ];
499
+ for (const s of stateScenarios) {
500
+ cases.push({ name: `score_state/${s.name}`, op: "score_state", args: s.args, expected: run("score_state", s.args) });
501
+ }
502
+ // wtc_result: band boundaries and symmetry.
503
+ const wtcPairs = [
504
+ [50, 50],
505
+ [48, 45],
506
+ [45, 50],
507
+ [56, 50],
508
+ [50, 61],
509
+ [100, 50],
510
+ [100, 49],
511
+ [0, 100],
512
+ [60, 40],
513
+ [55, 50],
514
+ ];
515
+ for (const [a, b] of wtcPairs) {
516
+ const args = { a, b };
517
+ cases.push({ name: `wtc_result/${a}-${b}`, op: "wtc_result", args, expected: run("wtc_result", args) });
518
+ }
519
+ writeJson(join(CONFORMANCE, "scoring", "cases.json"), cases);
520
+ console.log(`scoring/cases.json: ${cases.length} cases`);
521
+ }
372
522
  /**
373
523
  * Terrain-resolver corpus: resolve template-anchored layouts to absolute
374
524
  * board-space vertices (y-down inches). The TS resolver is the oracle; the Rust
@@ -530,5 +680,6 @@ genRosters();
530
680
  genLinkedApi();
531
681
  genAttribution();
532
682
  genScoringTranslation();
683
+ genScoring();
533
684
  genTerrainResolver();
534
685
  //# sourceMappingURL=gen-conformance.js.map