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:
Joey Yakimowich-Payne 2026-04-17 14:23:37 -06:00
commit de059fe707
No known key found for this signature in database
20 changed files with 1419 additions and 398 deletions

View file

@ -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();
});

View file

@ -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);
});
}); });
}); });

View file

@ -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();
} }

View file

@ -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,
}; };
} }

View file

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

View file

@ -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";

View file

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

View file

@ -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;
} }

View file

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

View 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);
});
});
});

View 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;
}
}

View file

@ -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")!;

View file

@ -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);
}); });
}); });

View file

@ -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);
} }
} }

View file

@ -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 */}

View file

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

View file

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

View file

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

View file

@ -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();

View file

@ -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];