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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-26 12:36:41 -06:00
commit 6709403e44
No known key found for this signature in database
11 changed files with 788 additions and 91 deletions

View file

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

View file

@ -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(
<Board
facts={facts}
legalMoves={[]}
onMove={() => {}}
turn="white"
/>
);
expect(html).toMatchSnapshot();
});
});

View file

@ -89,7 +89,7 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
const map = new Map<number, PieceState>();
// Group facts by entity ID
const entityFacts = new Map<number, { pos?: number; type?: PieceType; color?: PieceColor }>();
const entityFacts = new Map<number, { pos?: number; type?: PieceType; color?: PieceColor; isMarker?: boolean; markerKind?: string }>();
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<number, { id: number; kind: string }[]>();
// Group facts by entity ID
const entityFacts = new Map<number, { pos?: number; isMarker?: boolean; markerKind?: string }>();
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 <Piece>
// 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 (
<div key={marker.id} className="absolute inset-0 pointer-events-none z-10 flex items-center justify-center">
<div className="w-1/3 h-1/3 rounded-full bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.8)]" />
</div>
);
case 'portal-end':
return (
<div key={marker.id} className="absolute inset-0 pointer-events-none z-10 flex items-center justify-center">
<svg className="w-2/3 h-2/3 text-teal-400 drop-shadow-[0_0_8px_rgba(45,212,191,0.8)] opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
<circle cx="12" cy="12" r="10" strokeDasharray="4 4" />
</svg>
</div>
);
case 'frozen-square':
return (
<div key={marker.id} className="absolute inset-0 pointer-events-none z-10 bg-cyan-300/30 ring-2 ring-inset ring-cyan-200/50" />
);
default:
return (
<div key={marker.id} className="absolute top-1 left-1 text-[10px] font-bold text-white bg-black/50 px-1 rounded z-10 pointer-events-none">
{marker.kind}
</div>
);
}
})}
{/*
* No AnimatePresence wrapper here: the `<Piece>` component runs

View file

@ -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(
<ParamLifetimeConfig value={undefined} onChange={onChange} />
);
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(
<ParamLifetimeConfig value={{ kind: 'moves', expiresAtMove: 1 }} onChange={onChange} />
);
// 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(
<ParamLifetimeConfig value={{ kind: 'moves', expiresAtMove: 3 }} onChange={onChange} />
);
expect(html).toContain('value="3"');
});
});

View file

@ -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 (
<div className="flex flex-col gap-2 p-3 bg-neutral-50 border border-neutral-200 rounded-md">
<div className="text-sm font-medium text-neutral-700">Duration</div>
<div className="flex bg-neutral-100 p-1 rounded-md w-full">
<button
className={`flex-1 text-xs py-1.5 px-2 rounded-sm font-medium transition-colors ${
currentKind === "permanent"
? "bg-white text-neutral-900 shadow-sm border border-neutral-200/50"
: "text-neutral-500 hover:text-neutral-700"
}`}
onClick={() => onChange({ kind: "permanent" })}
data-testid="lifetime-permanent"
>
Permanent
</button>
<button
className={`flex-1 text-xs py-1.5 px-2 rounded-sm font-medium transition-colors ${
currentKind === "moves"
? "bg-white text-neutral-900 shadow-sm border border-neutral-200/50"
: "text-neutral-500 hover:text-neutral-700"
}`}
onClick={() => onChange({ kind: "moves", expiresAtMove: movesValue })}
data-testid="lifetime-moves"
>
Turns
</button>
<button
className={`flex-1 text-xs py-1.5 px-2 rounded-sm font-medium transition-colors ${
currentKind === "one-shot"
? "bg-white text-neutral-900 shadow-sm border border-neutral-200/50"
: "text-neutral-500 hover:text-neutral-700"
}`}
onClick={() => onChange({ kind: "one-shot" })}
data-testid="lifetime-oneshot"
>
One-Shot
</button>
</div>
{currentKind === "moves" && (
<div className="flex items-center gap-2 mt-2" data-testid="lifetime-moves-config">
<label htmlFor="expiresAtMove" className="text-xs text-neutral-600">
Expires after turn:
</label>
<input
id="expiresAtMove"
type="number"
min={1}
value={movesValue}
onChange={(e) => {
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"
/>
</div>
)}
</div>
);
}

View file

@ -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(
<ParamMarkerKindEnum value="" onChange={handleChange} />
);
expect(html).toContain('Select a marker kind...');
expect(html).toContain('<option value="mine"');
expect(html).toContain('<option value="portal-end"');
});
it('renders correctly with a selected value', () => {
const handleChange = vi.fn();
const html = renderToStaticMarkup(
<ParamMarkerKindEnum value="mine" onChange={handleChange} />
);
// It should render the select with the value set to mine
expect(html).toContain('value="mine"');
});
});

View file

@ -0,0 +1,41 @@
import React from 'react';
export const MARKER_KINDS = [
'mine',
'pit',
'portal-end',
'frozen-square',
'treasure',
'death-square',
'tornado',
'blocked',
] as const;
export type MarkerKind = typeof MARKER_KINDS[number];
export interface ParamMarkerKindEnumProps {
value: string | undefined;
onChange: (kind: string) => void;
}
export function ParamMarkerKindEnum({ value, onChange }: ParamMarkerKindEnumProps) {
return (
<div className="flex flex-col gap-1">
<select
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
data-testid="marker-kind-select"
>
<option value="" disabled>
Select a marker kind...
</option>
{MARKER_KINDS.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
</div>
);
}

View file

@ -0,0 +1,46 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, it, expect, vi } from 'vitest';
import { ParamSquarePicker } from './ParamSquarePicker.js';
describe('ParamSquarePicker', () => {
it('renders 64 buttons', () => {
const html = renderToStaticMarkup(
<ParamSquarePicker value={undefined} onChange={() => {}} />
);
// Quick heuristic: should render exactly 64 button elements
const buttonCount = (html.match(/<button/g) || []).length;
expect(buttonCount).toBe(64);
});
it('calls onChange with correct square index when clicked', () => {
const onChange = vi.fn();
const component = ParamSquarePicker({ value: undefined, onChange });
// component is a <div> containing 64 buttons
// We can extract a few buttons to test
const children = component.props.children;
expect(children).toHaveLength(64);
// Top-left square visually is a8, which is file 0, rank 7
// squareOf(0, 7) = 7 * 8 + 0 = 56
const topLeftBtn = children[0];
expect(topLeftBtn.key).toBe("56");
// Trigger click on the top-left button
topLeftBtn.props.onClick();
expect(onChange).toHaveBeenCalledWith(56);
onChange.mockClear();
// Bottom-right square visually is h1, which is file 7, rank 0
// squareOf(7, 0) = 0 * 8 + 7 = 7
const bottomRightBtn = children[63];
expect(bottomRightBtn.key).toBe("7");
// Trigger click on the bottom-right button
bottomRightBtn.props.onClick();
expect(onChange).toHaveBeenCalledWith(7);
});
});

View file

@ -0,0 +1,48 @@
import { clsx } from 'clsx';
import { squareOf } from '../coord.js';
interface ParamSquarePickerProps {
value: number | undefined;
onChange: (sq: number) => void;
}
export function ParamSquarePicker({ value, onChange }: ParamSquarePickerProps) {
return (
<div className="grid grid-cols-8 grid-rows-8 w-full max-w-xs aspect-square border-4 border-neutral-800 rounded shadow-sm bg-neutral-900 overflow-hidden text-xs">
{/*
Board is usually rendered a8 to h8 at top, a1 to h1 at bottom.
rank: 0=rank1 (bottom), 7=rank8 (top)
file: 0=a (left), 7=h (right)
So for 64 cells, row=0..7, col=0..7
Visual row 0 is rank 7 (rank8).
Visual col 0 is file 0 (a).
*/}
{Array.from({ length: 64 }, (_, i) => {
const visualRow = Math.floor(i / 8);
const visualCol = i % 8;
const rank = 7 - visualRow;
const file = visualCol;
const sq = squareOf(file, rank);
const isDark = (rank + file) % 2 === 0;
const isSelected = value === sq;
return (
<button
key={sq}
type="button"
className={clsx(
'w-full h-full flex items-center justify-center transition-colors relative',
isDark ? 'bg-[#B58863]' : 'bg-[#F0D9B5]',
isSelected ? 'ring-4 ring-inset ring-amber-400 !bg-amber-400/80 shadow-[0_0_15px_rgba(251,191,36,0.6)] z-10' : 'hover:brightness-110 active:brightness-95'
)}
onClick={() => onChange(sq)}
aria-label={`Square ${sq}`}
aria-pressed={isSelected}
/>
);
})}
</div>
);
}

