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 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
* actually changes what ChessEngine.getAllLegalMoves() returns.
* Integration tests proving that mutating a ChessEngine's
* `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
* 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 { PRESET_REGISTRY } from "./presets/index.js";
import "./presets/index.js";
import { algebraicToSquare } from "./coord.js";
describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
beforeEach(() => PRESET_REGISTRY.clear());
afterEach(() => PRESET_REGISTRY.clear());
describe("ActivePresetSet ↔ ChessEngine.getAllLegalMoves integration", () => {
it("standard chess: no preset lets a pawn move backward (sanity baseline)", () => {
const engine = new ChessEngine();
// Move e2 → e4 (standard two-square advance)
const advance = engine.findMove(
algebraicToSquare("e2"),
algebraicToSquare("e4"),
engine.applyMove(
engine.findMove(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.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
);
// Now try to move e4 → e3 (backward). Standard rules forbid it.
const backward = engine.findMove(
algebraicToSquare("e4"),
algebraicToSquare("e3"),
@ -37,11 +29,12 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
expect(backward).toBeNull();
});
it("pawns-move-backward preset: e4 pawn CAN move back to e3", () => {
PRESET_REGISTRY.activate("pawns-move-backward");
it("pawns-move-backward (scope=both): e4 pawn CAN move back to e3", () => {
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.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
);
@ -49,65 +42,55 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
);
// NOW backward move should be legal with preset active.
const backward = engine.findMove(
algebraicToSquare("e4"),
algebraicToSquare("e3"),
);
expect(backward).not.toBeNull();
expect(backward!.from).toBe(algebraicToSquare("e4"));
expect(backward!.to).toBe(algebraicToSquare("e3"));
expect(backward!.isCapture).toBe(false);
});
it("deactivating a preset mid-session removes its extra moves immediately", () => {
PRESET_REGISTRY.activate("pawns-move-backward");
const engine = new ChessEngine();
engine.activePresets.replaceAll([
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
]);
engine.applyMove(
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
);
engine.applyMove(
engine.findMove(algebraicToSquare("e7"), algebraicToSquare("e5"))!,
);
// Preset active: backward legal
expect(
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
).not.toBeNull();
// Deactivate — engine should immediately reflect the change
PRESET_REGISTRY.deactivate("pawns-move-backward");
engine.activePresets.replaceAll([]);
expect(
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
).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();
expect(
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("f3")),
).toBeNull();
// Before activation: a pawn on e2 cannot move diagonally to an empty
// d3 or f3 square (those squares are empty from the starting position,
// and FIDE pawns only go diagonal when capturing).
const diagonalEmptyBefore = engine.findMove(
engine.activePresets.replaceAll([
{ id: "pawn-diagonal-no-capture", scope: "both", turnsRemaining: null },
]);
const diag = engine.findMove(
algebraicToSquare("e2"),
algebraicToSquare("f3"),
);
expect(diagonalEmptyBefore).toBeNull();
// 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);
expect(diag).not.toBeNull();
expect(diag!.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();
// Play 3 moves of a normal game — no presets active.
engine.applyMove(
engine.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
);
@ -118,31 +101,21 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
engine.findMove(algebraicToSquare("d2"), algebraicToSquare("d4"))!,
);
// Black to move. Under standard rules the black e5 pawn cannot
// retreat to e6.
expect(
engine.findMove(algebraicToSquare("e5"), algebraicToSquare("e6")),
).toBeNull();
// Activate the preset NOW — no board reset, same engine instance.
PRESET_REGISTRY.activate("pawns-move-backward");
// Same engine, same position — but the backward pawn move is now legal
// because `getAllLegalMoves` reads the registry fresh on every call.
engine.activePresets.replaceAll([
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
]);
const retreat = engine.findMove(
algebraicToSquare("e5"),
algebraicToSquare("e6"),
);
expect(retreat).not.toBeNull();
// Black actually plays the retreat and the engine accepts it.
engine.applyMove(retreat!);
expect(engine.getCurrentTurn()).toBe("white");
// 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.activePresets.replaceAll([]);
engine.applyMove(
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", () => {
PRESET_REGISTRY.activate("pawns-move-backward");
PRESET_REGISTRY.activate("pawn-diagonal-no-capture");
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.findMove(algebraicToSquare("e2"), algebraicToSquare("e4"))!,
);
@ -164,10 +138,94 @@ describe("PRESET_REGISTRY ↔ ChessEngine.getAllLegalMoves integration", () => {
engine.findMove(algebraicToSquare("a7"), algebraicToSquare("a6"))!,
);
const backward = engine.findMove(
algebraicToSquare("e4"),
algebraicToSquare("e3"),
);
expect(backward).not.toBeNull();
expect(
engine.findMove(algebraicToSquare("e4"), algebraicToSquare("e3")),
).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";
import { applyCapture } from "./rules/capture.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[];
@ -70,11 +73,24 @@ export type GameResult =
export class ChessEngine {
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 });
generateStartingPosition(this.session);
recordPosition(this.session);
this.activePresets = activePresets ?? new ActivePresetSet();
}
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
// `filterMoves` operates on, so every active preset sees the full
// 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) {
pieceMoves = [
...pieceMoves,
@ -142,7 +162,7 @@ export class ChessEngine {
];
}
}
for (const preset of PRESET_REGISTRY.getActive()) {
for (const preset of activePresets) {
if (preset.filterMoves) {
pieceMoves = preset.filterMoves(pieceMoves, this, piece.id);
}
@ -219,6 +239,12 @@ export class ChessEngine {
// Record position for threefold repetition
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();
}

View file

@ -3,6 +3,7 @@ import { ChessEngine, type GameResult } from '../engine';
import type { PieceType } from '../schema';
import { saveAutoSave } from '../persist/autosave.js';
import * as audio from '../audio';
import type { PresetActivation } from '../net/types';
export function useChessEngine() {
const engineRef = useRef<ChessEngine | null>(null);
@ -97,6 +98,18 @@ export function useChessEngine() {
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 {
engine,
turn: getTurn(),
@ -109,5 +122,7 @@ export function useChessEngine() {
loadEngine,
refresh,
lastMove,
activations: engine.activePresets.list(),
setPresets,
};
}

View file

@ -34,7 +34,7 @@ import type { PieceType } from '../schema';
import type { GameResult } from '../engine';
import { GameClient } from '../net/client';
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';
const WS_URL =
@ -215,6 +215,17 @@ export function useMultiplayerGame(code: string, token: string) {
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(() => {
// Also a no-op: authoritative state is server-driven. The UI should
// 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.
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 {
engine,
turn,
@ -246,6 +263,8 @@ export function useMultiplayerGame(code: string, token: string) {
loadEngine,
refresh,
lastMove,
activations,
setPresets,
// Multiplayer-only metadata. GameView uses these to gate drag and
// render a "waiting for opponent" / error overlay.
myColor: meta.myColor,

View file

@ -29,3 +29,11 @@ export {
type ChessFact,
} from "./schema.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,
GameEndPayload,
GameMovePayload,
GamePresetsPayload,
GameStatePayload,
PresetActivation,
PromotionPiece,
RoomCreatedPayload,
RoomJoinedPayload,
RoomSetPresetsPayload,
} from "./types.js";
// ---------------------------------------------------------------------------
@ -30,6 +33,7 @@ export type GameClientEvent =
| { type: "game.state"; payload: GameStatePayload }
| { type: "game.delta"; payload: GameDeltaPayload }
| { type: "game.end"; payload: GameEndPayload }
| { type: "game.presets"; payload: GamePresetsPayload }
| { type: "room.created"; payload: RoomCreatedPayload }
| { type: "room.joined"; payload: RoomJoinedPayload }
| { type: "error"; payload: ErrorPayload }
@ -260,6 +264,17 @@ export class GameClient {
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)
// -------------------------------------------------------------------------
@ -414,6 +429,9 @@ export class GameClient {
case "game.end":
this.emit({ type, payload: payload as GameEndPayload });
return;
case "game.presets":
this.emit({ type, payload: payload as GamePresetsPayload });
return;
case "room.created":
this.emit({ type, payload: payload as RoomCreatedPayload });
return;

View file

@ -22,7 +22,9 @@ import type { GameClient } from "./client.js";
import type {
Fact,
GameDeltaPayload,
GamePresetsPayload,
GameStatePayload,
PresetActivation,
PromotionPiece,
} from "./types.js";
@ -112,6 +114,7 @@ export class PredictionManager {
private attachListeners(): void {
this.client.on("game.state", (e) => this.applyFullState(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) => {
// Fatal errors tear down the session; the app restarts from a fresh
// `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 {
// Full snapshot from the server (on join or after a large gap). Replace
// `baseEngine` entirely and drop any outstanding predictions — the
// server view supersedes everything.
const next = freshEngine();
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.predictedEngine = null;
this.pendingMoves = [];
@ -179,6 +219,11 @@ function cloneEngine(src: ChessEngine): ChessEngine {
attr: f.attr as string,
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;
}

View file

@ -39,15 +39,34 @@ export interface Fact {
// Server → Client payloads
// ---------------------------------------------------------------------------
export type PresetScope = "both" | "white" | "black";
export interface PresetActivation {
id: string;
scope: PresetScope;
turnsRemaining: number | null;
}
export interface GameStatePayload {
facts: Fact[];
turn: Color;
lastSeq: number;
moveHistory: 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;
}
export interface GamePresetsPayload {
activations: PresetActivation[];
}
export interface RoomSetPresetsPayload {
activations: PresetActivation[];
}
export interface GameOver {
winner: Winner;
reason: GameEndReason;
@ -121,6 +140,7 @@ export type ServerMessage =
| MessageEnvelope<"game.state", GameStatePayload>
| MessageEnvelope<"game.delta", GameDeltaPayload>
| MessageEnvelope<"game.end", GameEndPayload>
| MessageEnvelope<"game.presets", GamePresetsPayload>
| MessageEnvelope<"room.created", RoomCreatedPayload>
| MessageEnvelope<"room.joined", RoomJoinedPayload>
| MessageEnvelope<"error", ErrorPayload>;
@ -129,4 +149,5 @@ export type ClientMessage =
| MessageEnvelope<"room.create", RoomCreatePayload>
| MessageEnvelope<"room.join", RoomJoinPayload>
| 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
* required by P3.4.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect } from "vitest";
import type { EntityId } from "@paratype/rete";
import { ChessEngine } from "../engine.js";
import { PRESET_REGISTRY } from "./index.js";
import { ActivePresetSet } from "./active-set.js";
import type { Square } from "../schema.js";
describe("Preset registry (P3.4)", () => {
beforeEach(() => {
PRESET_REGISTRY.clear();
});
describe("Preset registry (catalog)", () => {
it("pawns-move-backward preset is registered", () => {
const all = PRESET_REGISTRY.getAll();
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);
});
it("activate / isActive / deactivate round-trip", () => {
expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(false);
PRESET_REGISTRY.activate("pawns-move-backward");
expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(true);
expect(PRESET_REGISTRY.getActive().map((p) => p.id)).toContain(
"pawns-move-backward",
);
PRESET_REGISTRY.deactivate("pawns-move-backward");
expect(PRESET_REGISTRY.isActive("pawns-move-backward")).toBe(false);
it("activating an unknown preset throws via ActivePresetSet", () => {
const set = new ActivePresetSet();
expect(() =>
set.replaceAll([
{ id: "nonexistent-rule", scope: "both", turnsRemaining: null },
]),
).toThrow(/not registered/);
});
it("activating an unknown preset throws", () => {
expect(() => PRESET_REGISTRY.activate("nonexistent-rule")).toThrow(
/Unknown preset/,
);
});
it("cannot activate two mutually-incompatible presets", () => {
PRESET_REGISTRY.activate("pawns-move-backward");
expect(() => PRESET_REGISTRY.activate("double-pawn-sprint")).toThrow(
/incompatible/,
);
it("cannot activate two mutually-incompatible presets under overlapping scope", () => {
const set = new ActivePresetSet();
expect(() =>
set.replaceAll([
{ id: "pawns-move-backward", scope: "both", turnsRemaining: null },
{ id: "double-pawn-sprint", scope: "both", turnsRemaining: null },
]),
).toThrow(/incompatible/);
});
});
describe("Preset: pawns-move-backward", () => {
beforeEach(() => {
PRESET_REGISTRY.clear();
});
const preset = () =>
PRESET_REGISTRY.getAll().find((p) => p.id === "pawns-move-backward")!;

View file

@ -3,8 +3,9 @@
* Verifies: all 15 presets registered, incompatibilities correct,
* 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 { ActivePresetSet } from "./active-set.js";
import { Session } from "@paratype/rete";
import type { EntityId } from "@paratype/rete";
import type { ChessEngine } from "../engine.js";
@ -95,8 +96,6 @@ describe("Requires declarations", () => {
});
describe("Functional hook: pawns-move-backward", () => {
beforeEach(() => { PRESET_REGISTRY.clear(); });
it("adds backward move for pawn on rank ≥2", () => {
const preset = PRESET_REGISTRY.getAll().find(p => p.id === "pawns-move-backward")!;
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", () => {
beforeEach(() => { PRESET_REGISTRY.clear(); });
it("activating incompatible pair throws", () => {
PRESET_REGISTRY.activate("rook-warp");
expect(() => PRESET_REGISTRY.activate("wrap-board")).toThrow();
describe("ActivePresetSet incompatibility + requires", () => {
it("activating incompatible pair (overlapping scope) throws", () => {
const set = new ActivePresetSet();
expect(() =>
set.replaceAll([
{ id: "rook-warp", scope: "both", turnsRemaining: null },
{ id: "wrap-board", scope: "both", turnsRemaining: null },
]),
).toThrow();
});
it("activating without dependency throws", () => {
expect(() => PRESET_REGISTRY.activate("king-heals")).toThrow(/requires/);
it("activating without required dependency throws", () => {
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[];
}
/**
* 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 {
private readonly presets = new Map<string, PresetDef>();
private readonly active = new Set<string>();
register(preset: PresetDef): void {
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[] {
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 { useMultiplayerGame } from '../hooks/useMultiplayerGame';
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 { LegalMove } from '../rules/types';
import { useEffect, useState } from 'react';
@ -28,6 +28,8 @@ interface GameEngineState {
canUndo: boolean;
lastMove: { from: number; to: number; [key: string]: unknown } | null | undefined;
refresh: () => void;
activations: PresetActivation[];
setPresets: (activations: PresetActivation[]) => void;
}
interface GameViewProps {
@ -106,7 +108,19 @@ function GameLayout({
state: GameEngineState;
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) => {
applyMove(from, to, promoteTo || 'queen');
@ -171,7 +185,11 @@ function GameLayout({
transition={{ duration: 0.3 }}
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">
{/* 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 type { PresetScope } from '../presets/active-set.js';
import type { PresetActivation } from '../net/types.js';
import { AnimatePresence, motion } from 'motion/react';
import { Settings2, X } from 'lucide-react';
import { toast } from 'sonner';
@ -7,57 +9,121 @@ import { toast } from 'sonner';
/**
* A collapsible side-drawer for toggling preset rules mid-game.
*
* Writes straight to PRESET_REGISTRY on every toggle. The ChessEngine reads
* PRESET_REGISTRY.getActive() fresh on every call to getAllLegalMoves(), so
* a toggle takes effect on the very next move calculation — no reset, no
* reload, no navigation.
*
* Mounts a small tick counter to force a re-render when the registry
* changes externally (e.g. via the /rules page), so this drawer stays in
* sync if both UIs are open.
* Server-authoritative: every toggle/scope/duration change builds a
* full replacement list and dispatches via `setPresets`. In multiplayer
* that goes to the server which echoes `game.presets` back; in local
* mode it mutates the engine directly. Either way the UI reads
* `activations` as its source of truth — we never flip a toggle locally
* and then try to reconcile.
*/
interface RulesDrawerProps {
/** Called after any toggle so the parent can recompute derived UI state
* (legal-move highlights, etc.) that depends on the active preset set. */
/** Current authoritative preset activations (from hook). */
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;
}
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);
// Force-refresh trigger. The registry itself has no event emitter, so we
// bump this counter on every open and every toggle click to re-read the
// active set. The value is never read directly — only the setter is used
// to trigger a re-render of this component.
const [, setTick] = useState(0);
// 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 presets = useMemo(() => PRESET_REGISTRY.getAll(), []);
const activeById = useMemo(() => {
const map = new Map<string, PresetActivation>();
for (const a of activations) map.set(a.id, a);
return map;
}, [activations]);
const toggle = (id: string, name: string) => {
const isActivating = !activeIds.has(id);
if (!isActivating) {
PRESET_REGISTRY.deactivate(id);
toast(`Preset disabled: ${name}`);
} else {
try {
PRESET_REGISTRY.activate(id);
toast.info(`Preset enabled: ${name}`);
} catch (err) {
// activate() throws for missing requires or incompatibilities. We
// surface the reason via the UI only for the user's next refresh.
console.warn(`Could not activate ${id}:`, err);
toast.error(`Could not activate ${name}`);
}
const currently = activeById.get(id);
const next: PresetActivation[] = currently
? applyEdit(activations, id, { remove: true })
: applyEdit(activations, id, { scope: 'both', turnsRemaining: null });
try {
setPresets(next);
toast(
currently ? `Preset disabled: ${name}` : `Preset enabled: ${name}`,
);
} catch (err) {
// Local mode throws synchronously; multiplayer rejects async via
// the server's error event. We tell the user either way.
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?.();
};
@ -75,14 +141,13 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
>
<Settings2 size={16} />
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">
{activeIds.size}
{activeById.size}
</span>
)}
</motion.button>
{/* Backdrop + drawer */}
<AnimatePresence>
{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">
<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">
Toggle rules mid-game
Toggle rules mid-game · scope + duration per rule
</p>
</div>
<button
@ -121,16 +188,8 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{presets.map((preset) => {
const isOn = activeIds.has(preset.id);
const blockedBy = preset.incompatibleWith.filter((id) =>
activeIds.has(id),
);
const missingReqs = preset.requires.filter(
(id) => !activeIds.has(id),
);
const blocked =
!isOn && (blockedBy.length > 0 || missingReqs.length > 0);
const active = activeById.get(preset.id);
const isOn = active !== undefined;
return (
<div
key={preset.id}
@ -138,8 +197,6 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
className={`border rounded-xl p-4 transition-all duration-200 ${
isOn
? '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'
}`}
>
@ -151,25 +208,14 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
{preset.description}
</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>
<button
type="button"
data-role="toggle"
disabled={blocked}
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 ${
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
} ${blocked ? 'cursor-not-allowed' : ''}`}
}`}
role="switch"
aria-checked={isOn}
>
@ -182,15 +228,87 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) {
/>
</button>
</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>
<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'
: `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`}
: `${activeById.size} preset${activeById.size === 1 ? '' : 's'} active`}
</footer>
</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 { PRESET_REGISTRY } from '../presets/index.js';
import { ChessEngine } from '../engine.js';
import { clearAutoSave } from '../persist/autosave.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 {
/** Only required when rules should drive a new game start. */
chessState?: ReturnType<typeof useChessEngine>;
/** Required — the view always shows the engine's current active set
* and delegates mutation back to `chessState.setPresets`. */
chessState: ReturnType<typeof useChessEngine>;
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) {
const navigate = useNavigate();
// Seed local selection from whatever is already active on the registry so
// navigating to /rules mid-game reflects the real state (not a fresh set).
const [selected, setSelected] = useState<Set<string>>(
() => new Set(PRESET_REGISTRY.getActive().map((p) => p.id)),
);
const presets = PRESET_REGISTRY.getAll();
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 { activations, setPresets } = chessState;
const presets = useMemo(() => PRESET_REGISTRY.getAll(), []);
const activeById = useMemo(() => {
const map = new Map<string, PresetActivation>();
for (const a of activations) map.set(a.id, a);
return map;
}, [activations]);
const togglePreset = (id: string) => {
if (isGameActive) return;
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const current = activeById.get(id);
setPresets(
current
? applyEdit(activations, id, { remove: true })
: applyEdit(activations, id, { scope: 'both', turnsRemaining: null }),
);
};
// Keep the singleton registry in sync with the user's current selection on
// every change. This means toggles take effect live — players navigating
// back and forth between /rules and /game see the new rules immediately,
// and any newly-created engine (P4 server path or /save load) will pick
// up the active presets automatically.
useEffect(() => {
// Deactivate first so the activation loop can't trip the registry's
// incompatibility guard against stale entries.
for (const p of PRESET_REGISTRY.getActive()) {
if (!selected.has(p.id)) PRESET_REGISTRY.deactivate(p.id);
const setScope = (id: string, scope: PresetScope) => {
if (isGameActive) return;
setPresets(applyEdit(activations, id, { scope }));
};
const setDuration = (id: string, raw: string) => {
if (isGameActive) return;
if (raw === '') {
setPresets(applyEdit(activations, id, { turnsRemaining: null }));
return;
}
// Activate in dependency-friendly order: items whose `requires` are
// already active first. Simple fixpoint loop — stops when no more
// progress can be made, at which point remaining items are either
// 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 n = Number(raw);
if (!Number.isInteger(n) || n <= 0) return;
setPresets(applyEdit(activations, id, { turnsRemaining: n }));
};
const handleApply = () => {
// Starting a new game with the selected ruleset: reset the engine so
// 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.
// Starting a new game preserving the currently configured rule set.
clearAutoSave();
if (chessState) {
chessState.loadEngine(new ChessEngine());
}
const newEngine = new ChessEngine();
newEngine.activePresets.replaceAll(activations);
chessState.loadEngine(newEngine);
navigate('/game');
};
const activeCount = selected.size;
const activeCount = activeById.size;
return (
<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>
<h1 className="text-3xl font-bold">Preset Rules</h1>
<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>
</div>
<button
@ -121,95 +111,114 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {
</button>
</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">
{presets.map((preset) => (
<div
key={preset.id}
data-preset={preset.id}
className={`border rounded-lg p-4 flex items-center justify-between transition-colors ${
isGameActive ? 'opacity-50 grayscale' : 'hover:border-blue-400'
}`}
>
<div className="pr-4">
<h3 className="text-lg font-semibold">{preset.name}</h3>
<p className="text-sm text-gray-600 mt-1">{preset.description}</p>
{preset.incompatibleWith.length > 0 && (
<p className="text-xs text-gray-400 mt-2">
Incompatible with:{' '}
{preset.incompatibleWith.map((x) => (
<code key={x} className="mx-1">
{x}
</code>
))}
</p>
)}
{preset.requires.length > 0 && (
<p className="text-xs text-gray-400 mt-1">
Requires:{' '}
{preset.requires.map((x) => (
<code key={x} className="mx-1">
{x}
</code>
))}
</p>
{presets.map((preset) => {
const active = activeById.get(preset.id);
const isOn = active !== undefined;
return (
<div
key={preset.id}
data-preset={preset.id}
className={`border rounded-lg p-4 transition-colors ${
isGameActive
? 'opacity-50 grayscale'
: 'hover:border-blue-400'
}`}
>
<div className="flex items-start justify-between">
<div className="pr-4">
<h3 className="text-lg font-semibold">{preset.name}</h3>
<p className="text-sm text-gray-600 mt-1">
{preset.description}
</p>
</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 ${
isOn ? 'bg-blue-600' : 'bg-gray-200'
} ${isGameActive ? 'cursor-not-allowed' : ''}`}
role="switch"
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>
<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 className="pt-6 space-y-3">
<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 className="text-sm text-gray-500">
{activeCount === 0

View file

@ -26,8 +26,10 @@ import {
type ErrorCode,
type Fact as WireFact,
type GameMovePayload,
type PresetActivation,
type RoomCreatePayload,
type RoomJoinPayload,
type RoomSetPresetsPayload,
type ServerMessage,
} from "./protocol.js";
import { DEFAULT_GRACE_MS, reconnectManager } from "./reconnect.js";
@ -228,11 +230,15 @@ export function handleMessage(
case "game.move":
handleGameMove(ws, msg.payload);
break;
case "room.setPresets":
handleSetPresets(ws, msg.payload);
break;
case "room.created":
case "room.joined":
case "game.state":
case "game.delta":
case "game.end":
case "game.presets":
case "error":
sendTo(
ws,
@ -357,6 +363,7 @@ function handleRoomJoin(
lastSeq: 0,
moveHistory: [],
activeRules: [...result.activeRules],
activations: session.getPresetActivations(),
// 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
// FEN can derive it from `facts`.
@ -446,6 +453,7 @@ function handleReconnect(
lastSeq: missed.length > 0 ? (missed[missed.length - 1]?.seq ?? 0) : 0,
moveHistory: [],
activeRules: [...room.rulesetIds],
activations: session.getPresetActivations(),
fen: "",
}),
);
@ -529,6 +537,12 @@ function handleGameMove(
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 moveResult = session.applyMove(
payload.from,
@ -575,6 +589,16 @@ function handleGameMove(
broadcastToRoom(roomCode, deltaMsg);
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
// PROTOCOL.md §game.end. `finalFen` is empty for v1 (see note above).
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
* (disconnected), buffer a copy of a just-broadcast delta so the

View file

@ -12,6 +12,9 @@
import {
ChessEngine,
algebraicToSquare,
PresetActivationError,
type ActivationRequest,
type PresetActivation,
type GameResult,
type PieceColor,
type PieceType,
@ -87,16 +90,64 @@ export class GameSession {
> | null = null;
/**
* @param _rulesetIds — activated preset IDs from room.create. v1: accepted
* for API shape but not yet wired to ChessEngine. Preset activation is
* tracked by PRESET_REGISTRY which is process-global today; per-room
* preset isolation is a follow-up (tracked in PROTOCOL.md).
* @param rulesetIds — preset IDs from room.create. Each is activated
* as `scope=both, turnsRemaining=null` (permanent) on the engine's
* private ActivePresetSet. Invalid ids are silently skipped here —
* the server validates them earlier at the protocol layer.
*/
constructor(_rulesetIds: readonly string[] = []) {
constructor(rulesetIds: readonly string[] = []) {
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();
}
/**
* 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. */
getAllFacts(): Fact[] {
return this.snapshotFacts();

View file

@ -109,6 +109,40 @@ export const GameMovePayloadSchema = z.object({
});
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
// ---------------------------------------------------------------------------
@ -133,11 +167,27 @@ export const GameStatePayloadSchema = z.object({
turn: ColorSchema,
lastSeq: z.number().int().nonnegative(),
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()),
/** 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(),
});
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({
winner: WinnerSchema,
reason: GameEndReasonSchema,
@ -188,12 +238,17 @@ export const RoomCreateMessageSchema = msg(
export const RoomJoinMessageSchema = msg("room.join", RoomJoinPayloadSchema);
export const RoomLeaveMessageSchema = msg("room.leave", RoomLeavePayloadSchema);
export const GameMoveMessageSchema = msg("game.move", GameMovePayloadSchema);
export const RoomSetPresetsMessageSchema = msg(
"room.setPresets",
RoomSetPresetsPayloadSchema,
);
export const ClientMessageSchema = z.discriminatedUnion("type", [
RoomCreateMessageSchema,
RoomJoinMessageSchema,
RoomLeaveMessageSchema,
GameMoveMessageSchema,
RoomSetPresetsMessageSchema,
]);
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
@ -208,6 +263,10 @@ export const RoomJoinedMessageSchema = msg(
export const GameStateMessageSchema = msg("game.state", GameStatePayloadSchema);
export const GameDeltaMessageSchema = msg("game.delta", GameDeltaPayloadSchema);
export const GameEndMessageSchema = msg("game.end", GameEndPayloadSchema);
export const GamePresetsMessageSchema = msg(
"game.presets",
GamePresetsPayloadSchema,
);
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
export const ServerMessageSchema = z.discriminatedUnion("type", [
@ -216,6 +275,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
GameStateMessageSchema,
GameDeltaMessageSchema,
GameEndMessageSchema,
GamePresetsMessageSchema,
ErrorMessageSchema,
]);
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
@ -225,11 +285,13 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
RoomJoinMessageSchema,
RoomLeaveMessageSchema,
GameMoveMessageSchema,
RoomSetPresetsMessageSchema,
RoomCreatedMessageSchema,
RoomJoinedMessageSchema,
GameStateMessageSchema,
GameDeltaMessageSchema,
GameEndMessageSchema,
GamePresetsMessageSchema,
ErrorMessageSchema,
]);
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
@ -239,11 +301,13 @@ export const KNOWN_MESSAGE_TYPES = [
"room.join",
"room.leave",
"game.move",
"room.setPresets",
"room.created",
"room.joined",
"game.state",
"game.delta",
"game.end",
"game.presets",
"error",
] as const;
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];