diff --git a/packages/chess/e2e/multiplayer.spec.ts b/packages/chess/e2e/multiplayer.spec.ts index c0b59dd..55a1702 100644 --- a/packages/chess/e2e/multiplayer.spec.ts +++ b/packages/chess/e2e/multiplayer.spec.ts @@ -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(); +}); diff --git a/packages/chess/src/engine-presets.test.ts b/packages/chess/src/engine-presets.test.ts index 6bcf327..70fb73d 100644 --- a/packages/chess/src/engine-presets.test.ts +++ b/packages/chess/src/engine-presets.test.ts @@ -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); + }); }); }); diff --git a/packages/chess/src/engine.ts b/packages/chess/src/engine.ts index 0698c89..f9b0ba6 100644 --- a/packages/chess/src/engine.ts +++ b/packages/chess/src/engine.ts @@ -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(); } diff --git a/packages/chess/src/hooks/useChessEngine.ts b/packages/chess/src/hooks/useChessEngine.ts index 20e6796..f67a876 100644 --- a/packages/chess/src/hooks/useChessEngine.ts +++ b/packages/chess/src/hooks/useChessEngine.ts @@ -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(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, }; } diff --git a/packages/chess/src/hooks/useMultiplayerGame.ts b/packages/chess/src/hooks/useMultiplayerGame.ts index 2fea876..590813a 100644 --- a/packages/chess/src/hooks/useMultiplayerGame.ts +++ b/packages/chess/src/hooks/useMultiplayerGame.ts @@ -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, diff --git a/packages/chess/src/index.ts b/packages/chess/src/index.ts index 42013ee..ae13cb0 100644 --- a/packages/chess/src/index.ts +++ b/packages/chess/src/index.ts @@ -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"; diff --git a/packages/chess/src/net/client.ts b/packages/chess/src/net/client.ts index c8b9be8..45295ce 100644 --- a/packages/chess/src/net/client.ts +++ b/packages/chess/src/net/client.ts @@ -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; diff --git a/packages/chess/src/net/prediction.ts b/packages/chess/src/net/prediction.ts index d1dbe9e..b91ee20 100644 --- a/packages/chess/src/net/prediction.ts +++ b/packages/chess/src/net/prediction.ts @@ -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; } diff --git a/packages/chess/src/net/types.ts b/packages/chess/src/net/types.ts index 9ed212d..5423905 100644 --- a/packages/chess/src/net/types.ts +++ b/packages/chess/src/net/types.ts @@ -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> - | MessageEnvelope<"game.move", GameMovePayload>; + | MessageEnvelope<"game.move", GameMovePayload> + | MessageEnvelope<"room.setPresets", RoomSetPresetsPayload>; diff --git a/packages/chess/src/presets/active-set.test.ts b/packages/chess/src/presets/active-set.test.ts new file mode 100644 index 0000000..dfeba6e --- /dev/null +++ b/packages/chess/src/presets/active-set.test.ts @@ -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); + }); + }); +}); diff --git a/packages/chess/src/presets/active-set.ts b/packages/chess/src/presets/active-set.ts new file mode 100644 index 0000000..66d4394 --- /dev/null +++ b/packages/chess/src/presets/active-set.ts @@ -0,0 +1,233 @@ +/** + * Per-game active preset tracking. + * + * Earlier iterations of the preset system used a module-level + * `PRESET_REGISTRY.active: Set` 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(); + + /** + * 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; + } +} diff --git a/packages/chess/src/presets/pawns-move-backward.test.ts b/packages/chess/src/presets/pawns-move-backward.test.ts index 30d7d41..c6503d8 100644 --- a/packages/chess/src/presets/pawns-move-backward.test.ts +++ b/packages/chess/src/presets/pawns-move-backward.test.ts @@ -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")!; diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index 65f3aad..69bf44a 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -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); }); }); diff --git a/packages/chess/src/presets/registry.ts b/packages/chess/src/presets/registry.ts index 12db5ab..49c28c1 100644 --- a/packages/chess/src/presets/registry.ts +++ b/packages/chess/src/presets/registry.ts @@ -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(); - private readonly active = new Set(); 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); } } diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index f753701..b421975 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -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" > - +
{/* Header/Info section */} diff --git a/packages/chess/src/ui/RulesDrawer.tsx b/packages/chess/src/ui/RulesDrawer.tsx index 9ba41ab..ade0a48 100644 --- a/packages/chess/src/ui/RulesDrawer.tsx +++ b/packages/chess/src/ui/RulesDrawer.tsx @@ -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 & { 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(); + 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, + ) => { + 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 = {}) { > Rules - {activeIds.size > 0 && ( + {activeById.size > 0 && ( - {activeIds.size} + {activeById.size} )} - {/* Backdrop + drawer */} {open && ( <> @@ -103,9 +168,11 @@ export function RulesDrawer({ onRulesChanged }: RulesDrawerProps = {}) { >
-

Live Rules

+

+ Live Rules +

- Toggle rules mid-game + Toggle rules mid-game · scope + duration per rule

+ + {isOn && ( +
+ {/* Scope radios */} +
+ +
+ {(['both', 'white', 'black'] as const).map( + (scope) => { + const selected = active.scope === scope; + return ( + + ); + }, + )} +
+
+ + {/* Duration input */} +
+ +
+ 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" + /> + + {active.turnsRemaining === null + ? 'permanent' + : 'turns'} + +
+
+
+ )} ); })}
- {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`}
diff --git a/packages/chess/src/ui/RulesView.tsx b/packages/chess/src/ui/RulesView.tsx index e87991d..360fc11 100644 --- a/packages/chess/src/ui/RulesView.tsx +++ b/packages/chess/src/ui/RulesView.tsx @@ -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; + /** Required — the view always shows the engine's current active set + * and delegates mutation back to `chessState.setPresets`. */ + chessState: ReturnType; isGameActive?: boolean; } +/** Build a fresh activation list that reflects a single edit. */ +function applyEdit( + current: readonly PresetActivation[], + id: string, + patch: Partial & { 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>( - () => 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(); + 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 (
@@ -109,7 +98,8 @@ export function RulesView({ chessState, isGameActive }: RulesViewProps) {

Preset Rules

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

+
+ + {isOn && ( +
+
+ +
+ {(['both', 'white', 'black'] as const).map((scope) => { + const selected = active.scope === scope; + return ( + + ); + })} +
+
+
+ + 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" + /> +
+
)} - - - ))} + ); + })}
- Tip: toggles above take effect immediately — including in the middle of a game. No reset required. + Tip: edits above take effect immediately — + including in the middle of a game. No reset required.
{activeCount === 0 diff --git a/packages/server/src/broadcast.ts b/packages/server/src/broadcast.ts index 1f926ec..56711ce 100644 --- a/packages/server/src/broadcast.ts +++ b/packages/server/src/broadcast.ts @@ -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, + 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 diff --git a/packages/server/src/game-session.ts b/packages/server/src/game-session.ts index 1f2083f..ba551db 100644 --- a/packages/server/src/game-session.ts +++ b/packages/server/src/game-session.ts @@ -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(); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index de6dfa5..3954750 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -109,6 +109,40 @@ export const GameMovePayloadSchema = z.object({ }); export type GameMovePayload = z.infer; +// --------------------------------------------------------------------------- +// Preset scope + activation — used by both directions of the preset sync. +// --------------------------------------------------------------------------- + +export const PresetScopeSchema = z.enum(["both", "white", "black"]); +export type PresetScope = z.infer; + +/** + * 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; + +/** + * 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; + // --------------------------------------------------------------------------- // 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; +/** + * 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; + 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; @@ -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; @@ -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; @@ -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];