diff --git a/packages/chess/src/ui/narrate.test.ts b/packages/chess/src/ui/narrate.test.ts new file mode 100644 index 0000000..dd8d10d --- /dev/null +++ b/packages/chess/src/ui/narrate.test.ts @@ -0,0 +1,548 @@ +/** + * T15 — Tests for the pure descriptor → narrative module. + * + * Coverage: + * - One inline snapshot test per primitive kind (21 scenarios). + * - Nested combinations (trigger→add-to-attribute, conditional both + * branches, on-turn-start with multiple children). + * - Cycle guard: node whose params.primitives contains itself. + * - Length cap: 60-primitive descriptor → ≤ 4000 chars + suffix. + * - Performance: 50-primitive descriptor averages < 1ms over 100 runs. + */ + +import { describe, expect, it } from "vitest"; +import { narrate, narrateNodes } from "./narrate.js"; +import type { CustomModifierDescriptor } from "../modifiers/custom/types.js"; +import type { EffectPrimitiveNode } from "../modifiers/primitives/types.js"; + +/** + * Helper: build an `EffectPrimitiveNode` including kinds from the T1- + * extension trigger set (`on-move`, `on-turn-end`, `on-promotion`, + * `on-check-received`, `on-check-delivered`, `on-moved-onto-square`, + * `on-captured`) that aren't in `PrimitiveKind` yet. Returns a + * structurally-valid node — `narrate()` handles unknown kinds + * gracefully, so the cast is safe for the narrative contract we're + * testing. + */ +type ExtKind = + | EffectPrimitiveNode["kind"] + | "on-move" + | "on-turn-end" + | "on-promotion" + | "on-check-received" + | "on-check-delivered" + | "on-moved-onto-square" + | "on-captured"; + +function extNode(kind: ExtKind, params: unknown): EffectPrimitiveNode { + // Structural cast: node shape is identical; kind union is the only + // divergence. Narrator contract is kind-name-driven regardless. + return { kind, params } as unknown as EffectPrimitiveNode; +} + +// ────────────────────────────────────────────────────────────────── +// Per-primitive narrator tests (21 kinds) +// ────────────────────────────────────────────────────────────────── + +describe("narrateNodes — per-primitive narrators", () => { + it("seed-attribute renders set-to form", () => { + expect( + narrateNodes([ + { kind: "seed-attribute", params: { attr: "Hp", value: 5 } }, + ]), + ).toBe("set Hp to 5"); + }); + + it("add-to-attribute renders additive form", () => { + expect( + narrateNodes([ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ]), + ).toBe("add 1 to Hp"); + }); + + it("add-to-attribute with negative delta renders subtract form", () => { + expect( + narrateNodes([ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -2 } }, + ]), + ).toBe("subtract 2 from Hp"); + }); + + it("multiply-attribute renders factor form", () => { + expect( + narrateNodes([ + { kind: "multiply-attribute", params: { attr: "Hp", factor: 2 } }, + ]), + ).toBe("multiply Hp by 2"); + }); + + it("add-direction renders direction list", () => { + expect( + narrateNodes([ + { + kind: "add-direction", + params: { directions: ["backward", "left"] }, + }, + ]), + ).toBe("add movement directions backward, left"); + }); + + it("set-capture-flag renders flag names", () => { + expect( + narrateNodes([{ kind: "set-capture-flag", params: { flag: 2 } }]), + ).toBe("set capture flag: cannot be captured"); + }); + + it("absorb-damage-with-attribute renders rate clause", () => { + expect( + narrateNodes([ + { + kind: "absorb-damage-with-attribute", + params: { attr: "ShieldCharges", rate: 1 }, + }, + ]), + ).toBe( + "absorb damage using ShieldCharges (1 point per damage)", + ); + }); + + it("reflect-damage renders percentage form", () => { + expect( + narrateNodes([ + { kind: "reflect-damage", params: { percentage: 50 } }, + ]), + ).toBe("reflect 50% of incoming damage back to the attacker"); + }); + + it("block-move-type renders move-type blocker", () => { + expect( + narrateNodes([ + { kind: "block-move-type", params: { moveType: "capture" } }, + ]), + ).toBe("block capture moves"); + }); + + it("modify-movement-range renders signed delta", () => { + expect( + narrateNodes([ + { kind: "modify-movement-range", params: { delta: 1 } }, + ]), + ).toBe("modify movement range by +1"); + }); + + it("override-promotion renders target piece", () => { + expect( + narrateNodes([ + { kind: "override-promotion", params: { target: "knight" } }, + ]), + ).toBe("force promotion to knight"); + }); + + it("add-aura renders radiate clause", () => { + expect( + narrateNodes([ + { + kind: "add-aura", + params: { radius: 2, targetAttr: "HpBonus", delta: 1 }, + }, + ]), + ).toBe("radiate +1 HpBonus to allies within 2 squares"); + }); + + it("on-turn-start with single child", () => { + expect( + narrateNodes([ + { + kind: "on-turn-start", + params: { + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ], + }, + }, + ]), + ).toBe("When this piece's turn starts: add 1 to Hp."); + }); + + it("on-capture with nested add-to-attribute (spec sample)", () => { + expect( + narrateNodes([ + { + kind: "on-capture", + params: { + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ], + }, + }, + ]), + ).toBe("When this piece captures: add 1 to Hp."); + }); + + it("on-damaged with nested reflect", () => { + expect( + narrateNodes([ + { + kind: "on-damaged", + params: { + primitives: [ + { kind: "reflect-damage", params: { percentage: 25 } }, + ], + }, + }, + ]), + ).toBe( + "When this piece is damaged: reflect 25% of incoming damage back to the attacker.", + ); + }); + + it("conditional with attr-lt renders branch prose", () => { + expect( + narrateNodes([ + { + kind: "conditional", + params: { + condition: { type: "attr-lt", attr: "Hp", value: 2 }, + then: [{ kind: "set-capture-flag", params: { flag: 2 } }], + }, + }, + ]), + ).toBe("If Hp < 2: set capture flag: cannot be captured."); + }); + + // ── T1-extension triggers (7) ────────────────────────────────── + + it("on-move renders move trigger", () => { + expect( + narrateNodes([ + extNode("on-move", { + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ], + }), + ]), + ).toBe("When this piece moves: add 1 to Hp."); + }); + + it("on-turn-end renders color-scoped trigger", () => { + expect( + narrateNodes([ + extNode("on-turn-end", { + color: "white", + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ], + }), + ]), + ).toBe("When white's turn ends: add 1 to Hp."); + }); + + it("on-promotion renders promotion trigger", () => { + expect( + narrateNodes([ + extNode("on-promotion", { + primitives: [ + { kind: "seed-attribute", params: { attr: "Hp", value: 10 } }, + ], + }), + ]), + ).toBe("When this piece promotes: set Hp to 10."); + }); + + it("on-check-received renders check-received trigger", () => { + expect( + narrateNodes([ + extNode("on-check-received", { + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } }, + ], + }), + ]), + ).toBe("When this piece is checked: subtract 1 from Hp."); + }); + + it("on-check-delivered renders check-delivered trigger", () => { + expect( + narrateNodes([ + extNode("on-check-delivered", { + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ], + }), + ]), + ).toBe("When this piece delivers check: add 1 to Hp."); + }); + + it("on-moved-onto-square with squares filter", () => { + expect( + narrateNodes([ + extNode("on-moved-onto-square", { + filter: { kind: "squares", squares: [28, 35] }, + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ], + }), + ]), + ).toBe("When this piece moves onto e4, d5: add 1 to Hp."); + }); + + it("on-moved-onto-square with predicate filter", () => { + expect( + narrateNodes([ + extNode("on-moved-onto-square", { + filter: { kind: "predicate", file: 4 }, + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ], + }), + ]), + ).toBe("When this piece moves onto file e: add 1 to Hp."); + }); + + it("on-captured with no target uses self narrative", () => { + expect( + narrateNodes([ + extNode("on-captured", { + primitives: [ + { kind: "reflect-damage", params: { percentage: 100 } }, + ], + }), + ]), + ).toBe( + "When this piece is captured: reflect 100% of incoming damage back to the attacker.", + ); + }); + + it("on-captured with explicit attacker target", () => { + expect( + narrateNodes([ + extNode("on-captured", { + target: "attacker", + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } }, + ], + }), + ]), + ).toBe( + "When this piece is captured (apply to attacker): subtract 1 from Hp.", + ); + }); + + it("unknown primitive kind falls through to default", () => { + // Intentionally unknown: narrator must render the kind verbatim. + const node: EffectPrimitiveNode = { + kind: "totally-made-up", + params: {}, + } as unknown as EffectPrimitiveNode; + expect(narrateNodes([node])).toBe("unknown primitive: totally-made-up"); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Nested combination tests +// ────────────────────────────────────────────────────────────────── + +describe("narrateNodes — nested combinations", () => { + it("conditional with both branches narrates both", () => { + expect( + narrateNodes([ + { + kind: "conditional", + params: { + condition: { type: "attr-gt", attr: "Hp", value: 5 }, + then: [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } }, + ], + else: [ + { + kind: "add-to-attribute", + params: { attr: "HpBonus", delta: -1 }, + }, + ], + }, + }, + ]), + ).toBe( + "If Hp > 5: add 1 to HpBonus; else: subtract 1 from HpBonus.", + ); + }); + + it("on-turn-start with multiple children uses semicolon separator", () => { + expect( + narrateNodes([ + { + kind: "on-turn-start", + params: { + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + { + kind: "add-to-attribute", + params: { attr: "HpBonus", delta: 1 }, + }, + ], + }, + }, + ]), + ).toBe( + "When this piece's turn starts: add 1 to Hp; add 1 to HpBonus.", + ); + }); + + it("trigger-within-trigger nests cleanly", () => { + expect( + narrateNodes([ + { + kind: "on-capture", + params: { + primitives: [ + { + kind: "on-turn-start", + params: { + primitives: [ + { + kind: "add-to-attribute", + params: { attr: "Hp", delta: 1 }, + }, + ], + }, + }, + ], + }, + }, + ]), + ).toBe( + "When this piece captures: When this piece's turn starts: add 1 to Hp..", + ); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Cycle guard +// ────────────────────────────────────────────────────────────────── + +describe("narrateNodes — cycle guard", () => { + it("self-referential node terminates with ellipsis, no hang", () => { + // Construct a node whose params.primitives[0] === the node itself. + const cyclic: { kind: string; params: { primitives: unknown[] } } = { + kind: "on-capture", + params: { primitives: [] }, + }; + cyclic.params.primitives.push(cyclic); + + const result = narrateNodes([cyclic as unknown as EffectPrimitiveNode]); + // Should start with the trigger prefix and contain "…" for the cycle. + expect(result).toContain("When this piece captures"); + expect(result).toContain("…"); + // No hang means the test simply returns; vitest's default timeout + // would catch an infinite loop. + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Length cap +// ────────────────────────────────────────────────────────────────── + +describe("narrateNodes — length cap", () => { + it("truncates 60-primitive descriptor with suffix and stays ≤ 4000 chars", () => { + const nodes: EffectPrimitiveNode[] = []; + for (let i = 0; i < 60; i += 1) { + nodes.push({ + kind: "seed-attribute", + // Use a long attr name so we comfortably exceed 4000 chars. + params: { + attr: `VeryLongAttributeName${i.toString().padStart(4, "0")}`, + value: `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-${i}`, + }, + }); + } + const result = narrateNodes(nodes); + expect(result.length).toBeLessThanOrEqual(4000); + expect(result).toMatch(/ … and \d+ more primitives?$/u); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Top-level narrate() wrapper +// ────────────────────────────────────────────────────────────────── + +describe("narrate — descriptor wrapper", () => { + it("narrates a descriptor's primitives", () => { + const descriptor: CustomModifierDescriptor = { + type: "data", + id: "test" as CustomModifierDescriptor["id"], + name: "Test", + description: "", + version: 1, + primitives: [ + { kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } }, + ], + targetAttrs: [], + uiForm: "primitive-composer", + source: "custom", + }; + expect(narrate(descriptor)).toBe("add 1 to Hp"); + }); + + it("empty descriptor reports no effect", () => { + const descriptor: CustomModifierDescriptor = { + type: "data", + id: "empty" as CustomModifierDescriptor["id"], + name: "Empty", + description: "", + version: 1, + primitives: [], + targetAttrs: [], + uiForm: "primitive-composer", + source: "custom", + }; + expect(narrate(descriptor)).toBe("No effect."); + }); +}); + +// ────────────────────────────────────────────────────────────────── +// Performance +// ────────────────────────────────────────────────────────────────── + +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 + // one trigger containing a handful of children (realistic shape + // for a preview-pane workload). + const leafs: EffectPrimitiveNode[] = []; + for (let i = 0; i < 40; i += 1) { + leafs.push({ + kind: "add-to-attribute", + params: { attr: "Hp", delta: (i % 5) - 2 }, + }); + } + const triggerChildren: EffectPrimitiveNode[] = []; + for (let i = 0; i < 9; i += 1) { + triggerChildren.push({ + kind: "add-to-attribute", + params: { attr: "HpBonus", delta: 1 }, + }); + } + const nodes: EffectPrimitiveNode[] = [ + ...leafs, + { + kind: "on-capture", + params: { primitives: triggerChildren }, + }, + ]; + // Sanity: 40 + 1 trigger node = 41 top-level + 9 nested = 50 primitives. + expect(nodes.length + triggerChildren.length).toBe(50); + + // Warm-up (JIT). + for (let i = 0; i < 5; i += 1) narrateNodes(nodes); + + const iterations = 100; + const start = performance.now(); + for (let i = 0; i < iterations; i += 1) { + narrateNodes(nodes); + } + const elapsed = performance.now() - start; + const avgMs = elapsed / iterations; + expect(avgMs).toBeLessThan(1); + }); +}); diff --git a/packages/chess/src/ui/narrate.ts b/packages/chess/src/ui/narrate.ts new file mode 100644 index 0000000..9eead73 --- /dev/null +++ b/packages/chess/src/ui/narrate.ts @@ -0,0 +1,523 @@ +/** + * T15 — pure descriptor → English narrative module. + * + * Consumed by the live preview pane (T17) and anywhere else in the + * editor that needs a human-readable description of a custom modifier + * descriptor or a subtree of primitive nodes. + * + * DESIGN RULES (locked — see decisions.md §`narrate.ts` (T15)): + * - ZERO imports from engine/session/Rete/PRIMITIVE_REGISTRY. This + * module must stand alone so it works server-side, in tests, and + * in tree-shaken chunks. + * - Static narrator map: every known `PrimitiveKind` → params + * narrator function. Unknown kinds fall through to a graceful + * "unknown primitive: " message. + * - Cycle guard via WeakSet on node identity — handles + * hand-constructed cyclic params (a node whose `params.primitives` + * includes the node itself) without hanging. + * - Length cap 4000 chars; once exceeded, stop walking and append + * "… and N more primitives". + * - Plain English, plain text. No HTML, no markdown, no i18n. + */ + +import type { ConditionSpec, SquareFilter } from "../schema.js"; +import type { CustomModifierDescriptor } from "../modifiers/custom/types.js"; +import type { + EffectPrimitiveNode, + PrimitiveKind, +} from "../modifiers/primitives/types.js"; + +const MAX_NARRATIVE_CHARS = 4000; + +/** + * Walk context threaded through every recursive narrator call. The + * visited set stores node-object identity so a cycle (node whose + * `params.primitives` array contains the node itself) terminates + * cleanly; the counter tracks total primitives we've chosen not to + * render once the char cap is exceeded. + */ +interface WalkContext { + readonly visited: WeakSet; + /** Primitives skipped due to cycle or length cap. */ + skipped: number; + /** + * Running length of the parent's accumulated string. Narrators + * check this lazily against MAX_NARRATIVE_CHARS — once exceeded, + * recursion stops and the outer caller appends the truncation tag. + */ + budgetExceeded: boolean; +} + +function newContext(): WalkContext { + return { + visited: new WeakSet(), + skipped: 0, + budgetExceeded: false, + }; +} + +// ────────────────────────────────────────────────────────────────── +// Number / target / square helpers +// ────────────────────────────────────────────────────────────────── + +/** Format a number dropping trailing zeros, never scientific notation. */ +function fmtNumber(n: number): string { + if (!Number.isFinite(n)) return String(n); + if (Number.isInteger(n)) return n.toString(10); + // Fixed 10 digits, then trim trailing zeros and a bare trailing dot. + const s = n.toFixed(10); + const trimmed = s.replace(/0+$/u, "").replace(/\.$/u, ""); + return trimmed.length === 0 ? "0" : trimmed; +} + +/** Signed delta — always prefixes a sign so "+1" reads naturally. */ +function fmtDelta(n: number): string { + if (!Number.isFinite(n)) return String(n); + if (n >= 0) return `+${fmtNumber(n)}`; + return fmtNumber(n); +} + +/** Square 0..63 → algebraic e.g. 28 → "e4". */ +function fmtSquare(sq: number): string { + if (!Number.isInteger(sq) || sq < 0 || sq > 63) return String(sq); + const file = sq % 8; + const rank = Math.floor(sq / 8) + 1; + return `${String.fromCharCode(97 + file)}${rank.toString(10)}`; +} + +function fmtSquares(squares: readonly number[]): string { + if (squares.length === 0) return "no squares"; + return squares.map(fmtSquare).join(", "); +} + +function fmtTarget(t: unknown): string { + if (t === undefined || t === null) return "self"; + if (t === "self" || t === "attacker" || t === "defender") return t; + if (typeof t !== "object") return "self"; + const obj = t as Record; + if (Array.isArray(obj.squares)) { + const squares = obj.squares.filter( + (s): s is number => typeof s === "number", + ); + return `squares ${fmtSquares(squares)}`; + } + if (typeof obj.relation === "string") { + const rel = obj.relation; + const filter = obj.filter as + | { readonly pieceType?: string } + | undefined; + const pt = filter?.pieceType; + if (typeof pt === "string" && pt.length > 0) { + return `${rel} ${pt}s`; + } + return `${rel === "ally" ? "allies" : "enemies"}`; + } + // T15-extension TargetResolver shape from schema.ts (forward ref). + if (obj.kind === "select-piece") return "selected pieces"; + if (obj.kind === "select-square") return "selected squares"; + return "self"; +} + +function fmtCondition(c: unknown): string { + if (c === null || typeof c !== "object") return ""; + const cond = c as ConditionSpec; + switch (cond.type) { + case "attr-lt": + return `${cond.attr} < ${fmtNumber(cond.value)}`; + case "attr-gt": + return `${cond.attr} > ${fmtNumber(cond.value)}`; + case "attr-eq": { + const v = cond.value; + if (v === null) return `${cond.attr} = null`; + if (typeof v === "string") return `${cond.attr} = "${v}"`; + if (typeof v === "number") return `${cond.attr} = ${fmtNumber(v)}`; + return `${cond.attr} = ${String(v)}`; + } + case "always": + return "always"; + case "never": + return "never"; + default: { + // Exhaustiveness guard without `never` throw — defensive. + const raw = c as { readonly type?: unknown }; + return ``; + } + } +} + +function fmtSquareFilter(f: unknown): string { + if (f === null || typeof f !== "object") return ""; + const filter = f as SquareFilter; + if (filter.kind === "squares") { + return fmtSquares(filter.squares); + } + if (filter.kind === "predicate") { + const parts: string[] = []; + if (filter.file !== undefined) { + parts.push(`file ${String.fromCharCode(97 + filter.file)}`); + } + if (filter.rank !== undefined) { + parts.push(`rank ${filter.rank + 1}`); + } + return parts.length === 0 ? "any square" : parts.join(" & "); + } + return ""; +} + +// ────────────────────────────────────────────────────────────────── +// Recursive children narrator +// ────────────────────────────────────────────────────────────────── + +/** + * Narrate an inner list of primitives as a single sentence-friendly + * clause ("X; Y; Z"). Nested trigger narrators call this via + * `narrateChildrenClause`. Semicolon separator avoids ambiguity with + * commas inside numbers / square lists. + */ +function narrateChildrenClause( + nodes: readonly EffectPrimitiveNode[] | undefined, + ctx: WalkContext, +): string { + if (nodes === undefined || nodes.length === 0) return "do nothing"; + const parts: string[] = []; + for (const node of nodes) { + if (ctx.budgetExceeded) { + ctx.skipped += 1; + continue; + } + if (ctx.visited.has(node)) { + // Cycle — render ellipsis and stop descending this branch. + parts.push("…"); + continue; + } + ctx.visited.add(node); + const piece = narrateNodeInternal(node, ctx); + ctx.visited.delete(node); + parts.push(piece); + if (parts.join("; ").length > MAX_NARRATIVE_CHARS) { + ctx.budgetExceeded = true; + } + } + return parts.length === 0 ? "do nothing" : parts.join("; "); +} + +// ────────────────────────────────────────────────────────────────── +// KIND_NARRATORS — static params → narrative map +// ────────────────────────────────────────────────────────────────── + +/** + * Narrator function signature. We pass the `WalkContext` through so + * trigger narrators (which contain nested primitive lists) can + * recurse with the same visited set / budget. + */ +type Narrator = (params: unknown, ctx: WalkContext) => string; + +/** + * All 21 primitive kinds: 14 existing + 7 T1-extension triggers. + * Kinds not yet implemented in code still render sensible narratives + * from their declared params shape (documented in plan + decisions). + * + * `PrimitiveKind` currently types only the 14 existing kinds; the + * additional 7 are indexed as plain string keys. Using a + * `Record` rather than `Record` + * lets both sets coexist cleanly — unknown kinds fall through to + * `defaultNarrator` in `narrateNodeInternal`. + */ +const KIND_NARRATORS: Record = { + // ─── 14 existing primitives ───────────────────────────────────── + "seed-attribute": (params) => { + const p = params as { attr?: unknown; value?: unknown } | null | undefined; + const attr = typeof p?.attr === "string" ? p.attr : ""; + const value = + typeof p?.value === "number" + ? fmtNumber(p.value) + : typeof p?.value === "string" + ? `"${p.value}"` + : String(p?.value); + return `set ${attr} to ${value}`; + }, + "add-to-attribute": (params) => { + const p = params as { attr?: unknown; delta?: unknown } | null | undefined; + const attr = typeof p?.attr === "string" ? p.attr : ""; + const delta = typeof p?.delta === "number" ? p.delta : 0; + if (delta < 0) return `subtract ${fmtNumber(Math.abs(delta))} from ${attr}`; + return `add ${fmtNumber(delta)} to ${attr}`; + }, + "multiply-attribute": (params) => { + const p = params as { attr?: unknown; factor?: unknown } | null | undefined; + const attr = typeof p?.attr === "string" ? p.attr : ""; + const factor = typeof p?.factor === "number" ? p.factor : 1; + return `multiply ${attr} by ${fmtNumber(factor)}`; + }, + "add-direction": (params) => { + const p = params as { directions?: unknown } | null | undefined; + const dirs = Array.isArray(p?.directions) + ? p.directions.filter((d): d is string => typeof d === "string") + : []; + if (dirs.length === 0) return "add no movement directions"; + return `add movement direction${dirs.length === 1 ? "" : "s"} ${dirs.join(", ")}`; + }, + "set-capture-flag": (params) => { + const p = params as { flag?: unknown } | null | undefined; + const flag = typeof p?.flag === "number" ? p.flag : 0; + const names: string[] = []; + if ((flag & 1) !== 0) names.push("can capture own color"); + if ((flag & 2) !== 0) names.push("cannot be captured"); + if ((flag & 4) !== 0) names.push("en-passant eligible"); + if (names.length === 0) return "set capture flags to none"; + return `set capture flag: ${names.join(", ")}`; + }, + "absorb-damage-with-attribute": (params) => { + const p = params as { attr?: unknown; rate?: unknown } | null | undefined; + const attr = typeof p?.attr === "string" ? p.attr : ""; + const rate = typeof p?.rate === "number" ? p.rate : 1; + return `absorb damage using ${attr} (${fmtNumber(rate)} point${rate === 1 ? "" : "s"} per damage)`; + }, + "reflect-damage": (params) => { + const p = params as { percentage?: unknown } | null | undefined; + const pct = typeof p?.percentage === "number" ? p.percentage : 0; + return `reflect ${fmtNumber(pct)}% of incoming damage back to the attacker`; + }, + "block-move-type": (params) => { + const p = params as { moveType?: unknown } | null | undefined; + const mt = typeof p?.moveType === "string" ? p.moveType : ""; + return `block ${mt} moves`; + }, + "modify-movement-range": (params) => { + const p = params as { delta?: unknown } | null | undefined; + const delta = typeof p?.delta === "number" ? p.delta : 0; + return `modify movement range by ${fmtDelta(delta)}`; + }, + "override-promotion": (params) => { + const p = params as { target?: unknown } | null | undefined; + const target = typeof p?.target === "string" ? p.target : ""; + return `force promotion to ${target}`; + }, + "add-aura": (params) => { + const p = params as + | { radius?: unknown; targetAttr?: unknown; delta?: unknown } + | null + | undefined; + const radius = typeof p?.radius === "number" ? p.radius : 0; + const attr = typeof p?.targetAttr === "string" ? p.targetAttr : ""; + const delta = typeof p?.delta === "number" ? p.delta : 0; + return `radiate ${fmtDelta(delta)} ${attr} to allies within ${fmtNumber(radius)} square${radius === 1 ? "" : "s"}`; + }, + "on-turn-start": (params, ctx) => { + const p = params as { primitives?: unknown } | null | undefined; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When this piece's turn starts: ${narrateChildrenClause(children, ctx)}.`; + }, + "on-capture": (params, ctx) => { + const p = params as { primitives?: unknown } | null | undefined; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When this piece captures: ${narrateChildrenClause(children, ctx)}.`; + }, + "on-damaged": (params, ctx) => { + const p = params as { primitives?: unknown } | null | undefined; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When this piece is damaged: ${narrateChildrenClause(children, ctx)}.`; + }, + conditional: (params, ctx) => { + const p = params as + | { condition?: unknown; then?: unknown; else?: unknown } + | null + | undefined; + const condStr = fmtCondition(p?.condition); + 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 ${condStr}: ${thenStr}; else: ${elseStr}.`; + } + return `If ${condStr}: ${thenStr}.`; + }, + + // ─── 7 T1-extension triggers (params shapes per plan/decisions) ── + "on-move": (params, ctx) => { + const p = params as { primitives?: unknown } | null | undefined; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When this piece moves: ${narrateChildrenClause(children, ctx)}.`; + }, + "on-turn-end": (params, ctx) => { + const p = params as + | { color?: unknown; primitives?: unknown } + | null + | undefined; + const color = typeof p?.color === "string" ? p.color : "both"; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + const subject = + color === "white" + ? "white's turn ends" + : color === "black" + ? "black's turn ends" + : "any turn ends"; + return `When ${subject}: ${narrateChildrenClause(children, ctx)}.`; + }, + "on-promotion": (params, ctx) => { + const p = params as { primitives?: unknown } | null | undefined; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When this piece promotes: ${narrateChildrenClause(children, ctx)}.`; + }, + "on-check-received": (params, ctx) => { + const p = params as { primitives?: unknown } | null | undefined; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When this piece is checked: ${narrateChildrenClause(children, ctx)}.`; + }, + "on-check-delivered": (params, ctx) => { + const p = params as { primitives?: unknown } | null | undefined; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When this piece delivers check: ${narrateChildrenClause(children, ctx)}.`; + }, + "on-moved-onto-square": (params, ctx) => { + const p = params as + | { filter?: unknown; primitives?: unknown } + | null + | undefined; + const filterStr = fmtSquareFilter(p?.filter); + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When this piece moves onto ${filterStr}: ${narrateChildrenClause(children, ctx)}.`; + }, + "on-captured": (params, ctx) => { + const p = params as + | { target?: unknown; primitives?: unknown } + | null + | undefined; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + const target = p?.target === undefined ? "self" : fmtTarget(p.target); + const prefix = + target === "self" + ? "When this piece is captured" + : `When this piece is captured (apply to ${target})`; + return `${prefix}: ${narrateChildrenClause(children, ctx)}.`; + }, +}; + +// ────────────────────────────────────────────────────────────────── +// Entry points +// ────────────────────────────────────────────────────────────────── + +/** Default fallback for unknown kinds — graceful degradation. */ +function defaultNarrator(node: EffectPrimitiveNode): string { + return `unknown primitive: ${node.kind}`; +} + +function narrateNodeInternal( + node: EffectPrimitiveNode, + ctx: WalkContext, +): string { + const narrator = KIND_NARRATORS[node.kind]; + if (narrator === undefined) return defaultNarrator(node); + return narrator(node.params, ctx); +} + +/** + * Narrate a raw list of primitive nodes. Joined with ". " for prose + * flow; triggers already terminate their own sentences with ".", so + * adjacent punctuation is suppressed by a small cleanup pass. + */ +export function narrateNodes(nodes: readonly EffectPrimitiveNode[]): string { + if (nodes.length === 0) return "No effect."; + const ctx = newContext(); + const parts: string[] = []; + let rendered = 0; + for (const node of nodes) { + if (ctx.budgetExceeded) { + ctx.skipped += 1; + continue; + } + if (ctx.visited.has(node)) { + parts.push("…"); + continue; + } + ctx.visited.add(node); + const piece = narrateNodeInternal(node, ctx); + ctx.visited.delete(node); + parts.push(piece); + rendered += 1; + // Check budget against the joined candidate length. + if (joinParts(parts).length > MAX_NARRATIVE_CHARS) { + ctx.budgetExceeded = true; + } + } + // Count unrendered tail if we cut short mid-loop. + if (ctx.budgetExceeded) { + ctx.skipped += nodes.length - rendered; + } + let out = joinParts(parts); + if (out.length > MAX_NARRATIVE_CHARS || ctx.skipped > 0) { + out = truncateWithSuffix(out, ctx.skipped); + } + return out; +} + +function joinParts(parts: readonly string[]): string { + // Add ". " between parts, but avoid ".." when a part already ends + // with "." (trigger narrators do). + let out = ""; + for (let i = 0; i < parts.length; i += 1) { + const p = parts[i]; + if (p === undefined) continue; + if (out.length > 0) { + out += p.startsWith(" ") ? "" : " "; + } + out += p; + // Ensure sentence break between sibling primitives. + if (i < parts.length - 1 && !p.endsWith(".") && !p.endsWith("…")) { + out += "."; + } + } + return out; +} + +function truncateWithSuffix(text: string, skipped: number): string { + const suffix = ` … and ${skipped} more primitive${skipped === 1 ? "" : "s"}`; + const available = MAX_NARRATIVE_CHARS - suffix.length; + if (available <= 0) { + // Pathological: suffix alone already longer than the cap. + return suffix.slice(0, MAX_NARRATIVE_CHARS); + } + const head = text.length > available ? text.slice(0, available) : text; + return head + suffix; +} + +/** + * Top-level entry. Returns a human-readable paragraph describing the + * descriptor — its optional description, then the effect narrative. + */ +export function narrate(descriptor: CustomModifierDescriptor): string { + const body = narrateNodes(descriptor.primitives); + // Descriptor-level prose (name/description) is the caller's job to + // surface if they want; narrate() focuses on the effect semantics so + // the preview pane can decide how to frame it. Return the effect + // body verbatim. + return body; +} + +// Re-export for consumers that want to assert on the kind set. +export type { PrimitiveKind };