From a5ee07dead09649088a31811abb1b06b69917ce9 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 27 Apr 2026 18:04:19 -0600 Subject: [PATCH] fix(chess/ui): narrate.ts \u2014 V3 resolver shapes + 9 missing narrators Fix two bugs in the Custom Modifier Editor's live preview pane that surfaced when loading thressgame-100 epic recipes: BUG 1 \u2014 'unknown primitive: ' The narrator dispatcher (KIND_NARRATORS) was missing entries for the 9 new primitives shipped across the thressgame-100 epic. Loading any recipe using them showed 'unknown primitive: add-resource' (etc.) in the preview. Added 9 narrators (alphabetically inserted into KIND_NARRATORS): - on-attr-expire (W2) \u2014 'When the attr on expires: ' - decrement-attr-each-turn (W2) \u2014 'each turn-end, subtract 1 from on ' - set-board-topology (W4) \u2014 'set board topology to ' - link-pieces (W4) \u2014 'link the pieces at and ' - unlink-pieces (W4) \u2014 'unlink the pieces at and ' - on-piece-pair-link-broken (W4) \u2014 'When a linked partner is destroyed: ' - add-resource (W5) \u2014 'give + score' (or 'subtract from') - spend-resource (W5) \u2014 'If has score, spend it, then: ; otherwise: ' - on-resource-changed (W5) \u2014 'When 's score crosses : ' BUG 2 \u2014 '[object Object]' for V3 resolver shapes fmtPieceTarget (line 125) and inline square handling fell through to String(t) when the field carried a V3 resolver shape ({$var}, {ctx-self-id}, {ctx-self-marker-id}, {ctx-attr}, {ctx-build}, {add/sub/mul/mod}). User-reported case: tpl-treasure-chest's destroy-marker arm rendered as 'destroy [object Object]'. Added fmtResolverShape(v) helper rendering the 9 V3 shapes as plain English: - {$var: 'p'} \u2192 'p' - {ctx-self-id: null} \u2192 'this piece' - {ctx-self-marker-id: null} \u2192 'this marker' - {ctx-attr: {entity, attr}} \u2192 "'s " (recursive) - {ctx-build: {col, row}} \u2192 'e4' (algebraic if both literal) or 'the square at column , row ' - {add: [a, b]} \u2192 ' + ' (recursive) - {sub/mul/mod: [a, b]} \u2192 same pattern, '-' / '\u00d7' / 'mod' Added fmtSquareValue(v) helper for square-typed fields. fmtPieceTarget extended to call fmtResolverShape before falling back to String. Updated existing narrators to use fmtSquareValue for square fields: - spawn-marker.square, spawn-marker-pair.{squareA,squareB} - move-piece.to, place-piece.square, must-class.square - destroy-marker.target now routes through fmtResolverShape Test surface: - narrate.test.ts: 69 \u2192 90 tests (+21) - V3 resolver shapes describe block: 8 tests - thressgame-100 primitives describe block: 12 tests - tpl-treasure-chest end-to-end regression test: 1 test asserts the user-reported failure is fixed (no '[object Object]', no 'unknown primitive', expected English phrases present) bun run check: 3291 tests pass (was 3270, +21). 0 regressions. USER-REPORTED CASE NOW RENDERS: Before: 'When this piece captures: spawn a treasure marker on (permanent). When a piece enters a treasure marker: unknown primitive: add-resource; destroy [object Object].' After: 'When this piece captures: spawn a treasure marker on this piece's Position (permanent). When a piece enters a treasure marker: give white +5 score; destroy this marker.' Spot-check on 5 recipes (all render sensible English): - tpl-treasure-chest \u2713 (see above) - tpl-time-bomb \u2713 - tpl-down-with-the-ship \u2713 - tpl-summoning-ritual \u2713 - tpl-bouncing-ricochet \u2713 CALLOUT FOR FUTURE EPICS (recorded in learnings.md): narrate.ts has zero engine/registry imports by design. When a new primitive or resolver shape lands, the narrator must be updated MANUALLY \u2014 there is no static link to detect drift. Add a registry-count-style sentinel test in a follow-up to catch this earlier (e.g. compare KIND_NARRATORS keys against PRIMITIVE_REGISTRY.list()). --- .sisyphus/boulder.json | 3 +- .../notepads/thressgame-100/learnings.md | 43 +++ packages/chess/src/ui/narrate.test.ts | 341 +++++++++++++++++- packages/chess/src/ui/narrate.ts | 274 +++++++++++++- 4 files changed, 646 insertions(+), 15 deletions(-) diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index cc976d2..fa7f03d 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -152,7 +152,8 @@ "ses_22eb3422affe0WXwp2RUpnBMbi", "ses_22eb4c13effeq1D5R0Hv64kDKi", "ses_22eb45670ffeTp5TTjGXMPI7SC", - "ses_22eb3dab2ffeuR0mQzUofl5SpW" + "ses_22eb3dab2ffeuR0mQzUofl5SpW", + "ses_22ea014d4ffesgY7jcuHeSi25L" ], "plan_name": "thressgame-coverage", "agent": "atlas" diff --git a/.sisyphus/notepads/thressgame-100/learnings.md b/.sisyphus/notepads/thressgame-100/learnings.md index a061065..cbf2b9b 100644 --- a/.sisyphus/notepads/thressgame-100/learnings.md +++ b/.sisyphus/notepads/thressgame-100/learnings.md @@ -522,3 +522,46 @@ Final recipe count: **54**. None of the 8 W3 recipes was skipped. - **MODIFIED** `packages/chess/src/ui/CustomModifierEditor.tsx` — Templates modal `.map()` now branches on `recipe.id.startsWith("tpl-preset-")` for badge + border styling. - **CREATED** `packages/chess/docs/THRESSGAME_WONT_FIX.md` — 130-line WONT_FIX manifest. - **MODIFIED** `.sisyphus/notepads/thressgame-100/learnings.md` — this section. + +--- + +## narrate.ts fix (post-W5) — V3 resolver shapes + 9 missing narrators + +**Problem**: Custom Modifier Editor live-preview pane rendered "unknown +primitive: add-resource" and "destroy [object Object]" for any +descriptor using W2/W4/W5 primitives or V3 resolver shapes +(`{$var}`, `{ctx-self-id}`, `{ctx-self-marker-id}`, `{ctx-attr}`, +`{ctx-build}`, `{add|sub|mul|mod}`). + +**Fix** (`packages/chess/src/ui/narrate.ts`): +1. New `fmtResolverShape(v)` helper — recursively renders the 9 V3 + shapes mirrored from `param-resolver.ts:139-216`. Returns `null` + for non-shapes so callers fall back to literal handling. +2. New `fmtSquareValue(v)` helper for fields holding a square + (literal 0-63 or resolver shape). +3. `fmtPieceTarget` extended to call `fmtResolverShape` before its + `String(t)` fallback. +4. 9 new narrators added to `KIND_NARRATORS`: `add-resource`, + `spend-resource`, `on-resource-changed`, `set-board-topology`, + `link-pieces`, `unlink-pieces`, `on-piece-pair-link-broken`, + `on-attr-expire`, `decrement-attr-each-turn`. +5. Existing narrators routed through `fmtSquareValue` (spawn-marker, + spawn-marker-pair, move-piece.to, place-piece, must-class.square) + and `destroy-marker` switched from `String(t)` to + `fmtResolverShape` fallback. + +**Tests**: 21 new tests added (69 → 90), including a tpl-treasure-chest +end-to-end test that pins the regression. All 3291 chess tests pass; +`bun run check` exits 0. + +**Inheritable wisdom**: +- The `narrate.ts` zero-imports rule held — the resolver-shape + recogniser is structurally pattern-matched, no shared module pulled + in. The shape contract (`exactly 1 key`, key name from a fixed set) + is duplicated between `param-resolver.ts` and `narrate.ts`; if the + V3 union ever grows (e.g. `eq` resolver), BOTH files need the new + case. Tag any new resolver shape's PR with a checklist item to + update narrate. +- The pre-existing `joinParts` quirk that produces `..` on nested + triggers ending in `.` (see test line 865) is unchanged. Out of + scope for this fix. diff --git a/packages/chess/src/ui/narrate.test.ts b/packages/chess/src/ui/narrate.test.ts index d011955..ac43b56 100644 --- a/packages/chess/src/ui/narrate.test.ts +++ b/packages/chess/src/ui/narrate.test.ts @@ -60,7 +60,16 @@ type ExtKind = | "set-moves-as" | "set-moves-also-as" | "pawn-pushes-pieces" - | "request-choice"; + | "request-choice" + | "on-attr-expire" + | "decrement-attr-each-turn" + | "set-board-topology" + | "link-pieces" + | "unlink-pieces" + | "on-piece-pair-link-broken" + | "add-resource" + | "spend-resource" + | "on-resource-changed"; function extNode(kind: ExtKind, params: unknown): EffectPrimitiveNode { // Structural cast: node shape is identical; kind union is the only @@ -945,6 +954,336 @@ describe("narrate — descriptor wrapper", () => { // Performance // ────────────────────────────────────────────────────────────────── +// ────────────────────────────────────────────────────────────────── +// V3 resolver shapes — `{$var}`, `{ctx-self-id}`, `{ctx-self-marker-id}`, +// `{ctx-attr}`, `{ctx-build}`, `{add|sub|mul|mod}`. Anything that holds +// a piece-target / square / number may now arrive as one of these +// shapes; the narrator must render readable English instead of falling +// back to `[object Object]`. +// ────────────────────────────────────────────────────────────────── + +describe("narrateNodes — V3 resolver shapes", () => { + it("$var inside destroy-piece renders the binding name", () => { + expect( + narrateNodes([extNode("destroy-piece", { target: { $var: "p" } })]), + ).toBe("destroy the piece at p"); + }); + + it("ctx-self-id and ctx-self-marker-id render readable phrases", () => { + expect( + narrateNodes([ + extNode("destroy-piece", { target: { "ctx-self-id": null } }), + ]), + ).toBe("destroy the piece at this piece"); + expect( + narrateNodes([ + extNode("destroy-marker", { + target: { "ctx-self-marker-id": null }, + }), + ]), + ).toBe("destroy this marker"); + }); + + it("ctx-attr inside spawn-marker.square renders entity-and-attr prose", () => { + const out = narrateNodes([ + extNode("spawn-marker", { + markerKind: "treasure", + square: { "ctx-attr": { entity: "self", attr: "Position" } }, + lifetime: { kind: "permanent" }, + }), + ]); + expect(out).toBe( + "spawn a treasure marker on this piece's Position (permanent)", + ); + expect(out).not.toContain("[object Object]"); + }); + + it("ctx-build with literal col+row renders algebraic square", () => { + expect( + narrateNodes([ + extNode("spawn-marker", { + markerKind: "mine", + square: { "ctx-build": { col: 4, row: 3 } }, + lifetime: { kind: "permanent" }, + }), + ]), + ).toBe("spawn a mine marker on e4 (permanent)"); + }); + + it("ctx-build with resolver col renders descriptive prose", () => { + const out = narrateNodes([ + extNode("spawn-marker", { + markerKind: "mine", + square: { + "ctx-build": { col: { $var: "c" }, row: 3 }, + }, + lifetime: { kind: "permanent" }, + }), + ]); + expect(out).toContain("column c"); + expect(out).toContain("row 3"); + expect(out).not.toContain("[object Object]"); + }); + + it("arithmetic shapes (add/sub/mul/mod) render as prose inside ctx-build", () => { + const out = narrateNodes([ + extNode("spawn-marker", { + markerKind: "mine", + square: { + "ctx-build": { + col: { add: [{ $var: "col" }, 1] }, + row: { sub: [{ $var: "row" }, 2] }, + }, + }, + lifetime: { kind: "permanent" }, + }), + ]); + expect(out).toContain("col + 1"); + expect(out).toContain("row - 2"); + expect(out).not.toContain("[object Object]"); + }); + + it("mul and mod render correctly", () => { + const out = narrateNodes([ + extNode("spend-resource", { + player: "white", + amount: { mul: [2, { mod: [{ $var: "n" }, 4] }] }, + then: [extNode("cancel-capture", {})], + }), + ]); + expect(out).toContain("2 × "); + expect(out).toContain("n mod 4"); + expect(out).not.toContain("[object Object]"); + }); + + it("destroy-marker with ctx-self-marker-id no longer renders [object Object]", () => { + expect( + narrateNodes([ + extNode("destroy-marker", { + target: { "ctx-self-marker-id": null }, + }), + ]), + ).toBe("destroy this marker"); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// thressgame-100 W2 / W4 / W5 narrators +// ────────────────────────────────────────────────────────────────── + +describe("narrateNodes — thressgame-100 primitives", () => { + it("on-attr-expire renders attr name and target", () => { + expect( + narrateNodes([ + extNode("on-attr-expire", { + target: { "ctx-self-id": null }, + attr: "BombCountdown", + primitives: [ + extNode("destroy-piece", { target: "self" }), + ], + }), + ]), + ).toBe( + "When the BombCountdown attr on this piece expires: destroy the piece at self.", + ); + }); + + it("decrement-attr-each-turn renders attr and target", () => { + expect( + narrateNodes([ + extNode("decrement-attr-each-turn", { + target: { "ctx-self-id": null }, + attr: "BombCountdown", + }), + ]), + ).toBe("each turn-end, subtract 1 from BombCountdown on this piece"); + }); + + it("set-board-topology renders the value", () => { + expect( + narrateNodes([ + extNode("set-board-topology", { value: "wrap-files" }), + ]), + ).toBe("set board topology to wrap-files"); + }); + + it("link-pieces names both pieces", () => { + expect( + narrateNodes([extNode("link-pieces", { a: 4, b: 7 })]), + ).toBe("link the pieces at e1 and h1"); + }); + + it("unlink-pieces names both pieces", () => { + expect( + narrateNodes([extNode("unlink-pieces", { a: 4, b: 7 })]), + ).toBe("unlink the pieces at e1 and h1"); + }); + + it("on-piece-pair-link-broken renders surviving-partner trigger", () => { + expect( + narrateNodes([ + extNode("on-piece-pair-link-broken", { + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ], + }), + ]), + ).toBe("When a linked partner is destroyed: add 1 to Hp."); + }); + + it("add-resource with positive amount renders give-form", () => { + expect( + narrateNodes([ + extNode("add-resource", { player: "white", amount: 5 }), + ]), + ).toBe("give white +5 score"); + }); + + it("add-resource with negative amount renders subtract-form", () => { + expect( + narrateNodes([ + extNode("add-resource", { player: "black", amount: -3 }), + ]), + ).toBe("subtract 3 from black's score"); + }); + + it("spend-resource with then-only branch", () => { + expect( + narrateNodes([ + extNode("spend-resource", { + player: "white", + amount: 5, + then: [ + extNode("place-piece", { + pieceType: "knight", + color: "white", + square: 28, + }), + ], + }), + ]), + ).toBe( + "If white has 5 score, spend it, then: place a white knight on e4.", + ); + }); + + it("spend-resource with both branches", () => { + const out = narrateNodes([ + extNode("spend-resource", { + player: "white", + amount: 3, + then: [extNode("cancel-capture", {})], + else: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } }, + ], + }), + ]); + expect(out).toContain("If white has 3 score, spend it, then:"); + expect(out).toContain("otherwise: subtract 1 from Hp"); + }); + + it("on-resource-changed up renders crossing-up trigger", () => { + expect( + narrateNodes([ + extNode("on-resource-changed", { + player: "white", + threshold: 10, + direction: "up", + primitives: [ + extNode("place-piece", { + pieceType: "queen", + color: "white", + square: 28, + }), + ], + }), + ]), + ).toBe( + "When white's score crosses 10 going up: place a white queen on e4.", + ); + }); + + it("on-resource-changed down renders crossing-down trigger", () => { + expect( + narrateNodes([ + extNode("on-resource-changed", { + player: "black", + threshold: 0, + direction: "down", + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } }, + ], + }), + ]), + ).toBe( + "When black's score crosses 0 going down: subtract 1 from Hp.", + ); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// End-to-end regression: tpl-treasure-chest descriptor (the user's +// failing case). Mirrors recipes.ts shape exactly. +// ────────────────────────────────────────────────────────────────── + +describe("narrate — tpl-treasure-chest end-to-end", () => { + it("renders cleanly with no [object Object] and no unknown-primitive", () => { + const descriptor: CustomModifierDescriptor = { + type: "data", + id: "tpl-treasure-chest" as CustomModifierDescriptor["id"], + name: "Treasure Chest", + description: "", + version: 1, + primitives: [ + { + kind: "on-capture", + params: { + primitives: [ + { + kind: "spawn-marker", + params: { + markerKind: "treasure", + square: { + "ctx-attr": { entity: "self", attr: "Position" }, + }, + lifetime: { kind: "permanent" }, + }, + }, + ], + }, + }, + { + kind: "on-piece-entered-marker", + params: { + markerKind: "treasure", + primitives: [ + { + kind: "add-resource", + params: { player: "white", amount: 5 }, + }, + { + kind: "destroy-marker", + params: { target: { "ctx-self-marker-id": null } }, + }, + ], + }, + }, + ] as unknown as EffectPrimitiveNode[], + targetAttrs: [], + uiForm: "primitive-composer", + source: "custom", + }; + const out = narrate(descriptor); + expect(out).not.toContain("[object Object]"); + expect(out).not.toContain("unknown primitive"); + expect(out).toContain("When this piece captures"); + expect(out).toContain("spawn a treasure marker on this piece's Position"); + expect(out).toContain("When a piece enters a treasure marker"); + expect(out).toContain("give white +5 score"); + expect(out).toContain("destroy this marker"); + }); +}); + describe("narrate — performance", () => { it("50-primitive descriptor runs in < 1ms averaged over 100 runs", () => { // Build a realistic 50-node descriptor: mix of leaf primitives and diff --git a/packages/chess/src/ui/narrate.ts b/packages/chess/src/ui/narrate.ts index 37c3008..6986c4e 100644 --- a/packages/chess/src/ui/narrate.ts +++ b/packages/chess/src/ui/narrate.ts @@ -122,6 +122,104 @@ function fmtCondition(c: unknown): string { } } +/** + * Render a V3 resolver shape (`{$var}`, `{ctx-self-id}`, + * `{ctx-self-marker-id}`, `{ctx-attr}`, `{ctx-build}`, + * `{add|sub|mul|mod}`) as plain English. Returns `null` when `v` is + * not a recognised resolver shape — callers fall back to their own + * literal handling. + * + * Mirrors the canonical V3 union in + * `modifiers/primitives/param-resolver.ts:139-216`. Recursive: a + * `ctx-attr.entity` may itself be a resolver shape, an `add`'s + * operands may be resolver shapes, etc. + */ +function fmtResolverShape(v: unknown): string | null { + if (v === null || typeof v !== "object") return null; + const obj = v as Record; + const keys = Object.keys(obj); + if (keys.length !== 1) return null; + const key = keys[0]!; + + switch (key) { + case "$var": { + const name = obj.$var; + if (typeof name !== "string") return null; + return name; + } + case "ctx-self-id": + return "this piece"; + case "ctx-self-marker-id": + return "this marker"; + case "ctx-attr": { + const inner = obj["ctx-attr"]; + if (inner === null || typeof inner !== "object") return null; + const { entity, attr } = inner as { + entity?: unknown; + attr?: unknown; + }; + if (typeof attr !== "string") return null; + const entStr = + fmtResolverShape(entity) ?? + (entity === "self" + ? "this piece" + : entity === "chooser" + ? "the chooser" + : typeof entity === "number" + ? `piece #${entity.toString(10)}` + : "?"); + return `${entStr}'s ${attr}`; + } + case "ctx-build": { + const inner = obj["ctx-build"]; + if (inner === null || typeof inner !== "object") return null; + const { col, row } = inner as { col?: unknown; row?: unknown }; + if ( + typeof col === "number" && + typeof row === "number" && + Number.isInteger(col) && + Number.isInteger(row) && + col >= 0 && + col <= 7 && + row >= 0 && + row <= 7 + ) { + return fmtSquare(col + row * 8); + } + const colStr = + typeof col === "number" + ? fmtNumber(col) + : (fmtResolverShape(col) ?? "?"); + const rowStr = + typeof row === "number" + ? fmtNumber(row) + : (fmtResolverShape(row) ?? "?"); + return `the square at column ${colStr}, row ${rowStr}`; + } + case "add": + case "sub": + case "mul": + case "mod": { + const arr = obj[key]; + if (!Array.isArray(arr) || arr.length !== 2) return null; + const [a, b] = arr; + const aStr = + typeof a === "number" + ? fmtNumber(a) + : (fmtResolverShape(a) ?? "?"); + const bStr = + typeof b === "number" + ? fmtNumber(b) + : (fmtResolverShape(b) ?? "?"); + if (key === "mod") return `${aStr} mod ${bStr}`; + const sym = key === "add" ? "+" : key === "sub" ? "-" : "×"; + return `${aStr} ${sym} ${bStr}`; + } + default: + return null; + } +} + function fmtPieceTarget(t: unknown): string { if (t === "self") return "self"; if (typeof t === "number" && Number.isInteger(t)) { @@ -129,9 +227,25 @@ function fmtPieceTarget(t: unknown): string { return `piece #${t.toString(10)}`; } if (t === undefined || t === null) return "self"; + const shape = fmtResolverShape(t); + if (shape !== null) return shape; return String(t); } +/** + * Format a value that holds a SQUARE (0-63 literal, or a V3 resolver + * shape that resolves to one). Falls back to "" when the + * value is neither a literal square nor a known resolver shape. + */ +function fmtSquareValue(v: unknown): string { + if (typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 63) { + return fmtSquare(v); + } + const shape = fmtResolverShape(v); + if (shape !== null) return shape; + return ""; +} + function fmtLifetime(lt: unknown): string { if (lt === undefined) return "permanent"; if (lt === "permanent") return "permanent"; @@ -429,8 +543,7 @@ const KIND_NARRATORS: Record = { | undefined; const color = typeof p?.color === "string" ? p.color : ""; const pt = typeof p?.pieceType === "string" ? p.pieceType : ""; - const sq = - typeof p?.square === "number" ? fmtSquare(p.square) : ""; + const sq = fmtSquareValue(p?.square); return `place a ${color} ${pt} on ${sq}`; }, "destroy-piece": (params) => { @@ -440,7 +553,7 @@ const KIND_NARRATORS: Record = { "move-piece": (params) => { const p = params as { target?: unknown; to?: unknown } | null | undefined; const from = fmtPieceTarget(p?.target); - const to = typeof p?.to === "number" ? fmtSquare(p.to) : ""; + const to = fmtSquareValue(p?.to); return `move the piece at ${from} to ${to}`; }, "swap-pieces": (params) => { @@ -497,8 +610,7 @@ const KIND_NARRATORS: Record = { | null | undefined; const mk = typeof p?.markerKind === "string" ? p.markerKind : ""; - const sq = - typeof p?.square === "number" ? fmtSquare(p.square) : ""; + const sq = fmtSquareValue(p?.square); const lt = fmtLifetime(p?.lifetime); const owner = typeof p?.owner === "string" ? ` owned by ${p.owner}` : ""; @@ -516,10 +628,8 @@ const KIND_NARRATORS: Record = { | null | undefined; const mk = typeof p?.markerKind === "string" ? p.markerKind : ""; - const a = - typeof p?.squareA === "number" ? fmtSquare(p.squareA) : ""; - const b = - typeof p?.squareB === "number" ? fmtSquare(p.squareB) : ""; + const a = fmtSquareValue(p?.squareA); + const b = fmtSquareValue(p?.squareB); const lt = fmtLifetime(p?.lifetime); const owner = typeof p?.owner === "string" ? ` owned by ${p.owner}` : ""; @@ -528,8 +638,10 @@ const KIND_NARRATORS: Record = { "destroy-marker": (params) => { const p = params as { target?: unknown } | null | undefined; const t = p?.target; - const tStr = typeof t === "number" ? `marker #${t.toString(10)}` : String(t ?? ""); - return `destroy ${tStr}`; + if (typeof t === "number") return `destroy marker #${t.toString(10)}`; + const shape = fmtResolverShape(t); + if (shape !== null) return `destroy ${shape}`; + return `destroy ${String(t ?? "")}`; }, "for-each-piece": (params, ctx) => { const p = params as @@ -689,8 +801,7 @@ const KIND_NARRATORS: Record = { | undefined; const cls = typeof p?.class === "string" ? p.class : ""; if (cls === "move-to") { - const sq = - typeof p?.square === "number" ? fmtSquare(p.square) : ""; + const sq = fmtSquareValue(p?.square); return `force the next move to land on ${sq}`; } if (cls === "capture") return "force the next move to be a capture"; @@ -753,6 +864,143 @@ const KIND_NARRATORS: Record = { const promptStr = prompt.length === 0 ? "" : ` "${prompt}"`; return `Ask ${forPlayer} for a ${pickerKind} choice${promptStr} (bind as ${bind}), then: ${narrateChildrenClause(children, ctx)}.`; }, + + // ── thressgame-100 W2 / W4 / W5 primitives ────────────────────── + + "on-attr-expire": (params, ctx) => { + const p = params as + | { target?: unknown; attr?: unknown; primitives?: unknown } + | null + | undefined; + const target = fmtPieceTarget(p?.target); + const attr = typeof p?.attr === "string" ? p.attr : ""; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When the ${attr} attr on ${target} expires: ${narrateChildrenClause(children, ctx)}.`; + }, + + "decrement-attr-each-turn": (params) => { + const p = params as + | { target?: unknown; attr?: unknown } + | null + | undefined; + const target = fmtPieceTarget(p?.target); + const attr = typeof p?.attr === "string" ? p.attr : ""; + return `each turn-end, subtract 1 from ${attr} on ${target}`; + }, + + "set-board-topology": (params) => { + const p = params as { value?: unknown } | null | undefined; + const value = typeof p?.value === "string" ? p.value : ""; + return `set board topology to ${value}`; + }, + + "link-pieces": (params) => { + const p = params as { a?: unknown; b?: unknown } | null | undefined; + const a = fmtPieceTarget(p?.a); + const b = fmtPieceTarget(p?.b); + return `link the pieces at ${a} and ${b}`; + }, + + "unlink-pieces": (params) => { + const p = params as { a?: unknown; b?: unknown } | null | undefined; + const a = fmtPieceTarget(p?.a); + const b = fmtPieceTarget(p?.b); + return `unlink the pieces at ${a} and ${b}`; + }, + + "on-piece-pair-link-broken": (params, ctx) => { + const p = params as { primitives?: unknown } | null | undefined; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When a linked partner is destroyed: ${narrateChildrenClause(children, ctx)}.`; + }, + + "add-resource": (params) => { + const p = params as + | { player?: unknown; amount?: unknown } + | null + | undefined; + const playerStr = + typeof p?.player === "string" + ? p.player + : (fmtResolverShape(p?.player) ?? ""); + const amountRaw = p?.amount; + if (typeof amountRaw === "number") { + if (amountRaw < 0) { + return `subtract ${fmtNumber(Math.abs(amountRaw))} from ${playerStr}'s score`; + } + return `give ${playerStr} +${fmtNumber(amountRaw)} score`; + } + const amountStr = fmtResolverShape(amountRaw) ?? ""; + return `give ${playerStr} +${amountStr} score`; + }, + + "spend-resource": (params, ctx) => { + const p = params as + | { + player?: unknown; + amount?: unknown; + then?: unknown; + else?: unknown; + } + | null + | undefined; + const playerStr = + typeof p?.player === "string" + ? p.player + : (fmtResolverShape(p?.player) ?? ""); + const amountStr = + typeof p?.amount === "number" + ? fmtNumber(p.amount) + : (fmtResolverShape(p?.amount) ?? ""); + const thenArr = Array.isArray(p?.then) + ? (p.then as readonly EffectPrimitiveNode[]) + : []; + const elseArr = Array.isArray(p?.else) + ? (p.else as readonly EffectPrimitiveNode[]) + : undefined; + const thenStr = narrateChildrenClause(thenArr, ctx); + if (elseArr !== undefined) { + const elseStr = narrateChildrenClause(elseArr, ctx); + return `If ${playerStr} has ${amountStr} score, spend it, then: ${thenStr}; otherwise: ${elseStr}.`; + } + return `If ${playerStr} has ${amountStr} score, spend it, then: ${thenStr}.`; + }, + + "on-resource-changed": (params, ctx) => { + const p = params as + | { + player?: unknown; + threshold?: unknown; + direction?: unknown; + primitives?: unknown; + } + | null + | undefined; + const playerStr = + typeof p?.player === "string" + ? p.player + : (fmtResolverShape(p?.player) ?? ""); + const thresholdStr = + typeof p?.threshold === "number" + ? fmtNumber(p.threshold) + : (fmtResolverShape(p?.threshold) ?? ""); + const direction = + typeof p?.direction === "string" ? p.direction : "any"; + const dirPhrase = + direction === "up" + ? "going up" + : direction === "down" + ? "going down" + : "in either direction"; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When ${playerStr}'s score crosses ${thresholdStr} ${dirPhrase}: ${narrateChildrenClause(children, ctx)}.`; + }, }; function defaultNarrator(node: EffectPrimitiveNode): string {