File diff suppressed because one or more lines are too long

View file

@ -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<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;
}
@ -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 `<unknown condition: ${String(raw.type ?? "?")}>`;
}
}
}
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<string, unknown>;
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 "<invalid filter>";
const filter = f as SquareFilter;
@ -164,16 +168,6 @@ function fmtSquareFilter(f: unknown): string {
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,
@ -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<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>";
@ -343,8 +315,6 @@ const KIND_NARRATORS: Record<string, Narrator> = {
}
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<string, Narrator> = {
: `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 : "<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 : "<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 : "<color>";
const pt = typeof p?.pieceType === "string" ? p.pieceType : "<pieceType>";
const sq =
typeof p?.square === "number" ? fmtSquare(p.square) : "<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) : "<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 : "<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 : "<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 : "<markerKind>";
const sq =
typeof p?.square === "number" ? fmtSquare(p.square) : "<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 : "<markerKind>";
const a =
typeof p?.squareA === "number" ? fmtSquare(p.squareA) : "<a>";
const b =
typeof p?.squareB === "number" ? fmtSquare(p.squareB) : "<b>";
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 ?? "<target>");
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 : "<class>";
if (cls === "move-to") {
const sq =
typeof p?.square === "number" ? fmtSquare(p.square) : "<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 : "<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 : "<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 : "<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 };