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:
parent
90942bc4a6
commit
6709403e44
11 changed files with 788 additions and 91 deletions
|
|
@ -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:
|
||||
|
|
|
|||
32
packages/chess/src/ui/Board.markers.test.tsx
Normal file
32
packages/chess/src/ui/Board.markers.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
38
packages/chess/src/ui/ParamLifetimeConfig.test.tsx
Normal file
38
packages/chess/src/ui/ParamLifetimeConfig.test.tsx
Normal 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"');
|
||||
});
|
||||
});
|
||||
80
packages/chess/src/ui/ParamLifetimeConfig.tsx
Normal file
80
packages/chess/src/ui/ParamLifetimeConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
packages/chess/src/ui/ParamMarkerKindEnum.test.tsx
Normal file
27
packages/chess/src/ui/ParamMarkerKindEnum.test.tsx
Normal 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"');
|
||||
});
|
||||
});
|
||||
41
packages/chess/src/ui/ParamMarkerKindEnum.tsx
Normal file
41
packages/chess/src/ui/ParamMarkerKindEnum.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
packages/chess/src/ui/ParamSquarePicker.test.tsx
Normal file
46
packages/chess/src/ui/ParamSquarePicker.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
48
packages/chess/src/ui/ParamSquarePicker.tsx
Normal file
48
packages/chess/src/ui/ParamSquarePicker.tsx
Normal 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
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue