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.
This commit is contained in:
parent
8b8a5179ec
commit
5fb96647eb
5 changed files with 509 additions and 90 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path="/" element={<PageTransition><Lobby chessState={chessState} /></PageTransition>} />
|
||||
<Route path="/game" element={<PageTransition><GameView engineState={chessState} /></PageTransition>} />
|
||||
<Route path="/game" element={<PageTransition><GameRoute chessState={chessState} /></PageTransition>} />
|
||||
<Route path="/rules" element={<PageTransition><RulesView chessState={chessState} /></PageTransition>} />
|
||||
<Route path="/save" element={<PageTransition><SaveWrapper chessState={chessState} /></PageTransition>} />
|
||||
</Routes>
|
||||
|
|
@ -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<typeof useChessEngine>
|
||||
}) {
|
||||
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 <MultiplayerGameView code={mpCreds.code} token={mpCreds.token} />
|
||||
}
|
||||
return <GameView engineState={chessState} />
|
||||
}
|
||||
|
||||
function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
|
|
|
|||
256
packages/chess/src/hooks/useMultiplayerGame.ts
Normal file
256
packages/chess/src/hooks/useMultiplayerGame.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
/**
|
||||
* React hook for a server-backed chess game.
|
||||
*
|
||||
* Wraps GameClient + PredictionManager and exposes the same surface as
|
||||
* `useChessEngine` so `<GameView>` 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<string, string> }).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<GameClient | null>(null);
|
||||
const managerRef = useRef<PredictionManager | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [meta, setMeta] = useState<MultiplayerGameState>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<number, PieceState>();
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<ChessFact<keyof ChessAttrMap>> | ReturnType<typeof useChessEngine>['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<typeof useChessEngine>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <GameLayout state={state} myColor={null} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="text-neutral-500 font-medium">Waiting for opponent…</div>
|
||||
<div className="mt-2 text-sm text-neutral-400" data-testid="mp-room-code">
|
||||
Room: {code}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.error !== null && (
|
||||
<div
|
||||
data-testid="mp-error"
|
||||
className="fixed top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 bg-red-50 border border-red-200 text-red-700 text-sm font-medium rounded-md shadow-sm"
|
||||
>
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
<GameLayout state={state} myColor={state.myColor} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
|
|
@ -84,33 +173,44 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
>
|
||||
<RulesDrawer onRulesChanged={refresh} />
|
||||
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
|
||||
|
||||
|
||||
{/* Header/Info section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-neutral-800">
|
||||
Chess
|
||||
</h1>
|
||||
<button
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-2 text-neutral-500 hover:text-neutral-800 hover:bg-neutral-100 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200"
|
||||
title={isMuted ? "Unmute sounds" : "Mute sounds"}
|
||||
>
|
||||
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
{myColor && (
|
||||
<span
|
||||
data-testid="my-color"
|
||||
className="text-sm font-medium text-neutral-500 px-2 py-1 bg-neutral-100 rounded"
|
||||
>
|
||||
You are {myColor}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
<div
|
||||
data-testid="turn-indicator"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-neutral-200 rounded-md font-medium text-neutral-700 shadow-sm"
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-inner ${turn === 'white' ? 'bg-white border border-neutral-300' : 'bg-neutral-900 border border-neutral-950'}`}
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-inner ${turn === 'white' ? 'bg-white border border-neutral-300' : 'bg-neutral-900 border border-neutral-950'}`}
|
||||
/>
|
||||
<span>{turn === 'white' ? "White's turn" : "Black's turn"}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Undo is only meaningful in local mode (server is authoritative
|
||||
* in multiplayer); the hook exposes canUndo=false there so the
|
||||
* button naturally stays disabled. */}
|
||||
<motion.button
|
||||
whileTap={canUndo ? { scale: 0.95 } : {}}
|
||||
data-action="undo"
|
||||
|
|
@ -127,7 +227,7 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
{/* Game Result Banner */}
|
||||
<AnimatePresence>
|
||||
{isGameOver && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ type: 'spring', damping: 20 }}
|
||||
|
|
@ -141,25 +241,26 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
</div>
|
||||
|
||||
<div className="w-full px-4 relative">
|
||||
<Board
|
||||
facts={facts as ChessFact<keyof ChessAttrMap>[]}
|
||||
legalMoves={legalMoves}
|
||||
turn={turn}
|
||||
onMove={handleMove}
|
||||
<Board
|
||||
facts={facts as ChessFact<keyof ChessAttrMap>[]}
|
||||
legalMoves={legalMoves}
|
||||
turn={turn as Color}
|
||||
myColor={myColor}
|
||||
onMove={handleMove}
|
||||
lastMove={lastMove}
|
||||
checkedKingSquare={checkedKingSquare}
|
||||
/>
|
||||
|
||||
|
||||
{/* Overlay for game over to prevent further interaction visually */}
|
||||
{isGameOver && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="absolute inset-0 bg-white/10 backdrop-blur-[1px] pointer-events-none z-30"
|
||||
className="absolute inset-0 bg-white/10 backdrop-blur-[1px] pointer-events-none z-30"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue