feat(chess): per-color preset scope, turn-limited duration, server-authoritative sync
Presets previously lived on a process-global singleton with only a
binary on/off toggle. Two bugs followed:
1. Multiplayer illegal-move errors — the client-side toggle didn`t
reach the server, so optimistic moves legal under client rules got
rejected by the server`s unmodified ChessEngine.
2. No way to apply a rule to just white or just black, or to time-box
it for N turns.
Replaces the shared `PRESET_REGISTRY.active: Set<string>` with
instance-owned `ChessEngine.activePresets: ActivePresetSet`. Each
activation carries:
- scope: `both` | `white` | `black`
- turnsRemaining: positive int or null (permanent)
Engine reads `getForColor(color)` per piece, so scope=white never
contributes moves during black`s turn. `applyMove` calls
`tickAfterMove(moverColor)` which implements player-local counting:
white-only durations tick only when white moves.
Compatibility is the LOOSE rule — `incompatibleWith` blocks only when
the two activations have overlapping scopes. `scope=white` + `scope=black`
pair of otherwise-incompatible presets is allowed because the engine
never evaluates both for the same side.
Server changes: GameSession owns an ActivePresetSet. New protocol
messages:
- client → server: `room.setPresets` with full activation list
- server → client: `game.presets` broadcast on every set change
(post-setPresets + post-move-with-expiry)
`game.state` snapshots now include `activations` so reconnects pick
up the current rule set without extra round-trips.
Client changes: PredictionManager applies `game.presets` to the base
engine`s ActivePresetSet and re-renders via onStateChange; cloneEngine
carries activations onto the predicted clone. New hook surface:
- activations: readonly PresetActivation[]
- setPresets(next): replace the active set
useMultiplayerGame dispatches setPresets through the socket
(server-authoritative); useChessEngine mutates in-place (local mode).
UI: RulesDrawer + RulesView render scope radios (Both/White/Black)
and a duration input per active preset. Empty duration means
permanent, positive integers last N player-local turns.
Tests:
- 15 new ActivePresetSet unit tests (scope, tick, loose compat,
atomicity, clone)
- 4 new engine-presets integration tests (per-color, duration,
white-only vs black-only)
- Migrated older preset tests from `PRESET_REGISTRY.activate` to
the instance API
- New E2E regression test: enable knights-leap-twice scope=white in
multiplayer; verify the double-leap is accepted by the server,
verify black`s knight cannot use it
This commit is contained in:
parent
5fb96647eb
commit
de059fe707
20 changed files with 1419 additions and 398 deletions
|
|
@ -317,3 +317,83 @@ test("multiplayer: two contexts, reconnect at move 7, Scholar's Mate checkmate",
|
||||||
await ctxA.close();
|
await ctxA.close();
|
||||||
await ctxB.close();
|
await ctxB.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope bug regression test.
|
||||||
|
*
|
||||||
|
* Before per-color preset scoping shipped, a client that enabled
|
||||||
|
* `knights-leap-twice` locally would predict a double-leap move
|
||||||
|
* (legal under the client's rule set), send it to the server, and
|
||||||
|
* receive ILLEGAL_MOVE because the server's ChessEngine didn't know
|
||||||
|
* the preset was active. The snap-back rolled the board back and the
|
||||||
|
* user saw an error toast.
|
||||||
|
*
|
||||||
|
* With per-room authoritative rule state + `room.setPresets`, the
|
||||||
|
* client dispatches the activation to the server, which broadcasts
|
||||||
|
* `game.presets` to both players. The next move calc on both sides
|
||||||
|
* uses the same rule set, so predictions and validations agree.
|
||||||
|
*
|
||||||
|
* The test specifically exercises `scope=white`: only white's knights
|
||||||
|
* can double-leap, proving both that the server respects the preset
|
||||||
|
* AND that the scope narrowing reaches the server.
|
||||||
|
*/
|
||||||
|
test('multiplayer presets: white-only knights-leap-twice applies to white, not black', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const ctxA = await browser.newContext();
|
||||||
|
const ctxB = await browser.newContext();
|
||||||
|
const pageA = await ctxA.newPage();
|
||||||
|
const pageB = await ctxB.newPage();
|
||||||
|
|
||||||
|
// 1. Room create/join.
|
||||||
|
await pageA.goto('http://localhost:5173/');
|
||||||
|
const roomA = await wsCreateRoom(pageA);
|
||||||
|
await pageA.evaluate((r) => {
|
||||||
|
sessionStorage.setItem('room-code', r.code);
|
||||||
|
sessionStorage.setItem('room-token', r.token);
|
||||||
|
sessionStorage.setItem('player-color', r.color);
|
||||||
|
}, roomA);
|
||||||
|
await pageA.goto('http://localhost:5173/game');
|
||||||
|
await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white');
|
||||||
|
|
||||||
|
await pageB.goto('http://localhost:5173/');
|
||||||
|
const roomB = await wsJoinRoom(pageB, roomA.code);
|
||||||
|
await pageB.evaluate((r) => {
|
||||||
|
sessionStorage.setItem('room-code', r.code);
|
||||||
|
sessionStorage.setItem('room-token', r.token);
|
||||||
|
sessionStorage.setItem('player-color', r.color);
|
||||||
|
}, roomB);
|
||||||
|
await pageB.goto('http://localhost:5173/game');
|
||||||
|
await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black');
|
||||||
|
|
||||||
|
// 2. White enables knights-leap-twice with scope=white via the drawer.
|
||||||
|
await pageA.locator('[data-action="open-rules-drawer"]').click();
|
||||||
|
const knightRow = pageA.locator('[data-preset="knights-leap-twice"]');
|
||||||
|
await knightRow.locator('[data-role="toggle"]').click();
|
||||||
|
// Default scope is 'both' — change to 'white'.
|
||||||
|
await knightRow.locator('[data-role="scope-white"]').click();
|
||||||
|
await pageA.locator('[data-action="close-rules-drawer"]').click();
|
||||||
|
|
||||||
|
// 3. Wait for pageB to receive the `game.presets` broadcast and render
|
||||||
|
// the active-preset count in its drawer pill.
|
||||||
|
await expect(pageB.locator('[data-action="open-rules-drawer"]')).toContainText('1');
|
||||||
|
|
||||||
|
// 4. White drags g1 knight to d4 — a double-leap that's ONLY legal
|
||||||
|
// with knights-leap-twice active. Before the fix this produced an
|
||||||
|
// ILLEGAL_MOVE; after the fix the move is accepted.
|
||||||
|
await drag(pageA, 'g1', 'd4');
|
||||||
|
await expect(pageA.locator('[data-square="d4"] [data-piece="white-knight"]')).toBeVisible();
|
||||||
|
await expect(pageB.locator('[data-square="d4"] [data-piece="white-knight"]')).toBeVisible();
|
||||||
|
|
||||||
|
// 5. Black attempts the same double-leap with its g8 knight. Preset
|
||||||
|
// scope is white-only so this must NOT be legal for black.
|
||||||
|
// `dragTo` still performs the mouse movement but the client's
|
||||||
|
// legal-move set won't include it, so the board stays unchanged.
|
||||||
|
await drag(pageB, 'g8', 'd5');
|
||||||
|
await expect(pageB.locator('[data-square="d5"]')).not.toContainText('black-knight');
|
||||||
|
// The knight should still be on g8.
|
||||||
|
await expect(pageB.locator('[data-square="g8"] [data-piece="black-knight"]')).toBeVisible();
|
||||||
|
|
||||||
|
await ctxA.close();
|
||||||
|
await ctxB.close();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,27 @@
|
||||||
/**
|
/**
|
||||||
* Integration tests proving that toggling presets on PRESET_REGISTRY
|
* Integration tests proving that mutating a ChessEngine's
|
||||||
* actually changes what ChessEngine.getAllLegalMoves() returns.
|
* `activePresets` set actually changes what `getAllLegalMoves()`
|
||||||
|
* returns — both for adding moves (getExtraMoves) and removing them
|
||||||
|
* (filterMoves).
|
||||||
*
|
*
|
||||||
* These tests cross the preset → engine boundary, so they live at the
|
* These tests cross the preset → engine boundary, so they live at the
|
||||||
* package root rather than under presets/.
|
* package root rather than under presets/.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { ChessEngine } from "./engine.js";
|
import { ChessEngine } from "./engine.js";
|
||||||
import { PRESET_REGISTRY } from "./presets/index.js";
|
import "./presets/index.js";
|
||||||
import { algebraicToSquare } from "./coord.js";
|
import { algebraicToSquare } from "./coord.js";
|
||||||
|
|
||||||
describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
describe("ActivePresetSet ↔ ChessEngine.getAllLegalMoves integration", () => {
|
||||||
beforeEach(() => PRESET_REGISTRY.clear());
|
|
||||||
afterEach(() => PRESET_REGISTRY.clear());
|
|
||||||
|
|
||||||
it("standard chess: no preset lets a pawn move backward (sanity baseline)", () => {
|
it("standard chess: no preset lets a pawn move backward (sanity baseline)", () => {
|
||||||
const engine = new ChessEngine();
|
const engine = new ChessEngine();
|
||||||
// Move e2 → e4 (standard two-square advance)
|
engine.applyMove(
|
||||||
const advance = engine.findMove(
|
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||||
algebraicToSquare("e2"),
|
|
||||||
algebraicToSquare("e4"),
|
|
||||||
);
|
);
|
||||||
expect(advance).not.toBeNull();
|
|
||||||
engine.applyMove(advance!);
|
|
||||||
|
|
||||||
// Black plays something random so it's white's turn again
|
|
||||||
engine.applyMove(
|
engine.applyMove(
|
||||||
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Now try to move e4 → e3 (backward). Standard rules forbid it.
|
|
||||||
const backward = engine.findMove(
|
const backward = engine.findMove(
|
||||||
algebraicToSquare("e4"),
|
algebraicToSquare("e4"),
|
||||||
algebraicToSquare("e3"),
|
algebraicToSquare("e3"),
|
||||||
|
|
@ -37,11 +29,12 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
||||||
expect(backward).toBeNull();
|
expect(backward).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pawns-move-backward preset: e4 pawn CAN move back to e3", () => {
|
it("pawns-move-backward (scope=both): e4 pawn CAN move back to e3", () => {
|
||||||
PRESET_REGISTRY.activate("pawns-move-backward");
|
|
||||||
const engine = new ChessEngine();
|
const engine = new ChessEngine();
|
||||||
|
engine.activePresets.replaceAll([
|
||||||
|
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
// Same setup: push e2 → e4, black plays e7 → e5
|
|
||||||
engine.applyMove(
|
engine.applyMove(
|
||||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||||
);
|
);
|
||||||
|
|
@ -49,65 +42,55 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
||||||
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||||
);
|
);
|
||||||
|
|
||||||
// NOW backward move should be legal with preset active.
|
|
||||||
const backward = engine.findMove(
|
const backward = engine.findMove(
|
||||||
algebraicToSquare("e4"),
|
algebraicToSquare("e4"),
|
||||||
algebraicToSquare("e3"),
|
algebraicToSquare("e3"),
|
||||||
);
|
);
|
||||||
expect(backward).not.toBeNull();
|
expect(backward).not.toBeNull();
|
||||||
expect(backward!.from).toBe(algebraicToSquare("e4"));
|
|
||||||
expect(backward!.to).toBe(algebraicToSquare("e3"));
|
|
||||||
expect(backward!.isCapture).toBe(false);
|
expect(backward!.isCapture).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deactivating a preset mid-session removes its extra moves immediately", () => {
|
it("deactivating a preset mid-session removes its extra moves immediately", () => {
|
||||||
PRESET_REGISTRY.activate("pawns-move-backward");
|
|
||||||
const engine = new ChessEngine();
|
const engine = new ChessEngine();
|
||||||
|
engine.activePresets.replaceAll([
|
||||||
|
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
engine.applyMove(
|
engine.applyMove(
|
||||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||||
);
|
);
|
||||||
engine.applyMove(
|
engine.applyMove(
|
||||||
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Preset active: backward legal
|
|
||||||
expect(
|
expect(
|
||||||
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
|
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
|
|
||||||
// Deactivate — engine should immediately reflect the change
|
engine.activePresets.replaceAll([]);
|
||||||
PRESET_REGISTRY.deactivate("pawns-move-backward");
|
|
||||||
expect(
|
expect(
|
||||||
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
|
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pawn-diagonal-no-capture preset ('Slanting Pawns'): adds diagonal quiet moves to empty squares", () => {
|
it("pawn-diagonal-no-capture (scope=both): adds diagonal quiet moves", () => {
|
||||||
const engine = new ChessEngine();
|
const engine = new ChessEngine();
|
||||||
|
expect(
|
||||||
|
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("f3")),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
// Before activation: a pawn on e2 cannot move diagonally to an empty
|
engine.activePresets.replaceAll([
|
||||||
// d3 or f3 square (those squares are empty from the starting position,
|
{ id: "pawn-diagonal-no-capture", scope: "both", turnsRemaining: null },
|
||||||
// and FIDE pawns only go diagonal when capturing).
|
]);
|
||||||
const diagonalEmptyBefore = engine.findMove(
|
const diag = engine.findMove(
|
||||||
algebraicToSquare("e2"),
|
algebraicToSquare("e2"),
|
||||||
algebraicToSquare("f3"),
|
algebraicToSquare("f3"),
|
||||||
);
|
);
|
||||||
expect(diagonalEmptyBefore).toBeNull();
|
expect(diag).not.toBeNull();
|
||||||
|
expect(diag!.isCapture).toBe(false);
|
||||||
// With preset active: e2 → f3 (empty, diagonal) becomes legal.
|
|
||||||
PRESET_REGISTRY.activate("pawn-diagonal-no-capture");
|
|
||||||
const diagonalEmptyAfter = engine.findMove(
|
|
||||||
algebraicToSquare("e2"),
|
|
||||||
algebraicToSquare("f3"),
|
|
||||||
);
|
|
||||||
expect(diagonalEmptyAfter).not.toBeNull();
|
|
||||||
expect(diagonalEmptyAfter!.isCapture).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mid-game toggle: activating a preset after moves already played affects the very next move calculation", () => {
|
it("mid-game toggle: activating a preset affects the very next move calc", () => {
|
||||||
const engine = new ChessEngine();
|
const engine = new ChessEngine();
|
||||||
|
|
||||||
// Play 3 moves of a normal game — no presets active.
|
|
||||||
engine.applyMove(
|
engine.applyMove(
|
||||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||||
);
|
);
|
||||||
|
|
@ -118,31 +101,21 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
||||||
engine.findMove(algebraicToSquare("d2"), algebraicToSquare("d4"))!,
|
engine.findMove(algebraicToSquare("d2"), algebraicToSquare("d4"))!,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Black to move. Under standard rules the black e5 pawn cannot
|
|
||||||
// retreat to e6.
|
|
||||||
expect(
|
expect(
|
||||||
engine.findMove(algebraicToSquare("e5"), algebraicToSquare("e6")),
|
engine.findMove(algebraicToSquare("e5"), algebraicToSquare("e6")),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
||||||
// Activate the preset NOW — no board reset, same engine instance.
|
engine.activePresets.replaceAll([
|
||||||
PRESET_REGISTRY.activate("pawns-move-backward");
|
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
// Same engine, same position — but the backward pawn move is now legal
|
|
||||||
// because `getAllLegalMoves` reads the registry fresh on every call.
|
|
||||||
const retreat = engine.findMove(
|
const retreat = engine.findMove(
|
||||||
algebraicToSquare("e5"),
|
algebraicToSquare("e5"),
|
||||||
algebraicToSquare("e6"),
|
algebraicToSquare("e6"),
|
||||||
);
|
);
|
||||||
expect(retreat).not.toBeNull();
|
expect(retreat).not.toBeNull();
|
||||||
|
|
||||||
// Black actually plays the retreat and the engine accepts it.
|
|
||||||
engine.applyMove(retreat!);
|
engine.applyMove(retreat!);
|
||||||
expect(engine.getCurrentTurn()).toBe("white");
|
engine.activePresets.replaceAll([]);
|
||||||
|
|
||||||
// Toggle off — next move calculation drops the extra move set.
|
|
||||||
PRESET_REGISTRY.deactivate("pawns-move-backward");
|
|
||||||
// After the retreat, black's pawn is now on e6. It's white's turn.
|
|
||||||
// Play a white move, then check black can no longer retreat e6→e7.
|
|
||||||
engine.applyMove(
|
engine.applyMove(
|
||||||
engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!,
|
engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!,
|
||||||
);
|
);
|
||||||
|
|
@ -152,11 +125,12 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("multiple presets compose: backward + diagonal-no-capture both apply", () => {
|
it("multiple presets compose: backward + diagonal-no-capture both apply", () => {
|
||||||
PRESET_REGISTRY.activate("pawns-move-backward");
|
|
||||||
PRESET_REGISTRY.activate("pawn-diagonal-no-capture");
|
|
||||||
const engine = new ChessEngine();
|
const engine = new ChessEngine();
|
||||||
|
engine.activePresets.replaceAll([
|
||||||
|
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "pawn-diagonal-no-capture", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
// Move a pawn up and back — both legal under this combo.
|
|
||||||
engine.applyMove(
|
engine.applyMove(
|
||||||
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||||
);
|
);
|
||||||
|
|
@ -164,10 +138,94 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
|
||||||
engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
|
engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
|
||||||
);
|
);
|
||||||
|
|
||||||
const backward = engine.findMove(
|
expect(
|
||||||
algebraicToSquare("e4"),
|
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
|
||||||
algebraicToSquare("e3"),
|
).not.toBeNull();
|
||||||
);
|
});
|
||||||
expect(backward).not.toBeNull();
|
|
||||||
|
describe("per-color scope", () => {
|
||||||
|
it("white-only preset does NOT apply to black's moves", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.activePresets.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "white", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// White to move — double-leap should be among the legal moves for
|
||||||
|
// the white g1 knight (g1 → e3 via f3 intermediate for instance).
|
||||||
|
const whiteMoves = engine.getAllLegalMoves();
|
||||||
|
const whiteKnightDoubleLeaps = whiteMoves.filter(
|
||||||
|
(m) => m.from === algebraicToSquare("g1"),
|
||||||
|
);
|
||||||
|
// Standard single leaps: f3, h3. Double-leap adds extras.
|
||||||
|
expect(whiteKnightDoubleLeaps.length).toBeGreaterThan(2);
|
||||||
|
|
||||||
|
// Apply a neutral white move so it's black's turn.
|
||||||
|
engine.applyMove(
|
||||||
|
engine.findMove(algebraicToSquare("a2"), algebraicToSquare("a3"))!,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now black to move — the preset is white-scope, so black's
|
||||||
|
// knights should only have the 2 standard single leaps.
|
||||||
|
const blackMoves = engine.getAllLegalMoves();
|
||||||
|
const blackKnightMoves = blackMoves.filter(
|
||||||
|
(m) => m.from === algebraicToSquare("g8"),
|
||||||
|
);
|
||||||
|
expect(blackKnightMoves).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("black-only preset does NOT apply to white's moves", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.activePresets.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "black", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const whiteKnightMoves = engine
|
||||||
|
.getAllLegalMoves()
|
||||||
|
.filter((m) => m.from === algebraicToSquare("g1"));
|
||||||
|
// Standard knight has only 2 legal leaps from g1.
|
||||||
|
expect(whiteKnightMoves).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("turn-limited duration", () => {
|
||||||
|
it("preset expires after its duration elapses", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.activePresets.replaceAll([
|
||||||
|
{ id: "pawns-move-backward", scope: "both", turnsRemaining: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
engine.applyMove(
|
||||||
|
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||||
|
);
|
||||||
|
// After white's move, scope=both ticks → 1 remaining.
|
||||||
|
expect(
|
||||||
|
engine.activePresets.list()[0]?.turnsRemaining,
|
||||||
|
).toBe(1);
|
||||||
|
|
||||||
|
engine.applyMove(
|
||||||
|
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
|
||||||
|
);
|
||||||
|
// After black's move, 0 → removed.
|
||||||
|
expect(engine.activePresets.list()).toHaveLength(0);
|
||||||
|
|
||||||
|
// Further backward move attempts should fail.
|
||||||
|
expect(
|
||||||
|
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scope=white duration ticks only on white's turns", () => {
|
||||||
|
const engine = new ChessEngine();
|
||||||
|
engine.activePresets.replaceAll([
|
||||||
|
{ id: "pawns-move-backward", scope: "white", turnsRemaining: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Black moves first? No — white always moves first. Play a white
|
||||||
|
// move — that ticks the white counter from 1 → expired.
|
||||||
|
engine.applyMove(
|
||||||
|
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
|
||||||
|
);
|
||||||
|
expect(engine.activePresets.list()).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,10 @@ import {
|
||||||
} from "./rules/draws.js";
|
} from "./rules/draws.js";
|
||||||
import { applyCapture } from "./rules/capture.js";
|
import { applyCapture } from "./rules/capture.js";
|
||||||
import type { LegalMove } from "./rules/types.js";
|
import type { LegalMove } from "./rules/types.js";
|
||||||
import { PRESET_REGISTRY } from "./presets/index.js";
|
import { ActivePresetSet } from "./presets/active-set.js";
|
||||||
|
// Importing from the barrel guarantees every preset module's
|
||||||
|
// side-effect registration has run before the first engine is created.
|
||||||
|
import "./presets/index.js";
|
||||||
|
|
||||||
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
||||||
|
|
||||||
|
|
@ -70,11 +73,24 @@ export type GameResult =
|
||||||
|
|
||||||
export class ChessEngine {
|
export class ChessEngine {
|
||||||
public readonly session: Session;
|
public readonly session: Session;
|
||||||
|
/**
|
||||||
|
* Per-engine preset activation. Every engine owns its own set so two
|
||||||
|
* concurrent games (most importantly: the authoritative server session
|
||||||
|
* and a client's predicted clone) can hold different rule sets without
|
||||||
|
* a shared-module dance.
|
||||||
|
*
|
||||||
|
* Mutable by design — UIs and servers replace its contents via
|
||||||
|
* `.replaceAll()`. The engine reads it fresh on every call to
|
||||||
|
* `getAllLegalMoves()` so mid-game toggles take effect on the next
|
||||||
|
* move calculation with no reset.
|
||||||
|
*/
|
||||||
|
public readonly activePresets: ActivePresetSet;
|
||||||
|
|
||||||
constructor() {
|
constructor(activePresets?: ActivePresetSet) {
|
||||||
this.session = new Session({ autoFire: false });
|
this.session = new Session({ autoFire: false });
|
||||||
generateStartingPosition(this.session);
|
generateStartingPosition(this.session);
|
||||||
recordPosition(this.session);
|
recordPosition(this.session);
|
||||||
|
this.activePresets = activePresets ?? new ActivePresetSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentTurn(): PieceColor {
|
getCurrentTurn(): PieceColor {
|
||||||
|
|
@ -130,11 +146,15 @@ export class ChessEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply active preset rules: add extra moves, then run filter hooks.
|
// Apply active preset rules for the color whose turn it is. The
|
||||||
|
// per-color filter implements `scope=white` / `scope=black` — a
|
||||||
|
// white-only preset never contributes moves while black is on move.
|
||||||
|
//
|
||||||
// Order matters — `getExtraMoves` contributes to the set that
|
// Order matters — `getExtraMoves` contributes to the set that
|
||||||
// `filterMoves` operates on, so every active preset sees the full
|
// `filterMoves` operates on, so every active preset sees the full
|
||||||
// aggregated set (including prior presets' additions).
|
// aggregated set (including prior presets' additions).
|
||||||
for (const preset of PRESET_REGISTRY.getActive()) {
|
const activePresets = this.activePresets.getForColor(color);
|
||||||
|
for (const preset of activePresets) {
|
||||||
if (preset.getExtraMoves) {
|
if (preset.getExtraMoves) {
|
||||||
pieceMoves = [
|
pieceMoves = [
|
||||||
...pieceMoves,
|
...pieceMoves,
|
||||||
|
|
@ -142,7 +162,7 @@ export class ChessEngine {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const preset of PRESET_REGISTRY.getActive()) {
|
for (const preset of activePresets) {
|
||||||
if (preset.filterMoves) {
|
if (preset.filterMoves) {
|
||||||
pieceMoves = preset.filterMoves(pieceMoves, this, piece.id);
|
pieceMoves = preset.filterMoves(pieceMoves, this, piece.id);
|
||||||
}
|
}
|
||||||
|
|
@ -219,6 +239,12 @@ export class ChessEngine {
|
||||||
// Record position for threefold repetition
|
// Record position for threefold repetition
|
||||||
recordPosition(this.session);
|
recordPosition(this.session);
|
||||||
|
|
||||||
|
// Tick preset durations with the color that JUST moved. Player-local
|
||||||
|
// turn counting: a `scope=white` preset with 3 turns remaining
|
||||||
|
// ticks only when white plays; a `scope=both` ticks on every
|
||||||
|
// half-move. Entries reaching 0 are removed.
|
||||||
|
this.activePresets.tickAfterMove(color);
|
||||||
|
|
||||||
return this.checkGameResult();
|
return this.checkGameResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { ChessEngine, type GameResult } from '../engine';
|
||||||
import type { PieceType } from '../schema';
|
import type { PieceType } from '../schema';
|
||||||
import { saveAutoSave } from '../persist/autosave.js';
|
import { saveAutoSave } from '../persist/autosave.js';
|
||||||
import * as audio from '../audio';
|
import * as audio from '../audio';
|
||||||
|
import type { PresetActivation } from '../net/types';
|
||||||
|
|
||||||
export function useChessEngine() {
|
export function useChessEngine() {
|
||||||
const engineRef = useRef<ChessEngine | null>(null);
|
const engineRef = useRef<ChessEngine | null>(null);
|
||||||
|
|
@ -97,6 +98,18 @@ export function useChessEngine() {
|
||||||
setTick(t => t + 1);
|
setTick(t => t + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the local engine's active preset set. Mirrors the
|
||||||
|
* multiplayer hook's shape so GameView / RulesDrawer can share a
|
||||||
|
* single setter. Unlike multiplayer, the write is synchronous and
|
||||||
|
* the local UI updates immediately (no server round-trip).
|
||||||
|
*/
|
||||||
|
const setPresets = useCallback((activations: PresetActivation[]) => {
|
||||||
|
engine.activePresets.replaceAll(activations);
|
||||||
|
saveAutoSave(engine.session.allFacts());
|
||||||
|
setTick(t => t + 1);
|
||||||
|
}, [engine]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
engine,
|
engine,
|
||||||
turn: getTurn(),
|
turn: getTurn(),
|
||||||
|
|
@ -109,5 +122,7 @@ export function useChessEngine() {
|
||||||
loadEngine,
|
loadEngine,
|
||||||
refresh,
|
refresh,
|
||||||
lastMove,
|
lastMove,
|
||||||
|
activations: engine.activePresets.list(),
|
||||||
|
setPresets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import type { PieceType } from '../schema';
|
||||||
import type { GameResult } from '../engine';
|
import type { GameResult } from '../engine';
|
||||||
import { GameClient } from '../net/client';
|
import { GameClient } from '../net/client';
|
||||||
import { PredictionManager } from '../net/prediction';
|
import { PredictionManager } from '../net/prediction';
|
||||||
import type { Color, PromotionPiece } from '../net/types';
|
import type { Color, PresetActivation, PromotionPiece } from '../net/types';
|
||||||
import * as audio from '../audio';
|
import * as audio from '../audio';
|
||||||
|
|
||||||
const WS_URL =
|
const WS_URL =
|
||||||
|
|
@ -215,6 +215,17 @@ export function useMultiplayerGame(code: string, token: string) {
|
||||||
setTick((t) => t + 1);
|
setTick((t) => t + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the room's preset set. The server validates and, on
|
||||||
|
* success, broadcasts `game.presets` which PredictionManager applies
|
||||||
|
* to the base engine and re-renders us via the tick bump. We do NOT
|
||||||
|
* flip local UI state here — the UI should mirror the server's echo,
|
||||||
|
* not the request, so that rejected requests leave the UI unchanged.
|
||||||
|
*/
|
||||||
|
const setPresets = useCallback((activations: PresetActivation[]) => {
|
||||||
|
clientRef.current?.sendSetPresets(activations);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadEngine = useCallback(() => {
|
const loadEngine = useCallback(() => {
|
||||||
// Also a no-op: authoritative state is server-driven. The UI should
|
// Also a no-op: authoritative state is server-driven. The UI should
|
||||||
// not have any need to swap the engine under multiplayer — all state
|
// not have any need to swap the engine under multiplayer — all state
|
||||||
|
|
@ -234,6 +245,12 @@ export function useMultiplayerGame(code: string, token: string) {
|
||||||
// because `engine.session.allFacts()` is called fresh each render.
|
// because `engine.session.allFacts()` is called fresh each render.
|
||||||
void tick;
|
void tick;
|
||||||
|
|
||||||
|
// Current authoritative preset activations. Read fresh each render
|
||||||
|
// so the UI reflects server echoes immediately after `setTick` fires.
|
||||||
|
const activations: PresetActivation[] = engine
|
||||||
|
? engine.activePresets.list()
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
engine,
|
engine,
|
||||||
turn,
|
turn,
|
||||||
|
|
@ -246,6 +263,8 @@ export function useMultiplayerGame(code: string, token: string) {
|
||||||
loadEngine,
|
loadEngine,
|
||||||
refresh,
|
refresh,
|
||||||
lastMove,
|
lastMove,
|
||||||
|
activations,
|
||||||
|
setPresets,
|
||||||
// Multiplayer-only metadata. GameView uses these to gate drag and
|
// Multiplayer-only metadata. GameView uses these to gate drag and
|
||||||
// render a "waiting for opponent" / error overlay.
|
// render a "waiting for opponent" / error overlay.
|
||||||
myColor: meta.myColor,
|
myColor: meta.myColor,
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,11 @@ export {
|
||||||
type ChessFact,
|
type ChessFact,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
export type { LegalMove } from "./rules/types.js";
|
export type { LegalMove } from "./rules/types.js";
|
||||||
|
export { PRESET_REGISTRY, type PresetDef } from "./presets/index.js";
|
||||||
|
export {
|
||||||
|
ActivePresetSet,
|
||||||
|
PresetActivationError,
|
||||||
|
type ActivationRequest,
|
||||||
|
type PresetActivation,
|
||||||
|
type PresetScope,
|
||||||
|
} from "./presets/active-set.js";
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,13 @@ import type {
|
||||||
GameDeltaPayload,
|
GameDeltaPayload,
|
||||||
GameEndPayload,
|
GameEndPayload,
|
||||||
GameMovePayload,
|
GameMovePayload,
|
||||||
|
GamePresetsPayload,
|
||||||
GameStatePayload,
|
GameStatePayload,
|
||||||
|
PresetActivation,
|
||||||
PromotionPiece,
|
PromotionPiece,
|
||||||
RoomCreatedPayload,
|
RoomCreatedPayload,
|
||||||
RoomJoinedPayload,
|
RoomJoinedPayload,
|
||||||
|
RoomSetPresetsPayload,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -30,6 +33,7 @@ export type GameClientEvent =
|
||||||
| { type: "game.state"; payload: GameStatePayload }
|
| { type: "game.state"; payload: GameStatePayload }
|
||||||
| { type: "game.delta"; payload: GameDeltaPayload }
|
| { type: "game.delta"; payload: GameDeltaPayload }
|
||||||
| { type: "game.end"; payload: GameEndPayload }
|
| { type: "game.end"; payload: GameEndPayload }
|
||||||
|
| { type: "game.presets"; payload: GamePresetsPayload }
|
||||||
| { type: "room.created"; payload: RoomCreatedPayload }
|
| { type: "room.created"; payload: RoomCreatedPayload }
|
||||||
| { type: "room.joined"; payload: RoomJoinedPayload }
|
| { type: "room.joined"; payload: RoomJoinedPayload }
|
||||||
| { type: "error"; payload: ErrorPayload }
|
| { type: "error"; payload: ErrorPayload }
|
||||||
|
|
@ -260,6 +264,17 @@ export class GameClient {
|
||||||
this.send({ type: "game.move", payload });
|
this.send({ type: "game.move", payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: send `room.setPresets` to replace the room's entire
|
||||||
|
* active preset set. The server validates and — on success —
|
||||||
|
* broadcasts `game.presets` to both players; on failure the caller
|
||||||
|
* gets a non-fatal `error` event with `INVALID_MESSAGE`.
|
||||||
|
*/
|
||||||
|
sendSetPresets(activations: PresetActivation[]): void {
|
||||||
|
const payload: RoomSetPresetsPayload = { activations };
|
||||||
|
this.send({ type: "room.setPresets", payload });
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Accessors (primarily for tests & reconnect logic)
|
// Accessors (primarily for tests & reconnect logic)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -414,6 +429,9 @@ export class GameClient {
|
||||||
case "game.end":
|
case "game.end":
|
||||||
this.emit({ type, payload: payload as GameEndPayload });
|
this.emit({ type, payload: payload as GameEndPayload });
|
||||||
return;
|
return;
|
||||||
|
case "game.presets":
|
||||||
|
this.emit({ type, payload: payload as GamePresetsPayload });
|
||||||
|
return;
|
||||||
case "room.created":
|
case "room.created":
|
||||||
this.emit({ type, payload: payload as RoomCreatedPayload });
|
this.emit({ type, payload: payload as RoomCreatedPayload });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ import type { GameClient } from "./client.js";
|
||||||
import type {
|
import type {
|
||||||
Fact,
|
Fact,
|
||||||
GameDeltaPayload,
|
GameDeltaPayload,
|
||||||
|
GamePresetsPayload,
|
||||||
GameStatePayload,
|
GameStatePayload,
|
||||||
|
PresetActivation,
|
||||||
PromotionPiece,
|
PromotionPiece,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
|
|
@ -112,6 +114,7 @@ export class PredictionManager {
|
||||||
private attachListeners(): void {
|
private attachListeners(): void {
|
||||||
this.client.on("game.state", (e) => this.applyFullState(e.payload));
|
this.client.on("game.state", (e) => this.applyFullState(e.payload));
|
||||||
this.client.on("game.delta", (e) => this.reconcile(e.payload));
|
this.client.on("game.delta", (e) => this.reconcile(e.payload));
|
||||||
|
this.client.on("game.presets", (e) => this.applyPresets(e.payload));
|
||||||
this.client.on("error", (e) => {
|
this.client.on("error", (e) => {
|
||||||
// Fatal errors tear down the session; the app restarts from a fresh
|
// Fatal errors tear down the session; the app restarts from a fresh
|
||||||
// `game.state`. Non-fatal errors (ILLEGAL_MOVE / NOT_YOUR_TURN / …)
|
// `game.state`. Non-fatal errors (ILLEGAL_MOVE / NOT_YOUR_TURN / …)
|
||||||
|
|
@ -121,12 +124,49 @@ export class PredictionManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync the authoritative preset set onto the base engine. Any current
|
||||||
|
* prediction is discarded: the preset change might invalidate
|
||||||
|
* previously-legal optimistic moves, and re-validating them here would
|
||||||
|
* duplicate server logic. Simpler to let the next user action
|
||||||
|
* re-predict against the freshly-synced base.
|
||||||
|
*/
|
||||||
|
private applyPresets(payload: GamePresetsPayload): void {
|
||||||
|
try {
|
||||||
|
this.baseEngine.activePresets.replaceAll(payload.activations);
|
||||||
|
} catch {
|
||||||
|
// A bad set from the server shouldn't crash the client; the server
|
||||||
|
// already validated, so this branch is defensive only. We clear
|
||||||
|
// instead of keeping stale rules.
|
||||||
|
this.baseEngine.activePresets.clear();
|
||||||
|
}
|
||||||
|
this.predictedEngine = null;
|
||||||
|
this.pendingMoves = [];
|
||||||
|
this.onStateChange(this.baseEngine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current authoritative preset activation list. Used by hooks to
|
||||||
|
* surface it to the UI. */
|
||||||
|
getPresetActivations(): readonly PresetActivation[] {
|
||||||
|
return this.baseEngine.activePresets.list();
|
||||||
|
}
|
||||||
|
|
||||||
private applyFullState(state: GameStatePayload): void {
|
private applyFullState(state: GameStatePayload): void {
|
||||||
// Full snapshot from the server (on join or after a large gap). Replace
|
// Full snapshot from the server (on join or after a large gap). Replace
|
||||||
// `baseEngine` entirely and drop any outstanding predictions — the
|
// `baseEngine` entirely and drop any outstanding predictions — the
|
||||||
// server view supersedes everything.
|
// server view supersedes everything.
|
||||||
const next = freshEngine();
|
const next = freshEngine();
|
||||||
loadFacts(next, state.facts);
|
loadFacts(next, state.facts);
|
||||||
|
// Also apply the authoritative preset set. `activations` is a newer
|
||||||
|
// field; tolerate older servers that don't include it by defaulting
|
||||||
|
// to an empty set.
|
||||||
|
if (Array.isArray(state.activations)) {
|
||||||
|
try {
|
||||||
|
next.activePresets.replaceAll(state.activations);
|
||||||
|
} catch {
|
||||||
|
next.activePresets.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
this.baseEngine = next;
|
this.baseEngine = next;
|
||||||
this.predictedEngine = null;
|
this.predictedEngine = null;
|
||||||
this.pendingMoves = [];
|
this.pendingMoves = [];
|
||||||
|
|
@ -179,6 +219,11 @@ function cloneEngine(src: ChessEngine): ChessEngine {
|
||||||
attr: f.attr as string,
|
attr: f.attr as string,
|
||||||
value: f.value,
|
value: f.value,
|
||||||
})));
|
})));
|
||||||
|
// Preset state must be copied so the prediction's tickAfterMove on
|
||||||
|
// applyMove doesn't mutate the base's duration counters. `replaceAll`
|
||||||
|
// with the base's current list is the public way to do this; since
|
||||||
|
// the base's list already passed validation, the call can't throw.
|
||||||
|
next.activePresets.replaceAll(src.activePresets.list());
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,15 +39,34 @@ export interface Fact {
|
||||||
// Server → Client payloads
|
// Server → Client payloads
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type PresetScope = "both" | "white" | "black";
|
||||||
|
|
||||||
|
export interface PresetActivation {
|
||||||
|
id: string;
|
||||||
|
scope: PresetScope;
|
||||||
|
turnsRemaining: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameStatePayload {
|
export interface GameStatePayload {
|
||||||
facts: Fact[];
|
facts: Fact[];
|
||||||
turn: Color;
|
turn: Color;
|
||||||
lastSeq: number;
|
lastSeq: number;
|
||||||
moveHistory: string[];
|
moveHistory: string[];
|
||||||
activeRules: string[];
|
activeRules: string[];
|
||||||
|
/** Optional for wire compat with older servers. Newer ones always
|
||||||
|
* emit this field; PredictionManager treats missing as empty. */
|
||||||
|
activations?: PresetActivation[];
|
||||||
fen: string;
|
fen: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GamePresetsPayload {
|
||||||
|
activations: PresetActivation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomSetPresetsPayload {
|
||||||
|
activations: PresetActivation[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameOver {
|
export interface GameOver {
|
||||||
winner: Winner;
|
winner: Winner;
|
||||||
reason: GameEndReason;
|
reason: GameEndReason;
|
||||||
|
|
@ -121,6 +140,7 @@ export type ServerMessage =
|
||||||
| MessageEnvelope<"game.state", GameStatePayload>
|
| MessageEnvelope<"game.state", GameStatePayload>
|
||||||
| MessageEnvelope<"game.delta", GameDeltaPayload>
|
| MessageEnvelope<"game.delta", GameDeltaPayload>
|
||||||
| MessageEnvelope<"game.end", GameEndPayload>
|
| MessageEnvelope<"game.end", GameEndPayload>
|
||||||
|
| MessageEnvelope<"game.presets", GamePresetsPayload>
|
||||||
| MessageEnvelope<"room.created", RoomCreatedPayload>
|
| MessageEnvelope<"room.created", RoomCreatedPayload>
|
||||||
| MessageEnvelope<"room.joined", RoomJoinedPayload>
|
| MessageEnvelope<"room.joined", RoomJoinedPayload>
|
||||||
| MessageEnvelope<"error", ErrorPayload>;
|
| MessageEnvelope<"error", ErrorPayload>;
|
||||||
|
|
@ -129,4 +149,5 @@ export type ClientMessage =
|
||||||
| MessageEnvelope<"room.create", RoomCreatePayload>
|
| MessageEnvelope<"room.create", RoomCreatePayload>
|
||||||
| MessageEnvelope<"room.join", RoomJoinPayload>
|
| MessageEnvelope<"room.join", RoomJoinPayload>
|
||||||
| MessageEnvelope<"room.leave", Record<string, never>>
|
| MessageEnvelope<"room.leave", Record<string, never>>
|
||||||
| MessageEnvelope<"game.move", GameMovePayload>;
|
| MessageEnvelope<"game.move", GameMovePayload>
|
||||||
|
| MessageEnvelope<"room.setPresets", RoomSetPresetsPayload>;
|
||||||
|
|
|
||||||
189
packages/chess/src/presets/active-set.test.ts
Normal file
189
packages/chess/src/presets/active-set.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import "./index.js";
|
||||||
|
import {
|
||||||
|
ActivePresetSet,
|
||||||
|
PresetActivationError,
|
||||||
|
} from "./active-set.js";
|
||||||
|
|
||||||
|
describe("ActivePresetSet", () => {
|
||||||
|
let set: ActivePresetSet;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
set = new ActivePresetSet();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replaceAll — single-entry validation", () => {
|
||||||
|
it("rejects unknown preset ids", () => {
|
||||||
|
expect(() =>
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "does-not-exist", scope: "both", turnsRemaining: null },
|
||||||
|
]),
|
||||||
|
).toThrow(PresetActivationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid scope strings", () => {
|
||||||
|
// TS would normally catch this, but wire traffic can pass anything.
|
||||||
|
expect(() =>
|
||||||
|
set.replaceAll([
|
||||||
|
{
|
||||||
|
id: "knights-leap-twice",
|
||||||
|
// @ts-expect-error — deliberately invalid for runtime test
|
||||||
|
scope: "sideways",
|
||||||
|
turnsRemaining: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toThrow(/invalid scope/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects zero / negative / non-integer turnsRemaining", () => {
|
||||||
|
for (const bad of [0, -1, 1.5, Number.NaN]) {
|
||||||
|
expect(() =>
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "both", turnsRemaining: bad },
|
||||||
|
]),
|
||||||
|
).toThrow(/turnsRemaining/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts null turnsRemaining as permanent", () => {
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
expect(set.has("knights-leap-twice")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts positive integer turnsRemaining", () => {
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "white", turnsRemaining: 3 },
|
||||||
|
]);
|
||||||
|
expect(set.list()[0]?.turnsRemaining).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replaceAll — loose compatibility", () => {
|
||||||
|
it("blocks incompatible presets when scopes overlap (both+any)", () => {
|
||||||
|
// pawns-move-backward is incompatibleWith double-pawn-sprint
|
||||||
|
expect(() =>
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "double-pawn-sprint", scope: "white", turnsRemaining: null },
|
||||||
|
]),
|
||||||
|
).toThrow(/incompatible/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows incompatible presets when scopes are disjoint", () => {
|
||||||
|
// White-only pawns-move-backward + black-only double-pawn-sprint —
|
||||||
|
// per the loose rule these never overlap, so it's allowed.
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "pawns-move-backward", scope: "white", turnsRemaining: null },
|
||||||
|
{ id: "double-pawn-sprint", scope: "black", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
expect(set.has("pawns-move-backward")).toBe(true);
|
||||||
|
expect(set.has("double-pawn-sprint")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks when both are the same explicit color", () => {
|
||||||
|
expect(() =>
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "pawns-move-backward", scope: "white", turnsRemaining: null },
|
||||||
|
{ id: "double-pawn-sprint", scope: "white", turnsRemaining: null },
|
||||||
|
]),
|
||||||
|
).toThrow(/incompatible/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replaceAll — atomicity", () => {
|
||||||
|
it("leaves existing set untouched if validation fails", () => {
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
expect(() =>
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "does-not-exist", scope: "both", turnsRemaining: null },
|
||||||
|
]),
|
||||||
|
).toThrow(PresetActivationError);
|
||||||
|
// The original single-entry set must still be intact.
|
||||||
|
expect(set.list()).toHaveLength(1);
|
||||||
|
expect(set.has("knights-leap-twice")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getForColor", () => {
|
||||||
|
it("returns `both`-scoped presets for either color", () => {
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
expect(set.getForColor("white").map((p) => p.id)).toEqual([
|
||||||
|
"knights-leap-twice",
|
||||||
|
]);
|
||||||
|
expect(set.getForColor("black").map((p) => p.id)).toEqual([
|
||||||
|
"knights-leap-twice",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters color-scoped presets", () => {
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "white", turnsRemaining: null },
|
||||||
|
{ id: "bishops-ignore-color", scope: "black", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
expect(set.getForColor("white").map((p) => p.id)).toEqual([
|
||||||
|
"knights-leap-twice",
|
||||||
|
]);
|
||||||
|
expect(set.getForColor("black").map((p) => p.id)).toEqual([
|
||||||
|
"bishops-ignore-color",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tickAfterMove — player-local turn counting", () => {
|
||||||
|
it("ticks `both`-scoped presets on every half-move", () => {
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "both", turnsRemaining: 2 },
|
||||||
|
]);
|
||||||
|
set.tickAfterMove("white");
|
||||||
|
expect(set.list()[0]?.turnsRemaining).toBe(1);
|
||||||
|
set.tickAfterMove("black");
|
||||||
|
// Second tick on `both` scope expires it.
|
||||||
|
expect(set.list()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ticks white-scoped presets only after white moves", () => {
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "white", turnsRemaining: 2 },
|
||||||
|
]);
|
||||||
|
set.tickAfterMove("black");
|
||||||
|
// Not our color; duration unchanged.
|
||||||
|
expect(set.list()[0]?.turnsRemaining).toBe(2);
|
||||||
|
set.tickAfterMove("white");
|
||||||
|
expect(set.list()[0]?.turnsRemaining).toBe(1);
|
||||||
|
set.tickAfterMove("black");
|
||||||
|
expect(set.list()[0]?.turnsRemaining).toBe(1);
|
||||||
|
set.tickAfterMove("white");
|
||||||
|
expect(set.list()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never ticks permanent (turnsRemaining=null) entries", () => {
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
set.tickAfterMove(i % 2 === 0 ? "white" : "black");
|
||||||
|
}
|
||||||
|
expect(set.has("knights-leap-twice")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clone", () => {
|
||||||
|
it("returns an independent deep copy", () => {
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "knights-leap-twice", scope: "white", turnsRemaining: 5 },
|
||||||
|
]);
|
||||||
|
const copy = set.clone();
|
||||||
|
copy.tickAfterMove("white");
|
||||||
|
// Original unaffected by copy's tick.
|
||||||
|
expect(set.list()[0]?.turnsRemaining).toBe(5);
|
||||||
|
expect(copy.list()[0]?.turnsRemaining).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
233
packages/chess/src/presets/active-set.ts
Normal file
233
packages/chess/src/presets/active-set.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
/**
|
||||||
|
* Per-game active preset tracking.
|
||||||
|
*
|
||||||
|
* Earlier iterations of the preset system used a module-level
|
||||||
|
* `PRESET_REGISTRY.active: Set<string>` which was shared by every
|
||||||
|
* ChessEngine in the process — fine for the browser, but wrong for the
|
||||||
|
* server where a single process hosts multiple concurrent rooms with
|
||||||
|
* different rule sets. It also couldn't express per-color scope or
|
||||||
|
* turn-limited durations.
|
||||||
|
*
|
||||||
|
* `ActivePresetSet` replaces that shared Set with an INSTANCE held by
|
||||||
|
* each `GameSession` / `ChessEngine`. It tracks:
|
||||||
|
*
|
||||||
|
* - which presets are active,
|
||||||
|
* - for which color they apply (`both` | `white` | `black`),
|
||||||
|
* - how many of that color's upcoming turns they remain active
|
||||||
|
* (`null` = permanent).
|
||||||
|
*
|
||||||
|
* The per-color-turn counting policy matches the user's confirmed
|
||||||
|
* design: for a `white`-scoped preset, one "tick" happens after each
|
||||||
|
* white move (not each half-move). For `both`-scoped presets we tick
|
||||||
|
* on every half-move. This is what the plan calls "player-local turns".
|
||||||
|
*
|
||||||
|
* Compatibility uses the LOOSE rule: two presets that list each other
|
||||||
|
* in `incompatibleWith` may still coexist if their SCOPES don't
|
||||||
|
* overlap — e.g. `A scope=white` + `B scope=black` is allowed even
|
||||||
|
* when A and B are nominally incompatible, because the engine only
|
||||||
|
* ever evaluates one side's rules at a time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PresetDef } from "./registry.js";
|
||||||
|
import { PRESET_REGISTRY } from "./registry.js";
|
||||||
|
|
||||||
|
export type PresetScope = "both" | "white" | "black";
|
||||||
|
|
||||||
|
export interface PresetActivation {
|
||||||
|
readonly id: string;
|
||||||
|
readonly scope: PresetScope;
|
||||||
|
/** Number of player-local turns remaining. `null` = permanent. */
|
||||||
|
readonly turnsRemaining: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input-shape when the UI / wire protocol asks to (re)activate a preset. */
|
||||||
|
export interface ActivationRequest {
|
||||||
|
readonly id: string;
|
||||||
|
readonly scope: PresetScope;
|
||||||
|
/** Player-local turns the preset should last. `null` = permanent. */
|
||||||
|
readonly turnsRemaining: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wire-serializable snapshot — identical shape today; kept as its own
|
||||||
|
* name so downstream protocol types can alias it. */
|
||||||
|
export type ActivationEntry = PresetActivation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true iff `a`'s scope and `b`'s scope intersect — i.e. there is
|
||||||
|
* at least one color where BOTH presets are active and would be applied
|
||||||
|
* to the same side's moves in the same turn.
|
||||||
|
*/
|
||||||
|
function scopesOverlap(a: PresetScope, b: PresetScope): boolean {
|
||||||
|
if (a === "both" || b === "both") return true;
|
||||||
|
return a === b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracted for testing and for the server to reject bad requests with a
|
||||||
|
* specific reason. Thrown from `ActivePresetSet.activate`.
|
||||||
|
*/
|
||||||
|
export class PresetActivationError extends Error {
|
||||||
|
readonly code:
|
||||||
|
| "UNKNOWN_PRESET"
|
||||||
|
| "INCOMPATIBLE"
|
||||||
|
| "MISSING_REQUIREMENT"
|
||||||
|
| "INVALID_SCOPE"
|
||||||
|
| "INVALID_DURATION";
|
||||||
|
constructor(code: PresetActivationError["code"], message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "PresetActivationError";
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActivePresetSet {
|
||||||
|
private readonly entries = new Map<string, PresetActivation>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the entire active set with `requests`. Used by the server's
|
||||||
|
* authoritative path and by client sync on `game.presets`. Validates
|
||||||
|
* each entry individually AND the whole set together (so cross-preset
|
||||||
|
* incompatibilities are caught before any mutation).
|
||||||
|
*
|
||||||
|
* On failure the existing set is untouched — errors abort the entire
|
||||||
|
* replacement so the caller never observes a half-applied state.
|
||||||
|
*/
|
||||||
|
replaceAll(requests: readonly ActivationRequest[]): void {
|
||||||
|
// Validate each request in isolation first (preset exists, scope
|
||||||
|
// legal, duration legal).
|
||||||
|
for (const r of requests) {
|
||||||
|
this.validateSingle(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then pairwise: `incompatibleWith` blocks only when scopes overlap;
|
||||||
|
// `requires` must hold with overlapping scope.
|
||||||
|
for (const r of requests) {
|
||||||
|
const def = PRESET_REGISTRY.get(r.id);
|
||||||
|
if (!def) continue; // validateSingle already threw if missing
|
||||||
|
|
||||||
|
for (const other of requests) {
|
||||||
|
if (other.id === r.id) continue;
|
||||||
|
if (def.incompatibleWith.includes(other.id) && scopesOverlap(r.scope, other.scope)) {
|
||||||
|
throw new PresetActivationError(
|
||||||
|
"INCOMPATIBLE",
|
||||||
|
`Preset "${r.id}" (scope=${r.scope}) is incompatible with "${other.id}" (scope=${other.scope}) under overlapping scope.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dep of def.requires) {
|
||||||
|
const met = requests.some(
|
||||||
|
(rq) => rq.id === dep && scopesOverlap(rq.scope, r.scope),
|
||||||
|
);
|
||||||
|
if (!met) {
|
||||||
|
throw new PresetActivationError(
|
||||||
|
"MISSING_REQUIREMENT",
|
||||||
|
`Preset "${r.id}" requires "${dep}" to also be active under an overlapping scope.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All valid — swap atomically.
|
||||||
|
this.entries.clear();
|
||||||
|
for (const r of requests) {
|
||||||
|
this.entries.set(r.id, {
|
||||||
|
id: r.id,
|
||||||
|
scope: r.scope,
|
||||||
|
turnsRemaining: r.turnsRemaining,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateSingle(r: ActivationRequest): void {
|
||||||
|
const def = PRESET_REGISTRY.get(r.id);
|
||||||
|
if (!def) {
|
||||||
|
throw new PresetActivationError(
|
||||||
|
"UNKNOWN_PRESET",
|
||||||
|
`Preset "${r.id}" is not registered.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (r.scope !== "both" && r.scope !== "white" && r.scope !== "black") {
|
||||||
|
throw new PresetActivationError(
|
||||||
|
"INVALID_SCOPE",
|
||||||
|
`Preset "${r.id}" has invalid scope "${String(r.scope)}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (r.turnsRemaining !== null) {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(r.turnsRemaining) ||
|
||||||
|
r.turnsRemaining <= 0
|
||||||
|
) {
|
||||||
|
throw new PresetActivationError(
|
||||||
|
"INVALID_DURATION",
|
||||||
|
`Preset "${r.id}" has invalid turnsRemaining "${String(r.turnsRemaining)}" (must be a positive integer or null).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of preset definitions that apply to moves made by
|
||||||
|
* the given color right now. Presets with `scope=both` are included for
|
||||||
|
* both colors; `scope=white` only for white; `scope=black` only for
|
||||||
|
* black. This is what ChessEngine.getAllLegalMoves calls per piece.
|
||||||
|
*/
|
||||||
|
getForColor(color: "white" | "black"): PresetDef[] {
|
||||||
|
const out: PresetDef[] = [];
|
||||||
|
for (const entry of this.entries.values()) {
|
||||||
|
if (entry.scope !== "both" && entry.scope !== color) continue;
|
||||||
|
const def = PRESET_REGISTRY.get(entry.id);
|
||||||
|
if (def) out.push(def);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by ChessEngine.applyMove AFTER the move is applied, with the
|
||||||
|
* color that just moved (NOT the new side to move). Decrements every
|
||||||
|
* entry whose scope applies to `moverColor` and removes entries whose
|
||||||
|
* `turnsRemaining` reaches 0.
|
||||||
|
*
|
||||||
|
* This implements the "player-local turns" policy: a `scope=white`
|
||||||
|
* preset ticks only after white moves; `scope=both` ticks on every
|
||||||
|
* half-move.
|
||||||
|
*/
|
||||||
|
tickAfterMove(moverColor: "white" | "black"): void {
|
||||||
|
const toRemove: string[] = [];
|
||||||
|
for (const entry of this.entries.values()) {
|
||||||
|
if (entry.scope !== "both" && entry.scope !== moverColor) continue;
|
||||||
|
if (entry.turnsRemaining === null) continue;
|
||||||
|
const next = entry.turnsRemaining - 1;
|
||||||
|
if (next <= 0) {
|
||||||
|
toRemove.push(entry.id);
|
||||||
|
} else {
|
||||||
|
this.entries.set(entry.id, { ...entry, turnsRemaining: next });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of toRemove) this.entries.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All active entries, in registration order. Used by UI + wire sync. */
|
||||||
|
list(): PresetActivation[] {
|
||||||
|
return [...this.entries.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fast "is X active under any scope?" query — mainly for tests. */
|
||||||
|
has(id: string): boolean {
|
||||||
|
return this.entries.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove everything — used on new-game / engine swap. */
|
||||||
|
clear(): void {
|
||||||
|
this.entries.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep copy — used by PredictionManager when cloning engines. */
|
||||||
|
clone(): ActivePresetSet {
|
||||||
|
const next = new ActivePresetSet();
|
||||||
|
for (const entry of this.entries.values()) {
|
||||||
|
next.entries.set(entry.id, { ...entry });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,17 +2,14 @@
|
||||||
* Tests for preset rule 1 (pawns-move-backward) and registry invariants
|
* Tests for preset rule 1 (pawns-move-backward) and registry invariants
|
||||||
* required by P3.4.
|
* required by P3.4.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import type { EntityId } from "@paratype/rete";
|
import type { EntityId } from "@paratype/rete";
|
||||||
import { ChessEngine } from "../engine.js";
|
import { ChessEngine } from "../engine.js";
|
||||||
import { PRESET_REGISTRY } from "./index.js";
|
import { PRESET_REGISTRY } from "./index.js";
|
||||||
|
import { ActivePresetSet } from "./active-set.js";
|
||||||
import type { Square } from "../schema.js";
|
import type { Square } from "../schema.js";
|
||||||
|
|
||||||
describe("Preset registry (P3.4)", () => {
|
describe("Preset registry (catalog)", () => {
|
||||||
beforeEach(() => {
|
|
||||||
PRESET_REGISTRY.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pawns-move-backward preset is registered", () => {
|
it("pawns-move-backward preset is registered", () => {
|
||||||
const all = PRESET_REGISTRY.getAll();
|
const all = PRESET_REGISTRY.getAll();
|
||||||
expect(all.some((p) => p.id === "pawns-move-backward")).toBe(true);
|
expect(all.some((p) => p.id === "pawns-move-backward")).toBe(true);
|
||||||
|
|
@ -26,36 +23,27 @@ describe("Preset registry (P3.4)", () => {
|
||||||
expect(PRESET_REGISTRY.getAll().length).toBeGreaterThanOrEqual(3);
|
expect(PRESET_REGISTRY.getAll().length).toBeGreaterThanOrEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("activate / isActive / deactivate round-trip", () => {
|
it("activating an unknown preset throws via ActivePresetSet", () => {
|
||||||
expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(false);
|
const set = new ActivePresetSet();
|
||||||
PRESET_REGISTRY.activate("pawns-move-backward");
|
expect(() =>
|
||||||
expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(true);
|
set.replaceAll([
|
||||||
expect(PRESET_REGISTRY.getActive().map((p) => p.id)).toContain(
|
{ id: "nonexistent-rule", scope: "both", turnsRemaining: null },
|
||||||
"pawns-move-backward",
|
]),
|
||||||
);
|
).toThrow(/not registered/);
|
||||||
PRESET_REGISTRY.deactivate("pawns-move-backward");
|
|
||||||
expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("activating an unknown preset throws", () => {
|
it("cannot activate two mutually-incompatible presets under overlapping scope", () => {
|
||||||
expect(() => PRESET_REGISTRY.activate("nonexistent-rule")).toThrow(
|
const set = new ActivePresetSet();
|
||||||
/Unknown preset/,
|
expect(() =>
|
||||||
);
|
set.replaceAll([
|
||||||
});
|
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "double-pawn-sprint", scope: "both", turnsRemaining: null },
|
||||||
it("cannot activate two mutually-incompatible presets", () => {
|
]),
|
||||||
PRESET_REGISTRY.activate("pawns-move-backward");
|
).toThrow(/incompatible/);
|
||||||
expect(() => PRESET_REGISTRY.activate("double-pawn-sprint")).toThrow(
|
|
||||||
/incompatible/,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Preset: pawns-move-backward", () => {
|
describe("Preset: pawns-move-backward", () => {
|
||||||
beforeEach(() => {
|
|
||||||
PRESET_REGISTRY.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
const preset = () =>
|
const preset = () =>
|
||||||
PRESET_REGISTRY.getAll().find((p) => p.id === "pawns-move-backward")!;
|
PRESET_REGISTRY.getAll().find((p) => p.id === "pawns-move-backward")!;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@
|
||||||
* Verifies: all 15 presets registered, incompatibilities correct,
|
* Verifies: all 15 presets registered, incompatibilities correct,
|
||||||
* functional hooks work for presets that have them.
|
* functional hooks work for presets that have them.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { PRESET_REGISTRY } from "./index.js";
|
import { PRESET_REGISTRY } from "./index.js";
|
||||||
|
import { ActivePresetSet } from "./active-set.js";
|
||||||
import { Session } from "@paratype/rete";
|
import { Session } from "@paratype/rete";
|
||||||
import type { EntityId } from "@paratype/rete";
|
import type { EntityId } from "@paratype/rete";
|
||||||
import type { ChessEngine } from "../engine.js";
|
import type { ChessEngine } from "../engine.js";
|
||||||
|
|
@ -95,8 +96,6 @@ describe("Requires declarations", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Functional hook: pawns-move-backward", () => {
|
describe("Functional hook: pawns-move-backward", () => {
|
||||||
beforeEach(() => { PRESET_REGISTRY.clear(); });
|
|
||||||
|
|
||||||
it("adds backward move for pawn on rank ≥2", () => {
|
it("adds backward move for pawn on rank ≥2", () => {
|
||||||
const preset = PRESET_REGISTRY.getAll().find(p => p.id === "pawns-move-backward")!;
|
const preset = PRESET_REGISTRY.getAll().find(p => p.id === "pawns-move-backward")!;
|
||||||
const engine = makeEngine([{ id: 1, type: "pawn", color: "white", sq: 20 }]); // e3
|
const engine = makeEngine([{ id: 1, type: "pawn", color: "white", sq: 20 }]); // e3
|
||||||
|
|
@ -152,15 +151,32 @@ describe("Functional hook: wrap-board", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PRESET_REGISTRY.activate with incompatibility check", () => {
|
describe("ActivePresetSet incompatibility + requires", () => {
|
||||||
beforeEach(() => { PRESET_REGISTRY.clear(); });
|
it("activating incompatible pair (overlapping scope) throws", () => {
|
||||||
|
const set = new ActivePresetSet();
|
||||||
it("activating incompatible pair throws", () => {
|
expect(() =>
|
||||||
PRESET_REGISTRY.activate("rook-warp");
|
set.replaceAll([
|
||||||
expect(() => PRESET_REGISTRY.activate("wrap-board")).toThrow();
|
{ id: "rook-warp", scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "wrap-board", scope: "both", turnsRemaining: null },
|
||||||
|
]),
|
||||||
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("activating without dependency throws", () => {
|
it("activating without required dependency throws", () => {
|
||||||
expect(() => PRESET_REGISTRY.activate("king-heals")).toThrow(/requires/);
|
const set = new ActivePresetSet();
|
||||||
|
expect(() =>
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "king-heals", scope: "both", turnsRemaining: null },
|
||||||
|
]),
|
||||||
|
).toThrow(/requires/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activating with satisfied requirement succeeds", () => {
|
||||||
|
const set = new ActivePresetSet();
|
||||||
|
set.replaceAll([
|
||||||
|
{ id: "piece-hp", scope: "both", turnsRemaining: null },
|
||||||
|
{ id: "king-heals", scope: "both", turnsRemaining: null },
|
||||||
|
]);
|
||||||
|
expect(set.has("king-heals")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,54 +31,29 @@ export interface PresetDef {
|
||||||
) => LegalMove[];
|
) => LegalMove[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure catalog of preset DEFINITIONS. Does NOT track activation any more
|
||||||
|
* — activation is per-engine via `ChessEngine.activePresets: ActivePresetSet`
|
||||||
|
* so concurrent games in the same process (e.g. server rooms) can hold
|
||||||
|
* different rule sets without sharing module-level state.
|
||||||
|
*/
|
||||||
class PresetRegistryClass {
|
class PresetRegistryClass {
|
||||||
private readonly presets = new Map<string, PresetDef>();
|
private readonly presets = new Map<string, PresetDef>();
|
||||||
private readonly active = new Set<string>();
|
|
||||||
|
|
||||||
register(preset: PresetDef): void {
|
register(preset: PresetDef): void {
|
||||||
this.presets.set(preset.id, preset);
|
this.presets.set(preset.id, preset);
|
||||||
}
|
}
|
||||||
|
|
||||||
activate(id: string): void {
|
|
||||||
const preset = this.presets.get(id);
|
|
||||||
if (!preset) throw new Error(`Unknown preset: ${id}`);
|
|
||||||
for (const other of this.active) {
|
|
||||||
const otherDef = this.presets.get(other);
|
|
||||||
if (otherDef?.incompatibleWith.includes(id)) {
|
|
||||||
throw new Error(`Preset ${id} is incompatible with already-active ${other}`);
|
|
||||||
}
|
|
||||||
if (preset.incompatibleWith.includes(other)) {
|
|
||||||
throw new Error(`Preset ${id} is incompatible with already-active ${other}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const dep of preset.requires) {
|
|
||||||
if (!this.active.has(dep)) {
|
|
||||||
throw new Error(`Preset ${id} requires ${dep} to be active first`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.active.add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
deactivate(id: string): void {
|
|
||||||
this.active.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
isActive(id: string): boolean {
|
|
||||||
return this.active.has(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
getActive(): PresetDef[] {
|
|
||||||
return [...this.active]
|
|
||||||
.map((id) => this.presets.get(id))
|
|
||||||
.filter((p): p is PresetDef => p !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(): PresetDef[] {
|
getAll(): PresetDef[] {
|
||||||
return [...this.presets.values()];
|
return [...this.presets.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
/**
|
||||||
this.active.clear();
|
* Look up a preset definition by id. Used by ActivePresetSet to resolve
|
||||||
|
* entries into callable hooks. Returns undefined for unknown ids.
|
||||||
|
*/
|
||||||
|
get(id: string): PresetDef | undefined {
|
||||||
|
return this.presets.get(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { RulesDrawer } from './RulesDrawer';
|
||||||
import { useChessEngine } from '../hooks/useChessEngine';
|
import { useChessEngine } from '../hooks/useChessEngine';
|
||||||
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
||||||
import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
|
import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
|
||||||
import type { Color } from '../net/types';
|
import type { Color, PresetActivation } from '../net/types';
|
||||||
import type { GameResult } from '../engine';
|
import type { GameResult } from '../engine';
|
||||||
import type { LegalMove } from '../rules/types';
|
import type { LegalMove } from '../rules/types';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
@ -28,6 +28,8 @@ interface GameEngineState {
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
lastMove: { from: number; to: number; [key: string]: unknown } | null | undefined;
|
lastMove: { from: number; to: number; [key: string]: unknown } | null | undefined;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
|
activations: PresetActivation[];
|
||||||
|
setPresets: (activations: PresetActivation[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GameViewProps {
|
interface GameViewProps {
|
||||||
|
|
@ -106,7 +108,19 @@ function GameLayout({
|
||||||
state: GameEngineState;
|
state: GameEngineState;
|
||||||
myColor: Color | null;
|
myColor: Color | null;
|
||||||
}) {
|
}) {
|
||||||
const { facts, legalMoves, turn, result, applyMove, undo, canUndo, lastMove, refresh } = state;
|
const {
|
||||||
|
facts,
|
||||||
|
legalMoves,
|
||||||
|
turn,
|
||||||
|
result,
|
||||||
|
applyMove,
|
||||||
|
undo,
|
||||||
|
canUndo,
|
||||||
|
lastMove,
|
||||||
|
refresh,
|
||||||
|
activations,
|
||||||
|
setPresets,
|
||||||
|
} = state;
|
||||||
|
|
||||||
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
|
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
|
||||||
applyMove(from, to, promoteTo || 'queen');
|
applyMove(from, to, promoteTo || 'queen');
|
||||||
|
|
@ -171,7 +185,11 @@ function GameLayout({
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="flex flex-col items-center gap-8 py-8 w-full max-w-4xl mx-auto"
|
className="flex flex-col items-center gap-8 py-8 w-full max-w-4xl mx-auto"
|
||||||
>
|
>
|
||||||
<RulesDrawer onRulesChanged={refresh} />
|
<RulesDrawer
|
||||||
|
activations={activations}
|
||||||
|
setPresets={setPresets}
|
||||||
|
onRulesChanged={refresh}
|
||||||
|
/>
|
||||||
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
|
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
|
||||||
|
|
||||||
{/* Header/Info section */}
|
{/* Header/Info section */}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useMemo, useState, type ChangeEvent } from 'react';
|
||||||
import { PRESET_REGISTRY } from '../presets/index.js';
|
import { PRESET_REGISTRY } from '../presets/index.js';
|
||||||
|
import type { PresetScope } from '../presets/active-set.js';
|
||||||
|
import type { PresetActivation } from '../net/types.js';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import { Settings2, X } from 'lucide-react';
|
import { Settings2, X } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
@ -7,57 +9,121 @@ import { toast } from 'sonner';
|
||||||
/**
|
/**
|
||||||
* A collapsible side-drawer for toggling preset rules mid-game.
|
* A collapsible side-drawer for toggling preset rules mid-game.
|
||||||
*
|
*
|
||||||
* Writes straight to PRESET_REGISTRY on every toggle. The ChessEngine reads
|
* Server-authoritative: every toggle/scope/duration change builds a
|
||||||
* PRESET_REGISTRY.getActive() fresh on every call to getAllLegalMoves(), so
|
* full replacement list and dispatches via `setPresets`. In multiplayer
|
||||||
* a toggle takes effect on the very next move calculation — no reset, no
|
* that goes to the server which echoes `game.presets` back; in local
|
||||||
* reload, no navigation.
|
* mode it mutates the engine directly. Either way the UI reads
|
||||||
*
|
* `activations` as its source of truth — we never flip a toggle locally
|
||||||
* Mounts a small tick counter to force a re-render when the registry
|
* and then try to reconcile.
|
||||||
* changes externally (e.g. via the /rules page), so this drawer stays in
|
|
||||||
* sync if both UIs are open.
|
|
||||||
*/
|
*/
|
||||||
interface RulesDrawerProps {
|
interface RulesDrawerProps {
|
||||||
/** Called after any toggle so the parent can recompute derived UI state
|
/** Current authoritative preset activations (from hook). */
|
||||||
* (legal-move highlights, etc.) that depends on the active preset set. */
|
activations: readonly PresetActivation[];
|
||||||
|
/** Replace the active set on the engine / server. */
|
||||||
|
setPresets: (activations: PresetActivation[]) => void;
|
||||||
|
/** Called after any toggle so the parent can recompute derived UI
|
||||||
|
* state (legal-move highlights, etc) — local mode only. */
|
||||||
onRulesChanged?: () => void;
|
onRulesChanged?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
/** Build a fresh activation list that reflects a single edit. */
|
||||||
|
function applyEdit(
|
||||||
|
current: readonly PresetActivation[],
|
||||||
|
id: string,
|
||||||
|
patch: Partial<PresetActivation> & { remove?: boolean },
|
||||||
|
): PresetActivation[] {
|
||||||
|
if (patch.remove) {
|
||||||
|
return current.filter((a) => a.id !== id);
|
||||||
|
}
|
||||||
|
const idx = current.findIndex((a) => a.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
return [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
scope: patch.scope ?? 'both',
|
||||||
|
turnsRemaining: patch.turnsRemaining ?? null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const next = [...current];
|
||||||
|
const existing = next[idx]!;
|
||||||
|
next[idx] = {
|
||||||
|
id: existing.id,
|
||||||
|
scope: patch.scope ?? existing.scope,
|
||||||
|
turnsRemaining:
|
||||||
|
patch.turnsRemaining === undefined
|
||||||
|
? existing.turnsRemaining
|
||||||
|
: patch.turnsRemaining,
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RulesDrawer({
|
||||||
|
activations,
|
||||||
|
setPresets,
|
||||||
|
onRulesChanged,
|
||||||
|
}: RulesDrawerProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Force-refresh trigger. The registry itself has no event emitter, so we
|
const presets = useMemo(() => PRESET_REGISTRY.getAll(), []);
|
||||||
// bump this counter on every open and every toggle click to re-read the
|
const activeById = useMemo(() => {
|
||||||
// active set. The value is never read directly — only the setter is used
|
const map = new Map<string, PresetActivation>();
|
||||||
// to trigger a re-render of this component.
|
for (const a of activations) map.set(a.id, a);
|
||||||
const [, setTick] = useState(0);
|
return map;
|
||||||
|
}, [activations]);
|
||||||
// Re-read active set on every open so external changes reflect.
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) setTick((t) => t + 1);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const presets = PRESET_REGISTRY.getAll();
|
|
||||||
const activeIds = new Set(PRESET_REGISTRY.getActive().map((p) => p.id));
|
|
||||||
|
|
||||||
const toggle = (id: string, name: string) => {
|
const toggle = (id: string, name: string) => {
|
||||||
const isActivating = !activeIds.has(id);
|
const currently = activeById.get(id);
|
||||||
if (!isActivating) {
|
const next: PresetActivation[] = currently
|
||||||
PRESET_REGISTRY.deactivate(id);
|
? applyEdit(activations, id, { remove: true })
|
||||||
toast(`Preset disabled: ${name}`);
|
: applyEdit(activations, id, { scope: 'both', turnsRemaining: null });
|
||||||
} else {
|
try {
|
||||||
try {
|
setPresets(next);
|
||||||
PRESET_REGISTRY.activate(id);
|
toast(
|
||||||
toast.info(`Preset enabled: ${name}`);
|
currently ? `Preset disabled: ${name}` : `Preset enabled: ${name}`,
|
||||||
} catch (err) {
|
);
|
||||||
// activate() throws for missing requires or incompatibilities. We
|
} catch (err) {
|
||||||
// surface the reason via the UI only for the user's next refresh.
|
// Local mode throws synchronously; multiplayer rejects async via
|
||||||
console.warn(`Could not activate ${id}:`, err);
|
// the server's error event. We tell the user either way.
|
||||||
toast.error(`Could not activate ${name}`);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
}
|
toast.error(`Could not update ${name}: ${msg}`);
|
||||||
|
}
|
||||||
|
onRulesChanged?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setScope = (id: string, scope: PresetScope) => {
|
||||||
|
const next = applyEdit(activations, id, { scope });
|
||||||
|
try {
|
||||||
|
setPresets(next);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
`Could not set scope: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onRulesChanged?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDuration = (
|
||||||
|
id: string,
|
||||||
|
event: ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const raw = event.target.value.trim();
|
||||||
|
// Empty string === permanent (null). Otherwise must parse to a
|
||||||
|
// positive integer; invalid input is silently ignored so a user
|
||||||
|
// mid-typing "12" doesn't crash on intermediate "1".
|
||||||
|
let turnsRemaining: number | null = null;
|
||||||
|
if (raw !== '') {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) return;
|
||||||
|
turnsRemaining = n;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setPresets(applyEdit(activations, id, { turnsRemaining }));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
`Could not set duration: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setTick((t) => t + 1);
|
|
||||||
// Ask the parent engine state to recompute legalMoves — otherwise the
|
|
||||||
// Board's highlighted drop targets won't reflect the new rule set
|
|
||||||
// until the next move bumps the hook's internal tick.
|
|
||||||
onRulesChanged?.();
|
onRulesChanged?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -75,14 +141,13 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
||||||
>
|
>
|
||||||
<Settings2 size={16} />
|
<Settings2 size={16} />
|
||||||
Rules
|
Rules
|
||||||
{activeIds.size > 0 && (
|
{activeById.size > 0 && (
|
||||||
<span className="bg-neutral-800 text-white rounded-full px-2 py-0.5 text-[10px] font-bold ml-1">
|
<span className="bg-neutral-800 text-white rounded-full px-2 py-0.5 text-[10px] font-bold ml-1">
|
||||||
{activeIds.size}
|
{activeById.size}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
{/* Backdrop + drawer */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -103,9 +168,11 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
||||||
>
|
>
|
||||||
<header className="px-6 py-5 border-b border-neutral-100 flex items-center justify-between bg-white">
|
<header className="px-6 py-5 border-b border-neutral-100 flex items-center justify-between bg-white">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold tracking-tight text-neutral-900">Live Rules</h2>
|
<h2 className="text-xl font-bold tracking-tight text-neutral-900">
|
||||||
|
Live Rules
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-neutral-500 mt-1">
|
<p className="text-sm text-neutral-500 mt-1">
|
||||||
Toggle rules mid-game
|
Toggle rules mid-game · scope + duration per rule
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -121,16 +188,8 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
{presets.map((preset) => {
|
{presets.map((preset) => {
|
||||||
const isOn = activeIds.has(preset.id);
|
const active = activeById.get(preset.id);
|
||||||
const blockedBy = preset.incompatibleWith.filter((id) =>
|
const isOn = active !== undefined;
|
||||||
activeIds.has(id),
|
|
||||||
);
|
|
||||||
const missingReqs = preset.requires.filter(
|
|
||||||
(id) => !activeIds.has(id),
|
|
||||||
);
|
|
||||||
const blocked =
|
|
||||||
!isOn && (blockedBy.length > 0 || missingReqs.length > 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
|
|
@ -138,8 +197,6 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
||||||
className={`border rounded-xl p-4 transition-all duration-200 ${
|
className={`border rounded-xl p-4 transition-all duration-200 ${
|
||||||
isOn
|
isOn
|
||||||
? 'border-neutral-300 bg-neutral-50 shadow-sm'
|
? 'border-neutral-300 bg-neutral-50 shadow-sm'
|
||||||
: blocked
|
|
||||||
? 'border-neutral-100 bg-neutral-50/50 opacity-60'
|
|
||||||
: 'border-neutral-200 hover:border-neutral-300'
|
: 'border-neutral-200 hover:border-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -151,25 +208,14 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
||||||
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
|
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
|
||||||
{preset.description}
|
{preset.description}
|
||||||
</p>
|
</p>
|
||||||
{blockedBy.length > 0 && (
|
|
||||||
<p className="text-xs font-medium text-amber-600 mt-2">
|
|
||||||
Conflicts with active: {blockedBy.join(', ')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{missingReqs.length > 0 && (
|
|
||||||
<p className="text-xs font-medium text-amber-600 mt-2">
|
|
||||||
Requires: {missingReqs.join(', ')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-role="toggle"
|
data-role="toggle"
|
||||||
disabled={blocked}
|
|
||||||
onClick={() => toggle(preset.id, preset.name)}
|
onClick={() => toggle(preset.id, preset.name)}
|
||||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white ${
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white ${
|
||||||
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
|
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
|
||||||
} ${blocked ? 'cursor-not-allowed' : ''}`}
|
}`}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={isOn}
|
aria-checked={isOn}
|
||||||
>
|
>
|
||||||
|
|
@ -182,15 +228,87 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isOn && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-neutral-200 space-y-3">
|
||||||
|
{/* Scope radios */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<label className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">
|
||||||
|
Apply to
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="flex rounded-lg bg-neutral-100 p-0.5 text-xs font-medium"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={`${preset.name} scope`}
|
||||||
|
data-role="scope-group"
|
||||||
|
>
|
||||||
|
{(['both', 'white', 'black'] as const).map(
|
||||||
|
(scope) => {
|
||||||
|
const selected = active.scope === scope;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={scope}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
data-role={`scope-${scope}`}
|
||||||
|
onClick={() => setScope(preset.id, scope)}
|
||||||
|
className={`px-3 py-1 rounded-md transition-colors capitalize ${
|
||||||
|
selected
|
||||||
|
? 'bg-white text-neutral-900 shadow-sm'
|
||||||
|
: 'text-neutral-500 hover:text-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{scope}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration input */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<label
|
||||||
|
htmlFor={`duration-${preset.id}`}
|
||||||
|
className="text-xs font-semibold text-neutral-600 uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
Duration
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id={`duration-${preset.id}`}
|
||||||
|
data-role="duration-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
placeholder="∞"
|
||||||
|
value={
|
||||||
|
active.turnsRemaining === null
|
||||||
|
? ''
|
||||||
|
: String(active.turnsRemaining)
|
||||||
|
}
|
||||||
|
onChange={(e) => setDuration(preset.id, e)}
|
||||||
|
className="w-20 px-2 py-1 border border-neutral-300 rounded-md text-sm text-right focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
{active.turnsRemaining === null
|
||||||
|
? 'permanent'
|
||||||
|
: 'turns'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="p-4 border-t border-neutral-100 bg-white text-xs font-medium text-neutral-400 text-center">
|
<footer className="p-4 border-t border-neutral-100 bg-white text-xs font-medium text-neutral-400 text-center">
|
||||||
{activeIds.size === 0
|
{activeById.size === 0
|
||||||
? 'Standard FIDE chess — no presets active'
|
? 'Standard FIDE chess — no presets active'
|
||||||
: `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`}
|
: `${activeById.size} preset${activeById.size === 1 ? '' : 's'} active`}
|
||||||
</footer>
|
</footer>
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,107 +1,96 @@
|
||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { PRESET_REGISTRY } from '../presets/index.js';
|
import { PRESET_REGISTRY } from '../presets/index.js';
|
||||||
import { ChessEngine } from '../engine.js';
|
import { ChessEngine } from '../engine.js';
|
||||||
import { clearAutoSave } from '../persist/autosave.js';
|
import { clearAutoSave } from '../persist/autosave.js';
|
||||||
import type { useChessEngine } from '../hooks/useChessEngine.js';
|
import type { useChessEngine } from '../hooks/useChessEngine.js';
|
||||||
|
import type { PresetActivation } from '../net/types.js';
|
||||||
|
import type { PresetScope } from '../presets/active-set.js';
|
||||||
|
|
||||||
interface RulesViewProps {
|
interface RulesViewProps {
|
||||||
/** Only required when rules should drive a new game start. */
|
/** Required — the view always shows the engine's current active set
|
||||||
chessState?: ReturnType<typeof useChessEngine>;
|
* and delegates mutation back to `chessState.setPresets`. */
|
||||||
|
chessState: ReturnType<typeof useChessEngine>;
|
||||||
isGameActive?: boolean;
|
isGameActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build a fresh activation list that reflects a single edit. */
|
||||||
|
function applyEdit(
|
||||||
|
current: readonly PresetActivation[],
|
||||||
|
id: string,
|
||||||
|
patch: Partial<PresetActivation> & { remove?: boolean },
|
||||||
|
): PresetActivation[] {
|
||||||
|
if (patch.remove) return current.filter((a) => a.id !== id);
|
||||||
|
const idx = current.findIndex((a) => a.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
return [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
scope: patch.scope ?? 'both',
|
||||||
|
turnsRemaining: patch.turnsRemaining ?? null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const next = [...current];
|
||||||
|
const existing = next[idx]!;
|
||||||
|
next[idx] = {
|
||||||
|
id: existing.id,
|
||||||
|
scope: patch.scope ?? existing.scope,
|
||||||
|
turnsRemaining:
|
||||||
|
patch.turnsRemaining === undefined
|
||||||
|
? existing.turnsRemaining
|
||||||
|
: patch.turnsRemaining,
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export function RulesView({ chessState, isGameActive }: RulesViewProps) {
|
export function RulesView({ chessState, isGameActive }: RulesViewProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// Seed local selection from whatever is already active on the registry so
|
const { activations, setPresets } = chessState;
|
||||||
// navigating to /rules mid-game reflects the real state (not a fresh set).
|
const presets = useMemo(() => PRESET_REGISTRY.getAll(), []);
|
||||||
const [selected, setSelected] = useState<Set<string>>(
|
const activeById = useMemo(() => {
|
||||||
() => new Set(PRESET_REGISTRY.getActive().map((p) => p.id)),
|
const map = new Map<string, PresetActivation>();
|
||||||
);
|
for (const a of activations) map.set(a.id, a);
|
||||||
|
return map;
|
||||||
const presets = PRESET_REGISTRY.getAll();
|
}, [activations]);
|
||||||
|
|
||||||
const hasIncompatibilities = useMemo(() => {
|
|
||||||
for (const a of selected) {
|
|
||||||
const aDef = presets.find((p) => p.id === a);
|
|
||||||
if (!aDef) continue;
|
|
||||||
for (const b of selected) {
|
|
||||||
if (a === b) continue;
|
|
||||||
if (aDef.incompatibleWith.includes(b)) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [selected, presets]);
|
|
||||||
|
|
||||||
const missingRequires = useMemo(() => {
|
|
||||||
const missing: Array<{ id: string; needs: string }> = [];
|
|
||||||
for (const a of selected) {
|
|
||||||
const def = presets.find((p) => p.id === a);
|
|
||||||
if (!def) continue;
|
|
||||||
for (const need of def.requires) {
|
|
||||||
if (!selected.has(need)) missing.push({ id: a, needs: need });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return missing;
|
|
||||||
}, [selected, presets]);
|
|
||||||
|
|
||||||
const togglePreset = (id: string) => {
|
const togglePreset = (id: string) => {
|
||||||
if (isGameActive) return;
|
if (isGameActive) return;
|
||||||
setSelected((prev) => {
|
const current = activeById.get(id);
|
||||||
const next = new Set(prev);
|
setPresets(
|
||||||
if (next.has(id)) next.delete(id);
|
current
|
||||||
else next.add(id);
|
? applyEdit(activations, id, { remove: true })
|
||||||
return next;
|
: applyEdit(activations, id, { scope: 'both', turnsRemaining: null }),
|
||||||
});
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep the singleton registry in sync with the user's current selection on
|
const setScope = (id: string, scope: PresetScope) => {
|
||||||
// every change. This means toggles take effect live — players navigating
|
if (isGameActive) return;
|
||||||
// back and forth between /rules and /game see the new rules immediately,
|
setPresets(applyEdit(activations, id, { scope }));
|
||||||
// and any newly-created engine (P4 server path or /save load) will pick
|
};
|
||||||
// up the active presets automatically.
|
|
||||||
useEffect(() => {
|
const setDuration = (id: string, raw: string) => {
|
||||||
// Deactivate first so the activation loop can't trip the registry's
|
if (isGameActive) return;
|
||||||
// incompatibility guard against stale entries.
|
if (raw === '') {
|
||||||
for (const p of PRESET_REGISTRY.getActive()) {
|
setPresets(applyEdit(activations, id, { turnsRemaining: null }));
|
||||||
if (!selected.has(p.id)) PRESET_REGISTRY.deactivate(p.id);
|
return;
|
||||||
}
|
}
|
||||||
// Activate in dependency-friendly order: items whose `requires` are
|
const n = Number(raw);
|
||||||
// already active first. Simple fixpoint loop — stops when no more
|
if (!Number.isInteger(n) || n <= 0) return;
|
||||||
// progress can be made, at which point remaining items are either
|
setPresets(applyEdit(activations, id, { turnsRemaining: n }));
|
||||||
// already active or blocked by missing prerequisites / conflicts.
|
};
|
||||||
const toActivate = [...selected].filter((id) => !PRESET_REGISTRY.isActive(id));
|
|
||||||
let progress = true;
|
|
||||||
while (progress && toActivate.length > 0) {
|
|
||||||
progress = false;
|
|
||||||
for (let i = toActivate.length - 1; i >= 0; i--) {
|
|
||||||
const id = toActivate[i]!;
|
|
||||||
try {
|
|
||||||
PRESET_REGISTRY.activate(id);
|
|
||||||
toActivate.splice(i, 1);
|
|
||||||
progress = true;
|
|
||||||
} catch {
|
|
||||||
/* requires not met yet, or incompatible — try again next pass */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
// Starting a new game with the selected ruleset: reset the engine so
|
// Starting a new game preserving the currently configured rule set.
|
||||||
// opening moves are generated under the active presets. Clear the
|
|
||||||
// autosave too — otherwise a full-page reload would re-hydrate the
|
|
||||||
// previous in-progress game. The registry is already synced via the
|
|
||||||
// useEffect above, so the fresh engine will see the active presets
|
|
||||||
// on its first getAllLegalMoves() call.
|
|
||||||
clearAutoSave();
|
clearAutoSave();
|
||||||
if (chessState) {
|
const newEngine = new ChessEngine();
|
||||||
chessState.loadEngine(new ChessEngine());
|
newEngine.activePresets.replaceAll(activations);
|
||||||
}
|
chessState.loadEngine(newEngine);
|
||||||
navigate('/game');
|
navigate('/game');
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeCount = selected.size;
|
const activeCount = activeById.size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-3xl mx-auto space-y-6" data-testid="page-rules">
|
<div className="p-6 max-w-3xl mx-auto space-y-6" data-testid="page-rules">
|
||||||
|
|
@ -109,7 +98,8 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Preset Rules</h1>
|
<h1 className="text-3xl font-bold">Preset Rules</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
Toggle custom chess rules. Changes apply to the next game.
|
Toggle custom chess rules. Each rule can target a color
|
||||||
|
(white/black/both) and run for a limited number of turns.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -121,95 +111,114 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{isGameActive && (
|
|
||||||
<div className="bg-gray-100 text-gray-700 p-4 rounded-md">
|
|
||||||
Rules can only be changed between games
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasIncompatibilities && (
|
|
||||||
<div
|
|
||||||
data-testid="compat-warning"
|
|
||||||
className="bg-amber-100 text-amber-800 p-4 rounded-md font-semibold"
|
|
||||||
>
|
|
||||||
Warning: Some selected rules are mutually incompatible — they will not both apply.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{missingRequires.length > 0 && (
|
|
||||||
<div
|
|
||||||
data-testid="requires-warning"
|
|
||||||
className="bg-amber-100 text-amber-800 p-4 rounded-md"
|
|
||||||
>
|
|
||||||
Some selected rules are missing prerequisites:
|
|
||||||
<ul className="list-disc list-inside mt-1">
|
|
||||||
{missingRequires.map((m) => (
|
|
||||||
<li key={`${m.id}-${m.needs}`}>
|
|
||||||
<code>{m.id}</code> requires <code>{m.needs}</code>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{presets.map((preset) => (
|
{presets.map((preset) => {
|
||||||
<div
|
const active = activeById.get(preset.id);
|
||||||
key={preset.id}
|
const isOn = active !== undefined;
|
||||||
data-preset={preset.id}
|
return (
|
||||||
className={`border rounded-lg p-4 flex items-center justify-between transition-colors ${
|
<div
|
||||||
isGameActive ? 'opacity-50 grayscale' : 'hover:border-blue-400'
|
key={preset.id}
|
||||||
}`}
|
data-preset={preset.id}
|
||||||
>
|
className={`border rounded-lg p-4 transition-colors ${
|
||||||
<div className="pr-4">
|
isGameActive
|
||||||
<h3 className="text-lg font-semibold">{preset.name}</h3>
|
? 'opacity-50 grayscale'
|
||||||
<p className="text-sm text-gray-600 mt-1">{preset.description}</p>
|
: 'hover:border-blue-400'
|
||||||
{preset.incompatibleWith.length > 0 && (
|
}`}
|
||||||
<p className="text-xs text-gray-400 mt-2">
|
>
|
||||||
Incompatible with:{' '}
|
<div className="flex items-start justify-between">
|
||||||
{preset.incompatibleWith.map((x) => (
|
<div className="pr-4">
|
||||||
<code key={x} className="mx-1">
|
<h3 className="text-lg font-semibold">{preset.name}</h3>
|
||||||
{x}
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
</code>
|
{preset.description}
|
||||||
))}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
)}
|
<button
|
||||||
{preset.requires.length > 0 && (
|
type="button"
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
data-role="toggle"
|
||||||
Requires:{' '}
|
disabled={isGameActive}
|
||||||
{preset.requires.map((x) => (
|
onClick={() => togglePreset(preset.id)}
|
||||||
<code key={x} className="mx-1">
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
||||||
{x}
|
isOn ? 'bg-blue-600' : 'bg-gray-200'
|
||||||
</code>
|
} ${isGameActive ? 'cursor-not-allowed' : ''}`}
|
||||||
))}
|
role="switch"
|
||||||
</p>
|
aria-checked={isOn}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
isOn ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOn && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-neutral-200 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-neutral-600 uppercase tracking-wide mb-2">
|
||||||
|
Apply to
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="flex rounded-lg bg-neutral-100 p-0.5 text-sm font-medium"
|
||||||
|
role="radiogroup"
|
||||||
|
data-role="scope-group"
|
||||||
|
>
|
||||||
|
{(['both', 'white', 'black'] as const).map((scope) => {
|
||||||
|
const selected = active.scope === scope;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={scope}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
data-role={`scope-${scope}`}
|
||||||
|
onClick={() => setScope(preset.id, scope)}
|
||||||
|
className={`flex-1 px-3 py-1.5 rounded-md transition-colors capitalize ${
|
||||||
|
selected
|
||||||
|
? 'bg-white text-neutral-900 shadow-sm'
|
||||||
|
: 'text-neutral-500 hover:text-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{scope}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={`duration-${preset.id}`}
|
||||||
|
className="block text-xs font-semibold text-neutral-600 uppercase tracking-wide mb-2"
|
||||||
|
>
|
||||||
|
Duration (turns, blank = permanent)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`duration-${preset.id}`}
|
||||||
|
data-role="duration-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
placeholder="∞"
|
||||||
|
value={
|
||||||
|
active.turnsRemaining === null
|
||||||
|
? ''
|
||||||
|
: String(active.turnsRemaining)
|
||||||
|
}
|
||||||
|
onChange={(e) => setDuration(preset.id, e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 border border-neutral-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
);
|
||||||
type="button"
|
})}
|
||||||
data-role="toggle"
|
|
||||||
disabled={isGameActive}
|
|
||||||
onClick={() => togglePreset(preset.id)}
|
|
||||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
|
||||||
selected.has(preset.id) ? 'bg-blue-600' : 'bg-gray-200'
|
|
||||||
} ${isGameActive ? 'cursor-not-allowed' : ''}`}
|
|
||||||
role="switch"
|
|
||||||
aria-checked={selected.has(preset.id)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
|
||||||
selected.has(preset.id) ? 'translate-x-5' : 'translate-x-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-6 space-y-3">
|
<div className="pt-6 space-y-3">
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-900">
|
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-900">
|
||||||
<strong>Tip:</strong> toggles above take effect <em>immediately</em> — including in the middle of a game. No reset required.
|
<strong>Tip:</strong> edits above take effect <em>immediately</em> —
|
||||||
|
including in the middle of a game. No reset required.
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{activeCount === 0
|
{activeCount === 0
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,10 @@ import {
|
||||||
type ErrorCode,
|
type ErrorCode,
|
||||||
type Fact as WireFact,
|
type Fact as WireFact,
|
||||||
type GameMovePayload,
|
type GameMovePayload,
|
||||||
|
type PresetActivation,
|
||||||
type RoomCreatePayload,
|
type RoomCreatePayload,
|
||||||
type RoomJoinPayload,
|
type RoomJoinPayload,
|
||||||
|
type RoomSetPresetsPayload,
|
||||||
type ServerMessage,
|
type ServerMessage,
|
||||||
} from "./protocol.js";
|
} from "./protocol.js";
|
||||||
import { DEFAULT_GRACE_MS, reconnectManager } from "./reconnect.js";
|
import { DEFAULT_GRACE_MS, reconnectManager } from "./reconnect.js";
|
||||||
|
|
@ -228,11 +230,15 @@ export function handleMessage(
|
||||||
case "game.move":
|
case "game.move":
|
||||||
handleGameMove(ws, msg.payload);
|
handleGameMove(ws, msg.payload);
|
||||||
break;
|
break;
|
||||||
|
case "room.setPresets":
|
||||||
|
handleSetPresets(ws, msg.payload);
|
||||||
|
break;
|
||||||
case "room.created":
|
case "room.created":
|
||||||
case "room.joined":
|
case "room.joined":
|
||||||
case "game.state":
|
case "game.state":
|
||||||
case "game.delta":
|
case "game.delta":
|
||||||
case "game.end":
|
case "game.end":
|
||||||
|
case "game.presets":
|
||||||
case "error":
|
case "error":
|
||||||
sendTo(
|
sendTo(
|
||||||
ws,
|
ws,
|
||||||
|
|
@ -357,6 +363,7 @@ function handleRoomJoin(
|
||||||
lastSeq: 0,
|
lastSeq: 0,
|
||||||
moveHistory: [],
|
moveHistory: [],
|
||||||
activeRules: [...result.activeRules],
|
activeRules: [...result.activeRules],
|
||||||
|
activations: session.getPresetActivations(),
|
||||||
// fen is a UI convenience for v1; we haven't wired FEN generation
|
// fen is a UI convenience for v1; we haven't wired FEN generation
|
||||||
// on the server yet, so we send an empty string. Clients that need
|
// on the server yet, so we send an empty string. Clients that need
|
||||||
// FEN can derive it from `facts`.
|
// FEN can derive it from `facts`.
|
||||||
|
|
@ -446,6 +453,7 @@ function handleReconnect(
|
||||||
lastSeq: missed.length > 0 ? (missed[missed.length - 1]?.seq ?? 0) : 0,
|
lastSeq: missed.length > 0 ? (missed[missed.length - 1]?.seq ?? 0) : 0,
|
||||||
moveHistory: [],
|
moveHistory: [],
|
||||||
activeRules: [...room.rulesetIds],
|
activeRules: [...room.rulesetIds],
|
||||||
|
activations: session.getPresetActivations(),
|
||||||
fen: "",
|
fen: "",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -529,6 +537,12 @@ function handleGameMove(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot the preset set before the move so we can detect whether
|
||||||
|
// any durations expired during `tickAfterMove`. We only broadcast
|
||||||
|
// `game.presets` when the set actually changed — otherwise the
|
||||||
|
// message is noise.
|
||||||
|
const presetsBefore = session.getPresetActivations();
|
||||||
|
|
||||||
const tickStart = performance.now();
|
const tickStart = performance.now();
|
||||||
const moveResult = session.applyMove(
|
const moveResult = session.applyMove(
|
||||||
payload.from,
|
payload.from,
|
||||||
|
|
@ -575,6 +589,16 @@ function handleGameMove(
|
||||||
broadcastToRoom(roomCode, deltaMsg);
|
broadcastToRoom(roomCode, deltaMsg);
|
||||||
bufferDeltaForDisconnected(roomCode, token, deltaMsg.seq, deltaPayload);
|
bufferDeltaForDisconnected(roomCode, token, deltaMsg.seq, deltaPayload);
|
||||||
|
|
||||||
|
// If any preset durations expired during this move's tick, push the
|
||||||
|
// new set so clients stop rendering those rules. We skip the broadcast
|
||||||
|
// when the set is byte-identical to pre-move — the common case —
|
||||||
|
// so a vanilla game doesn't generate a `game.presets` message on
|
||||||
|
// every half-move.
|
||||||
|
const presetsAfter = session.getPresetActivations();
|
||||||
|
if (JSON.stringify(presetsBefore) !== JSON.stringify(presetsAfter)) {
|
||||||
|
broadcastPresets(roomCode, presetsAfter);
|
||||||
|
}
|
||||||
|
|
||||||
// Terminal positions also get an explicit game.end for clarity per
|
// Terminal positions also get an explicit game.end for clarity per
|
||||||
// PROTOCOL.md §game.end. `finalFen` is empty for v1 (see note above).
|
// PROTOCOL.md §game.end. `finalFen` is empty for v1 (see note above).
|
||||||
if (moveResult.gameOver !== null) {
|
if (moveResult.gameOver !== null) {
|
||||||
|
|
@ -589,6 +613,52 @@ function handleGameMove(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSetPresets(
|
||||||
|
ws: ServerWebSocket<ClientData>,
|
||||||
|
payload: RoomSetPresetsPayload,
|
||||||
|
): void {
|
||||||
|
const { roomCode, token } = ws.data;
|
||||||
|
if (roomCode === undefined || token === undefined) {
|
||||||
|
sendTo(
|
||||||
|
ws,
|
||||||
|
errorMessage("BAD_TOKEN", "not authenticated into a room", false),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = sessionRegistry.get(roomCode);
|
||||||
|
if (!session) {
|
||||||
|
sendTo(
|
||||||
|
ws,
|
||||||
|
errorMessage("INVALID_MESSAGE", "internal error: missing game session", true),
|
||||||
|
);
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate by handing to the GameSession which delegates to
|
||||||
|
// ActivePresetSet. Bad inputs (unknown id, incompatible pair under
|
||||||
|
// overlapping scope, missing requirement) come back as structured
|
||||||
|
// errors that we surface as INVALID_MESSAGE so the client can show
|
||||||
|
// them without disconnecting.
|
||||||
|
const result = session.setPresets(payload.activations);
|
||||||
|
if (!result.ok) {
|
||||||
|
sendTo(ws, errorMessage("INVALID_MESSAGE", result.error, false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastPresets(roomCode, session.getPresetActivations());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast the current preset set to everyone in `code`. Used both
|
||||||
|
* after an explicit `room.setPresets` and after a move whose duration
|
||||||
|
* tick expired some entries. */
|
||||||
|
function broadcastPresets(
|
||||||
|
code: string,
|
||||||
|
activations: PresetActivation[],
|
||||||
|
): void {
|
||||||
|
broadcastToRoom(code, envelope("game.presets", { activations }));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For every OTHER player in `code` whose slot is mid-grace-window
|
* For every OTHER player in `code` whose slot is mid-grace-window
|
||||||
* (disconnected), buffer a copy of a just-broadcast delta so the
|
* (disconnected), buffer a copy of a just-broadcast delta so the
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@
|
||||||
import {
|
import {
|
||||||
ChessEngine,
|
ChessEngine,
|
||||||
algebraicToSquare,
|
algebraicToSquare,
|
||||||
|
PresetActivationError,
|
||||||
|
type ActivationRequest,
|
||||||
|
type PresetActivation,
|
||||||
type GameResult,
|
type GameResult,
|
||||||
type PieceColor,
|
type PieceColor,
|
||||||
type PieceType,
|
type PieceType,
|
||||||
|
|
@ -87,16 +90,64 @@ export class GameSession {
|
||||||
> | null = null;
|
> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param _rulesetIds — activated preset IDs from room.create. v1: accepted
|
* @param rulesetIds — preset IDs from room.create. Each is activated
|
||||||
* for API shape but not yet wired to ChessEngine. Preset activation is
|
* as `scope=both, turnsRemaining=null` (permanent) on the engine's
|
||||||
* tracked by PRESET_REGISTRY which is process-global today; per-room
|
* private ActivePresetSet. Invalid ids are silently skipped here —
|
||||||
* preset isolation is a follow-up (tracked in PROTOCOL.md).
|
* the server validates them earlier at the protocol layer.
|
||||||
*/
|
*/
|
||||||
constructor(_rulesetIds: readonly string[] = []) {
|
constructor(rulesetIds: readonly string[] = []) {
|
||||||
this.engine = new ChessEngine();
|
this.engine = new ChessEngine();
|
||||||
|
if (rulesetIds.length > 0) {
|
||||||
|
try {
|
||||||
|
this.engine.activePresets.replaceAll(
|
||||||
|
rulesetIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
scope: "both" as const,
|
||||||
|
turnsRemaining: null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// A bad initial rulesetId shouldn't prevent session creation —
|
||||||
|
// start empty and let clients reconfigure via room.setPresets.
|
||||||
|
this.engine.activePresets.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
this.prevFacts = this.snapshotFacts();
|
this.prevFacts = this.snapshotFacts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the full active preset set. Returns `{ ok: true }` on
|
||||||
|
* success so the server can then broadcast `game.presets`; returns
|
||||||
|
* `{ ok: false, error }` on validation failure (unknown id,
|
||||||
|
* incompatibility, missing requirement) so the server can surface
|
||||||
|
* an `INVALID_MESSAGE` error to the requesting client.
|
||||||
|
*
|
||||||
|
* Rejections leave the pre-existing set intact (see
|
||||||
|
* ActivePresetSet.replaceAll for atomicity).
|
||||||
|
*/
|
||||||
|
setPresets(
|
||||||
|
activations: readonly ActivationRequest[],
|
||||||
|
): { ok: true } | { ok: false; error: string } {
|
||||||
|
try {
|
||||||
|
this.engine.activePresets.replaceAll(activations);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
const msg =
|
||||||
|
e instanceof PresetActivationError
|
||||||
|
? `${e.code}: ${e.message}`
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: String(e);
|
||||||
|
return { ok: false, error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot of the current active preset set, wire-shape. Used by
|
||||||
|
* broadcast to populate `game.state.activations` and `game.presets`. */
|
||||||
|
getPresetActivations(): PresetActivation[] {
|
||||||
|
return this.engine.activePresets.list();
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns a fresh snapshot of current facts in deterministic order. */
|
/** Returns a fresh snapshot of current facts in deterministic order. */
|
||||||
getAllFacts(): Fact[] {
|
getAllFacts(): Fact[] {
|
||||||
return this.snapshotFacts();
|
return this.snapshotFacts();
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,40 @@ export const GameMovePayloadSchema = z.object({
|
||||||
});
|
});
|
||||||
export type GameMovePayload = z.infer<typeof GameMovePayloadSchema>;
|
export type GameMovePayload = z.infer<typeof GameMovePayloadSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Preset scope + activation — used by both directions of the preset sync.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const PresetScopeSchema = z.enum(["both", "white", "black"]);
|
||||||
|
export type PresetScope = z.infer<typeof PresetScopeSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire shape for one active preset. `turnsRemaining` is `null` for
|
||||||
|
* permanent activations; otherwise a positive integer count of
|
||||||
|
* player-local turns (see ActivePresetSet for counting policy).
|
||||||
|
*/
|
||||||
|
export const PresetActivationSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
scope: PresetScopeSchema,
|
||||||
|
turnsRemaining: z.number().int().positive().nullable(),
|
||||||
|
});
|
||||||
|
export type PresetActivation = z.infer<typeof PresetActivationSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client → server: replace the room's entire active preset set. Server
|
||||||
|
* validates (catalog lookup, pairwise compatibility under the scope-overlap
|
||||||
|
* rule, requires) and either accepts — broadcasting `game.presets` to all
|
||||||
|
* players — or rejects with `INVALID_MESSAGE`.
|
||||||
|
*
|
||||||
|
* We chose "replace whole set" over "toggle one" because it sidesteps
|
||||||
|
* ordering issues when two clients race toggles and gives us idempotency
|
||||||
|
* for reconnect: the server just re-sends the current set on game.state.
|
||||||
|
*/
|
||||||
|
export const RoomSetPresetsPayloadSchema = z.object({
|
||||||
|
activations: z.array(PresetActivationSchema),
|
||||||
|
});
|
||||||
|
export type RoomSetPresetsPayload = z.infer<typeof RoomSetPresetsPayloadSchema>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Server → Client payloads
|
// Server → Client payloads
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -133,11 +167,27 @@ export const GameStatePayloadSchema = z.object({
|
||||||
turn: ColorSchema,
|
turn: ColorSchema,
|
||||||
lastSeq: z.number().int().nonnegative(),
|
lastSeq: z.number().int().nonnegative(),
|
||||||
moveHistory: z.array(z.string()),
|
moveHistory: z.array(z.string()),
|
||||||
|
/** Legacy rule-id list (v1 compat). Present alongside `activations`
|
||||||
|
* which is the new authoritative shape. */
|
||||||
activeRules: z.array(z.string()),
|
activeRules: z.array(z.string()),
|
||||||
|
/** Full preset activation set authoritative on the server. Optional
|
||||||
|
* on the wire so older servers don't break the schema check. */
|
||||||
|
activations: z.array(PresetActivationSchema).optional(),
|
||||||
fen: z.string(),
|
fen: z.string(),
|
||||||
});
|
});
|
||||||
export type GameStatePayload = z.infer<typeof GameStatePayloadSchema>;
|
export type GameStatePayload = z.infer<typeof GameStatePayloadSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server → client: authoritative preset set just changed. Sent after a
|
||||||
|
* successful `room.setPresets` or after a move that expired durations.
|
||||||
|
* Clients apply this directly to their local engines' ActivePresetSet
|
||||||
|
* — never store rules locally, always mirror what the server says.
|
||||||
|
*/
|
||||||
|
export const GamePresetsPayloadSchema = z.object({
|
||||||
|
activations: z.array(PresetActivationSchema),
|
||||||
|
});
|
||||||
|
export type GamePresetsPayload = z.infer<typeof GamePresetsPayloadSchema>;
|
||||||
|
|
||||||
export const GameOverSchema = z.object({
|
export const GameOverSchema = z.object({
|
||||||
winner: WinnerSchema,
|
winner: WinnerSchema,
|
||||||
reason: GameEndReasonSchema,
|
reason: GameEndReasonSchema,
|
||||||
|
|
@ -188,12 +238,17 @@ export const RoomCreateMessageSchema = msg(
|
||||||
export const RoomJoinMessageSchema = msg("room.join", RoomJoinPayloadSchema);
|
export const RoomJoinMessageSchema = msg("room.join", RoomJoinPayloadSchema);
|
||||||
export const RoomLeaveMessageSchema = msg("room.leave", RoomLeavePayloadSchema);
|
export const RoomLeaveMessageSchema = msg("room.leave", RoomLeavePayloadSchema);
|
||||||
export const GameMoveMessageSchema = msg("game.move", GameMovePayloadSchema);
|
export const GameMoveMessageSchema = msg("game.move", GameMovePayloadSchema);
|
||||||
|
export const RoomSetPresetsMessageSchema = msg(
|
||||||
|
"room.setPresets",
|
||||||
|
RoomSetPresetsPayloadSchema,
|
||||||
|
);
|
||||||
|
|
||||||
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||||
RoomCreateMessageSchema,
|
RoomCreateMessageSchema,
|
||||||
RoomJoinMessageSchema,
|
RoomJoinMessageSchema,
|
||||||
RoomLeaveMessageSchema,
|
RoomLeaveMessageSchema,
|
||||||
GameMoveMessageSchema,
|
GameMoveMessageSchema,
|
||||||
|
RoomSetPresetsMessageSchema,
|
||||||
]);
|
]);
|
||||||
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
||||||
|
|
||||||
|
|
@ -208,6 +263,10 @@ export const RoomJoinedMessageSchema = msg(
|
||||||
export const GameStateMessageSchema = msg("game.state", GameStatePayloadSchema);
|
export const GameStateMessageSchema = msg("game.state", GameStatePayloadSchema);
|
||||||
export const GameDeltaMessageSchema = msg("game.delta", GameDeltaPayloadSchema);
|
export const GameDeltaMessageSchema = msg("game.delta", GameDeltaPayloadSchema);
|
||||||
export const GameEndMessageSchema = msg("game.end", GameEndPayloadSchema);
|
export const GameEndMessageSchema = msg("game.end", GameEndPayloadSchema);
|
||||||
|
export const GamePresetsMessageSchema = msg(
|
||||||
|
"game.presets",
|
||||||
|
GamePresetsPayloadSchema,
|
||||||
|
);
|
||||||
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
|
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
|
||||||
|
|
||||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||||
|
|
@ -216,6 +275,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||||
GameStateMessageSchema,
|
GameStateMessageSchema,
|
||||||
GameDeltaMessageSchema,
|
GameDeltaMessageSchema,
|
||||||
GameEndMessageSchema,
|
GameEndMessageSchema,
|
||||||
|
GamePresetsMessageSchema,
|
||||||
ErrorMessageSchema,
|
ErrorMessageSchema,
|
||||||
]);
|
]);
|
||||||
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
||||||
|
|
@ -225,11 +285,13 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
|
||||||
RoomJoinMessageSchema,
|
RoomJoinMessageSchema,
|
||||||
RoomLeaveMessageSchema,
|
RoomLeaveMessageSchema,
|
||||||
GameMoveMessageSchema,
|
GameMoveMessageSchema,
|
||||||
|
RoomSetPresetsMessageSchema,
|
||||||
RoomCreatedMessageSchema,
|
RoomCreatedMessageSchema,
|
||||||
RoomJoinedMessageSchema,
|
RoomJoinedMessageSchema,
|
||||||
GameStateMessageSchema,
|
GameStateMessageSchema,
|
||||||
GameDeltaMessageSchema,
|
GameDeltaMessageSchema,
|
||||||
GameEndMessageSchema,
|
GameEndMessageSchema,
|
||||||
|
GamePresetsMessageSchema,
|
||||||
ErrorMessageSchema,
|
ErrorMessageSchema,
|
||||||
]);
|
]);
|
||||||
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
|
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
|
||||||
|
|
@ -239,11 +301,13 @@ export const KNOWN_MESSAGE_TYPES = [
|
||||||
"room.join",
|
"room.join",
|
||||||
"room.leave",
|
"room.leave",
|
||||||
"game.move",
|
"game.move",
|
||||||
|
"room.setPresets",
|
||||||
"room.created",
|
"room.created",
|
||||||
"room.joined",
|
"room.joined",
|
||||||
"game.state",
|
"game.state",
|
||||||
"game.delta",
|
"game.delta",
|
||||||
"game.end",
|
"game.end",
|
||||||
|
"game.presets",
|
||||||
"error",
|
"error",
|
||||||
] as const;
|
] as const;
|
||||||
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];
|
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue