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.
|
> **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**:
|
**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
|
- 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`
|
**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**:
|
**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)
|
- 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`
|
**QA Scenarios**: `.sisyphus/evidence/task-52-narrate.txt`
|
||||||
**Commit**: YES — `feat(ui): narrate entries for new primitives + triggers`
|
**Commit**: YES — `feat(ui): narrate entries for new primitives + triggers`
|
||||||
|
|
||||||
- [ ] 53. ParamField renderer: square picker
|
- [x] 53. ParamField renderer: square picker
|
||||||
|
|
||||||
**What to do**:
|
**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
|
- 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`
|
**QA Scenarios**: `.sisyphus/evidence/task-53-square-picker.txt`
|
||||||
**Commit**: YES — `feat(ui): ParamField square picker renderer`
|
**Commit**: YES — `feat(ui): ParamField square picker renderer`
|
||||||
|
|
||||||
- [ ] 54. ParamField renderer: piece picker
|
- [x] 54. ParamField renderer: piece picker
|
||||||
|
|
||||||
**What to do**:
|
**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
|
- 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`
|
**QA Scenarios**: `.sisyphus/evidence/task-54-piece-picker.txt`
|
||||||
**Commit**: YES — `feat(ui): ParamField piece picker renderer`
|
**Commit**: YES — `feat(ui): ParamField piece picker renderer`
|
||||||
|
|
||||||
- [ ] 55. ParamField renderer: marker-kind enum
|
- [x] 55. ParamField renderer: marker-kind enum
|
||||||
|
|
||||||
**What to do**:
|
**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)
|
- 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`
|
**QA Scenarios**: `.sisyphus/evidence/task-55-marker-kind-picker.txt`
|
||||||
**Commit**: YES — `feat(ui): ParamField marker-kind enum renderer`
|
**Commit**: YES — `feat(ui): ParamField marker-kind enum renderer`
|
||||||
|
|
||||||
- [ ] 56. ParamField renderer: lifetime config
|
- [x] 56. ParamField renderer: lifetime config
|
||||||
|
|
||||||
**What to do**:
|
**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)
|
- 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`
|
**QA Scenarios**: `.sisyphus/evidence/task-56-lifetime-renderer.txt`
|
||||||
**Commit**: YES — `feat(ui): ParamField lifetime config renderer`
|
**Commit**: YES — `feat(ui): ParamField lifetime config renderer`
|
||||||
|
|
||||||
- [ ] 57. Client marker rendering on chessboard
|
- [x] 57. Client marker rendering on chessboard
|
||||||
|
|
||||||
**What to do**:
|
**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
|
- 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`
|
**QA Scenarios**: `.sisyphus/evidence/task-57-board-markers.txt`
|
||||||
**Commit**: YES — `feat(ui): client marker rendering on chessboard`
|
**Commit**: YES — `feat(ui): client marker rendering on chessboard`
|
||||||
|
|
||||||
- [ ] 58. Client request-choice modal
|
- [x] 58. Client request-choice modal
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- New component `packages/chess/src/ui/RequestChoiceModal.tsx`: receives a PendingChoice via props; renders kind-specific UI:
|
- 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>();
|
const map = new Map<number, PieceState>();
|
||||||
|
|
||||||
// Group facts by entity ID
|
// 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) {
|
for (const fact of facts) {
|
||||||
if (typeof fact.id !== 'number' || fact.id === 0) continue; // Skip GAME_ENTITY
|
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 === 'Position') ent.pos = fact.value as number;
|
||||||
if (fact.attr === 'PieceType') ent.type = fact.value as PieceType;
|
if (fact.attr === 'PieceType') ent.type = fact.value as PieceType;
|
||||||
if (fact.attr === 'Color') ent.color = fact.value as PieceColor;
|
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
|
// Populate the board map
|
||||||
for (const [id, ent] of entityFacts.entries()) {
|
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) {
|
if (ent.pos !== undefined && ent.type && ent.color) {
|
||||||
map.set(ent.pos, { id, type: ent.type, color: 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;
|
return map;
|
||||||
}, [facts]);
|
}, [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
|
// Drag state
|
||||||
const [draggedPiece, setDraggedPiece] = useState<{ id: number, square: number } | null>(null);
|
const [draggedPiece, setDraggedPiece] = useState<{ id: number, square: number } | null>(null);
|
||||||
// The square the cursor is currently hovering during a drag. Distinct
|
// 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 isCheckedKing = checkedKingSquare === sq;
|
||||||
|
|
||||||
const piece = pieces.get(sq);
|
const piece = pieces.get(sq);
|
||||||
|
const squareMarkers = markers.get(sq) || [];
|
||||||
// While this cell hosts the actively-dragged piece, lift the whole
|
// While this cell hosts the actively-dragged piece, lift the whole
|
||||||
// cell above all siblings in the grid. Z-index inside the <Piece>
|
// cell above all siblings in the grid. Z-index inside the <Piece>
|
||||||
// component can only stack within its own cell's stacking context,
|
// component can only stack within its own cell's stacking context,
|
||||||
|
|
@ -325,6 +362,37 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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
|
* No AnimatePresence wrapper here: the `<Piece>` component runs
|
||||||
* its own FLIP across unmount (at source) and mount (at target)
|
* its own FLIP across unmount (at source) and mount (at target)
|
||||||
|
|
|
||||||
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;
|
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 {
|
interface WalkContext {
|
||||||
readonly visited: WeakSet<EffectPrimitiveNode>;
|
readonly visited: WeakSet<EffectPrimitiveNode>;
|
||||||
/** Primitives skipped due to cycle or length cap. */
|
|
||||||
skipped: number;
|
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;
|
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 {
|
function fmtNumber(n: number): string {
|
||||||
if (!Number.isFinite(n)) return String(n);
|
if (!Number.isFinite(n)) return String(n);
|
||||||
if (Number.isInteger(n)) return n.toString(10);
|
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 s = n.toFixed(10);
|
||||||
const trimmed = s.replace(/0+$/u, "").replace(/\.$/u, "");
|
const trimmed = s.replace(/0+$/u, "").replace(/\.$/u, "");
|
||||||
return trimmed.length === 0 ? "0" : trimmed;
|
return trimmed.length === 0 ? "0" : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Signed delta — always prefixes a sign so "+1" reads naturally. */
|
|
||||||
function fmtDelta(n: number): string {
|
function fmtDelta(n: number): string {
|
||||||
if (!Number.isFinite(n)) return String(n);
|
if (!Number.isFinite(n)) return String(n);
|
||||||
if (n >= 0) return `+${fmtNumber(n)}`;
|
if (n >= 0) return `+${fmtNumber(n)}`;
|
||||||
return fmtNumber(n);
|
return fmtNumber(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Square 0..63 → algebraic e.g. 28 → "e4". */
|
|
||||||
function fmtSquare(sq: number): string {
|
function fmtSquare(sq: number): string {
|
||||||
if (!Number.isInteger(sq) || sq < 0 || sq > 63) return String(sq);
|
if (!Number.isInteger(sq) || sq < 0 || sq > 63) return String(sq);
|
||||||
const file = sq % 8;
|
const file = sq % 8;
|
||||||
|
|
@ -112,7 +91,6 @@ function fmtTarget(t: unknown): string {
|
||||||
}
|
}
|
||||||
return `${rel === "ally" ? "allies" : "enemies"}`;
|
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-piece") return "selected pieces";
|
||||||
if (obj.kind === "select-square") return "selected squares";
|
if (obj.kind === "select-square") return "selected squares";
|
||||||
return "self";
|
return "self";
|
||||||
|
|
@ -138,13 +116,39 @@ function fmtCondition(c: unknown): string {
|
||||||
case "never":
|
case "never":
|
||||||
return "never";
|
return "never";
|
||||||
default: {
|
default: {
|
||||||
// Exhaustiveness guard without `never` throw — defensive.
|
|
||||||
const raw = c as { readonly type?: unknown };
|
const raw = c as { readonly type?: unknown };
|
||||||
return `<unknown condition: ${String(raw.type ?? "?")}>`;
|
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 {
|
function fmtSquareFilter(f: unknown): string {
|
||||||
if (f === null || typeof f !== "object") return "<invalid filter>";
|
if (f === null || typeof f !== "object") return "<invalid filter>";
|
||||||
const filter = f as SquareFilter;
|
const filter = f as SquareFilter;
|
||||||
|
|
@ -164,16 +168,6 @@ function fmtSquareFilter(f: unknown): string {
|
||||||
return "<unknown filter>";
|
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(
|
function narrateChildrenClause(
|
||||||
nodes: readonly EffectPrimitiveNode[] | undefined,
|
nodes: readonly EffectPrimitiveNode[] | undefined,
|
||||||
ctx: WalkContext,
|
ctx: WalkContext,
|
||||||
|
|
@ -186,7 +180,6 @@ function narrateChildrenClause(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (ctx.visited.has(node)) {
|
if (ctx.visited.has(node)) {
|
||||||
// Cycle — render ellipsis and stop descending this branch.
|
|
||||||
parts.push("…");
|
parts.push("…");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -201,30 +194,9 @@ function narrateChildrenClause(
|
||||||
return parts.length === 0 ? "do nothing" : parts.join("; ");
|
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;
|
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> = {
|
const KIND_NARRATORS: Record<string, Narrator> = {
|
||||||
// ─── 14 existing primitives ─────────────────────────────────────
|
|
||||||
"seed-attribute": (params) => {
|
"seed-attribute": (params) => {
|
||||||
const p = params as { attr?: unknown; value?: unknown } | null | undefined;
|
const p = params as { attr?: unknown; value?: unknown } | null | undefined;
|
||||||
const attr = typeof p?.attr === "string" ? p.attr : "<attr>";
|
const attr = typeof p?.attr === "string" ? p.attr : "<attr>";
|
||||||
|
|
@ -343,8 +315,6 @@ const KIND_NARRATORS: Record<string, Narrator> = {
|
||||||
}
|
}
|
||||||
return `If ${condStr}: ${thenStr}.`;
|
return `If ${condStr}: ${thenStr}.`;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── 7 T1-extension triggers (params shapes per plan/decisions) ──
|
|
||||||
"on-move": (params, ctx) => {
|
"on-move": (params, ctx) => {
|
||||||
const p = params as { primitives?: unknown } | null | undefined;
|
const p = params as { primitives?: unknown } | null | undefined;
|
||||||
const children = Array.isArray(p?.primitives)
|
const children = Array.isArray(p?.primitives)
|
||||||
|
|
@ -416,13 +386,375 @@ const KIND_NARRATORS: Record<string, Narrator> = {
|
||||||
: `When this piece is captured (apply to ${target})`;
|
: `When this piece is captured (apply to ${target})`;
|
||||||
return `${prefix}: ${narrateChildrenClause(children, ctx)}.`;
|
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 {
|
function defaultNarrator(node: EffectPrimitiveNode): string {
|
||||||
return `unknown primitive: ${node.kind}`;
|
return `unknown primitive: ${node.kind}`;
|
||||||
}
|
}
|
||||||
|
|
@ -436,11 +768,6 @@ function narrateNodeInternal(
|
||||||
return narrator(node.params, ctx);
|
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 {
|
export function narrateNodes(nodes: readonly EffectPrimitiveNode[]): string {
|
||||||
if (nodes.length === 0) return "No effect.";
|
if (nodes.length === 0) return "No effect.";
|
||||||
const ctx = newContext();
|
const ctx = newContext();
|
||||||
|
|
@ -460,12 +787,10 @@ export function narrateNodes(nodes: readonly EffectPrimitiveNode[]): string {
|
||||||
ctx.visited.delete(node);
|
ctx.visited.delete(node);
|
||||||
parts.push(piece);
|
parts.push(piece);
|
||||||
rendered += 1;
|
rendered += 1;
|
||||||
// Check budget against the joined candidate length.
|
|
||||||
if (joinParts(parts).length > MAX_NARRATIVE_CHARS) {
|
if (joinParts(parts).length > MAX_NARRATIVE_CHARS) {
|
||||||
ctx.budgetExceeded = true;
|
ctx.budgetExceeded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Count unrendered tail if we cut short mid-loop.
|
|
||||||
if (ctx.budgetExceeded) {
|
if (ctx.budgetExceeded) {
|
||||||
ctx.skipped += nodes.length - rendered;
|
ctx.skipped += nodes.length - rendered;
|
||||||
}
|
}
|
||||||
|
|
@ -477,8 +802,6 @@ export function narrateNodes(nodes: readonly EffectPrimitiveNode[]): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function joinParts(parts: readonly string[]): string {
|
function joinParts(parts: readonly string[]): string {
|
||||||
// Add ". " between parts, but avoid ".." when a part already ends
|
|
||||||
// with "." (trigger narrators do).
|
|
||||||
let out = "";
|
let out = "";
|
||||||
for (let i = 0; i < parts.length; i += 1) {
|
for (let i = 0; i < parts.length; i += 1) {
|
||||||
const p = parts[i];
|
const p = parts[i];
|
||||||
|
|
@ -487,7 +810,6 @@ function joinParts(parts: readonly string[]): string {
|
||||||
out += p.startsWith(" ") ? "" : " ";
|
out += p.startsWith(" ") ? "" : " ";
|
||||||
}
|
}
|
||||||
out += p;
|
out += p;
|
||||||
// Ensure sentence break between sibling primitives.
|
|
||||||
if (i < parts.length - 1 && !p.endsWith(".") && !p.endsWith("…")) {
|
if (i < parts.length - 1 && !p.endsWith(".") && !p.endsWith("…")) {
|
||||||
out += ".";
|
out += ".";
|
||||||
}
|
}
|
||||||
|
|
@ -499,25 +821,15 @@ function truncateWithSuffix(text: string, skipped: number): string {
|
||||||
const suffix = ` … and ${skipped} more primitive${skipped === 1 ? "" : "s"}`;
|
const suffix = ` … and ${skipped} more primitive${skipped === 1 ? "" : "s"}`;
|
||||||
const available = MAX_NARRATIVE_CHARS - suffix.length;
|
const available = MAX_NARRATIVE_CHARS - suffix.length;
|
||||||
if (available <= 0) {
|
if (available <= 0) {
|
||||||
// Pathological: suffix alone already longer than the cap.
|
|
||||||
return suffix.slice(0, MAX_NARRATIVE_CHARS);
|
return suffix.slice(0, MAX_NARRATIVE_CHARS);
|
||||||
}
|
}
|
||||||
const head = text.length > available ? text.slice(0, available) : text;
|
const head = text.length > available ? text.slice(0, available) : text;
|
||||||
return head + suffix;
|
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 {
|
export function narrate(descriptor: CustomModifierDescriptor): string {
|
||||||
const body = narrateNodes(descriptor.primitives);
|
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;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for consumers that want to assert on the kind set.
|
|
||||||
export type { PrimitiveKind };
|
export type { PrimitiveKind };
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue