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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue