From 8b8a5179ec4e3f754ac3fa0954e657cf5501e084 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 13:31:44 -0600 Subject: [PATCH 01/10] fix(chess): lift dragged piece above all other pieces The piece`s own z-index could only stack within its grid cell`s context, so when translated over neighbouring cells it rendered underneath their pieces. Promote the hosting cell to z-50 while it holds the dragged piece so the whole cell (and piece inside) float above the board. --- packages/chess/src/ui/Board.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx index 1bba8b3..bc722ff 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -125,14 +125,20 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq const isCheckedKing = checkedKingSquare === sq; const piece = pieces.get(sq); - + // While this cell hosts the actively-dragged piece, lift the whole + // cell above all siblings in the grid. Z-index inside the + // component can only stack within its own cell's stacking context, + // so the piece would render *under* neighbouring cells' pieces when + // translated over them. Promoting the cell itself fixes that. + const isHostingDragged = draggedPiece?.square === sq; + squares.push(
handleDragOver(e, sq)} onDrop={(e) => handleDrop(e, sq)} > From 5fb96647ebacd8a8c98470b1b8156a8c47e7c12c Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 13:54:06 -0600 Subject: [PATCH 02/10] fix(chess): wire multiplayer live sync via GameClient + PredictionManager Previously, GameView used a local ChessEngine regardless of whether the user was in a multiplayer room. Moves were never sent to the server and the opponent only saw updates on full page reload. Introduce useMultiplayerGame, a React hook that wraps GameClient and PredictionManager and exposes the same shape as useChessEngine. App reads sessionStorage once at mount of /game and dispatches to either MultiplayerGameView (server-backed) or GameView (local) accordingly. Board now accepts myColor to gate drag by piece ownership in addition to turn, so black pieces never become draggable on white`s board and vice versa. Rewrites the multiplayer E2E to actually validate live sync: each drag on one page is asserted to appear on the opposite page before the next move. The previous test drove both colors from one page because the GameClient wiring was missing; that workaround is no longer needed. --- packages/chess/e2e/multiplayer.spec.ts | 138 ++++++---- packages/chess/src/app/App.tsx | 36 ++- .../chess/src/hooks/useMultiplayerGame.ts | 256 ++++++++++++++++++ packages/chess/src/ui/Board.tsx | 22 +- packages/chess/src/ui/GameView.tsx | 147 ++++++++-- 5 files changed, 509 insertions(+), 90 deletions(-) create mode 100644 packages/chess/src/hooks/useMultiplayerGame.ts diff --git a/packages/chess/e2e/multiplayer.spec.ts b/packages/chess/e2e/multiplayer.spec.ts index 1ce9cee..c0b59dd 100644 --- a/packages/chess/e2e/multiplayer.spec.ts +++ b/packages/chess/e2e/multiplayer.spec.ts @@ -1,30 +1,28 @@ /** * P4.12 — E2E multiplayer scenario (Phase 4 gate) * - * Two browser contexts create/join a room, play 9 moves alternating sides, - * ctx B (black) disconnects at move 6 (Nc6), ctx A (white) plays move 7 - * (Qh5) during the grace window, ctx B reconnects at move 8 and resumes, - * game completes to Scholar's Mate checkmate, both clients reach game-over. + * Two browser contexts create/join a room and play Scholar's Mate with + * LIVE sync: every drag on one page appears on the other via server + * `game.delta` broadcast. Ctx B disconnects mid-game, ctx A plays a move + * during B's grace window, ctx B reconnects and catches up via the + * server's buffered deltas, then the game completes to checkmate on + * both boards. * * Architecture notes * ────────────────── - * 1. Lobby room creation: GameClient.connectAndCreate() passes - * `autoCreate: undefined` to openConnection(), which requires - * `autoCreate !== undefined` to fire room.create — the message is never - * sent via the UI button. The test therefore drives room create/join - * directly via page.evaluate (raw WebSocket from the browser context) - * so it stays inside the browser security model (Origin header = - * http://localhost:5173, which is in the server's allow-list). + * 1. Room create/join is driven directly via raw WebSocket inside + * page.evaluate (Origin = http://localhost:5173 so it's inside the + * server's allow-list). This matches what the Lobby UI does. * - * 2. GameView uses a local ChessEngine (no GameClient integration yet). - * Real-time board sync is not wired; each context runs its own game. - * The test validates: - * a. WebSocket server handles room.create / room.join correctly - * b. Both contexts navigate to /game and display a playable board - * c. Disconnect-then-reconnect: ctx B closes its page mid-game and - * reopens in the SAME browser context (sessionStorage preserved), - * just as a real client would reuse a stored token on reconnect - * d. Scholar's Mate checkmate renders game-over on both boards + * 2. Once room-code/room-token/player-color are in sessionStorage, + * navigating to /game mounts MultiplayerGameView, which opens its + * own GameClient + PredictionManager. Moves are sent as `game.move` + * and the server echoes `game.delta` to BOTH sockets, so each player + * sees the opponent's moves live. + * + * 3. Drag is turn-gated AND color-gated: white moves are dragged on page + * A, black moves on page B. The Piece component sets `draggable=false` + * for pieces that don't match `myColor`. * * Server-level reconnect (seq tracking, game.state replay, game.delta * buffering) is unit-tested in packages/server/src/broadcast.test.ts. @@ -230,68 +228,92 @@ test("multiplayer: two contexts, reconnect at move 7, Scholar's Mate checkmate", await pageB.goto('http://localhost:5173/game'); await expect(pageB.locator('[data-testid="turn-indicator"]')).toBeVisible(); - // ── Step 3: Play moves 1–6 on ctx A's local board ──────────────────────── + // Both pages should now be connected to the server via their own + // MultiplayerGameViews. Wait for each to show its color badge so we + // know the initial game.state snapshot has arrived and the board is + // interactive. + await expect(pageA.locator('[data-testid="my-color"]')).toContainText('white'); + await expect(pageB.locator('[data-testid="my-color"]')).toContainText('black'); + + // ── Step 3: Play moves 1–6 with live sync ───────────────────────────────── // - // 9-move Scholar's Mate sequence: - // 1. a2-a3 (white) — filler opening move - // 2. h7-h6 (black) — filler opening move - // 3. e2-e4 (white) — Scholar's Mate setup - // 4. e7-e5 (black) - // 5. f1-c4 (Bc4) - // 6. b8-c6 (Nc6) ← disconnect ctx B after this move - // 7. d1-h5 (Qh5) — threat Qxf7#; played while B is disconnected - // ← reconnect ctx B - // 8. g8-f6 (Nf6??) — fatal Scholar's Mate blunder - // 9. h5-f7 (Qxf7#) — CHECKMATE + // 9-move Scholar's Mate sequence, alternating sides: + // 1. a2-a3 (white on A) + // 2. h7-h6 (black on B) + // 3. e2-e4 (white on A) + // 4. e7-e5 (black on B) + // 5. f1-c4 (white on A) Bc4 + // 6. b8-c6 (black on B) Nc6 ← disconnect B after this move + // 7. d1-h5 (white on A) Qh5 played while B is disconnected + // ← reconnect B + // 8. g8-f6 (black on B) Nf6?? — fatal Scholar's Mate blunder + // 9. h5-f7 (white on A) Qxf7# — CHECKMATE + // + // After each drag we assert that the OPPOSITE page's board reflects + // the move — that's the live-sync check. await drag(pageA, 'a2', 'a3'); // 1. white - await drag(pageA, 'h7', 'h6'); // 2. black + await expect(pageB.locator('[data-square="a3"] [data-piece="white-pawn"]')).toBeVisible(); + + await drag(pageB, 'h7', 'h6'); // 2. black + await expect(pageA.locator('[data-square="h6"] [data-piece="black-pawn"]')).toBeVisible(); + await drag(pageA, 'e2', 'e4'); // 3. white - await drag(pageA, 'e7', 'e5'); // 4. black - await drag(pageA, 'f1', 'c4'); // 5. white Bc4 - await drag(pageA, 'b8', 'c6'); // 6. black Nc6 + await expect(pageB.locator('[data-square="e4"] [data-piece="white-pawn"]')).toBeVisible(); + + await drag(pageB, 'e7', 'e5'); // 4. black + await expect(pageA.locator('[data-square="e5"] [data-piece="black-pawn"]')).toBeVisible(); + + await drag(pageA, 'f1', 'c4'); // 5. white Bc4 + await expect(pageB.locator('[data-square="c4"] [data-piece="white-bishop"]')).toBeVisible(); + + await drag(pageB, 'b8', 'c6'); // 6. black Nc6 + await expect(pageA.locator('[data-square="c6"] [data-piece="black-knight"]')).toBeVisible(); await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText('White'); + await expect(pageB.locator('[data-testid="turn-indicator"]')).toContainText('White'); // ── Step 4: ctx B disconnects (simulates network drop) ─────────────────── await pageB.close(); // ── Step 5: ctx A plays move 7 (Qh5) during B's grace window ───────────── - await drag(pageA, 'd1', 'h5'); // 7. white Qh5 + await drag(pageA, 'd1', 'h5'); // 7. white Qh5 await expect(pageA.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible(); await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText('Black'); // ── Step 6: ctx B reconnects ───────────────────────────────────────────── // A new page in the SAME browser context inherits sessionStorage - // (room-code, room-token, player-color), mirroring GameClient's token - // reuse on reconnect. Navigate straight to /game — App reads autosave - // if present (empty in this context) and renders the initial position. + // (room-code, room-token, player-color). MultiplayerGameView opens a + // new GameClient, sends `room.join` with the stored token, and the + // server's reconnect path sends a fresh game.state snapshot reflecting + // ALL moves (including Qh5 played while we were disconnected). const pageB2 = await ctxB.newPage(); + // Re-plant sessionStorage on the new tab. sessionStorage is per-tab + // per the browser spec, so ctxB.newPage() does NOT inherit pageB's + // session storage — only localStorage. This mirrors a real client + // that reloaded its tab (same-tab reload DOES preserve sessionStorage); + // the test uses a new tab because pageB.close() is the easiest way to + // simulate a disconnect, so we manually restore the token the way the + // Lobby would. + await pageB2.goto('http://localhost:5173/'); + await pageB2.evaluate((r) => { + sessionStorage.setItem('room-code', r.code); + sessionStorage.setItem('room-token', r.token); + sessionStorage.setItem('player-color', r.color); + }, roomB); await pageB2.goto('http://localhost:5173/game'); await expect(pageB2.locator('[data-testid="turn-indicator"]')).toBeVisible(); + // The snapshot should include Qh5 on h5. + await expect(pageB2.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible(); - // ── Step 7: Complete Scholar's Mate on ctx A's board ───────────────────── - await drag(pageA, 'g8', 'f6'); // 8. black Nf6?? (fatal blunder) - await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText('White'); + // ── Step 7: Complete Scholar's Mate with live sync ─────────────────────── + await drag(pageB2, 'g8', 'f6'); // 8. black Nf6?? + await expect(pageA.locator('[data-square="f6"] [data-piece="black-knight"]')).toBeVisible(); - await drag(pageA, 'h5', 'f7'); // 9. white Qxf7# — CHECKMATE + await drag(pageA, 'h5', 'f7'); // 9. white Qxf7# — CHECKMATE await expect(pageA.locator('[data-testid="game-over"]')).toBeVisible(); - - // ── Step 8: Play Scholar's Mate on ctx B2's reconnected (fresh) board ───── - // B's local board starts from the initial position after reconnect. - // Playing the same mate on B's board verifies both clients can reach - // game-over independently — the Phase 4 gate condition. - await drag(pageB2, 'e2', 'e4'); // 1. white - await drag(pageB2, 'e7', 'e5'); // 1... black - await drag(pageB2, 'f1', 'c4'); // 2. Bc4 - await drag(pageB2, 'b8', 'c6'); // 2... Nc6 - await drag(pageB2, 'd1', 'h5'); // 3. Qh5 - await drag(pageB2, 'g8', 'f6'); // 3... Nf6?? - await drag(pageB2, 'h5', 'f7'); // 4. Qxf7# — CHECKMATE - await expect(pageB2.locator('[data-testid="game-over"]')).toBeVisible(); - // Both clients see game-over — Phase 4 gate condition satisfied. await ctxA.close(); await ctxB.close(); }); diff --git a/packages/chess/src/app/App.tsx b/packages/chess/src/app/App.tsx index 7b926b3..dbf9ecd 100644 --- a/packages/chess/src/app/App.tsx +++ b/packages/chess/src/app/App.tsx @@ -1,7 +1,7 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { Routes, Route, useNavigate, useLocation } from 'react-router-dom' import { Lobby } from '../ui/Lobby' -import { GameView } from '../ui/GameView' +import { GameView, MultiplayerGameView } from '../ui/GameView' import { RulesView } from '../ui/RulesView' import { SavePanel } from '../ui/SavePanel' import { ImportExport } from '../ui/ImportExport' @@ -33,7 +33,7 @@ export function App() { } /> - } /> + } /> } /> } /> @@ -42,6 +42,36 @@ export function App() { ) } +/** + * /game route dispatcher. Reads sessionStorage ONCE at mount to decide + * between single-player (local engine via useChessEngine) and + * multiplayer (server-backed via useMultiplayerGame). + * + * We snapshot into state so that a subsequent sessionStorage mutation + * (e.g. a route change back to the lobby) doesn't swap the hook used + * by an already-mounted game view — React's rules of hooks require the + * chosen branch to stay stable for the component's lifetime. + * + * The Lobby writes room-code + room-token just before navigating here, + * so this pickup is deterministic. If either is missing we render the + * local mode. + */ +function GameRoute({ + chessState, +}: { + chessState: ReturnType +}) { + const [mpCreds] = useState(() => { + const code = sessionStorage.getItem('room-code') + const token = sessionStorage.getItem('room-token') + return code !== null && token !== null ? { code, token } : null + }) + if (mpCreds !== null) { + return + } + return +} + function PageTransition({ children }: { children: React.ReactNode }) { return ( ` doesn't need to care whether it's + * running locally or over the wire. Consumers pick the hook based on + * whether sessionStorage has a room-code/token pair (see GameView). + * + * Lifecycle + * ───────── + * - On mount: opens a WebSocket, sends `room.join` with the stored token. + * The server recognises the token (set by the Lobby's one-shot create/ + * join flow that closed its socket moments before) and follows the + * reconnect code path, which responds with `room.joined` + a full + * `game.state` snapshot. PredictionManager populates `baseEngine` from + * that snapshot. + * - While connected: remote `game.delta` events are applied to + * `baseEngine`, and `onStateChange` fires — which bumps a React tick + * and re-derives the hook's outputs from the (now updated) engine. + * Local `applyMove` calls `applyPrediction` which updates UI optimistically + * and sends `game.move` to the server. The server echoes a `game.delta` + * that clears the prediction. + * - On unmount: closes the socket. A fresh mount will reconnect again. + * + * Turn gating + * ─────────── + * The hook exposes `myColor` so the UI can disable drag for opponent + * pieces. The server rejects out-of-turn `game.move` frames too, which + * would roll the prediction back via the `error` event — but we prefer + * not to even fire the optimistic update so the UI stays honest. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; +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 * as audio from '../audio'; + +const WS_URL = + (import.meta as { env?: Record }).env?.['VITE_WS_URL'] ?? + 'ws://localhost:7357/ws'; + +export interface MultiplayerGameState { + /** Whether the socket is currently open. */ + connected: boolean; + /** The side this browser controls ('white' | 'black'), or null until + * room.joined arrives. */ + myColor: Color | null; + /** True while we're waiting for the initial game.state snapshot to + * arrive (engine may be empty). */ + loading: boolean; + /** Room error surfaced to the UI (e.g. ROOM_NOT_FOUND on reconnect + * after grace expiry). Null when healthy. */ + error: string | null; +} + +/** + * @param code Room code from sessionStorage. + * @param token Player token from sessionStorage. + */ +export function useMultiplayerGame(code: string, token: string) { + // One tick counter drives re-renders on every PredictionManager state + // change. The engine itself is stored on a ref so we can read its + // latest state synchronously inside callbacks without a stale closure. + const clientRef = useRef(null); + const managerRef = useRef(null); + const [tick, setTick] = useState(0); + const [meta, setMeta] = useState({ + connected: false, + myColor: null, + loading: true, + error: null, + }); + + // Mount the connection exactly once per code/token pair. + useEffect(() => { + const client = new GameClient(WS_URL); + const manager = new PredictionManager(client, () => { + // Any change in authoritative or predicted state: bump tick so the + // component re-renders and picks up the new facts. + setTick((t) => t + 1); + }); + clientRef.current = client; + managerRef.current = manager; + + const onConnected = () => { + setMeta((m) => ({ ...m, connected: true, error: null })); + }; + const onDisconnected = (e: { willReconnect: boolean }) => { + setMeta((m) => ({ + ...m, + connected: false, + error: e.willReconnect ? null : 'Disconnected from server', + })); + }; + const onJoined = (e: { payload: { color: Color } }) => { + setMeta((m) => ({ + ...m, + myColor: e.payload.color, + loading: false, + })); + }; + const onGameState = () => { + // The first game.state snapshot after (re)connect clears `loading`. + setMeta((m) => ({ ...m, loading: false })); + }; + const onGameDelta = (e: { + payload: { + gameOver: { winner: string; reason: string } | null; + turn: Color; + }; + }) => { + // Play sound based on the delta outcome. We deliberately classify + // by the delta rather than by inspecting retracted/inserted facts + // because the server doesn't include move semantics (capture, + // check, etc.) in the wire payload today. + if (e.payload.gameOver !== null) { + audio.play( + e.payload.gameOver.reason === 'checkmate' ? 'checkmate' : 'move', + ); + } else { + audio.play('move'); + } + }; + const onError = (e: { payload: { code: string; message: string; fatal: boolean } }) => { + // Fatal errors tear down the session entirely and the server + // closes the socket. Non-fatal server errors (ILLEGAL_MOVE, + // NOT_YOUR_TURN, GAME_OVER) roll back the prediction inside + // PredictionManager; we surface them briefly so users see why + // their move didn't stick. + if (e.payload.fatal) { + setMeta((m) => ({ ...m, error: e.payload.message })); + } else { + setMeta((m) => ({ ...m, error: e.payload.message })); + // Clear non-fatal errors after a short delay so a single bad + // click doesn't leave a stale banner. + setTimeout(() => { + setMeta((m) => + m.error === e.payload.message ? { ...m, error: null } : m, + ); + }, 2000); + } + }; + + client.on('connected', onConnected); + client.on('disconnected', onDisconnected); + client.on('room.joined', onJoined); + client.on('game.state', onGameState); + client.on('game.delta', onGameDelta); + client.on('error', onError); + + client.connect(code, token).catch((err: unknown) => { + setMeta((m) => ({ + ...m, + error: err instanceof Error ? err.message : 'Connection failed', + loading: false, + })); + }); + + return () => { + // Remove listeners before close so in-flight events don't trigger + // state updates after unmount. + client.off('connected', onConnected); + client.off('disconnected', onDisconnected); + client.off('room.joined', onJoined); + client.off('game.state', onGameState); + client.off('game.delta', onGameDelta); + client.off('error', onError); + client.close(); + clientRef.current = null; + managerRef.current = null; + }; + }, [code, token]); + + // Outputs derived from the manager's current engine. We recompute on + // every tick; PredictionManager guarantees `onStateChange` fires for + // every mutation. + const manager = managerRef.current; + const engine = manager?.getCurrentEngine() ?? null; + + const facts = engine?.session.allFacts() ?? []; + const turn = engine?.getCurrentTurn() ?? 'white'; + const legalMoves = engine?.getAllLegalMoves() ?? []; + const result: GameResult = engine?.checkGameResult() ?? 'ongoing'; + + const applyMove = useCallback( + (from: number, to: number, promoteTo: PieceType = 'queen'): GameResult | null => { + const mgr = managerRef.current; + if (!mgr) return null; + const promo = promoteTo as PromotionPiece; + const ok = mgr.applyPrediction(from, to, promo); + if (!ok) return null; + // `applyPrediction` updated the engine synchronously via + // `onStateChange`; we can just return the new result. + const eng = mgr.getCurrentEngine(); + const moveResult = eng.checkGameResult(); + // Local sound playback for the moving player. The opponent's client + // plays its own sound off the `game.delta` event. + if (moveResult === 'checkmate') audio.play('checkmate'); + else audio.play('move'); + return moveResult; + }, + [], + ); + + // Undo is not meaningful in multiplayer: moves are authoritative on + // the server. Return a no-op + canUndo=false so GameView's shared + // contract still works. + const undo = useCallback(() => { + // intentional no-op + }, []); + + const refresh = useCallback(() => { + setTick((t) => t + 1); + }, []); + + 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 + // changes come through `game.state` / `game.delta`. + }, []); + + // `lastMove` in multiplayer: we don't track an optimistic move history + // here because re-renders are driven by engine snapshots, not a list. + // Deriving last-move from Position facts is O(n); acceptable for + // boards with ≤ 32 pieces. We return null for simplicity — the board's + // yellow last-move highlight will simply not appear in multiplayer + // until we wire it through `game.delta.moveNotation` in a later pass. + const lastMove = null; + + // Explicit reference so lint doesn't flag `tick` as unused. Each tick + // change invalidates the derived outputs above naturally via closure + // because `engine.session.allFacts()` is called fresh each render. + void tick; + + return { + engine, + turn, + facts, + legalMoves, + result, + applyMove, + undo, + canUndo: false, + loadEngine, + refresh, + lastMove, + // Multiplayer-only metadata. GameView uses these to gate drag and + // render a "waiting for opponent" / error overlay. + myColor: meta.myColor, + connected: meta.connected, + loading: meta.loading, + error: meta.error, + }; +} diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx index bc722ff..30613d5 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -11,6 +11,10 @@ interface BoardProps { legalMoves: LegalMove[]; onMove: (from: number, to: number, promoteTo?: PieceType) => void; turn: PieceColor; + /** The color the local player controls. `null` means local/solo play + * (either color can be dragged on its turn). In multiplayer this + * restricts drag to the player's own pieces only. */ + myColor?: PieceColor | null; /** Last move played — extra fields ignored. Accepting the wider shape lets * callers pass the hook's return value directly without stripping keys. */ lastMove?: { from: number; to: number; [key: string]: unknown } | null | undefined; @@ -23,7 +27,7 @@ interface PieceState { color: PieceColor; } -export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSquare }: BoardProps) { +export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare }: BoardProps) { // Build pieces map: square -> { id, type, color } const pieces = useMemo(() => { const map = new Map(); @@ -74,12 +78,15 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq return targets; }, [draggedPiece, legalMoves]); - // Handlers + // Handlers. Drag is only allowed for pieces of the side to move AND, + // in multiplayer, the local player's own pieces. `myColor === null` + // indicates local/solo mode where both sides are controlled from this + // client. const handleDragStart = (id: number, square: number) => { const p = pieces.get(square); - if (p && p.color === turn) { // only drag pieces of current turn - setDraggedPiece({ id, square }); - } + if (!p || p.color !== turn) return; + if (myColor !== null && myColor !== undefined && p.color !== myColor) return; + setDraggedPiece({ id, square }); }; const handleDragEnd = () => { @@ -175,7 +182,10 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq type={piece.type} pieceId={piece.id} square={sq} - isDraggable={piece.color === turn} + isDraggable={ + piece.color === turn && + (myColor === null || myColor === undefined || piece.color === myColor) + } onDragStart={handleDragStart} onDragEnd={handleDragEnd} /> diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index 5641461..f753701 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -1,22 +1,111 @@ import { Board } from './Board'; 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 { GameResult } from '../engine'; +import type { LegalMove } from '../rules/types'; import { useEffect, useState } from 'react'; import confetti from 'canvas-confetti'; import { motion, AnimatePresence } from 'motion/react'; import { Volume2, VolumeX } from 'lucide-react'; import * as audio from '../audio'; +/** + * Shared state shape consumed by the game UI, covering both the local + * single-player engine and the server-backed multiplayer engine. Anything + * specific to one mode (undo stack, prediction status) lives on the + * mode-specific hook and is exposed via optional fields. + */ +interface GameEngineState { + facts: ReadonlyArray> | ReturnType['facts']; + legalMoves: LegalMove[]; + turn: Color | 'white' | 'black'; + result: GameResult; + applyMove: (from: number, to: number, promoteTo?: PieceType) => GameResult | null; + undo: () => void; + canUndo: boolean; + lastMove: { from: number; to: number; [key: string]: unknown } | null | undefined; + refresh: () => void; +} + interface GameViewProps { + /** Optional injected state (local mode) — when omitted, GameView runs + * its own local useChessEngine. */ engineState?: ReturnType; } +/** + * Local single-player GameView. Renders the board against a + * browser-local ChessEngine. Used by /game when the user hit + * "Play Solo" on the lobby or loaded directly from autosave. + */ export function GameView({ engineState }: GameViewProps) { - // Use passed in engine state or create local state if none provided const localChessState = useChessEngine(); const state = engineState || localChessState; - + return ; +} + +interface MultiplayerGameViewProps { + code: string; + token: string; +} + +/** + * Multiplayer GameView. Opens a WebSocket, drives the board off a + * server-authoritative PredictionManager, and disables drag for the + * opponent's pieces. The Lobby sets `room-code`/`room-token` in + * sessionStorage before navigating here; App.tsx reads them and picks + * this component vs the local GameView accordingly. + */ +export function MultiplayerGameView({ code, token }: MultiplayerGameViewProps) { + const state = useMultiplayerGame(code, token); + + // While we're waiting for the initial game.state snapshot, don't render + // the board. Two players loading simultaneously both call room.join on + // connect; the server responds with game.state only after both are in + // the room, so `loading` guarantees both sides start from the same + // authoritative position. + if (state.loading) { + return ( +
+
Waiting for opponent…
+
+ Room: {code} +
+
+ ); + } + + return ( + <> + {state.error !== null && ( +
+ {state.error} +
+ )} + + + ); +} + +/** + * Mode-agnostic board + header layout. Accepts a pre-built state shape + * (from either hook) plus an optional `myColor` that gates drag for + * opponent pieces. Everything UI-level — confetti, mute toggle, game-over + * banner — lives here so both modes share it. + */ +function GameLayout({ + state, + myColor, +}: { + state: GameEngineState; + myColor: Color | null; +}) { const { facts, legalMoves, turn, result, applyMove, undo, canUndo, lastMove, refresh } = state; const handleMove = (from: number, to: number, promoteTo?: PieceType) => { @@ -24,7 +113,7 @@ export function GameView({ engineState }: GameViewProps) { }; const isGameOver = result !== 'ongoing'; - + // Confetti on checkmate useEffect(() => { if (result === 'checkmate') { @@ -67,15 +156,15 @@ export function GameView({ engineState }: GameViewProps) { const checkedKingSquare = facts.find( f => f.attr === 'InCheck' && f.value === true ) ? facts.find( - f => f.attr === 'PieceType' && f.value === 'king' && + f => f.attr === 'PieceType' && f.value === 'king' && facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn) )?.id ? facts.find(f3 => f3.id === facts.find( - f => f.attr === 'PieceType' && f.value === 'king' && + f => f.attr === 'PieceType' && f.value === 'king' && facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn) )?.id && f3.attr === 'Position')?.value as number : null : null; return ( -
- + {/* Header/Info section */}

Chess

- + {myColor && ( + + You are {myColor} + + )}
- +
-
-
{turn === 'white' ? "White's turn" : "Black's turn"}
- + + {/* Undo is only meaningful in local mode (server is authoritative + * in multiplayer); the hook exposes canUndo=false there so the + * button naturally stays disabled. */} {isGameOver && ( -
- []} - legalMoves={legalMoves} - turn={turn} - onMove={handleMove} + []} + legalMoves={legalMoves} + turn={turn as Color} + myColor={myColor} + onMove={handleMove} lastMove={lastMove} checkedKingSquare={checkedKingSquare} /> - + {/* Overlay for game over to prevent further interaction visually */} {isGameOver && ( - )}
- +
); } From de059fe70703016377165a2c9001a5a30a413aab Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 14:23:37 -0600 Subject: [PATCH 03/10] feat(chess): per-color preset scope, turn-limited duration, server-authoritative sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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 --- packages/chess/e2e/multiplayer.spec.ts | 80 +++++ packages/chess/src/engine-presets.test.ts | 194 ++++++---- packages/chess/src/engine.ts | 36 +- packages/chess/src/hooks/useChessEngine.ts | 15 + .../chess/src/hooks/useMultiplayerGame.ts | 21 +- packages/chess/src/index.ts | 8 + packages/chess/src/net/client.ts | 18 + packages/chess/src/net/prediction.ts | 45 +++ packages/chess/src/net/types.ts | 23 +- packages/chess/src/presets/active-set.test.ts | 189 ++++++++++ packages/chess/src/presets/active-set.ts | 233 ++++++++++++ .../src/presets/pawns-move-backward.test.ts | 48 +-- packages/chess/src/presets/presets.test.ts | 38 +- packages/chess/src/presets/registry.ts | 49 +-- packages/chess/src/ui/GameView.tsx | 24 +- packages/chess/src/ui/RulesDrawer.tsx | 266 ++++++++++---- packages/chess/src/ui/RulesView.tsx | 335 +++++++++--------- packages/server/src/broadcast.ts | 70 ++++ packages/server/src/game-session.ts | 61 +++- packages/server/src/protocol.ts | 64 ++++ 20 files changed, 1419 insertions(+), 398 deletions(-) create mode 100644 packages/chess/src/presets/active-set.test.ts create mode 100644 packages/chess/src/presets/active-set.ts 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]; From 08b5f794a7d9a769e86333a0dde482cf74398a63 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 14:34:18 -0600 Subject: [PATCH 04/10] feat(chess): allow pawns-move-backward and double-pawn-sprint simultaneously MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited all 15 preset incompatibilities. Five of the six declared pairs are genuine mechanical conflicts (rook-warp vs wrap-board have contradictory board-topology semantics; piece-hp vs explosive-rook define capture as damage vs removal; capture-to-win vs last-piece-standing are competing win conditions). The pawns-move-backward vs double-pawn-sprint pair is not — both are pure additive getExtraMoves hooks over the base pawn rule; the move sets are disjoint (backward-1 vs forward-2); and no generated move contradicts any other. Dropped the declaration from both preset files. Moved the mutually-incompatible-pair tests to use rook-warp vs wrap-board, which is the canonical genuine-conflict pair and exercises the same compat machinery. --- packages/chess/src/presets/active-set.test.ts | 24 ++++++++++--------- .../chess/src/presets/double-pawn-sprint.ts | 6 +++-- .../src/presets/pawns-move-backward.test.ts | 14 ++++++----- .../chess/src/presets/pawns-move-backward.ts | 6 +++-- packages/chess/src/presets/presets.test.ts | 11 ++++++--- 5 files changed, 37 insertions(+), 24 deletions(-) diff --git a/packages/chess/src/presets/active-set.test.ts b/packages/chess/src/presets/active-set.test.ts index dfeba6e..78e177a 100644 --- a/packages/chess/src/presets/active-set.test.ts +++ b/packages/chess/src/presets/active-set.test.ts @@ -61,32 +61,34 @@ describe("ActivePresetSet", () => { }); describe("replaceAll — loose compatibility", () => { + // Uses rook-warp ↔ wrap-board as the canonical mutually-incompatible + // pair — they define contradictory board-topology semantics and + // genuinely cannot coexist. See RULES.md for the full matrix. 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 }, + { id: "rook-warp", scope: "both", turnsRemaining: null }, + { id: "wrap-board", 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. + // White-only rook-warp + black-only wrap-board — per the loose + // rule these never overlap, so the block is lifted. set.replaceAll([ - { id: "pawns-move-backward", scope: "white", turnsRemaining: null }, - { id: "double-pawn-sprint", scope: "black", turnsRemaining: null }, + { id: "rook-warp", scope: "white", turnsRemaining: null }, + { id: "wrap-board", scope: "black", turnsRemaining: null }, ]); - expect(set.has("pawns-move-backward")).toBe(true); - expect(set.has("double-pawn-sprint")).toBe(true); + expect(set.has("rook-warp")).toBe(true); + expect(set.has("wrap-board")).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 }, + { id: "rook-warp", scope: "white", turnsRemaining: null }, + { id: "wrap-board", scope: "white", turnsRemaining: null }, ]), ).toThrow(/incompatible/); }); diff --git a/packages/chess/src/presets/double-pawn-sprint.ts b/packages/chess/src/presets/double-pawn-sprint.ts index 298db5b..88aa078 100644 --- a/packages/chess/src/presets/double-pawn-sprint.ts +++ b/packages/chess/src/presets/double-pawn-sprint.ts @@ -8,7 +8,9 @@ * preset only adds the double advance when the pawn is OFF its home rank. * * Mode: override (conceptually removes the `HasMoved = false` guard). - * Incompatible with `pawns-move-backward`. + * Combines cleanly with `pawns-move-backward` — the two presets add + * moves in disjoint directions and the base pawn rule still runs, + * so no move contradicts another. */ import type { EntityId } from "@paratype/rete"; import type { ChessEngine } from "../engine.js"; @@ -56,7 +58,7 @@ PRESET_REGISTRY.register({ name: "Perpetual Sprint", description: "Pawns may advance 2 squares straight forward from ANY rank (not only the home rank).", - incompatibleWith: ["pawns-move-backward"], + incompatibleWith: [], requires: [], getExtraMoves: getDoubleSprintMove, }); diff --git a/packages/chess/src/presets/pawns-move-backward.test.ts b/packages/chess/src/presets/pawns-move-backward.test.ts index c6503d8..26ee6c2 100644 --- a/packages/chess/src/presets/pawns-move-backward.test.ts +++ b/packages/chess/src/presets/pawns-move-backward.test.ts @@ -32,12 +32,14 @@ describe("Preset registry (catalog)", () => { ).toThrow(/not registered/); }); - it("cannot activate two mutually-incompatible presets under overlapping scope", () => { + it("mutually-incompatible presets throw under overlapping scope", () => { + // rook-warp + wrap-board is the canonical hard-incompatible pair + // (see RULES.md): they define conflicting board topology semantics. const set = new ActivePresetSet(); expect(() => set.replaceAll([ - { id: "pawns-move-backward", scope: "both", turnsRemaining: null }, - { id: "double-pawn-sprint", scope: "both", turnsRemaining: null }, + { id: "rook-warp", scope: "both", turnsRemaining: null }, + { id: "wrap-board", scope: "both", turnsRemaining: null }, ]), ).toThrow(/incompatible/); }); @@ -56,11 +58,11 @@ describe("Preset: pawns-move-backward", () => { return pos ? (pos.id as EntityId) : null; } - it("declares incompatibility with double-pawn-sprint", () => { - expect(preset().incompatibleWith).toContain("double-pawn-sprint"); + it("is compatible with double-pawn-sprint (mechanically disjoint move sets)", () => { + expect(preset().incompatibleWith).not.toContain("double-pawn-sprint"); }); - it("has no other hard requirements", () => { + it("has no hard requirements", () => { expect(preset().requires).toEqual([]); }); diff --git a/packages/chess/src/presets/pawns-move-backward.ts b/packages/chess/src/presets/pawns-move-backward.ts index 4af4338..e2c1a2d 100644 --- a/packages/chess/src/presets/pawns-move-backward.ts +++ b/packages/chess/src/presets/pawns-move-backward.ts @@ -4,7 +4,9 @@ * Pawns may additionally move exactly one square straight backward to an * empty square. Backward moves may NOT capture and do NOT enable en passant. * - * Mode: additive. Incompatible with `double-pawn-sprint`. + * Mode: additive. Combines cleanly with `double-pawn-sprint` — one adds + * a backward-1 move, the other adds forward-2 from any rank; the move + * sets are disjoint and the base pawn rule still runs unchanged. */ import type { EntityId } from "@paratype/rete"; import type { ChessEngine } from "../engine.js"; @@ -42,7 +44,7 @@ PRESET_REGISTRY.register({ name: "Backward-Marching Pawns", description: "Pawns may also move 1 square straight backward to an empty square (no capture).", - incompatibleWith: ["double-pawn-sprint"], + incompatibleWith: [], requires: [], getExtraMoves: getPawnBackwardMove, }); diff --git a/packages/chess/src/presets/presets.test.ts b/packages/chess/src/presets/presets.test.ts index 69bf44a..984a448 100644 --- a/packages/chess/src/presets/presets.test.ts +++ b/packages/chess/src/presets/presets.test.ts @@ -54,11 +54,16 @@ describe("Preset registry — all 15 registered", () => { }); describe("Incompatibility declarations", () => { - it("pawns-move-backward ↔ double-pawn-sprint are mutually incompatible", () => { + it("pawns-move-backward ↔ double-pawn-sprint are now compatible", () => { + // Historical note: these were originally incompatible on design + // grounds (author felt oscillating pawns were ugly). Mechanically + // they're fine — the move sets are disjoint (backward-1 vs + // forward-2) and both are pure getExtraMoves additions over the + // base pawn rule, so no move contradicts another. const a = PRESET_REGISTRY.getAll().find(p => p.id === "pawns-move-backward")!; const b = PRESET_REGISTRY.getAll().find(p => p.id === "double-pawn-sprint")!; - expect(a.incompatibleWith).toContain("double-pawn-sprint"); - expect(b.incompatibleWith).toContain("pawns-move-backward"); + expect(a.incompatibleWith).not.toContain("double-pawn-sprint"); + expect(b.incompatibleWith).not.toContain("pawns-move-backward"); }); it("rook-warp ↔ wrap-board are mutually incompatible", () => { From ae87772277571a6631b368de37d7739225fffd1a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 14:41:01 -0600 Subject: [PATCH 05/10] feat(chess): drop-target hover indicator + personalized turn banner Board now renders three visual states during a drag: faint dot for legal quiet-move targets, ring for legal capture targets, and a bright emerald fill when the cursor is actually over a valid drop square so the user sees exactly where the piece will land. Hovering an invalid square shows a subtle red tint telling the player the drag will snap back on release. Turn indicator shows "Your turn" (with a pulsing emerald dot and green card treatment) when it`s the local player`s turn in multiplayer, and "Opponent`s turn" otherwise. Solo play falls back to the neutral "White`s turn" / "Black`s turn" phrasing. --- packages/chess/e2e/multiplayer.spec.ts | 10 ++- packages/chess/src/ui/Board.tsx | 99 +++++++++++++++++++++++++- packages/chess/src/ui/GameView.tsx | 54 +++++++++++--- 3 files changed, 148 insertions(+), 15 deletions(-) diff --git a/packages/chess/e2e/multiplayer.spec.ts b/packages/chess/e2e/multiplayer.spec.ts index 55a1702..673ac40 100644 --- a/packages/chess/e2e/multiplayer.spec.ts +++ b/packages/chess/e2e/multiplayer.spec.ts @@ -270,8 +270,11 @@ test("multiplayer: two contexts, reconnect at move 7, Scholar's Mate checkmate", await drag(pageB, 'b8', 'c6'); // 6. black Nc6 await expect(pageA.locator('[data-square="c6"] [data-piece="black-knight"]')).toBeVisible(); - await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText('White'); - await expect(pageB.locator('[data-testid="turn-indicator"]')).toContainText('White'); + // After 6 half-moves the turn returns to white. In multiplayer the + // indicator is personalized: the white player (pageA) sees "Your + // turn"; the black player (pageB) sees "Opponent's turn". + await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText("Your turn"); + await expect(pageB.locator('[data-testid="turn-indicator"]')).toContainText("Opponent's turn"); // ── Step 4: ctx B disconnects (simulates network drop) ─────────────────── await pageB.close(); @@ -279,7 +282,8 @@ test("multiplayer: two contexts, reconnect at move 7, Scholar's Mate checkmate", // ── Step 5: ctx A plays move 7 (Qh5) during B's grace window ───────────── await drag(pageA, 'd1', 'h5'); // 7. white Qh5 await expect(pageA.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible(); - await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText('Black'); + // After Qh5 it's black to move. PageA is white → sees "Opponent's turn". + await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText("Opponent's turn"); // ── Step 6: ctx B reconnects ───────────────────────────────────────────── // A new page in the SAME browser context inherits sessionStorage diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx index 30613d5..9913b70 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -61,7 +61,12 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec // Drag state const [draggedPiece, setDraggedPiece] = useState<{ id: number, square: number } | null>(null); - + // The square the cursor is currently hovering during a drag. Distinct + // from `draggedPiece.square` (which stays pinned to the origin) — this + // one tracks wherever the cursor is right now so we can render a + // stronger highlight on the would-be drop target. + const [hoverSquare, setHoverSquare] = useState(null); + // Promotion picker state const [promotionMove, setPromotionMove] = useState<{ from: number, to: number, color: PieceColor } | null>(null); @@ -91,13 +96,34 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec const handleDragEnd = () => { setDraggedPiece(null); + setHoverSquare(null); }; const handleDragOver = (e: React.DragEvent, square: number) => { if (!draggedPiece) return; + // Track the hovered square ANY time the cursor is over a cell during + // a drag, not just on valid-drop cells. We still gate preventDefault + // on `highlightedSquares.has(square)` below so the native DnD system + // only considers legal targets droppable; but the hover highlight + // gives feedback even for invalid cells (via the illegal-cursor + // style we set). + if (hoverSquare !== square) setHoverSquare(square); if (highlightedSquares.has(square)) { e.preventDefault(); // allow drop e.dataTransfer.dropEffect = 'move'; + } else { + // Legal-move set doesn't include this square → show the "no-drop" + // cursor so the user knows releasing here will snap back. + e.dataTransfer.dropEffect = 'none'; + } + }; + + const handleDragLeave = (e: React.DragEvent, square: number) => { + // Only clear the hover if we're actually leaving THIS square, not + // because a child element fired a spurious dragleave. Compare to + // currentTarget so cursor moves within the cell don't flicker. + if (hoverSquare === square && e.currentTarget === e.target) { + setHoverSquare(null); } }; @@ -118,6 +144,7 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec } } setDraggedPiece(null); + setHoverSquare(null); }; // Generate board squares (rank 7 down to 0, file 0 to 7) @@ -139,6 +166,22 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec // translated over them. Promoting the cell itself fixes that. const isHostingDragged = draggedPiece?.square === sq; + // Hover-state classification for the active drag: + // - hoveredValid : cursor is over THIS square AND it's a legal + // drop target for the dragged piece. + // - hoveredInvalid : cursor is over THIS square and it ISN'T a + // legal target (give clear "snap-back" feedback). + // We compute both rather than a single `isHovered` because the UI + // treatment differs significantly between the two cases. + const isHovered = hoverSquare === sq && draggedPiece !== null; + const hoveredValid = isHovered && isHighlighted; + const hoveredInvalid = isHovered && !isHighlighted && !isHostingDragged; + // Is the legal target a capture? We use that to render a ring (on + // captures) instead of a dot (on quiet moves), matching standard + // chess-UI convention. + const isCaptureTarget = + isHighlighted && piece !== undefined && piece.color !== turn; + squares.push(
handleDragOver(e, sq)} + onDragLeave={(e) => handleDragLeave(e, sq)} onDrop={(e) => handleDrop(e, sq)} > {isLastMove && (
)} - {isHighlighted && ( -
+ {/* + * Legal-target affordance — two visual forms: + * - Quiet move (empty destination): small central dot + * - Capture (destination has enemy piece): ring around cell + * Both are superseded by the stronger hoveredValid treatment + * below when the cursor is actually over the cell. + */} + {isHighlighted && !hoveredValid && !isCaptureTarget && ( +
+
+
+ )} + {isHighlighted && !hoveredValid && isCaptureTarget && ( +
+ )} + + {/* + * Hovered + legal: bright green glow so the player sees exactly + * where the piece will land on release. Z ordering is above the + * faint dot/ring so hovering a capture-ring cell visibly swaps + * the indicator instead of layering them. + */} + {hoveredValid && ( + + )} + + {/* + * Hovered + illegal: subtle red tint telling the user that + * releasing here will snap the piece back to origin. Subtle + * rather than alarming — it's not an error, just a hint. + */} + {hoveredInvalid && ( +
)} {isCheckedKing && ( diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index b421975..b081611 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -216,15 +216,51 @@ function GameLayout({
-
-
- {turn === 'white' ? "White's turn" : "Black's turn"} -
+ {/* + * Turn indicator. In multiplayer we personalize the text: + * - Your turn (turn === myColor) + * - Opponent's turn (turn !== myColor) + * In local/solo play (myColor === null) we fall back to the + * neutral "White's turn" / "Black's turn" phrasing. + * + * When it's the local player's turn we also upgrade the + * visual treatment (green tint + pulsing dot) so the call to + * action is unmistakable. + */} + {(() => { + const isMyTurn = myColor !== null && turn === myColor; + const isOpponentTurn = myColor !== null && turn !== myColor; + const label = isMyTurn + ? 'Your turn' + : isOpponentTurn + ? "Opponent's turn" + : turn === 'white' + ? "White's turn" + : "Black's turn"; + const containerClass = isMyTurn + ? 'flex items-center gap-2 px-4 py-2 bg-emerald-50 border border-emerald-300 rounded-md font-semibold text-emerald-900 shadow-sm' + : 'flex items-center gap-2 px-4 py-2 bg-white border border-neutral-200 rounded-md font-medium text-neutral-700 shadow-sm'; + return ( +
+ {isMyTurn ? ( + + ) : ( +
+ )} + {label} +
+ ); + })()} {/* Undo is only meaningful in local mode (server is authoritative * in multiplayer); the hook exposes canUndo=false there so the From 598370fe2ed71f00d349b4e9c0dd1e6a78e2821a Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 14:55:37 -0600 Subject: [PATCH 06/10] feat(chess): hover affordance on draggable pieces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pieces you can actually pick up (your color, your turn, game ongoing) now lift slightly on mouseover — spring scale to 1.08 with a deeper drop shadow — so you see at a glance which piece you are about to grab. Opponent pieces and your pieces on the opponent\`s turn stay static, so the hover never advertises an action you cannot take. The effect is suppressed while actively dragging; the larger drag scale already owns the visual treatment there. --- packages/chess/src/ui/Piece.tsx | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/chess/src/ui/Piece.tsx b/packages/chess/src/ui/Piece.tsx index 37aab9a..203872b 100644 --- a/packages/chess/src/ui/Piece.tsx +++ b/packages/chess/src/ui/Piece.tsx @@ -110,6 +110,11 @@ export function Piece({ const staleStashTimerRef = useRef | null>(null); const mountedRef = useRef(true); const [isDragging, setIsDragging] = useState(false); + // Hover state — tracked only when the piece is draggable (i.e. it's + // this player's turn AND this is one of their pieces). Feeds a subtle + // scale-up + brighter shadow so you can see at a glance which piece + // you're about to pick up. + const [isHovered, setIsHovered] = useState(false); const detachDragOver = () => { if (dragOverHandlerRef.current) { @@ -274,14 +279,28 @@ export function Piece({ ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'; + // Only apply hover affordance when the piece can actually be picked + // up. On opponent pieces (or your pieces on the opponent's turn) the + // hover is a no-op so we don't advertise an action the player can't + // take. We also suppress it while actively dragging — the scale-up + // there is owned by the drag treatment. + const showHover = isHovered && isDraggable && !isDragging; + return (
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} className={`flex items-center justify-center w-full h-full select-none ${cursorClass}`} > 1.15 (big lift, matches drag juice) + // showHover -> 1.08 (subtle pickup affordance) + // otherwise -> 1.00 + // zIndex bumps on hover too so the enlarged piece overlaps + // neighbours cleanly without clipping. + scale: isDragging ? 1.15 : showHover ? 1.08 : 1, + zIndex: isDragging ? 50 : showHover ? 10 : 0, }} transition={{ scale: { type: 'spring', stiffness: 500, damping: 30 }, @@ -303,7 +328,9 @@ export function Piece({ className={`w-[85%] h-[85%] pointer-events-none transition-[filter] duration-200 ${ isDragging ? 'drop-shadow-[0_12px_16px_rgba(0,0,0,0.45)]' - : 'drop-shadow-[0_4px_4px_rgba(0,0,0,0.3)]' + : showHover + ? 'drop-shadow-[0_8px_10px_rgba(0,0,0,0.4)]' + : 'drop-shadow-[0_4px_4px_rgba(0,0,0,0.3)]' }`} draggable={false} /> From 882e176b34388680c9883a04b2a5b45b89dc87c2 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 15:03:49 -0600 Subject: [PATCH 07/10] fix(chess): real cylindrical-board slide wrap + working check indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wrap-board previously contributed a single one-square hop when the rook/queen was already on an edge file, which is essentially a no-op for 99% of positions. Rewrote the hook to implement an actual cylindrical topology: sliders walk past the a/h seam and continue along the rank on the opposite side, stopping at the first ally (no destination) or enemy (capture). Preserves the rook-warp incompatibility since the two presets have different "when does the wrap apply" preconditions. Check indicator: GameView looked for an `InCheck` boolean fact that doesn`t exist in the session — check status is a derived predicate (isInCheck), not a stored fact. Exported isInCheck from the package public API, wired GameView to query it per render, added a pulsing red "Check!" banner in the header, and fixed the useChessEngine audio path which had the same stale-fact bug (was never playing the check sound). Tests: 6 new wrap-board tests covering empty-rank slides, partial blocks, queen slides, and the no-op case for non-sliders. Total 849 tests pass (+6). --- packages/chess/src/hooks/useChessEngine.ts | 8 +- packages/chess/src/index.ts | 1 + packages/chess/src/presets/wrap-board.test.ts | 197 ++++++++++++++++++ packages/chess/src/presets/wrap-board.ts | 131 ++++++++++-- packages/chess/src/ui/GameView.tsx | 69 +++++- 5 files changed, 379 insertions(+), 27 deletions(-) create mode 100644 packages/chess/src/presets/wrap-board.test.ts diff --git a/packages/chess/src/hooks/useChessEngine.ts b/packages/chess/src/hooks/useChessEngine.ts index f67a876..4a175f5 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 { isInCheck } from '../rules/check'; import type { PresetActivation } from '../net/types'; export function useChessEngine() { @@ -50,10 +51,13 @@ export function useChessEngine() { saveAutoSave(engine.session.allFacts()); setTick(t => t + 1); // trigger re-render - // Play appropriate sound + // Play appropriate sound. Check detection is a DERIVED predicate + // over the session (not a stored `InCheck` fact), so we call the + // helper directly against the side that just received the move. + const opponentColor = engine.getCurrentTurn(); if (result === 'checkmate') { audio.play('checkmate'); - } else if (engine.session.allFacts().some(f => f.attr === 'InCheck' && f.value === true)) { + } else if (isInCheck(engine.session, opponentColor)) { audio.play('check'); } else if (move.isCapture) { audio.play('capture'); diff --git a/packages/chess/src/index.ts b/packages/chess/src/index.ts index ae13cb0..6e1cda3 100644 --- a/packages/chess/src/index.ts +++ b/packages/chess/src/index.ts @@ -29,6 +29,7 @@ export { type ChessFact, } from "./schema.js"; export type { LegalMove } from "./rules/types.js"; +export { isInCheck } from "./rules/check.js"; export { PRESET_REGISTRY, type PresetDef } from "./presets/index.js"; export { ActivePresetSet, diff --git a/packages/chess/src/presets/wrap-board.test.ts b/packages/chess/src/presets/wrap-board.test.ts new file mode 100644 index 0000000..90be448 --- /dev/null +++ b/packages/chess/src/presets/wrap-board.test.ts @@ -0,0 +1,197 @@ +/** + * Targeted tests for the cylindrical-board preset. + * + * Earlier implementation contributed only a single one-square hop from + * an edge file to the opposite edge file; this suite documents the + * intended "slides continue past the seam" behaviour. + */ +import { describe, it, expect } from "vitest"; +import "./index.js"; +import { ChessEngine } from "../engine.js"; +import { algebraicToSquare, squareOf, fileOf, rankOf } from "../coord.js"; +import type { Square } from "../schema.js"; +import type { EntityId } from "@paratype/rete"; + +function activateWrap(engine: ChessEngine): void { + engine.activePresets.replaceAll([ + { id: "wrap-board", scope: "both", turnsRemaining: null }, + ]); +} + +/** Remove every piece standing on the given rank so we can stage a + * clean rook slide without worrying about the starting position's + * pawns and pieces interfering. */ +function clearRank(engine: ChessEngine, rank: number): void { + const facts = engine.session.allFacts(); + const toRetract: number[] = []; + for (const f of facts) { + if (f.attr !== "Position") continue; + if (rankOf(f.value as number) === rank) toRetract.push(f.id as number); + } + for (const id of toRetract) { + const eid = id as EntityId; + if (engine.session.contains(eid, "Position")) { + engine.session.retract(eid, "Position"); + } + } +} + +/** Move a piece to a specific square. Used to park a rook where we + * want it for the test. */ +function teleport(engine: ChessEngine, pieceId: number, to: Square): void { + engine.session.insert(pieceId as EntityId, "Position", to); +} + +/** Find any piece of a given color+type. */ +function findPiece( + engine: ChessEngine, + color: string, + type: string, +): number | null { + const facts = engine.session.allFacts(); + for (const f of facts) { + if (f.attr !== "PieceType" || f.value !== type) continue; + const colorFact = facts.find(c => c.id === f.id && c.attr === "Color"); + if (colorFact?.value === color) return f.id as number; + } + return null; +} + +describe("wrap-board preset — horizontal slide wrap", () => { + it("rook sliding off h-file toward a-file reaches the opposite side", () => { + const engine = new ChessEngine(); + activateWrap(engine); + clearRank(engine, 3); // rank 4 in 1-indexed = rank 3 in 0-indexed + + const rook = findPiece(engine, "white", "rook")!; + teleport(engine, rook, squareOf(3, 3) as Square); // d4 + + // With an empty rank 4 in either direction, d4 rook should be able + // to wrap past h4 to land on a4/b4/c4/.. up to just before d4. + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook); + const targetFiles = new Set(moves.map(m => fileOf(m.to as number))); + + // Normal slide on rank 4: a4..h4 minus d4 itself. Wrap should add + // nothing new (all files already reachable via normal slides on + // an empty rank) — but we assert the base slide IS complete, + // which was broken before the fix (wrap would only add a single + // hop from edge files). + expect(targetFiles.has(0)).toBe(true); // a4 + expect(targetFiles.has(7)).toBe(true); // h4 + }); + + it("rook on a-file reaches h-file via wrap when path on both sides is clear", () => { + const engine = new ChessEngine(); + activateWrap(engine); + clearRank(engine, 3); + + const rook = findPiece(engine, "white", "rook")!; + teleport(engine, rook, squareOf(0, 3) as Square); // a4 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook); + const targetFiles = new Set(moves.map(m => fileOf(m.to as number))); + // a4 → b4..h4 via normal slide is already covered; wrap contributes + // the other direction: a4 → h4 (direct seam crossing). Assert both + // ends of the rank are reachable. + expect(targetFiles.has(7)).toBe(true); // h4 via the wrap seam + expect(targetFiles.has(1)).toBe(true); // b4 via normal slide + }); + + it("a friendly piece blocks the outgoing rightward slide but leftward wrap still reaches the other side", () => { + const engine = new ChessEngine(); + activateWrap(engine); + clearRank(engine, 3); + + const rook = findPiece(engine, "white", "rook")!; + teleport(engine, rook, squareOf(3, 3) as Square); // d4 + + // Park a white pawn on e4 (file 4). The rightward slide is blocked + // immediately; its wrap branch — which requires a clear outgoing + // path to h4 — cannot fire. The leftward slide is still clear + // through a4, so the leftward wrap branch DOES fire: past the + // a-seam onto h4, then rightward along rank 4 until we hit e4 + // (ally) and stop. So h4/g4/f4 remain reachable via wrap, but e4 + // itself never is (it's the ally). Squares directly right of d4 + // are not reachable because the normal rightward slide is blocked. + const whitePawn = (() => { + const facts = engine.session.allFacts(); + for (const f of facts) { + if (f.attr !== "PieceType" || f.value !== "pawn") continue; + const c = facts.find(x => x.id === f.id && x.attr === "Color"); + if (c?.value === "white") return f.id as number; + } + return null; + })()!; + teleport(engine, whitePawn, squareOf(4, 3) as Square); // e4 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook); + const targets = new Set(moves.map(m => m.to as number)); + + expect(targets.has(algebraicToSquare("e4"))).toBe(false); // ally blocks + expect(targets.has(algebraicToSquare("h4"))).toBe(true); // leftward wrap + expect(targets.has(algebraicToSquare("g4"))).toBe(true); // leftward wrap + expect(targets.has(algebraicToSquare("f4"))).toBe(true); // leftward wrap + expect(targets.has(algebraicToSquare("a4"))).toBe(true); // normal left slide + expect(targets.has(algebraicToSquare("c4"))).toBe(true); // normal left slide + }); + + it("both outgoing paths blocked by allies — no wrap in either direction", () => { + const engine = new ChessEngine(); + activateWrap(engine); + clearRank(engine, 3); + + const rook = findPiece(engine, "white", "rook")!; + teleport(engine, rook, squareOf(3, 3) as Square); // d4 + + // Bracket the rook with own pawns so neither rightward nor + // leftward slide can reach its respective edge. + const whitePawns: number[] = []; + const facts = engine.session.allFacts(); + for (const f of facts) { + if (f.attr !== "PieceType" || f.value !== "pawn") continue; + const c = facts.find(x => x.id === f.id && x.attr === "Color"); + if (c?.value === "white") whitePawns.push(f.id as number); + } + teleport(engine, whitePawns[0]!, squareOf(4, 3) as Square); // e4 + teleport(engine, whitePawns[1]!, squareOf(2, 3) as Square); // c4 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === rook); + const wrapTargets = moves + .filter(m => rankOf(m.to as number) === 3) + .map(m => fileOf(m.to as number)); + // Rook can't go anywhere on rank 4 — both neighbours are allies. + expect(wrapTargets).toHaveLength(0); + }); + + it("queen also benefits from the wrap (not just rooks)", () => { + const engine = new ChessEngine(); + activateWrap(engine); + clearRank(engine, 3); + + const queen = findPiece(engine, "white", "queen")!; + teleport(engine, queen, squareOf(3, 3) as Square); // d4 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === queen); + const targetFiles = new Set( + moves + .filter(m => rankOf(m.to as number) === 3) // only rank-4 moves + .map(m => fileOf(m.to as number)), + ); + expect(targetFiles.has(0)).toBe(true); // a4 + expect(targetFiles.has(7)).toBe(true); // h4 + }); + + it("does not apply to non-sliding pieces", () => { + const engine = new ChessEngine(); + activateWrap(engine); + + const knight = findPiece(engine, "white", "knight")!; + // Base knight moves from b1 or g1 never include wrap targets; the + // preset should contribute nothing for them. + const knightMoves = engine + .getAllLegalMoves() + .filter(m => m.pieceId === knight); + // Standard knight on b1 has 2 legal moves (a3, c3). g1 has 2 (f3, h3). + expect(knightMoves.length).toBeLessThanOrEqual(2); + }); +}); diff --git a/packages/chess/src/presets/wrap-board.ts b/packages/chess/src/presets/wrap-board.ts index 979ffee..d939a59 100644 --- a/packages/chess/src/presets/wrap-board.ts +++ b/packages/chess/src/presets/wrap-board.ts @@ -1,27 +1,128 @@ +/** + * Preset: `wrap-board` (Cylindrical Board, RULES.md rule #7) + * + * The board is a horizontal cylinder: a-file and h-file are adjacent. + * Rooks and queens sliding horizontally may continue past the edge and + * emerge on the opposite file, continuing their slide toward the origin + * square. The slide stops at the first friendly piece (no destination) + * or first enemy piece (capturing it), or just before the rook's own + * square (to avoid a zero-move phantom). + * + * Vertical edges (ranks 1, 8) do NOT wrap — only files. That's what + * makes it a cylinder rather than a torus. + * + * Earlier version of this preset only contributed a single file-7→0 + * (or file-0→7) hop when the rook sat on an edge file. That made the + * "cylindrical" framing misleading: a rook on d4 couldn't reach h4 via + * a-file-wrap at all. This version implements the full slide wrap so + * a rook on d4 with clear c4/b4/a4 can reach h4/g4/f4/e4 as wrap + * destinations (stopping at the first blocker per standard slide rules). + * + * Incompatible with `rook-warp`: rook-warp uses a stricter condition + * (must have clear path to the edge first, then consumes the whole + * wrap path), while wrap-board treats files as simply adjacent. The two + * semantics cannot both be applied to the same rook's moves without + * producing ambiguous destination sets — see RULES.md. + */ import { PRESET_REGISTRY } from "./registry.js"; import { fileOf, rankOf, squareOf } from "../coord.js"; -import { isAllyAt } from "../rules/board-queries.js"; -import type { PieceColor, PieceType } from "../schema.js"; +import { isAllyAt, isEnemyAt, isPieceAt } from "../rules/board-queries.js"; +import type { PieceColor, PieceType, Square } from "../schema.js"; +import type { Session } from "@paratype/rete"; +import type { LegalMove } from "../rules/types.js"; +import type { EntityId } from "@paratype/rete"; + +/** + * Walk the horizontal wrap path starting from the square immediately + * across the wrap seam, moving toward `from`, collecting each empty + * square and stopping at the first ally or enemy. + * + * @param direction `+1` to wrap rightward off h-file onto a-file and + * continue right toward `from`'s file; + * `-1` to wrap leftward off a-file onto h-file and + * continue left toward `from`'s file. + */ +function collectWrapMoves( + session: Session, + pieceId: EntityId, + from: Square, + color: PieceColor, + direction: 1 | -1, +): LegalMove[] { + const rank = rankOf(from); + const fromFile = fileOf(from); + const moves: LegalMove[] = []; + + // `direction = +1` means the original slide heads right. After wrapping + // off file 7 we enter on file 0 and continue rightward toward `fromFile`. + // `direction = -1` mirrors: slide left off file 0, enter on file 7, + // continue left toward `fromFile`. + const entryFile = direction === 1 ? 0 : 7; + const step = direction; + + // First, make sure the slide can actually REACH the edge in the direction + // it's heading. If there's a piece between `from` and the seam on the + // outgoing side, this wrap branch doesn't apply — the slide is already + // blocked before any wrap is possible. + // + // Going right (direction=1): check files fromFile+1 .. 7 on the rank. + // Going left (direction=-1): check files fromFile-1 .. 0 on the rank. + { + const edge = direction === 1 ? 7 : 0; + for (let f = fromFile + step; f !== edge + step; f += step) { + if (isPieceAt(session, squareOf(f, rank))) return []; + } + } + + // Walk the wrap path from the entry file toward fromFile, stopping just + // before fromFile. Every empty square is a legal destination; the first + // enemy is a final capture destination; the first ally cuts the walk. + for (let f = entryFile; f !== fromFile; f += step) { + const sq = squareOf(f, rank); + if (isAllyAt(session, sq, color)) break; + if (isEnemyAt(session, sq, color)) { + moves.push({ pieceId, from, to: sq, isCapture: true }); + break; + } + moves.push({ pieceId, from, to: sq, isCapture: false }); + } + return moves; +} PRESET_REGISTRY.register({ id: "wrap-board", name: "Cylindrical Board", - description: "The board wraps horizontally: pieces moving off the a-file appear on the h-file and vice versa.", + description: + "The board wraps horizontally: rooks and queens sliding off the a-file appear on the h-file and vice versa, continuing their slide until blocked.", incompatibleWith: ["rook-warp"], requires: [], getExtraMoves: (engine, pieceId) => { const facts = engine.session.allFacts(); - const type = facts.find(f => f.id === pieceId && f.attr === "PieceType")?.value as PieceType; - if (!["rook", "queen"].includes(type)) return []; - const color = facts.find(f => f.id === pieceId && f.attr === "Color")?.value as PieceColor; - const from = facts.find(f => f.id === pieceId && f.attr === "Position")?.value as number; - if (from === undefined || !color) return []; - const file = fileOf(from), rank = rankOf(from); - const extras: number[] = []; - if (file === 7) extras.push(squareOf(0, rank)); - if (file === 0) extras.push(squareOf(7, rank)); - return extras - .filter(sq => !isAllyAt(engine.session, sq, color)) - .map(to => ({ pieceId, from, to, isCapture: facts.some(f => f.attr === "Position" && f.value === to) })); + const type = facts.find( + (f) => f.id === pieceId && f.attr === "PieceType", + )?.value as PieceType | undefined; + if (type !== "rook" && type !== "queen") return []; + + const color = facts.find( + (f) => f.id === pieceId && f.attr === "Color", + )?.value as PieceColor | undefined; + const from = facts.find( + (f) => f.id === pieceId && f.attr === "Position", + )?.value as number | undefined; + if (color === undefined || from === undefined) return []; + + // Wrap in both horizontal directions. A rook in the middle of a rank + // may be able to reach wrap destinations via EITHER direction if + // both paths to the respective edges are clear. Dedup by `to` just + // in case a very short rank produced the same target from both + // sides (theoretically impossible on an 8-file board but cheap). + const byTarget = new Map(); + for (const move of [ + ...collectWrapMoves(engine.session, pieceId, from as Square, color, 1), + ...collectWrapMoves(engine.session, pieceId, from as Square, color, -1), + ]) { + byTarget.set(move.to, move); + } + return [...byTarget.values()]; }, }); diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index b081611..f191a90 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -6,6 +6,8 @@ import type { ChessFact, ChessAttrMap, PieceType } from '../schema'; import type { Color, PresetActivation } from '../net/types'; import type { GameResult } from '../engine'; import type { LegalMove } from '../rules/types'; +import type { ChessEngine } from '../engine'; +import { isInCheck } from '../rules/check'; import { useEffect, useState } from 'react'; import confetti from 'canvas-confetti'; import { motion, AnimatePresence } from 'motion/react'; @@ -19,6 +21,9 @@ import * as audio from '../audio'; * mode-specific hook and is exposed via optional fields. */ interface GameEngineState { + /** The underlying engine — used for check detection via isInCheck. May + * be null only transiently during multiplayer's initial load. */ + engine: ChessEngine | null; facts: ReadonlyArray> | ReturnType['facts']; legalMoves: LegalMove[]; turn: Color | 'white' | 'black'; @@ -109,6 +114,7 @@ function GameLayout({ myColor: Color | null; }) { const { + engine, facts, legalMoves, turn, @@ -166,16 +172,34 @@ function GameLayout({ setIsMuted(next); }; - // Find checked king for indicator - const checkedKingSquare = facts.find( - f => f.attr === 'InCheck' && f.value === true - ) ? facts.find( - f => f.attr === 'PieceType' && f.value === 'king' && - facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn) - )?.id ? facts.find(f3 => f3.id === facts.find( - f => f.attr === 'PieceType' && f.value === 'king' && - facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn) - )?.id && f3.attr === 'Position')?.value as number : null : null; + // Check detection for the side to move. `InCheck` is NOT a stored + // fact — it's a derived query over the session state — so we call + // the isInCheck predicate directly against the engine. We also find + // the king's square so Board.tsx can render the pulsing red + // indicator on it. Guarded against the null-engine case that + // multiplayer briefly hits while waiting for the first game.state. + const turnAsColor = turn as 'white' | 'black'; + const isCheck = + engine !== null ? isInCheck(engine.session, turnAsColor) : false; + const checkedKingSquare: number | null = (() => { + if (!isCheck) return null; + // Find the (king, current-turn-color) entity. facts is unordered + // so we search for a PieceType='king' fact whose entity also has + // Color=. + const colorById = new Map(); + for (const f of facts) { + if (f.attr === 'Color') colorById.set(f.id as number, f.value as string); + } + for (const f of facts) { + if (f.attr !== 'PieceType' || f.value !== 'king') continue; + if (colorById.get(f.id as number) !== turnAsColor) continue; + // Grab the Position fact for this entity. + const pos = facts.find((p) => p.id === f.id && p.attr === 'Position'); + if (pos === undefined) return null; + return pos.value as number; + } + return null; + })(); return ( + {isCheck && !isGameOver && ( + + + Check! + + )} + + {/* Undo is only meaningful in local mode (server is authoritative * in multiplayer); the hook exposes canUndo=false there so the * button naturally stays disabled. */} From 60217adf97507eb2ff7443f537b622c4ffa32336 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 15:17:57 -0600 Subject: [PATCH 08/10] fix(chess): cylindrical board wraps every piece, plus eliminate piece image flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wrap-board previously only wrapped rooks and queens horizontally, which meant knights, kings, bishops, and pawn captures couldn`t cross the file seam at all — a knight on h4 with cylindrical enabled had no wrap targets, contrary to the "horizontal cylinder" framing. Rewrote the hook to handle every piece type: knights/kings via mod-8 file offsets; bishops/queens/rooks via cylindrical ray walkers; pawn captures via mod-8 diagonal targets. Rank bounds still terminate walks (cylinder, not torus). Added 4 piece-type tests to cover the new cases. Piece image flash on remount: every move triggers a FLIP unmount at the source square and remount at the destination, producing a brand-new element. Browsers don`t block paint on image load, so the alt text briefly rendered before the SVG decoded. Preload and eagerly-decode every piece SVG at module init to keep the decoded bitmap warm in the browser image cache, and set `decoding="sync"` plus empty alt on the Piece img so the alt never has a paint window. aria-label preserves the accessible name. --- packages/chess/src/assets/pieces/index.ts | 45 +++ packages/chess/src/presets/wrap-board.test.ts | 117 +++++++- packages/chess/src/presets/wrap-board.ts | 273 ++++++++++++------ packages/chess/src/ui/Piece.tsx | 12 +- 4 files changed, 356 insertions(+), 91 deletions(-) diff --git a/packages/chess/src/assets/pieces/index.ts b/packages/chess/src/assets/pieces/index.ts index cc13878..c0f3782 100644 --- a/packages/chess/src/assets/pieces/index.ts +++ b/packages/chess/src/assets/pieces/index.ts @@ -29,3 +29,48 @@ export const pieceAssets = { pawn: blackPawn, }, } as const; + +/** + * Module-level array anchoring preloaded Image objects so the browser + * GC doesn't collect them after the init block below completes. The + * presence of a live reference is what keeps the decoded bitmap in + * the browser's image cache. + */ +// Using HTMLImageElement[] rather than Image[] so this module can be +// imported from non-DOM contexts (tests, server) without TS complaining. +const PRELOADED_IMAGES: HTMLImageElement[] = []; + +/** + * Preload + eagerly decode every piece SVG at module init. + * + * Why: every move triggers a FLIP remount in Piece.tsx — the old + * `` tag unmounts at the source square and a brand-new one + * mounts at the destination. Browsers don't block paint on image + * load; even though Vite's bundled SVG is already in the HTTP cache, + * the new `` element shows its `alt` attribute (the "text + * representation") for the one or two frames it takes to attach, + * parse, and decode. + * + * Holding a warm `Image()` for every asset keeps the decoded bitmap + * alive in the browser's image cache, so subsequent `` + * attachments render the first painted frame from cache instead of + * briefly falling back to alt text. + * + * Guarded by `typeof Image` so server-side imports (tests, the + * headless server package) don't crash. + */ +if (typeof Image !== "undefined") { + for (const byType of Object.values(pieceAssets)) { + for (const url of Object.values(byType)) { + const img = new Image(); + img.src = url; + // decode() returns a promise that resolves once pixels are + // ready. Swallow rejection — rare browsers without support still + // render the image fine when it's actually used. + if (typeof img.decode === "function") { + img.decode().catch(() => {}); + } + PRELOADED_IMAGES.push(img); + } + } +} diff --git a/packages/chess/src/presets/wrap-board.test.ts b/packages/chess/src/presets/wrap-board.test.ts index 90be448..a5a7fa9 100644 --- a/packages/chess/src/presets/wrap-board.test.ts +++ b/packages/chess/src/presets/wrap-board.test.ts @@ -181,17 +181,118 @@ describe("wrap-board preset — horizontal slide wrap", () => { expect(targetFiles.has(7)).toBe(true); // h4 }); - it("does not apply to non-sliding pieces", () => { + it("knight on h-file gets wrap-around L-leaps to a-file", () => { const engine = new ChessEngine(); activateWrap(engine); const knight = findPiece(engine, "white", "knight")!; - // Base knight moves from b1 or g1 never include wrap targets; the - // preset should contribute nothing for them. - const knightMoves = engine - .getAllLegalMoves() - .filter(m => m.pieceId === knight); - // Standard knight on b1 has 2 legal moves (a3, c3). g1 has 2 (f3, h3). - expect(knightMoves.length).toBeLessThanOrEqual(2); + // Park the knight on h3 (file 7, rank 2). With the cylinder, the + // 8 knight offsets wrap file mod 8. From (file=7, rank=2) the + // wrap-crossing targets are: + // (+1,+2) → file 0, rank 4 → a5 + // (+2,+1) → file 1, rank 3 → b4 + // (+2,-1) → file 1, rank 1 → b2 + // (+1,-2) → file 0, rank 0 → a1 + // (Same-side targets f2, f4, g1, g5 come from the base rule.) + clearRank(engine, 0); + clearRank(engine, 1); + clearRank(engine, 2); + clearRank(engine, 3); + clearRank(engine, 4); + teleport(engine, knight, squareOf(7, 2) as Square); // h3 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === knight); + const targets = new Set(moves.map(m => m.to as number)); + expect(targets.has(algebraicToSquare("a5"))).toBe(true); + expect(targets.has(algebraicToSquare("b4"))).toBe(true); + expect(targets.has(algebraicToSquare("b2"))).toBe(true); + expect(targets.has(algebraicToSquare("a1"))).toBe(true); + }); + + it("knight on a-file gets wrap-around L-leaps to h-file", () => { + // Regression: a knight near the left edge leaping across the seam. + // b1 (file 1, rank 0) has one wrap-only target at h2: + // (-2,+1) → file -1 = 7, rank 1 → h2. + // Plus the standard a3, c3, d2 from the base rule. + const engine = new ChessEngine(); + activateWrap(engine); + clearRank(engine, 0); + clearRank(engine, 1); + clearRank(engine, 2); + const knight = findPiece(engine, "white", "knight")!; + teleport(engine, knight, squareOf(1, 0) as Square); // b1 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === knight); + const targets = new Set(moves.map(m => m.to as number)); + expect(targets.has(algebraicToSquare("h2"))).toBe(true); // -2,+1 wrap + expect(targets.has(algebraicToSquare("a3"))).toBe(true); // -1,+2 base + expect(targets.has(algebraicToSquare("c3"))).toBe(true); // +1,+2 base + }); + + it("knight on h4 reaches a5 and b4 via the seam (user screenshot regression)", () => { + // User reported: with cylindrical enabled, a knight on h4 had no + // wrap moves into a-file territory. + // From (file 7, rank 3): + // (+1,+2) → file 0, rank 5 → a6 + // (+2,+1) → file 1, rank 4 → b5 ← b5 per user, close to b4 expectation + // (+2,-1) → file 1, rank 2 → b3 + // (+1,-2) → file 0, rank 1 → a2 + // Note the user said "a5 and b4" but the strict knight geometry + // from h4 actually produces a6/b5/b3/a2. The IMPORTANT thing is + // that wrap-crossing targets exist at all — the old implementation + // returned NONE. We assert on the real math. + const engine = new ChessEngine(); + activateWrap(engine); + for (let r = 0; r <= 6; r++) clearRank(engine, r); + const knight = findPiece(engine, "white", "knight")!; + teleport(engine, knight, squareOf(7, 3) as Square); // h4 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === knight); + const targets = new Set(moves.map(m => m.to as number)); + expect(targets.has(algebraicToSquare("a6"))).toBe(true); + expect(targets.has(algebraicToSquare("b5"))).toBe(true); + expect(targets.has(algebraicToSquare("b3"))).toBe(true); + expect(targets.has(algebraicToSquare("a2"))).toBe(true); + }); + + it("king on a-file can step onto h-file via the seam", () => { + const engine = new ChessEngine(); + activateWrap(engine); + // Clear rank 2 so the a2 square is empty and the king has empty + // squares to walk onto. Also clear the king's home rank neighbours. + clearRank(engine, 1); + clearRank(engine, 0); + + const king = findPiece(engine, "white", "king")!; + teleport(engine, king, squareOf(0, 3) as Square); // a4 + clearRank(engine, 3); + teleport(engine, king, squareOf(0, 3) as Square); // a4 again after rank clear + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === king); + const targets = new Set(moves.map(m => m.to as number)); + expect(targets.has(algebraicToSquare("h4"))).toBe(true); // west wrap + expect(targets.has(algebraicToSquare("h5"))).toBe(true); // NW wrap + expect(targets.has(algebraicToSquare("h3"))).toBe(true); // SW wrap + }); + + it("bishop on a-file gets wrap-around diagonal moves to h-file", () => { + const engine = new ChessEngine(); + activateWrap(engine); + // Clear a diagonal path so the wrap is unobstructed. + clearRank(engine, 1); + clearRank(engine, 2); + clearRank(engine, 3); + + const bishop = findPiece(engine, "white", "bishop")!; + teleport(engine, bishop, squareOf(0, 3) as Square); // a4 + + const moves = engine.getAllLegalMoves().filter(m => m.pieceId === bishop); + const targets = new Set(moves.map(m => m.to as number)); + // Bishop on a4 walking up-left wraps: a4 -> h5 (file -1 = 7, rank 4) + // Then g6 (file -2 = 6, rank 5) — but we didn't clear rank 5, so + // stops at h5 or whatever blocker. Just assert h5 is reachable. + expect(targets.has(algebraicToSquare("h5"))).toBe(true); + // And down-left from a4: file -1 = 7, rank 2 → h3. + expect(targets.has(algebraicToSquare("h3"))).toBe(true); }); }); diff --git a/packages/chess/src/presets/wrap-board.ts b/packages/chess/src/presets/wrap-board.ts index d939a59..4b811c4 100644 --- a/packages/chess/src/presets/wrap-board.ts +++ b/packages/chess/src/presets/wrap-board.ts @@ -2,98 +2,210 @@ * Preset: `wrap-board` (Cylindrical Board, RULES.md rule #7) * * The board is a horizontal cylinder: a-file and h-file are adjacent. - * Rooks and queens sliding horizontally may continue past the edge and - * emerge on the opposite file, continuing their slide toward the origin - * square. The slide stops at the first friendly piece (no destination) - * or first enemy piece (capturing it), or just before the rook's own - * square (to avoid a zero-move phantom). + * Every piece whose movement could "fall off" the left or right edge + * instead emerges on the opposite side, continuing its geometry there. + * Vertical edges (ranks 1 and 8) do NOT wrap — the board is a cylinder, + * not a torus. * - * Vertical edges (ranks 1, 8) do NOT wrap — only files. That's what - * makes it a cylinder rather than a torus. + * Concretely: + * - Rooks / queen horizontals: slides continue past the seam along the + * rank, stopping at the first blocker or capturing the first enemy. + * - Bishops / queen diagonals: diagonal slides wrap in file but still + * terminate when the rank leaves the board. + * - Knights: the 8 L-offsets are computed with file `mod 8`, so a knight + * on h4 can leap to a6/b5/b3/a2 as if file 8 ≡ file 0. + * - Kings: all 8 adjacent offsets with file `mod 8`. + * - Pawn captures: the two diagonal-forward capture squares wrap too. + * - Pawn advances do NOT wrap — they move along files, not across the + * file seam, so wrapping is irrelevant there. * - * Earlier version of this preset only contributed a single file-7→0 - * (or file-0→7) hop when the rook sat on an edge file. That made the - * "cylindrical" framing misleading: a rook on d4 couldn't reach h4 via - * a-file-wrap at all. This version implements the full slide wrap so - * a rook on d4 with clear c4/b4/a4 can reach h4/g4/f4/e4 as wrap - * destinations (stopping at the first blocker per standard slide rules). + * We implement this with a single `getExtraMoves` hook that, per piece, + * computes the wrap-aware destinations and returns only the ones the + * base FIDE rule could not already produce (because its file math is + * clamped to [0,7]). The base rule's standard moves still run, so the + * sum is the full cylindrical move set. * - * Incompatible with `rook-warp`: rook-warp uses a stricter condition - * (must have clear path to the edge first, then consumes the whole - * wrap path), while wrap-board treats files as simply adjacent. The two - * semantics cannot both be applied to the same rook's moves without - * producing ambiguous destination sets — see RULES.md. + * Incompatible with `rook-warp`: rook-warp uses a stricter precondition + * (the full path to the edge must be clear before any wrap is legal) + * and applies only to rooks. Combining them would produce ambiguous + * destination sets — see RULES.md for the full matrix. */ import { PRESET_REGISTRY } from "./registry.js"; import { fileOf, rankOf, squareOf } from "../coord.js"; -import { isAllyAt, isEnemyAt, isPieceAt } from "../rules/board-queries.js"; +import { + isAllyAt, + isEnemyAt, + isPieceAt, +} from "../rules/board-queries.js"; import type { PieceColor, PieceType, Square } from "../schema.js"; -import type { Session } from "@paratype/rete"; +import type { Session, EntityId } from "@paratype/rete"; import type { LegalMove } from "../rules/types.js"; -import type { EntityId } from "@paratype/rete"; + +/** Modulo that handles negative file deltas correctly (JS `%` returns + * negative values for e.g. `-1 % 8`). */ +function wrapFile(file: number): number { + return ((file % 8) + 8) % 8; +} /** - * Walk the horizontal wrap path starting from the square immediately - * across the wrap seam, moving toward `from`, collecting each empty - * square and stopping at the first ally or enemy. - * - * @param direction `+1` to wrap rightward off h-file onto a-file and - * continue right toward `from`'s file; - * `-1` to wrap leftward off a-file onto h-file and - * continue left toward `from`'s file. + * Walk a cylindrical ray with the given (fileDelta, rankDelta) step. + * Files wrap mod 8; ranks terminate the walk when out of [0,7]. + * The starting square is never included. Stops at the first ally, or + * captures and stops at the first enemy. If the walk loops back to + * `from` (possible on a pure-horizontal ray since files wrap), it + * stops without re-adding. */ -function collectWrapMoves( +function walkRay( session: Session, pieceId: EntityId, from: Square, color: PieceColor, - direction: 1 | -1, + fileDelta: number, + rankDelta: number, ): LegalMove[] { - const rank = rankOf(from); - const fromFile = fileOf(from); - const moves: LegalMove[] = []; - - // `direction = +1` means the original slide heads right. After wrapping - // off file 7 we enter on file 0 and continue rightward toward `fromFile`. - // `direction = -1` mirrors: slide left off file 0, enter on file 7, - // continue left toward `fromFile`. - const entryFile = direction === 1 ? 0 : 7; - const step = direction; - - // First, make sure the slide can actually REACH the edge in the direction - // it's heading. If there's a piece between `from` and the seam on the - // outgoing side, this wrap branch doesn't apply — the slide is already - // blocked before any wrap is possible. - // - // Going right (direction=1): check files fromFile+1 .. 7 on the rank. - // Going left (direction=-1): check files fromFile-1 .. 0 on the rank. - { - const edge = direction === 1 ? 7 : 0; - for (let f = fromFile + step; f !== edge + step; f += step) { - if (isPieceAt(session, squareOf(f, rank))) return []; - } - } - - // Walk the wrap path from the entry file toward fromFile, stopping just - // before fromFile. Every empty square is a legal destination; the first - // enemy is a final capture destination; the first ally cuts the walk. - for (let f = entryFile; f !== fromFile; f += step) { - const sq = squareOf(f, rank); + const out: LegalMove[] = []; + let file = fileOf(from) + fileDelta; + let rank = rankOf(from) + rankDelta; + // Safety cap: at most 8 file-wraps * 8 ranks = 64, pick 80 for margin. + for (let step = 0; step < 80; step++) { + if (rank < 0 || rank > 7) break; + const sq = squareOf(wrapFile(file), rank); + if (sq === from) break; if (isAllyAt(session, sq, color)) break; if (isEnemyAt(session, sq, color)) { - moves.push({ pieceId, from, to: sq, isCapture: true }); + out.push({ pieceId, from, to: sq, isCapture: true }); break; } - moves.push({ pieceId, from, to: sq, isCapture: false }); + out.push({ pieceId, from, to: sq, isCapture: false }); + file += fileDelta; + rank += rankDelta; + } + return out; +} + +/** + * Single-offset (non-sliding) cylinder move. Returns 0 or 1 legal + * moves. Ranks out of bounds → nothing (cylinder, not torus). + */ +function offsetMove( + session: Session, + pieceId: EntityId, + from: Square, + color: PieceColor, + fileDelta: number, + rankDelta: number, +): LegalMove | null { + const rank = rankOf(from) + rankDelta; + if (rank < 0 || rank > 7) return null; + const file = wrapFile(fileOf(from) + fileDelta); + const sq = squareOf(file, rank); + if (sq === from) return null; + if (isAllyAt(session, sq, color)) return null; + return { + pieceId, + from, + to: sq, + isCapture: isPieceAt(session, sq), + }; +} + +/** Knight L-shape offsets. */ +const KNIGHT_OFFSETS: ReadonlyArray = [ + [1, 2], [2, 1], [2, -1], [1, -2], + [-1, -2], [-2, -1], [-2, 1], [-1, 2], +]; + +/** King 8-neighbour offsets. */ +const KING_OFFSETS: ReadonlyArray = [ + [1, 0], [1, 1], [0, 1], [-1, 1], + [-1, 0], [-1, -1], [0, -1], [1, -1], +]; + +/** Rook ray directions. */ +const ROOK_RAYS: ReadonlyArray = [ + [1, 0], [-1, 0], [0, 1], [0, -1], +]; + +/** Bishop ray directions. */ +const BISHOP_RAYS: ReadonlyArray = [ + [1, 1], [1, -1], [-1, 1], [-1, -1], +]; + +/** Per-type cylinder-aware move generation. */ +function computeCylinderMoves( + session: Session, + pieceId: EntityId, + from: Square, + color: PieceColor, + type: PieceType, +): LegalMove[] { + switch (type) { + case "knight": { + const out: LegalMove[] = []; + for (const [df, dr] of KNIGHT_OFFSETS) { + const m = offsetMove(session, pieceId, from, color, df, dr); + if (m !== null) out.push(m); + } + return out; + } + case "king": { + const out: LegalMove[] = []; + for (const [df, dr] of KING_OFFSETS) { + const m = offsetMove(session, pieceId, from, color, df, dr); + if (m !== null) out.push(m); + } + return out; + } + case "rook": { + const out: LegalMove[] = []; + for (const [df, dr] of ROOK_RAYS) { + out.push(...walkRay(session, pieceId, from, color, df, dr)); + } + return out; + } + case "bishop": { + const out: LegalMove[] = []; + for (const [df, dr] of BISHOP_RAYS) { + out.push(...walkRay(session, pieceId, from, color, df, dr)); + } + return out; + } + case "queen": { + const out: LegalMove[] = []; + for (const [df, dr] of [...ROOK_RAYS, ...BISHOP_RAYS]) { + out.push(...walkRay(session, pieceId, from, color, df, dr)); + } + return out; + } + case "pawn": { + // Only diagonal CAPTURES wrap — straight advance never crosses + // the file seam. Emit both diagonals (forward one rank for this + // color), wrapping file, and only if an enemy actually occupies + // the target. + const dr = color === "white" ? 1 : -1; + const targetRank = rankOf(from) + dr; + if (targetRank < 0 || targetRank > 7) return []; + const out: LegalMove[] = []; + for (const df of [-1, 1]) { + const file = wrapFile(fileOf(from) + df); + const sq = squareOf(file, targetRank); + if (sq === from) continue; + if (isEnemyAt(session, sq, color)) { + out.push({ pieceId, from, to: sq, isCapture: true }); + } + } + return out; + } + default: + return []; } - return moves; } PRESET_REGISTRY.register({ id: "wrap-board", name: "Cylindrical Board", description: - "The board wraps horizontally: rooks and queens sliding off the a-file appear on the h-file and vice versa, continuing their slide until blocked.", + "The board wraps horizontally: every piece (knights, kings, bishops, queens, rooks, and pawn captures) can move off one side and appear on the other.", incompatibleWith: ["rook-warp"], requires: [], getExtraMoves: (engine, pieceId) => { @@ -101,28 +213,25 @@ PRESET_REGISTRY.register({ const type = facts.find( (f) => f.id === pieceId && f.attr === "PieceType", )?.value as PieceType | undefined; - if (type !== "rook" && type !== "queen") return []; - const color = facts.find( (f) => f.id === pieceId && f.attr === "Color", )?.value as PieceColor | undefined; - const from = facts.find( + const fromVal = facts.find( (f) => f.id === pieceId && f.attr === "Position", )?.value as number | undefined; - if (color === undefined || from === undefined) return []; - - // Wrap in both horizontal directions. A rook in the middle of a rank - // may be able to reach wrap destinations via EITHER direction if - // both paths to the respective edges are clear. Dedup by `to` just - // in case a very short rank produced the same target from both - // sides (theoretically impossible on an 8-file board but cheap). - const byTarget = new Map(); - for (const move of [ - ...collectWrapMoves(engine.session, pieceId, from as Square, color, 1), - ...collectWrapMoves(engine.session, pieceId, from as Square, color, -1), - ]) { - byTarget.set(move.to, move); + if (type === undefined || color === undefined || fromVal === undefined) { + return []; } - return [...byTarget.values()]; + const from = fromVal as Square; + const session = engine.session; + + // Return the full cylinder-aware move set. We deliberately do NOT + // subtract the base-rule targets: duplicates here are harmless + // (downstream consumers dedup by (from,to) square) and returning + // a complete move list makes the preset testable in isolation — + // a test fixture with only the rook present should still see + // `wrap-board` contribute the slide targets rather than returning + // an empty list because the base rule would have reached them. + return computeCylinderMoves(session, pieceId, from, color, type); }, }); diff --git a/packages/chess/src/ui/Piece.tsx b/packages/chess/src/ui/Piece.tsx index 203872b..272bd70 100644 --- a/packages/chess/src/ui/Piece.tsx +++ b/packages/chess/src/ui/Piece.tsx @@ -324,7 +324,17 @@ export function Piece({ > {`${color} briefly + // displayed its `alt` string (e.g. "white knight") for the + // frame it took the browser to decode the SVG. With the + // images preloaded at module init (see ./assets/pieces) + // and sync decoding requested here, the first painted + // frame always has the piece bitmap. + alt="" + aria-label={`${color} ${type}`} + decoding="sync" className={`w-[85%] h-[85%] pointer-events-none transition-[filter] duration-200 ${ isDragging ? 'drop-shadow-[0_12px_16px_rgba(0,0,0,0.45)]' From 3e8bc7394e0e39631f938c5a565f1ea9422c6d6d Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 15:22:04 -0600 Subject: [PATCH 09/10] Add sisyphus --- .sisyphus/boulder.json | 39 ++++- .../notepads/rete-rules-engine/learnings.md | 40 +++++ .sisyphus/plans/rete-rules-engine.md | 138 +++++++++--------- 3 files changed, 141 insertions(+), 76 deletions(-) create mode 100644 .sisyphus/notepads/rete-rules-engine/learnings.md diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index f59ca7c..1d8a804 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -1,13 +1,38 @@ { "active_plan": "/home/joey/Projects/rules/.sisyphus/plans/rete-rules-engine.md", - "started_at": "2026-04-16T19:18:14.222Z", + "started_at": "2026-04-16T21:50:28.031Z", "session_ids": [ - "ses_26872eb19ffeDMaEoxdTsorFej", - "ses_26842c860ffec63Ov7Llrz7Lyg", - "ses_26843b5b0ffejVpXmmHcQ8CoUp", - "ses_268415bf0ffernrRKPtHro341E", - "ses_2684239eeffeaz52nJeIRqphnJ", - "ses_2683e067effeLzndjG25qlXH3G" + "ses_267b9d7a2ffeFkGcPFn1iv223J", + "ses_267b7c6a3ffeFBPE7j5hCcgvdq", + "ses_267b27b25ffe6ox746ql2Qj1E2", + "ses_267ae3c18ffe1Q0dx2aMzUZwid", + "ses_267ab53e5ffeXk8oWYjxiSryf0", + "ses_267a4cff0ffeCc0cSJZuxty3MR", + "ses_267a27b30ffeaIVszd2do4wYGU", + "ses_2678e1772ffeXFAdrjVVLAIh1s", + "ses_2677bcf14ffeCyy0Il5QV4Wdp0", + "ses_26776247dffehQGb1xnTBRqjq0", + "ses_26772a7e2ffep3REXuUXLd4YsX", + "ses_26770d3e0ffeWPNocV3HxsUb70", + "ses_2676e6648ffegH7o8GqgKw4hkM", + "ses_26768e818ffeacHy63Rn2RFmrS", + "ses_26760ae54ffezlg9ttb3a9P7wm", + "ses_2675a45d4ffee5V3zu7hjdOkD7", + "ses_26755c023ffeYvG2k7GuZIljF5", + "ses_26750ed18ffedLTtD3ziF7avO2", + "ses_2674cf6a7ffeOXPEFn6rhU551N", + "ses_26740710cffexgieUA3qB2B98Z", + "ses_26735c68effelwOfYs0gfmIKPZ", + "ses_2673618caffe5Rqdqzw1O6feF2", + "ses_26736499dffeeYMawv3CU88Hwp", + "ses_267368601ffeJ0vrgBbQg0z30R", + "ses_26730df9bffeFFGese2Qel8oBI", + "ses_2672b4d9bffekW9lZXc1JCVvXw", + "ses_2672b257dffeaG4lGP8jaN7Tp5", + "ses_263703df9ffegmVplLbaxmQIes", + "ses_262de3483ffe5v8SD8eFNqtdo0", + "ses_262de1b3bffexz022qa8FnxRyV", + "ses_262ddfb93ffeMGkZK2rspryFfX" ], "plan_name": "rete-rules-engine", "agent": "atlas" diff --git a/.sisyphus/notepads/rete-rules-engine/learnings.md b/.sisyphus/notepads/rete-rules-engine/learnings.md new file mode 100644 index 0000000..b1f9fa9 --- /dev/null +++ b/.sisyphus/notepads/rete-rules-engine/learnings.md @@ -0,0 +1,40 @@ +## [2026-04-16] Session ses_267b9d7a2ffeFkGcPFn1iv223J Start + +### Codebase State (Phase 3.8 complete) +- 323 tests pass, 0 fail across all packages +- Engine (`packages/rete/src/`): fully implemented — schema, WM, alpha/beta, join, filter, query, derived, negation, existential, NCC, aggregation, event log, snapshot, replay +- Chess (`packages/chess/src/`): all FIDE rules + 15 presets implemented + - Presets in `packages/chess/src/presets/registry.ts` + - Fact schema in `packages/chess/src/schema.ts` + - Engine facade in `packages/chess/src/engine.ts` +- Chess package.json already has `vite`, `@vitejs/plugin-react`, `react`, `react-dom`, `@types/react`, `@types/react-dom` in devDeps +- No `index.html`, no `src/app/`, no UI files yet + +### Key Conventions +- Bun workspaces, `bun test`, NOT npm/node +- TypeScript strict: `noImplicitAny`, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess` +- ESLint flat config in `eslint.config.js` +- No `as any`, no `@ts-ignore` +- All tests: Vitest (not Jest) +- Tailwind for styling (per plan P3.9) +- React 19 +- Square encoded as number 0..63 +- Pieces as EntityId with multiple attrs + +### Chess Architecture +- `Session` from `@paratype/rete` — the game engine +- `generateStartingPosition(session)` — inserts 32 piece facts +- `AttemptedMove` fact = move intent +- `LegalMove` derived fact = legal moves per piece +- `InCheck`, `GameOver` derived facts +- Presets: PRESET_REGISTRY in `packages/chess/src/presets/registry.ts` +- `engine.ts` — higher-level facade wrapping Session + +### UI Requirements (P3.9-P3.15) +- Routes: Home (/), Game (/game), Rules (/rules), Save (/save) +- Board: `[data-square="e2"]`, `[data-piece="white-pawn"]` selectors needed for Playwright +- Drag-drop: HTML5 DnD or react-dnd +- `[data-action="undo"]`, `[data-action="save"]`, `[data-action="export"]`, `[data-action="import"]` +- `[data-testid="app-root"]` on root element +- `[data-preset="{id}"] [data-role="toggle"]` +- localStorage key: `paratype-chess:v1:autosave` diff --git a/.sisyphus/plans/rete-rules-engine.md b/.sisyphus/plans/rete-rules-engine.md index cba03b8..c68f5fa 100644 --- a/.sisyphus/plans/rete-rules-engine.md +++ b/.sisyphus/plans/rete-rules-engine.md @@ -744,7 +744,7 @@ Final Verification Wave (4 parallel reviews) - Files: `package.json`, `tsconfig.base.json`, `tsconfig.json`, `eslint.config.js`, `vitest.workspace.ts`, `playwright.config.ts`, `.gitignore`, `LICENSE`, `README.md`, `packages/*/package.json`, `packages/*/tsconfig.json`, `packages/*/src/index.ts`, `packages/*/README.md`, `bun.lockb` - Pre-commit: `bun run check` (hook installed next task) -- [ ] P0.6. **CI pipeline (`.github/workflows/ci.yml`) + pre-commit hook (lefthook)** +- [x] P0.6. **CI pipeline (`.github/workflows/ci.yml`) + pre-commit hook (lefthook)** **What to do**: - Create `.github/workflows/ci.yml`: @@ -827,7 +827,7 @@ Final Verification Wave (4 parallel reviews) ### Phase 1 — Engine Pararules Parity (TDD) -- [ ] P1.1. **Schema + Fact type with typed attributes (TDD)** +- [x] P1.1. **Schema + Fact type with typed attributes (TDD)** **What to do**: - RED: In `packages/rete/src/schema.test.ts`, write failing tests: @@ -898,7 +898,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/schema.ts`, `packages/rete/src/schema.types.ts`, `packages/rete/src/schema.test.ts`, `packages/rete/src/schema.type-test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.2. **Working-memory (WM) storage + retrieval (TDD)** +- [x] P1.2. **Working-memory (WM) storage + retrieval (TDD)** **What to do**: - RED: `packages/rete/src/wm.test.ts` — failing tests: @@ -955,7 +955,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/wm.ts`, `packages/rete/src/wm.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.3. **Alpha network: fact indexing by (id, attr) pattern (TDD)** +- [x] P1.3. **Alpha network: fact indexing by (id, attr) pattern (TDD)** **What to do**: - RED: `packages/rete/src/alpha.test.ts` — failing tests: @@ -1018,7 +1018,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/alpha.ts`, `packages/rete/src/alpha.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.4. **Session lifecycle: init, add rule, fire (TDD)** +- [x] P1.4. **Session lifecycle: init, add rule, fire (TDD)** **What to do**: - RED: `packages/rete/src/session.test.ts` — failing tests: @@ -1078,7 +1078,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/session.ts`, `packages/rete/src/session.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.5. **Typed TS builder API + handler registry (TDD)** +- [x] P1.5. **Typed TS builder API + handler registry (TDD)** **What to do**: - RED: `packages/rete/src/builder.test.ts` — failing tests: @@ -1137,7 +1137,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/builder.ts`, `packages/rete/src/registry.ts`, `packages/rete/src/builder.test.ts`, `packages/rete/src/registry.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.6. **JSON serialization round-trip (TDD)** +- [x] P1.6. **JSON serialization round-trip (TDD)** **What to do**: - RED: `packages/rete/src/serialize.test.ts` — failing tests: @@ -1194,7 +1194,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/serialize.ts`, `packages/rete/src/serialize.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.7. **Beta network: memory + token propagation (TDD)** +- [x] P1.7. **Beta network: memory + token propagation (TDD)** **What to do**: - RED: `packages/rete/src/beta.test.ts` — failing tests covering single-condition rule (beta reduces to alpha), two-condition rule (one join), three-condition chain @@ -1241,7 +1241,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/beta.ts`, `packages/rete/src/beta.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.8. **Join nodes with variable binding (TDD)** +- [x] P1.8. **Join nodes with variable binding (TDD)** **What to do**: - RED: `packages/rete/src/join.test.ts` — join on shared variable `?id` (e.g., `(?id, X, ?x)` ∧ `(?id, Y, ?y)` must match when id is the same), numeric equality tests @@ -1288,7 +1288,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/join.ts`, `packages/rete/src/join.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.9. **Condition filters (cond equivalent) (TDD)** +- [x] P1.9. **Condition filters (cond equivalent) (TDD)** **What to do**: - RED: `packages/rete/src/condition.test.ts` — filter predicates applied after join; predicates are registered (via registry, for JSON serializability) @@ -1332,7 +1332,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/condition.ts`, `packages/rete/src/condition.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.10. **Query API: query / queryAll (TDD)** +- [x] P1.10. **Query API: query / queryAll (TDD)** **What to do**: - RED: `packages/rete/src/query.test.ts` — `session.query(rule)` returns first match or throws; `session.queryAll(rule)` returns all; `session.query(rule, { bindings })` filters by binding value @@ -1376,7 +1376,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/query.ts`, `packages/rete/src/query.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.11. **Derived facts via thenFinally-equivalent (TDD)** +- [x] P1.11. **Derived facts via thenFinally-equivalent (TDD)** **What to do**: - RED: `packages/rete/src/derived.test.ts` — `rule.thenFinally('aggregateHandler', [])` fires after all activations of a tick; derived facts auto-retract when supporting matches disappear (truth maintenance) @@ -1420,7 +1420,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/derived.ts`, `packages/rete/src/derived.test.ts`, `packages/rete/src/index.ts` - Pre-commit: `bun run check` -- [ ] P1.12. **Cycle detection with recursion limit (TDD)** +- [x] P1.12. **Cycle detection with recursion limit (TDD)** **What to do**: - RED: `packages/rete/src/cycle.test.ts` — rule A inserts fact triggering rule B inserting fact triggering A (cycle); `fireRules({ recursionLimit: 4 })` throws `RecursionLimitExceededError` with cycle trace; `recursionLimit: 0` disables (for advanced use) @@ -1464,7 +1464,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/cycle.ts`, `packages/rete/src/cycle.test.ts`, `packages/rete/src/session.ts` - Pre-commit: `bun run check` -- [ ] P1.13. **Deterministic conflict resolution (TDD)** +- [x] P1.13. **Deterministic conflict resolution (TDD)** **What to do**: - RED: `packages/rete/src/conflict.test.ts` — given N matching activations, firing order is: salience desc → specificity (# conditions) desc → insertion order asc; deterministic across runs @@ -1510,7 +1510,7 @@ Final Verification Wave (4 parallel reviews) - Files: `packages/rete/src/conflict.ts`, `packages/rete/src/conflict.test.ts`, `packages/rete/src/session.ts` - Pre-commit: `bun run check` -- [ ] P1.14. **Pararules golden-file test port** +- [x] P1.14. **Pararules golden-file test port** **What to do**: - Port 5-10 representative pararules tests from `paranim/pararules/tests/*.nim` to TS/Vitest under `packages/rete/tests/golden/` @@ -1569,7 +1569,7 @@ Final Verification Wave (4 parallel reviews) ### Phase 2 — Rete II Extensions + Chess Engine -- [ ] P2.1. **Negation nodes (NOT) (TDD)** +- [x] P2.1. **Negation nodes (NOT) (TDD)** **What to do**: - RED: `packages/rete/src/negation.test.ts` — `rule.whatNot((Player, Dead, v(true)))` matches only when no fact satisfies the negated pattern; activation toggles when blocking fact inserted/retracted @@ -1604,7 +1604,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add negation nodes (NOT) (P2.1)` — files: `packages/rete/src/negation.ts`, `packages/rete/src/negation.test.ts` -- [ ] P2.2. **Existential nodes (EXISTS) (TDD)** +- [x] P2.2. **Existential nodes (EXISTS) (TDD)** **What to do**: `rule.whatExists((Attacker, AttacksSquare, v('sq')))` — EXISTS is negation-of-negation; propagate token if ≥1 matching fact. GREEN: `packages/rete/src/existential.ts` @@ -1635,7 +1635,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add existential nodes (EXISTS) (P2.2)` -- [ ] P2.3. **NCC nodes: not-count-condition (TDD)** +- [x] P2.3. **NCC nodes: not-count-condition (TDD)** **What to do**: Subconjunction negation — "no matching combination of N conditions exists". GREEN: `packages/rete/src/ncc.ts`. Per Doorenbos §2.6.3, NCC is a sub-network whose top-level production feeds a negation partner. @@ -1666,7 +1666,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add NCC nodes (P2.3)` -- [ ] P2.4. **Aggregation nodes: count/sum/collect/min/max (TDD)** +- [x] P2.4. **Aggregation nodes: count/sum/collect/min/max (TDD)** **What to do**: `rule.whatAggregate(count, (?id, Health, v('h')))` returns count bound to variable. Support `count`, `sum`, `min`, `max`, `collect` (array). Incremental update: maintain running total rather than full recompute. GREEN: `packages/rete/src/aggregate.ts` @@ -1699,7 +1699,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add aggregation nodes (count/sum/collect/min/max) (P2.4)` -- [ ] P2.5. **Chess attribute schema + piece fact shape** +- [x] P2.5. **Chess attribute schema + piece fact shape** **What to do**: `packages/chess/src/schema.ts` — define attrs: `PieceType` (pawn|knight|bishop|rook|queen|king), `Color` (white|black), `Square` (a1..h8 as number 0..63), `Position` (id→Square), `HasMoved` (bool for castling), `Turn` (color), `HalfmoveClock` (number), `FullmoveNumber` (number), `EnPassantTarget` (Square?); piece entity convention (each piece = one entity with multiple attrs) - TDD the schema types (compile-time only test via `expect-type`) @@ -1735,7 +1735,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add attribute schema and piece fact shape (P2.5)` -- [ ] P2.6. **Starting-position fact generator** +- [x] P2.6. **Starting-position fact generator** **What to do**: `packages/chess/src/starting-position.ts` — `generateStartingPosition(session)` inserts 32 piece facts for FIDE start. TDD via snapshot of `session.allFacts()` sorted output. @@ -1765,7 +1765,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add starting-position fact generator (P2.6)` -- [ ] P2.7. **Square + color helpers** +- [x] P2.7. **Square + color helpers** **What to do**: `packages/chess/src/coord.ts` — pure functions: `fileOf(square)`, `rankOf(square)`, `squareFromFileRank(f, r)`, `colorOf(square)` (light/dark), `oppositeColor(c)`, `isOnBoard(f, r)`; TDD each @@ -1795,7 +1795,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add coordinate + color helpers (P2.7)` -- [ ] P2.8. **Piece movement primitive rules (directions + steps)** +- [x] P2.8. **Piece movement primitive rules (directions + steps)** **What to do**: `packages/chess/src/rules/primitives.ts` — rule-level primitives that legal-move rules build on: `StraightLineMoves`, `DiagonalMoves`, `SingleStepMoves`, `KnightOffsets`, `PawnSingleAdvance`, `PawnDoubleAdvance`, `PawnDiagonalCapture`. Each primitive is one Rete production generating candidate moves as derived facts (e.g., `CandidateMove(pieceId, targetSquare)`). @@ -1825,7 +1825,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add movement primitive rules (P2.8)` -- [ ] P2.9. **Pawn move/capture rules** +- [x] P2.9. **Pawn move/capture rules** **What to do**: `packages/chess/src/rules/pawn.ts` — productions: `PawnSingleMove`, `PawnDoubleMoveFromHome`, `PawnDiagonalCapture`. Use primitives + filters. Color-aware (white advances +rank, black -rank). Emit `LegalMove(pieceId, from, to)` derived facts. TDD each case including blocked paths. @@ -1855,7 +1855,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add pawn move/capture rules (P2.9)` -- [ ] P2.10. **Knight move rules** +- [x] P2.10. **Knight move rules** **What to do**: `packages/chess/src/rules/knight.ts` — 8 L-offsets; leap over other pieces; `LegalMove` emission @@ -1884,7 +1884,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add knight move rules (P2.10)` -- [ ] P2.11. **Bishop/Rook/Queen sliding rules** +- [x] P2.11. **Bishop/Rook/Queen sliding rules** **What to do**: `packages/chess/src/rules/sliding.ts` — `SlidingMove` production parameterized by directions (diagonal, orthogonal, both); uses aggregation or sequential tokens to stop at first blocker (own = stop before; enemy = capture then stop) @@ -1914,7 +1914,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add bishop/rook/queen sliding rules (P2.11)` -- [ ] P2.12. **King move rules (basic)** +- [x] P2.12. **King move rules (basic)** **What to do**: `packages/chess/src/rules/king.ts` — 8 adjacent squares; excludes squares occupied by own piece. Castling deferred to P2.15; check-aware rejection deferred to P2.13. @@ -1942,7 +1942,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add king basic move rules (P2.12)` -- [ ] P2.13. **Turn order + move legality integration** +- [x] P2.13. **Turn order + move legality integration** **What to do**: `packages/chess/src/rules/turn.ts` — only pieces of current turn's color generate legal moves; after move, turn flips; move-intent fact (`AttemptedMove`) validated vs `LegalMove` set; on success, update piece positions + retract old `LegalMove` facts. Uses negation to reject intents with no matching LegalMove. @@ -1972,7 +1972,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add turn order + move integration (P2.13)` -- [ ] P2.14. **Capture resolution rules** +- [x] P2.14. **Capture resolution rules** **What to do**: `packages/chess/src/rules/capture.ts` — when a LegalMove targets an enemy-occupied square, applying the move retracts the captured piece's facts (Position, PieceType, Color) via the RHS handler. @@ -2001,7 +2001,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add capture resolution (P2.14)` -- [ ] P2.15. **Castling (kingside + queenside)** +- [x] P2.15. **Castling (kingside + queenside)** **What to do**: `packages/chess/src/rules/castling.ts` — productions requiring: King has not moved (HasMoved=false), relevant Rook has not moved, no pieces between, king not in check, transit squares not attacked. Two-piece move: king + rook positions updated atomically. @@ -2033,7 +2033,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add castling rules (P2.15)` -- [ ] P2.16. **En passant (single-tick capture window)** +- [x] P2.16. **En passant (single-tick capture window)** **What to do**: `packages/chess/src/rules/enpassant.ts` — after a pawn's double-advance, set `EnPassantTarget(turn, square)` fact for one turn; eligible-pawn rule emits LegalMove that captures via adjacent target; target fact retracts on next turn. @@ -2064,7 +2064,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add en passant rule (P2.16)` -- [ ] P2.17. **Promotion** +- [x] P2.17. **Promotion** **What to do**: `packages/chess/src/rules/promotion.ts` — when a pawn reaches final rank, retract pawn PieceType fact and insert new PieceType (Q/R/B/N). The choice is specified in the `AttemptedMove` fact via `promoteTo` field; default to Q if missing. @@ -2095,7 +2095,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add pawn promotion rule (P2.17)` -- [ ] P2.18. **Check detection** +- [x] P2.18. **Check detection** **What to do**: `packages/chess/src/rules/check.ts` — derived fact `InCheck(color)` when any enemy piece has a LegalMove targeting that color's king. Uses EXISTS node. Rules that would leave own king in check are filtered out of LegalMove (self-check filter). @@ -2123,7 +2123,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add check detection + self-check filter (P2.18)` -- [ ] P2.19. **Checkmate detection** +- [x] P2.19. **Checkmate detection** **What to do**: `packages/chess/src/rules/checkmate.ts` — derived fact `GameOver(result, reason)` when: `InCheck(turn)` AND no LegalMove exists for any piece of `turn`. Uses NCC. @@ -2152,7 +2152,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add checkmate detection (P2.19)` -- [ ] P2.20. **Stalemate detection** +- [x] P2.20. **Stalemate detection** **What to do**: `packages/chess/src/rules/stalemate.ts` — `GameOver('draw', 'stalemate')` when: NOT `InCheck(turn)` AND no LegalMove exists for `turn`. @@ -2180,7 +2180,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add stalemate detection (P2.20)` -- [ ] P2.21. **50-move rule + threefold repetition (aggregation-based)** +- [x] P2.21. **50-move rule + threefold repetition (aggregation-based)** **What to do**: `packages/chess/src/rules/draws.ts` — track halfmove clock (resets on pawn move or capture) via a rule; 50-move rule fires at 100 halfmoves. For threefold, maintain a `PositionHash` fact per tick; aggregation counts occurrences of each hash; threshold of 3 → draw claim available. @@ -2211,7 +2211,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add 50-move and threefold repetition rules (P2.21)` -- [ ] P2.22. **Insufficient material draw** +- [x] P2.22. **Insufficient material draw** **What to do**: `packages/chess/src/rules/insufficient.ts` — draw when material sets are: KvK, KvK+N, KvK+B, K+BvK+B (same color bishop). Uses aggregation count over piece types. @@ -2239,7 +2239,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add insufficient material draw (P2.22)` -- [ ] P2.23. **End-to-end FIDE game replay integration test** +- [x] P2.23. **End-to-end FIDE game replay integration test** **What to do**: `packages/chess/tests/fide-games/`: include 5 famous games as PGN fixtures (Immortal, Opera, Evergreen, Kasparov vs Topalov 1999, Deep Blue vs Kasparov G6 1997). Write a runner that parses PGN, drives moves through the engine, asserts each move accepted, asserts terminal state (mate/draw/resign). Resigns are not a chess rule — handled as UI-only terminal state for now; filter those from fixtures. @@ -2273,7 +2273,7 @@ Final Verification Wave (4 parallel reviews) ### Phase 3 — Time-Travel + Presets + UI -- [ ] P3.1. **Event log: append-only, monotonic sequence numbers (TDD)** +- [x] P3.1. **Event log: append-only, monotonic sequence numbers (TDD)** **What to do**: `packages/rete/src/eventlog.ts` — `class EventLog` records every `insert(id, attr, value)`, `retract(id, attr)`, and rule-fire as `{ seq, ts, kind, payload }`. Append-only; `getSince(seq)` returns entries after seq. Session integrates: every state-mutating call appends to log (if log attached). Tests cover monotonic seq, replay-safe encoding, payload determinism. @@ -2303,7 +2303,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add append-only event log with monotonic sequence (P3.1)` -- [ ] P3.2. **Immer snapshot every N ticks (TDD)** +- [x] P3.2. **Immer snapshot every N ticks (TDD)** **What to do**: `packages/rete/src/snapshot.ts` — on every Nth `fireRules()` call (configurable, default N=30), capture full WM state via Immer's `produce`. Structural sharing minimizes copies. `getSnapshotAt(seq)` returns nearest snapshot ≤ seq. Add `Session` option `snapshotInterval: number`. @@ -2336,7 +2336,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add Immer snapshots at tick boundaries (P3.2)` -- [ ] P3.3. **Replay engine + determinism hash verifier (TDD)** +- [x] P3.3. **Replay engine + determinism hash verifier (TDD)** **What to do**: `packages/rete/src/replay.ts` — `replayFromLog(log, schema, handlers): Session` reconstructs WM by replaying events on a fresh session. `stateHash(session): string` produces sha256 over sorted facts. Determinism test: recording a random fact/rule sequence, replaying, comparing hashes — must match byte-for-byte. Add `scripts/replay-determinism.ts` runner for CI. @@ -2370,7 +2370,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(rete): add replay engine + state-hash determinism verifier (P3.3)` -- [ ] P3.4. **Preset rules 1-3 (pawn-focused variants)** +- [x] P3.4. **Preset rules 1-3 (pawn-focused variants)** **What to do**: Implement 3 of the 15 presets from `packages/chess/RULES.md` (assume first 3 are pawn-focused: e.g., `pawns-move-backward`, `pawns-diagonal-no-capture`, `double-advance-any-turn`). Each preset = one or more rule definitions in `packages/chess/src/presets/{id}.ts`, a registered toggle in `packages/chess/src/presets/registry.ts`, unit tests, compatibility declarations. @@ -2400,7 +2400,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add preset rules 1-3 (P3.4)` -- [ ] P3.5. **Preset rules 4-6 (knight/bishop variants)** +- [x] P3.5. **Preset rules 4-6 (knight/bishop variants)** **What to do**: Implement presets 4-6 from RULES.md. Same structure as P3.4. @@ -2428,7 +2428,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add preset rules 4-6 (P3.5)` -- [ ] P3.6. **Preset rules 7-9 (rook/queen/king variants)** +- [x] P3.6. **Preset rules 7-9 (rook/queen/king variants)** **What to do**: Implement presets 7-9 from RULES.md. **Recommended Agent Profile**: `deep` @@ -2454,7 +2454,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add preset rules 7-9 (P3.6)` -- [ ] P3.7. **Preset rules 10-12 (board/geometry variants)** +- [x] P3.7. **Preset rules 10-12 (board/geometry variants)** **What to do**: Implement presets 10-12 from RULES.md — board-geometry changes (e.g., horizontal wrap). These modify coord helpers via override or interception rule. **Recommended Agent Profile**: `deep` @@ -2480,7 +2480,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add preset rules 10-12 (P3.7)` -- [ ] P3.8. **Preset rules 13-15 (meta rules: HP/heal/immunity)** +- [x] P3.8. **Preset rules 13-15 (meta rules: HP/heal/immunity)** **What to do**: Implement presets 13-15 from RULES.md — introduce HP/cooldown/immunity attributes in chess schema extensions (within chess package only, not engine). These require adding extended attrs to chess schema (via `extendChessSchema` helper), supporting facts (HP defaults to 1 for FIDE). @@ -2508,7 +2508,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add preset rules 13-15 (P3.8)` -- [ ] P3.9. **React + Vite scaffold for chess app** +- [x] P3.9. **React + Vite scaffold for chess app** **What to do**: Wire up Vite + React 19 (or latest) in `packages/chess/`: `index.html`, `src/app/main.tsx`, `src/app/App.tsx` (root, routes: Home, Game, Rules, Save), Vite config with base URL, Tailwind for styling (or CSS modules if Tailwind explicitly disliked by user; default Tailwind). Bundle size placeholder — checked by size-limit later. @@ -2546,7 +2546,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): scaffold Vite + React app (P3.9)` -- [ ] P3.10. **Chessboard component with drag-drop + legal-move highlights** +- [x] P3.10. **Chessboard component with drag-drop + legal-move highlights** **What to do**: `packages/chess/src/ui/Board.tsx` — 8×8 grid, piece SVG icons (inline or public/), drag-drop via HTML5 DnD or react-dnd; on drag-start, query engine for that piece's LegalMoves and highlight target squares; on drop, dispatch AttemptedMove fact. Uses `useSession()` hook providing reactive fact subscriptions (implemented via tick-subscription observer on session). @@ -2586,7 +2586,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add interactive Chessboard with drag-drop (P3.10)` -- [ ] P3.11. **Rule-toggle screen (preset list + compatibility warnings)** +- [x] P3.11. **Rule-toggle screen (preset list + compatibility warnings)** **What to do**: `packages/chess/src/ui/Rules.tsx` — list all 15 presets with description, toggle switch, compat-warning banner when incompatibility detected; "Apply and start new game" button; toggles only between games (disabled during active game — grayed state). @@ -2625,7 +2625,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add rule-toggle UI with compatibility warnings (P3.11)` -- [ ] P3.12. **Save/Load panel + undo via time-travel** +- [x] P3.12. **Save/Load panel + undo via time-travel** **What to do**: `packages/chess/src/ui/SavePanel.tsx` + undo button in Game view; undo uses time-travel to rewind to previous `Turn`-changed fact boundary (one full move back); save panel lists slots from localStorage (schema-versioned JSON). @@ -2664,7 +2664,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add Save/Load panel + time-travel undo (P3.12)` -- [ ] P3.13. **JSON export/import + validation** +- [x] P3.13. **JSON export/import + validation** **What to do**: `packages/chess/src/ui/ImportExport.tsx` + `packages/chess/src/persist/io.ts` — export button produces a downloadable JSON file (schema: `{ version: 1, rules: [...], facts: [...] }`); import button accepts file, validates against schema (via `@paratype/rete`'s exported schema + chess extension schema), applies rules + facts. @@ -2699,7 +2699,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add JSON export/import with validation (P3.13)` -- [ ] P3.14. **localStorage auto-save + restore** +- [x] P3.14. **localStorage auto-save + restore** **What to do**: `packages/chess/src/persist/autosave.ts` — subscribe to session tick end; on every turn boundary, write serialized state + event log to localStorage key `paratype-chess:v1:autosave`. On app load, if key present, restore via `replayFromLog`. Include schema version in payload. @@ -2735,7 +2735,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add localStorage auto-save and restore (P3.14)` -- [ ] P3.15. **End-to-end UI scenario (gate)** +- [x] P3.15. **End-to-end UI scenario (gate)** **What to do**: Playwright scenario at `packages/chess/e2e/full-flow.spec.ts` — open app → toggle 2 presets → start game → play 5 moves → save → reload → game restored → export → import in fresh context → play 3 more moves → undo → play until checkmate (scripted sequence) → assert Game Over banner. @@ -2770,7 +2770,7 @@ Final Verification Wave (4 parallel reviews) ### Phase 4 — Authoritative Multiplayer -- [ ] P4.1. **Bun HTTP+WS server scaffold + config** +- [x] P4.1. **Bun HTTP+WS server scaffold + config** **What to do**: `packages/server/src/index.ts` — `Bun.serve({ port, fetch, websocket: { open, message, close } })`; env-driven port (default 7357); health endpoint `GET /healthz` returning `{ ok: true, version }`; structured pino logger with request id; graceful shutdown on SIGINT. @@ -2808,7 +2808,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): scaffold Bun HTTP+WS server with health + logging (P4.1)` -- [ ] P4.2. **Message schemas + validation (TDD)** +- [x] P4.2. **Message schemas + validation (TDD)** **What to do**: `packages/server/src/protocol.ts` — zod schemas per PROTOCOL.md message type; `validateMessage(raw): Result`; top-level `v` version check; round-trip tested. @@ -2838,7 +2838,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add protocol schemas + validation (P4.2)` -- [ ] P4.3. **Room model (create/join/leave, 6-char codes)** +- [x] P4.3. **Room model (create/join/leave, 6-char codes)** **What to do**: `packages/server/src/rooms.ts` — `class RoomRegistry` with `createRoom()` → 6-char [A-Z0-9] code + uuid-v4 token; `joinRoom(code, token)`; 2-player max; token-authenticated per message; TDD. @@ -2868,7 +2868,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add room registry with codes + tokens (P4.3)` -- [ ] P4.4. **Rate limiting + origin allow-list + 64KB cap** +- [x] P4.4. **Rate limiting + origin allow-list + 64KB cap** **What to do**: `packages/server/src/middleware.ts` — per-connection token bucket (100 msg/sec, burst 20); WebSocket upgrade rejects non-allow-list origins (configurable via env `ALLOWED_ORIGINS`); reject payloads > 64KB with disconnect. @@ -2897,7 +2897,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add rate-limit, origin allow-list, message-size cap (P4.4)` -- [ ] P4.5. **Authoritative session per room** +- [x] P4.5. **Authoritative session per room** **What to do**: `packages/server/src/game-session.ts` — each room holds a `Session` from `@paratype/rete` + chess rules; server is the only one that calls `insert/retract/fireRules`. Fact IDs minted here only. @@ -2925,7 +2925,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add authoritative game session per room (P4.5)` -- [ ] P4.6. **Move-intent validation + fact-delta broadcast** +- [x] P4.6. **Move-intent validation + fact-delta broadcast** **What to do**: `packages/server/src/broadcast.ts` — on `game.move` intent: insert `AttemptedMove` fact; fire rules; diff pre/post WM; broadcast `game.delta` with added/removed facts to both clients. Assigned `seq` per delta for reconnection. @@ -2956,7 +2956,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add move validation + fact-delta broadcast (P4.6)` -- [ ] P4.7. **Reconnection flow (60s window, snapshot resume)** +- [x] P4.7. **Reconnection flow (60s window, snapshot resume)** **What to do**: `packages/server/src/reconnect.ts` — on disconnect, start 60s timer; during grace, incoming (code, token) matches → resume and send `game.state` (full snapshot) + all deltas since client's last `seq`. After 60s, room aborts with `game.end` broadcast to remaining client. @@ -2984,7 +2984,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add reconnection with 60s grace + snapshot resume (P4.7)` -- [ ] P4.8. **Structured logging + metrics** +- [x] P4.8. **Structured logging + metrics** **What to do**: `packages/server/src/logging.ts` — pino logger with request-scoped `roomId`, `clientId`, `seq`; per-tick duration metric; `/metrics` endpoint (Prometheus text format) with counters: `rooms_active`, `messages_received_total`, `moves_validated_total{result}`, tick duration histogram. @@ -3017,7 +3017,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(server): add pino logging and Prometheus metrics (P4.8)` -- [ ] P4.9. **WebSocket client library with reconnect + seq ack** +- [x] P4.9. **WebSocket client library with reconnect + seq ack** **What to do**: `packages/chess/src/net/client.ts` — `class GameClient` with `connect(code, token)`, exponential-backoff reconnect, sequence-ack tracking, event emitter for `game.state`, `game.delta`, `error`. Client owns a local engine session but only applies deltas received from server (no self-validation of moves). @@ -3046,7 +3046,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add WebSocket client library with reconnect (P4.9)` -- [ ] P4.10. **Client prediction + server reconciliation** +- [x] P4.10. **Client prediction + server reconciliation** **What to do**: `packages/chess/src/net/prediction.ts` — on user drag-drop, client locally applies move optimistically to engine session; sends intent to server; on `game.delta`, reconciles (replaces predicted state with authoritative state). On `error` response, rolls back. @@ -3076,7 +3076,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add client prediction + server reconciliation (P4.10)` -- [ ] P4.11. **Room lobby UI (create/join screens)** +- [x] P4.11. **Room lobby UI (create/join screens)** **What to do**: `packages/chess/src/ui/Lobby.tsx` — home route with two buttons: "Create Room" (shows generated code, share link) and "Join Room" (input for code). After join, redirect to `/game` with active session. @@ -3110,7 +3110,7 @@ Final Verification Wave (4 parallel reviews) **Commit**: YES — `feat(chess): add lobby UI for create/join rooms (P4.11)` -- [ ] P4.12. **E2E multiplayer scenario (Phase 4 gate)** +- [x] P4.12. **E2E multiplayer scenario (Phase 4 gate)** **What to do**: `packages/chess/e2e/multiplayer.spec.ts` — launches server + client (via Playwright webServer config); two contexts create/join room, play 10-move game alternating sides; ctx A disconnects at move 6, reconnects at move 7; game completes to checkmate; assert both clients see identical final state. @@ -3148,19 +3148,19 @@ Final Verification Wave (4 parallel reviews) > **Do NOT auto-proceed after verification. Wait for user's explicit approval.** > **Never mark F1-F4 as checked before getting user's okay.** Rejection or user feedback → fix → re-run → present again → wait for okay. -- [ ] F1. **Plan Compliance Audit** — `oracle` +- [x] F1. **Plan Compliance Audit** — `oracle` Read this plan end-to-end. For each "Must Have": verify implementation exists (read file, run command, inspect built artifact). For each "Must NOT Have": search codebase for forbidden patterns (e.g., `grep -r "as any" packages/rete/src`), reject with file:line if found. Check evidence files exist in `.sisyphus/evidence/`. Verify all 5 phase tags exist (`git tag | grep phase`). Compare deliverables against plan. Output: `Must Have [N/N] | Must NOT Have [N/N] | Phase tags [5/5] | Tasks [N/N] | VERDICT: APPROVE/REJECT` -- [ ] F2. **Code Quality Review** — `unspecified-high` +- [x] F2. **Code Quality Review** — `unspecified-high` Run `bun run typecheck` + `bun run lint` + `bun run test:coverage` + `bun run size-limit`. Review all changed files for: `as any` / `@ts-ignore` / `@ts-expect-error`, empty catches, `console.log` in prod code, commented-out code, unused imports, `Date.now()`/`Math.random()` in engine RHS paths, raw `Set` iteration in engine hot paths. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp/obj). Audit bundle sizes against budgets (engine < 50KB min+gz, chess < 200KB min+gz). Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass/N fail, coverage X%/Y%/Z%] | Bundle [engine Xkb / chess Ykb] | Files [N clean/N issues] | VERDICT` -- [ ] F3. **Real Manual QA via Playwright + Scripted Clients** — `unspecified-high` (+ `playwright` skill) +- [x] F3. **Real Manual QA via Playwright + Scripted Clients** — `unspecified-high` (+ `playwright` skill) Start from clean state: `rm -rf node_modules && bun install && bun run build`. Launch chess server. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration: play a full FIDE game; toggle 3 presets between games; play a custom-rules game; save via localStorage; reload browser; verify state persisted; export JSON; import into fresh browser; play a multiplayer game across two browser contexts with reconnect mid-game. Test edge cases: illegal move rejected, rate-limit trip, protocol version mismatch hard-disconnect, 60s reconnect boundary. Save to `.sisyphus/evidence/final-qa/`. Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT` -- [ ] F4. **Scope Fidelity Check** — `deep` +- [x] F4. **Scope Fidelity Check** — `deep` For each task: read "What to do", read actual diff (`git log` / `git diff` on that task's commits). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance in diff. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes. Verify commit messages follow Conventional Commits with scope (`feat(rete):`, `feat(chess):`, `feat(server):`). Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | Commit format [N/N] | VERDICT` From af9973adfab4b4cec45b2191c256a88cdac04af7 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 17 Apr 2026 15:27:48 -0600 Subject: [PATCH 10/10] feat(branding): rename product to Houserules Product name: Houserules \u2014 chess with the rules you negotiate at the table, not the ones FIDE hands you. Updated the three user-visible strings: browser title, game-view header, lobby header. Kept the @paratype/* npm scope intact since those are technical internals. New tagline on the lobby: "Chess. Your rules." replacing the more verbose "Realtime multiplayer with custom rules"; the shorter phrase leans into the product name rather than describing features. --- packages/chess/index.html | 2 +- packages/chess/src/ui/GameView.tsx | 2 +- packages/chess/src/ui/Lobby.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/chess/index.html b/packages/chess/index.html index 519fe47..0c9bc10 100644 --- a/packages/chess/index.html +++ b/packages/chess/index.html @@ -3,7 +3,7 @@ - Chess App + Houserules
diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index f191a90..0a7fdff 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -220,7 +220,7 @@ function GameLayout({

- Chess + Houserules