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: <kind>'
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> attr on <target> expires: <children>'
- decrement-attr-each-turn (W2)    \u2014 'each turn-end, subtract 1 from <attr> on <target>'
- set-board-topology (W4)          \u2014 'set board topology to <value>'
- link-pieces (W4)                 \u2014 'link the pieces at <a> and <b>'
- unlink-pieces (W4)               \u2014 'unlink the pieces at <a> and <b>'
- on-piece-pair-link-broken (W4)   \u2014 'When a linked partner is destroyed: <children>'
- add-resource (W5)                \u2014 'give <player> +<amount> score' (or 'subtract from')
- spend-resource (W5)              \u2014 'If <player> has <amount> score, spend it, then: <then>; otherwise: <else>'
- on-resource-changed (W5)         \u2014 'When <player>'s score crosses <threshold> <direction>: <children>'

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 "<entity>'s <attr>" (recursive)
- {ctx-build: {col, row}}                  \u2192 'e4' (algebraic if both literal) or
                                            'the square at column <c>, row <r>'
- {add: [a, b]}                            \u2192 '<a> + <b>' (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 <square>
           (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()).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-27 18:04:19 -06:00
commit a5ee07dead
No known key found for this signature in database
4 changed files with 646 additions and 15 deletions

View file

@ -152,7 +152,8 @@
"ses_22eb3422affe0WXwp2RUpnBMbi",
"ses_22eb4c13effeq1D5R0Hv64kDKi",
"ses_22eb45670ffeTp5TTjGXMPI7SC",
"ses_22eb3dab2ffeuR0mQzUofl5SpW"
"ses_22eb3dab2ffeuR0mQzUofl5SpW",
"ses_22ea014d4ffesgY7jcuHeSi25L"
],
"plan_name": "thressgame-coverage",
"agent": "atlas"

View file

@ -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.

View file

@ -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

View file

@ -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<string, unknown>;
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 "<square>" 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 "<square>";
}
function fmtLifetime(lt: unknown): string {
if (lt === undefined) return "permanent";
if (lt === "permanent") return "permanent";
@ -429,8 +543,7 @@ const KIND_NARRATORS: Record<string, Narrator> = {
| undefined;
const color = typeof p?.color === "string" ? p.color : "<color>";
const pt = typeof p?.pieceType === "string" ? p.pieceType : "<pieceType>";
const sq =
typeof p?.square === "number" ? fmtSquare(p.square) : "<square>";
const sq = fmtSquareValue(p?.square);
return `place a ${color} ${pt} on ${sq}`;
},
"destroy-piece": (params) => {
@ -440,7 +553,7 @@ const KIND_NARRATORS: Record<string, Narrator> = {
"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) : "<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<string, Narrator> = {
| null
| undefined;
const mk = typeof p?.markerKind === "string" ? p.markerKind : "<markerKind>";
const sq =
typeof p?.square === "number" ? fmtSquare(p.square) : "<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<string, Narrator> = {
| null
| undefined;
const mk = typeof p?.markerKind === "string" ? p.markerKind : "<markerKind>";
const a =
typeof p?.squareA === "number" ? fmtSquare(p.squareA) : "<a>";
const b =
typeof p?.squareB === "number" ? fmtSquare(p.squareB) : "<b>";
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<string, Narrator> = {
"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 ?? "<target>");
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 ?? "<target>")}`;
},
"for-each-piece": (params, ctx) => {
const p = params as
@ -689,8 +801,7 @@ const KIND_NARRATORS: Record<string, Narrator> = {
| undefined;
const cls = typeof p?.class === "string" ? p.class : "<class>";
if (cls === "move-to") {
const sq =
typeof p?.square === "number" ? fmtSquare(p.square) : "<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<string, Narrator> = {
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 : "<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 : "<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 : "<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) ?? "<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) ?? "<amount>";
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) ?? "<player>");
const amountStr =
typeof p?.amount === "number"
? fmtNumber(p.amount)
: (fmtResolverShape(p?.amount) ?? "<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) ?? "<player>");
const thresholdStr =
typeof p?.threshold === "number"
? fmtNumber(p.threshold)
: (fmtResolverShape(p?.threshold) ?? "<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 {