feat(chess/ui): add narrate.ts pure module for descriptor → English

Pure tree-walker that converts CustomModifierDescriptor (or just an
EffectPrimitiveNode array) to a human-readable English narrative.

- Static KIND_NARRATORS map covers all 21 primitives (14 existing + 7
  T1-extension trigger kinds: on-move, on-turn-end, on-promotion,
  on-check-received, on-check-delivered, on-moved-onto-square,
  on-captured) — no PRIMITIVE_REGISTRY lookup so the module stays free
  of engine/Session/Rete imports.
- Cycle guard via WeakSet on node identity outputs '…' on revisit;
  length cap 4000 chars truncates with ' … and N more primitives'.
- Performance: 0.091ms avg on 50-node descriptor (11x under the 1ms
  budget for live preview).
- 34 tests cover every kind, nested combinations, cycle handling,
  truncation, and perf microbenchmark.

Used by the live-preview pane (T17) added later in this epic.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 16:57:10 -06:00
commit d44b433889
No known key found for this signature in database
2 changed files with 1071 additions and 0 deletions

View file

@ -0,0 +1,548 @@
/**
* T15 Tests for the pure descriptor narrative module.
*
* Coverage:
* - One inline snapshot test per primitive kind (21 scenarios).
* - Nested combinations (triggeradd-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);
});
});

View file

@ -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: <kind>" 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<EffectPrimitiveNode>;
/** 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<EffectPrimitiveNode>(),
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<string, unknown>;
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 "<invalid condition>";
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 `<unknown condition: ${String(raw.type ?? "?")}>`;
}
}
}
function fmtSquareFilter(f: unknown): string {
if (f === null || typeof f !== "object") return "<invalid filter>";
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 "<unknown filter>";
}
// ──────────────────────────────────────────────────────────────────
// 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<string, Narrator>` rather than `Record<PrimitiveKind, …>`
* lets both sets coexist cleanly unknown kinds fall through to
* `defaultNarrator` in `narrateNodeInternal`.
*/
const KIND_NARRATORS: Record<string, Narrator> = {
// ─── 14 existing primitives ─────────────────────────────────────
"seed-attribute": (params) => {
const p = params as { attr?: unknown; value?: unknown } | null | undefined;
const attr = typeof p?.attr === "string" ? p.attr : "<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 : "<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 : "<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 : "<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 : "<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 : "<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 : "<attr>";
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 };