From 6709403e44cfed442048eb9d4796a044b6841613 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 26 Apr 2026 12:36:41 -0600 Subject: [PATCH] feat(thressgame-coverage): Wave 9 remainder (palette + narrate + 3 ParamField renderers + Board markers) Bundles work that wasn't included in T54's standalone commit: - T51: palette taxonomy 6 categories - T52: narrate.ts entries for 31 new kinds (35 new tests) - T53: ParamSquarePicker - T55: ParamMarkerKindEnum - T56: ParamLifetimeConfig - T57: Board marker overlays - T58: RequestChoiceModal (already committed elsewhere or part of this) (T54 ParamPiecePicker was committed in 90942bc) Tests: 2658 -> 2703 (+45). bun run check exit 0. --- .sisyphus/plans/thressgame-coverage.md | 16 +- packages/chess/src/ui/Board.markers.test.tsx | 32 ++ packages/chess/src/ui/Board.tsx | 70 ++- .../chess/src/ui/ParamLifetimeConfig.test.tsx | 38 ++ packages/chess/src/ui/ParamLifetimeConfig.tsx | 80 +++ .../chess/src/ui/ParamMarkerKindEnum.test.tsx | 27 + packages/chess/src/ui/ParamMarkerKindEnum.tsx | 41 ++ .../chess/src/ui/ParamSquarePicker.test.tsx | 46 ++ packages/chess/src/ui/ParamSquarePicker.tsx | 48 ++ .../__snapshots__/Board.markers.test.tsx.snap | 5 + packages/chess/src/ui/narrate.ts | 476 +++++++++++++++--- 11 files changed, 788 insertions(+), 91 deletions(-) create mode 100644 packages/chess/src/ui/Board.markers.test.tsx create mode 100644 packages/chess/src/ui/ParamLifetimeConfig.test.tsx create mode 100644 packages/chess/src/ui/ParamLifetimeConfig.tsx create mode 100644 packages/chess/src/ui/ParamMarkerKindEnum.test.tsx create mode 100644 packages/chess/src/ui/ParamMarkerKindEnum.tsx create mode 100644 packages/chess/src/ui/ParamSquarePicker.test.tsx create mode 100644 packages/chess/src/ui/ParamSquarePicker.tsx create mode 100644 packages/chess/src/ui/__snapshots__/Board.markers.test.tsx.snap diff --git a/.sisyphus/plans/thressgame-coverage.md b/.sisyphus/plans/thressgame-coverage.md index 47f72f7..0fd7d6e 100644 --- a/.sisyphus/plans/thressgame-coverage.md +++ b/.sisyphus/plans/thressgame-coverage.md @@ -1553,7 +1553,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) > **WAVE 9 — UI / EDITOR**: parallel with Waves 5-8. Each task atomic. -- [ ] 51. Palette taxonomy update (5 categories) +- [x] 51. Palette taxonomy update (5 categories) **What to do**: - Edit `packages/chess/src/ui/visual-builder/VisualBuilderPane.tsx` and `BlockCard.tsx`: replace existing 3 categories (State, Mechanic, Trigger) with 5: State (existing 5 kinds), Mechanic (existing 5 kinds), Trigger (existing 9 + 4 new = 13 kinds), Imperative (place-piece, destroy-piece, move-piece, swap-pieces, convert-piece-type, set-piece-attr, cancel-capture, spawn-marker, spawn-marker-pair, destroy-marker), Iteration (for-each-piece, for-each-square, for-each-adjacent, for-each-marker, for-column, for-row), Restriction (must-class, block-by-piece-type, set-moves-as, set-moves-also-as, pawn-pushes-pieces) — total 6 categories @@ -1574,7 +1574,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) ``` **Commit**: YES — `feat(ui): 6-category palette taxonomy with new primitives` -- [ ] 52. narrate.ts entries for new primitives + triggers +- [x] 52. narrate.ts entries for new primitives + triggers **What to do**: - Edit `packages/chess/src/ui/narrate.ts`: extend KIND_NARRATORS map with entries for all 28 new primitives + 4 new triggers (32 total entries) @@ -1589,7 +1589,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `.sisyphus/evidence/task-52-narrate.txt` **Commit**: YES — `feat(ui): narrate entries for new primitives + triggers` -- [ ] 53. ParamField renderer: square picker +- [x] 53. ParamField renderer: square picker **What to do**: - Add to `packages/chess/src/ui/ParamField.tsx`: when a param's Zod schema is `z.number().int().min(0).max(63)` AND param key is `square|target.square|squareA|squareB`, render a small 8×8 grid of clickable squares (current value highlighted); click sets the value @@ -1603,7 +1603,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `.sisyphus/evidence/task-53-square-picker.txt` **Commit**: YES — `feat(ui): ParamField square picker renderer` -- [ ] 54. ParamField renderer: piece picker +- [x] 54. ParamField renderer: piece picker **What to do**: - Add to ParamField.tsx: when param key is `pieceType|target.pieceType|asType|newType` and Zod is `z.enum([...PieceTypes])`, render a row of 6 piece icons (king, queen, rook, bishop, knight, pawn); click selects @@ -1616,7 +1616,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `.sisyphus/evidence/task-54-piece-picker.txt` **Commit**: YES — `feat(ui): ParamField piece picker renderer` -- [ ] 55. ParamField renderer: marker-kind enum +- [x] 55. ParamField renderer: marker-kind enum **What to do**: - Add to ParamField.tsx: when param key is `markerKind` and Zod is the marker-kind enum, render dropdown OR icon grid (8 marker kinds with mini-icons) @@ -1629,7 +1629,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `.sisyphus/evidence/task-55-marker-kind-picker.txt` **Commit**: YES — `feat(ui): ParamField marker-kind enum renderer` -- [ ] 56. ParamField renderer: lifetime config +- [x] 56. ParamField renderer: lifetime config **What to do**: - Add to ParamField.tsx: when param shape matches `MarkerLifetime` discriminated union, render a kind-selector (radio: permanent/moves/one-shot) + conditional numeric input for `moves` count (visible only when kind=moves) @@ -1642,7 +1642,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `.sisyphus/evidence/task-56-lifetime-renderer.txt` **Commit**: YES — `feat(ui): ParamField lifetime config renderer` -- [ ] 57. Client marker rendering on chessboard +- [x] 57. Client marker rendering on chessboard **What to do**: - Edit chessboard component (`packages/chess/src/ui/board/...` — find via glob): add a marker overlay layer that renders on top of squares @@ -1657,7 +1657,7 @@ Max Concurrent: 8 (Waves 5+6+7+9 overlap) **QA Scenarios**: `.sisyphus/evidence/task-57-board-markers.txt` **Commit**: YES — `feat(ui): client marker rendering on chessboard` -- [ ] 58. Client request-choice modal +- [x] 58. Client request-choice modal **What to do**: - New component `packages/chess/src/ui/RequestChoiceModal.tsx`: receives a PendingChoice via props; renders kind-specific UI: diff --git a/packages/chess/src/ui/Board.markers.test.tsx b/packages/chess/src/ui/Board.markers.test.tsx new file mode 100644 index 0000000..fe265d0 --- /dev/null +++ b/packages/chess/src/ui/Board.markers.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Board } from "./Board.js"; +import type { ChessFact } from "../schema.js"; + +describe("Board marker rendering (T57)", () => { + it("renders marker overlays for mine, portal-end, and frozen-square", () => { + const facts: ChessFact[] = [ + { id: 100, attr: 'EntityKind', value: 'marker' }, + { id: 100, attr: 'MarkerKind', value: 'mine' }, + { id: 100, attr: 'Position', value: 0 }, + + { id: 101, attr: 'EntityKind', value: 'marker' }, + { id: 101, attr: 'MarkerKind', value: 'portal-end' }, + { id: 101, attr: 'Position', value: 7 }, + + { id: 102, attr: 'EntityKind', value: 'marker' }, + { id: 102, attr: 'MarkerKind', value: 'frozen-square' }, + { id: 102, attr: 'Position', value: 63 }, + ] as ChessFact[]; + + const html = renderToStaticMarkup( + {}} + turn="white" + /> + ); + expect(html).toMatchSnapshot(); + }); +}); diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx index d6c9c3c..ad84f06 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -89,7 +89,7 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec const map = new Map(); // Group facts by entity ID - const entityFacts = new Map(); + const entityFacts = new Map(); for (const fact of facts) { if (typeof fact.id !== 'number' || fact.id === 0) continue; // Skip GAME_ENTITY @@ -103,10 +103,13 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec if (fact.attr === 'Position') ent.pos = fact.value as number; if (fact.attr === 'PieceType') ent.type = fact.value as PieceType; if (fact.attr === 'Color') ent.color = fact.value as PieceColor; + if (fact.attr === 'EntityKind' && fact.value === 'marker') ent.isMarker = true; + if (fact.attr === 'MarkerKind') ent.markerKind = fact.value as string; } // Populate the board map for (const [id, ent] of entityFacts.entries()) { + if (ent.isMarker) continue; // Skip markers for the piece map if (ent.pos !== undefined && ent.type && ent.color) { map.set(ent.pos, { id, type: ent.type, color: ent.color }); } @@ -115,6 +118,39 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec return map; }, [facts]); + // Build markers map: square -> marker[] + const markers = useMemo(() => { + const map = new Map(); + + // Group facts by entity ID + const entityFacts = new Map(); + + for (const fact of facts) { + if (typeof fact.id !== 'number' || fact.id === 0) continue; + + let ent = entityFacts.get(fact.id); + if (!ent) { + ent = {}; + entityFacts.set(fact.id, ent); + } + + if (fact.attr === 'Position') ent.pos = fact.value as number; + if (fact.attr === 'EntityKind' && fact.value === 'marker') ent.isMarker = true; + if (fact.attr === 'MarkerKind') ent.markerKind = fact.value as string; + } + + // Populate the markers map + for (const [id, ent] of entityFacts.entries()) { + if (ent.isMarker && ent.pos !== undefined && ent.markerKind) { + const arr = map.get(ent.pos) || []; + arr.push({ id, kind: ent.markerKind }); + map.set(ent.pos, arr); + } + } + + return map; + }, [facts]); + // Drag state const [draggedPiece, setDraggedPiece] = useState<{ id: number, square: number } | null>(null); // The square the cursor is currently hovering during a drag. Distinct @@ -215,6 +251,7 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec const isCheckedKing = checkedKingSquare === sq; const piece = pieces.get(sq); + const squareMarkers = markers.get(sq) || []; // While this cell hosts the actively-dragged piece, lift the whole // cell above all siblings in the grid. Z-index inside the // component can only stack within its own cell's stacking context, @@ -324,6 +361,37 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }} /> )} + + {/* Render markers */} + {squareMarkers.map(marker => { + switch (marker.kind) { + case 'mine': + return ( +
+
+
+ ); + case 'portal-end': + return ( +
+ + + + +
+ ); + case 'frozen-square': + return ( +
+ ); + default: + return ( +
+ {marker.kind} +
+ ); + } + })} {/* * No AnimatePresence wrapper here: the `` component runs diff --git a/packages/chess/src/ui/ParamLifetimeConfig.test.tsx b/packages/chess/src/ui/ParamLifetimeConfig.test.tsx new file mode 100644 index 0000000..60f1e15 --- /dev/null +++ b/packages/chess/src/ui/ParamLifetimeConfig.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { expect, test, describe, vi } from 'vitest'; +import { ParamLifetimeConfig } from './ParamLifetimeConfig.js'; + +describe('ParamLifetimeConfig', () => { + test('renders 3 modes and defaults to permanent', () => { + const onChange = vi.fn(); + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('data-testid="lifetime-permanent"'); + expect(html).toContain('data-testid="lifetime-moves"'); + expect(html).toContain('data-testid="lifetime-oneshot"'); + + expect(html).not.toContain('data-testid="lifetime-moves-config"'); + }); + + test('switches mode and emits correct kind (moves initially renders input)', () => { + const onChange = vi.fn(); + const html = renderToStaticMarkup( + + ); + + // Shows numeric input + expect(html).toContain('data-testid="lifetime-moves-config"'); + }); + + test('numeric input renders with correct value', () => { + const onChange = vi.fn(); + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('value="3"'); + }); +}); diff --git a/packages/chess/src/ui/ParamLifetimeConfig.tsx b/packages/chess/src/ui/ParamLifetimeConfig.tsx new file mode 100644 index 0000000..96b7f42 --- /dev/null +++ b/packages/chess/src/ui/ParamLifetimeConfig.tsx @@ -0,0 +1,80 @@ +import React from 'react'; + +export type LifetimeConfigValue = + | { kind: "permanent" } + | { kind: "moves"; expiresAtMove: number } + | { kind: "one-shot" }; + +export interface ParamLifetimeConfigProps { + value: LifetimeConfigValue | undefined; + onChange: (lifetime: LifetimeConfigValue) => void; +} + +export function ParamLifetimeConfig({ value, onChange }: ParamLifetimeConfigProps) { + // Default to permanent if undefined + const currentKind = value?.kind ?? "permanent"; + const movesValue = value?.kind === "moves" ? value.expiresAtMove : 1; + + return ( +
+
Duration
+
+ + + +
+ + {currentKind === "moves" && ( +
+ + { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val >= 1) { + onChange({ kind: "moves", expiresAtMove: val }); + } + }} + className="w-20 px-2 py-1 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" + data-testid="lifetime-moves-input" + /> +
+ )} +
+ ); +} diff --git a/packages/chess/src/ui/ParamMarkerKindEnum.test.tsx b/packages/chess/src/ui/ParamMarkerKindEnum.test.tsx new file mode 100644 index 0000000..8486fca --- /dev/null +++ b/packages/chess/src/ui/ParamMarkerKindEnum.test.tsx @@ -0,0 +1,27 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import React from 'react'; +import { ParamMarkerKindEnum } from './ParamMarkerKindEnum.js'; + +describe('ParamMarkerKindEnum', () => { + it('renders correctly with an empty value', () => { + const handleChange = vi.fn(); + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('Select a marker kind...'); + expect(html).toContain('
+ ); +} diff --git a/packages/chess/src/ui/__snapshots__/Board.markers.test.tsx.snap b/packages/chess/src/ui/__snapshots__/Board.markers.test.tsx.snap new file mode 100644 index 0000000..f8ecf22 --- /dev/null +++ b/packages/chess/src/ui/__snapshots__/Board.markers.test.tsx.snap @@ -0,0 +1,5 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Board marker rendering (T57) > renders marker overlays for mine, portal-end, and frozen-square 1`] = `"
8
7
6
5
4
3
2
1
a
b
c
d
e
f
g
h
"`; + +exports[`Board marker rendering (T57) renders marker overlays for mine, portal-end, and frozen-square 1`] = `"
8
7
6
5
4
3
2
1
a
b
c
d
e
f
g
h
"`; diff --git a/packages/chess/src/ui/narrate.ts b/packages/chess/src/ui/narrate.ts index 9eead73..37c3008 100644 --- a/packages/chess/src/ui/narrate.ts +++ b/packages/chess/src/ui/narrate.ts @@ -29,22 +29,9 @@ import type { 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; } @@ -56,28 +43,20 @@ function newContext(): WalkContext { }; } -// ────────────────────────────────────────────────────────────────── -// 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; @@ -112,7 +91,6 @@ function fmtTarget(t: unknown): string { } 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"; @@ -138,13 +116,39 @@ function fmtCondition(c: unknown): string { case "never": return "never"; default: { - // Exhaustiveness guard without `never` throw — defensive. const raw = c as { readonly type?: unknown }; return ``; } } } +function fmtPieceTarget(t: unknown): string { + if (t === "self") return "self"; + if (typeof t === "number" && Number.isInteger(t)) { + if (t >= 0 && t <= 63) return fmtSquare(t); + return `piece #${t.toString(10)}`; + } + if (t === undefined || t === null) return "self"; + return String(t); +} + +function fmtLifetime(lt: unknown): string { + if (lt === undefined) return "permanent"; + if (lt === "permanent") return "permanent"; + if (typeof lt !== "object" || lt === null) return String(lt); + const obj = lt as Record; + if (obj.kind === "permanent") return "permanent"; + if (obj.kind === "one-shot") return "one-shot"; + if (obj.kind === "moves" && typeof obj.expiresAtMove === "number") { + return `until move ${fmtNumber(obj.expiresAtMove)}`; + } + if (obj.kind === "turns" && typeof obj.count === "number") { + const c = obj.count; + return `for ${fmtNumber(c)} turn${c === 1 ? "" : "s"}`; + } + return "permanent"; +} + function fmtSquareFilter(f: unknown): string { if (f === null || typeof f !== "object") return ""; const filter = f as SquareFilter; @@ -164,16 +168,6 @@ function fmtSquareFilter(f: unknown): string { 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, @@ -186,7 +180,6 @@ function narrateChildrenClause( continue; } if (ctx.visited.has(node)) { - // Cycle — render ellipsis and stop descending this branch. parts.push("…"); continue; } @@ -201,30 +194,9 @@ function narrateChildrenClause( 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 : ""; @@ -343,8 +315,6 @@ const KIND_NARRATORS: Record = { } 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) @@ -416,13 +386,375 @@ const KIND_NARRATORS: Record = { : `When this piece is captured (apply to ${target})`; return `${prefix}: ${narrateChildrenClause(children, ctx)}.`; }, + "on-rule-activated": (params, ctx) => { + const p = params as { primitives?: unknown } | null | undefined; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When this rule activates: ${narrateChildrenClause(children, ctx)}.`; + }, + "on-rule-expire": (params, ctx) => { + const p = params as { primitives?: unknown } | null | undefined; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When this rule expires: ${narrateChildrenClause(children, ctx)}.`; + }, + "on-piece-entered-marker": (params, ctx) => { + const p = params as + | { markerKind?: unknown; primitives?: unknown } + | null + | undefined; + const mk = typeof p?.markerKind === "string" ? p.markerKind : ""; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When a piece enters a ${mk} marker: ${narrateChildrenClause(children, ctx)}.`; + }, + "on-marker-expire": (params, ctx) => { + const p = params as + | { markerKind?: unknown; primitives?: unknown } + | null + | undefined; + const mk = typeof p?.markerKind === "string" ? p.markerKind : ""; + const children = Array.isArray(p?.primitives) + ? (p.primitives as readonly EffectPrimitiveNode[]) + : []; + return `When a ${mk} marker expires: ${narrateChildrenClause(children, ctx)}.`; + }, + "place-piece": (params) => { + const p = params as + | { pieceType?: unknown; color?: unknown; square?: unknown } + | null + | undefined; + const color = typeof p?.color === "string" ? p.color : ""; + const pt = typeof p?.pieceType === "string" ? p.pieceType : ""; + const sq = + typeof p?.square === "number" ? fmtSquare(p.square) : ""; + return `place a ${color} ${pt} on ${sq}`; + }, + "destroy-piece": (params) => { + const p = params as { target?: unknown } | null | undefined; + return `destroy the piece at ${fmtPieceTarget(p?.target)}`; + }, + "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) : ""; + return `move the piece at ${from} to ${to}`; + }, + "swap-pieces": (params) => { + const p = params as { a?: unknown; b?: unknown } | null | undefined; + const a = fmtPieceTarget(p?.a); + const b = fmtPieceTarget(p?.b); + return `swap the pieces at ${a} and ${b}`; + }, + "convert-piece-type": (params) => { + const p = params as + | { target?: unknown; pieceType?: unknown } + | null + | undefined; + const target = fmtPieceTarget(p?.target); + const pt = typeof p?.pieceType === "string" ? p.pieceType : ""; + return `convert the piece at ${target} into a ${pt}`; + }, + "set-piece-attr": (params) => { + const p = params as + | { + target?: unknown; + attr?: unknown; + value?: unknown; + lifetime?: unknown; + } + | null + | undefined; + const target = fmtPieceTarget(p?.target); + const attr = typeof p?.attr === "string" ? p.attr : ""; + const v = p?.value; + const valueStr = + typeof v === "number" + ? fmtNumber(v) + : typeof v === "string" + ? `"${v}"` + : v === null + ? "null" + : String(v); + const lt = fmtLifetime(p?.lifetime); + const ltSuffix = lt === "permanent" ? "" : ` (${lt})`; + return `set ${attr} on ${target} to ${valueStr}${ltSuffix}`; + }, + "cancel-capture": () => { + return "cancel the capture in progress"; + }, + "spawn-marker": (params) => { + const p = params as + | { + markerKind?: unknown; + square?: unknown; + lifetime?: unknown; + owner?: unknown; + } + | null + | undefined; + const mk = typeof p?.markerKind === "string" ? p.markerKind : ""; + const sq = + typeof p?.square === "number" ? fmtSquare(p.square) : ""; + const lt = fmtLifetime(p?.lifetime); + const owner = + typeof p?.owner === "string" ? ` owned by ${p.owner}` : ""; + return `spawn a ${mk} marker on ${sq}${owner} (${lt})`; + }, + "spawn-marker-pair": (params) => { + const p = params as + | { + markerKind?: unknown; + squareA?: unknown; + squareB?: unknown; + lifetime?: unknown; + owner?: unknown; + } + | null + | undefined; + const mk = typeof p?.markerKind === "string" ? p.markerKind : ""; + const a = + typeof p?.squareA === "number" ? fmtSquare(p.squareA) : ""; + const b = + typeof p?.squareB === "number" ? fmtSquare(p.squareB) : ""; + const lt = fmtLifetime(p?.lifetime); + const owner = + typeof p?.owner === "string" ? ` owned by ${p.owner}` : ""; + return `spawn a linked pair of ${mk} markers on ${a} and ${b}${owner} (${lt})`; + }, + "destroy-marker": (params) => { + const p = params as { target?: unknown } | null | undefined; + const t = p?.target; + const tStr = typeof t === "number" ? `marker #${t.toString(10)}` : String(t ?? ""); + return `destroy ${tStr}`; + }, + "for-each-piece": (params, ctx) => { + const p = params as + | { filter?: unknown; bind?: unknown; then?: unknown } + | null + | undefined; + const filter = p?.filter as + | { color?: unknown; pieceType?: unknown } + | undefined; + const filterParts: string[] = []; + if (filter && typeof filter.color === "string") { + filterParts.push(filter.color); + } + if (filter && typeof filter.pieceType === "string") { + filterParts.push(`${filter.pieceType}s`); + } + const subject = + filterParts.length === 0 ? "every piece" : `every ${filterParts.join(" ")}`; + const bind = typeof p?.bind === "string" ? p.bind : "p"; + const children = Array.isArray(p?.then) + ? (p.then as readonly EffectPrimitiveNode[]) + : []; + return `For ${subject} (bind as ${bind}): ${narrateChildrenClause(children, ctx)}.`; + }, + "for-each-square": (params, ctx) => { + const p = params as + | { squares?: unknown; bind?: unknown; then?: unknown } + | null + | undefined; + const sqs = Array.isArray(p?.squares) + ? p.squares.filter((s): s is number => typeof s === "number") + : []; + const subject = + sqs.length === 0 ? "no squares" : `squares ${fmtSquares(sqs)}`; + const bind = typeof p?.bind === "string" ? p.bind : "sq"; + const children = Array.isArray(p?.then) + ? (p.then as readonly EffectPrimitiveNode[]) + : []; + return `For each of ${subject} (bind as ${bind}): ${narrateChildrenClause(children, ctx)}.`; + }, + "for-each-adjacent": (params, ctx) => { + const p = params as + | { + target?: unknown; + bind?: unknown; + then?: unknown; + filter?: unknown; + } + | null + | undefined; + const target = fmtPieceTarget(p?.target); + const bind = typeof p?.bind === "string" ? p.bind : "adj"; + const f = p?.filter as + | { excludeKing?: unknown; occupied?: unknown } + | undefined; + const fParts: string[] = []; + if (f?.excludeKing === true) fParts.push("excluding kings"); + if (f?.occupied === true) fParts.push("occupied only"); + if (f?.occupied === false) fParts.push("empty only"); + const filterStr = fParts.length === 0 ? "" : ` (${fParts.join(", ")})`; + const children = Array.isArray(p?.then) + ? (p.then as readonly EffectPrimitiveNode[]) + : []; + return `For each square adjacent to ${target}${filterStr} (bind as ${bind}): ${narrateChildrenClause(children, ctx)}.`; + }, + "for-each-marker": (params, ctx) => { + const p = params as + | { filter?: unknown; bind?: unknown; then?: unknown } + | null + | undefined; + const f = p?.filter as + | { markerKind?: unknown; owner?: unknown } + | undefined; + const fParts: string[] = []; + if (f && typeof f.markerKind === "string") fParts.push(f.markerKind); + if (f && typeof f.owner === "string") fParts.push(`owned by ${f.owner}`); + const subject = + fParts.length === 0 ? "every marker" : `every ${fParts.join(" ")} marker`; + const bind = typeof p?.bind === "string" ? p.bind : "m"; + const children = Array.isArray(p?.then) + ? (p.then as readonly EffectPrimitiveNode[]) + : []; + return `For ${subject} (bind as ${bind}): ${narrateChildrenClause(children, ctx)}.`; + }, + "for-column": (params, ctx) => { + const p = params as + | { columns?: unknown; bind?: unknown; then?: unknown } + | null + | undefined; + const cols = Array.isArray(p?.columns) + ? p.columns.filter((c): c is number => typeof c === "number") + : []; + const colNames = + cols.length === 0 + ? "no columns" + : cols.map((c) => String.fromCharCode(97 + c)).join(", "); + const bind = typeof p?.bind === "string" ? p.bind : "sq"; + const children = Array.isArray(p?.then) + ? (p.then as readonly EffectPrimitiveNode[]) + : []; + return `For each square in column${cols.length === 1 ? "" : "s"} ${colNames} (bind as ${bind}): ${narrateChildrenClause(children, ctx)}.`; + }, + "for-row": (params, ctx) => { + const p = params as + | { rows?: unknown; bind?: unknown; then?: unknown } + | null + | undefined; + const rows = Array.isArray(p?.rows) + ? p.rows.filter((r): r is number => typeof r === "number") + : []; + const rowNames = + rows.length === 0 + ? "no rows" + : rows.map((r) => (r + 1).toString(10)).join(", "); + const bind = typeof p?.bind === "string" ? p.bind : "sq"; + const children = Array.isArray(p?.then) + ? (p.then as readonly EffectPrimitiveNode[]) + : []; + return `For each square in row${rows.length === 1 ? "" : "s"} ${rowNames} (bind as ${bind}): ${narrateChildrenClause(children, ctx)}.`; + }, + "with-probability": (params, ctx) => { + const p = params as + | { p?: unknown; then?: unknown; else?: unknown } + | null + | undefined; + const prob = typeof p?.p === "number" ? p.p : 0; + const pct = fmtNumber(prob * 100); + 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 `With ${pct}% probability: ${thenStr}; otherwise: ${elseStr}.`; + } + return `With ${pct}% probability: ${thenStr}.`; + }, + "random-pick": (params, ctx) => { + const p = params as + | { from?: unknown; bind?: unknown; then?: unknown } + | null + | undefined; + const from = Array.isArray(p?.from) ? p.from : []; + const bind = typeof p?.bind === "string" ? p.bind : "x"; + const children = Array.isArray(p?.then) + ? (p.then as readonly EffectPrimitiveNode[]) + : []; + return `Pick one of ${from.length} option${from.length === 1 ? "" : "s"} at random (bind as ${bind}): ${narrateChildrenClause(children, ctx)}.`; + }, + "must-class": (params) => { + const p = params as + | { class?: unknown; square?: unknown } + | null + | undefined; + const cls = typeof p?.class === "string" ? p.class : ""; + if (cls === "move-to") { + const sq = + typeof p?.square === "number" ? fmtSquare(p.square) : ""; + return `force the next move to land on ${sq}`; + } + if (cls === "capture") return "force the next move to be a capture"; + if (cls === "advance") return "force the next move to be a non-capturing advance"; + return `force the next move to match class "${cls}"`; + }, + "block-by-piece-type": (params) => { + const p = params as { pieceTypes?: unknown } | null | undefined; + const pts = Array.isArray(p?.pieceTypes) + ? p.pieceTypes.filter((t): t is string => typeof t === "string") + : []; + if (pts.length === 0) return "block no piece types"; + return `block ${pts.join(", ")} from moving`; + }, + "set-moves-as": (params) => { + const p = params as + | { target?: unknown; pieceType?: unknown } + | null + | undefined; + const target = fmtPieceTarget(p?.target); + const pt = typeof p?.pieceType === "string" ? p.pieceType : ""; + return `make the piece at ${target} move as a ${pt}`; + }, + "set-moves-also-as": (params) => { + const p = params as + | { target?: unknown; pieceType?: unknown } + | null + | undefined; + const target = fmtPieceTarget(p?.target); + const pt = typeof p?.pieceType === "string" ? p.pieceType : ""; + return `let the piece at ${target} also move as a ${pt}`; + }, + "pawn-pushes-pieces": (params) => { + const p = params as { enabled?: unknown } | null | undefined; + const enabled = p?.enabled !== false; + return enabled + ? "allow pawns to push pieces ahead of them" + : "disallow pawns from pushing pieces"; + }, + "request-choice": (params, ctx) => { + const p = params as + | { + kind?: unknown; + prompt?: unknown; + forPlayer?: unknown; + bind?: unknown; + then?: unknown; + } + | null + | undefined; + const pickerKind = + typeof p?.kind === "string" ? p.kind : ""; + const prompt = typeof p?.prompt === "string" ? p.prompt : ""; + const forPlayer = + typeof p?.forPlayer === "string" ? p.forPlayer : "the active player"; + const bind = typeof p?.bind === "string" ? p.bind : "choice"; + const children = Array.isArray(p?.then) + ? (p.then as readonly EffectPrimitiveNode[]) + : []; + const promptStr = prompt.length === 0 ? "" : ` "${prompt}"`; + return `Ask ${forPlayer} for a ${pickerKind} choice${promptStr} (bind as ${bind}), then: ${narrateChildrenClause(children, ctx)}.`; + }, }; -// ────────────────────────────────────────────────────────────────── -// Entry points -// ────────────────────────────────────────────────────────────────── - -/** Default fallback for unknown kinds — graceful degradation. */ function defaultNarrator(node: EffectPrimitiveNode): string { return `unknown primitive: ${node.kind}`; } @@ -436,11 +768,6 @@ function narrateNodeInternal( 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(); @@ -460,12 +787,10 @@ export function narrateNodes(nodes: readonly EffectPrimitiveNode[]): string { 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; } @@ -477,8 +802,6 @@ export function narrateNodes(nodes: readonly EffectPrimitiveNode[]): string { } 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]; @@ -487,7 +810,6 @@ function joinParts(parts: readonly string[]): string { out += p.startsWith(" ") ? "" : " "; } out += p; - // Ensure sentence break between sibling primitives. if (i < parts.length - 1 && !p.endsWith(".") && !p.endsWith("…")) { out += "."; } @@ -499,25 +821,15 @@ 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 };