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