diff --git a/docs/08-match-flow-next-steps.md b/docs/08-match-flow-next-steps.md index b9962f1..82094f0 100644 --- a/docs/08-match-flow-next-steps.md +++ b/docs/08-match-flow-next-steps.md @@ -47,14 +47,15 @@ match server e2e, svelte-check (no new errors), production build, lobby SSR. ## Known gaps / follow-ups -- **Reconnect**: after a reload the game isn't rejoined automatically - (`GET /api/games/[id]` exists; needs a "current game" lookup + auto-enter). -- **Escape** leaves the board view but the match stays active (no - resign/abandon lifecycle yet). +- ✅ **Reconnect** (done, PR #10): `GET /api/games/current` + auto-enter after + the lobby socket connects. +- ✅ **Resign** (done, PR #12): Escape in an active match confirms → resigns + (`POST /api/games/[id]/resign` → `game_over` delta → opponent wins). - Local sandbox `movePiece` still swaps pieces (no capture) — match mode is unaffected (server board is authoritative). - No optimistic move application (small perceived latency: move renders when the delta returns). +- No draw offer / threefold / clocks (Tier 5 game features). --- diff --git a/e2e/match.e2e.mjs b/e2e/match.e2e.mjs index 8048313..6a664e9 100644 --- a/e2e/match.e2e.mjs +++ b/e2e/match.e2e.mjs @@ -11,9 +11,16 @@ for (const r of (await (await p1('/api/rooms')).json()).rooms.filter((r) => r.ow await p1(`/api/rooms/${r.id}`, { method: 'DELETE' }); } -// NOTE: earlier runs may leave active games (no resign/abandon endpoint yet); -// /api/games/current returns the NEWEST active game, so the assertions below -// stay valid on repeat runs. +// clean slate: resign any active games left over from earlier runs +for (const p of [p1, p2]) { + for (let i = 0; i < 10; i++) { + const { game } = await (await p('/api/games/current')).json(); + if (!game) break; + await p(`/api/games/${game.id}/resign`, { method: 'POST' }); + } +} +const pre = await (await p1('/api/games/current')).json(); +assert(pre.game === null, 'no active game before the match (leftovers resigned)'); // create + join const createRes = await p1('/api/rooms', { @@ -97,6 +104,31 @@ assert(stateRes.status === 200, 'participant can GET game state'); const unauth = await fetch(`${BASE}/api/games/${game.id}`); assert(unauth.status === 401, 'unauthenticated GET rejected'); +// resign: black (p2) resigns -> white (p1) wins, both get game_over +const resignRes = await p2(`/api/games/${game.id}/resign`, { method: 'POST' }); +assert(resignRes.status === 200, `resign accepted (200, got ${resignRes.status})`); +const resigned = (await resignRes.json()).game; +assert(resigned.status === 'finished' && resigned.winnerId === game.whitePlayerId, 'resigning as black makes white the winner'); + +await wait(200); +const over1 = c1.msgs.find((m) => m.kind === 'game_over'); +assert(over1 && over1.winnerId === game.whitePlayerId && over1.reason === 'resign', 'p1 received game_over(resign) with the right winner'); +assert(c2.msgs.some((m) => m.kind === 'game_over'), 'p2 received game_over too'); + +// game is over: further moves and repeat resigns are rejected +const postMove = await p2(`/api/games/${game.id}/move`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ from: [2, 5, 2], to: [2, 4, 2] }) +}); +assert(postMove.status === 409, `move after game over rejected (409, got ${postMove.status})`); +const resignAgain = await p2(`/api/games/${game.id}/resign`, { method: 'POST' }); +assert(resignAgain.status === 409, `second resign rejected (409, got ${resignAgain.status})`); + +// no active game remains for either player (reconnect returns null) +const post1 = await (await p1('/api/games/current')).json(); +const post2 = await (await p2('/api/games/current')).json(); +assert(post1.game === null && post2.game === null, 'no active game after resign (e2e leaves no leftovers)'); + c1.ws.close(); c2.ws.close(); finish('MATCH E2E'); diff --git a/package.json b/package.json index 1e71960..ec997c9 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --plugin-search-dir . --check . && eslint .", "format": "prettier --plugin-search-dir . --write .", - "test": "vitest run", - "test:watch": "vitest", + "test": "svelte-kit sync && vitest run", + "test:watch": "svelte-kit sync && vitest", "e2e": "node e2e/seed.mjs && node e2e/auth.e2e.mjs && node e2e/rooms.e2e.mjs && node e2e/match.e2e.mjs", "model-pipeline:run": "node scripts/model-pipeline.js" }, diff --git a/src/lib/async/websockets/match/handler.ts b/src/lib/async/websockets/match/handler.ts index 1335108..c61ba04 100644 --- a/src/lib/async/websockets/match/handler.ts +++ b/src/lib/async/websockets/match/handler.ts @@ -49,11 +49,21 @@ export async function enterMatchById(gameId: string): Promise { await enterMatch(game); } -/** Apply a server-confirmed move: the delta's board is authoritative. */ +/** Apply a server-confirmed delta: the server state is authoritative. */ export function matchDeltaHandler(delta: MatchDelta): void { const current = get(gameStore); if (!current) return; + if (delta.kind === 'game_over') { + gameStore.set({ ...current, status: 'finished', winnerId: delta.winnerId }); + const won = delta.winnerId === get(userStore).id; + pushNotification({ + message: won ? 'You won — your opponent resigned.' : 'You resigned.', + type: won ? 'success' : 'error' + }); + return; + } + hydrateBoard(delta.board); gameStore.set({ ...current, @@ -72,6 +82,19 @@ export function matchDeltaHandler(delta: MatchDelta): void { } } +/** Resign the active game; the game_over delta finishes the UI transition. */ +export async function resignGame(): Promise { + const game = get(gameStore); + if (!game || game.status !== 'active') return true; // nothing to resign + + const res = await fetch(`/api/games/${game.id}/resign`, { method: 'POST' }); + if (!res.ok && res.status !== 409) { + pushNotification({ message: 'Could not resign', type: 'error' }); + return false; + } + return true; +} + /** POST a move; the resulting board arrives via the MATCH delta. */ export async function sendMove(from: number[], to: number[]): Promise { const game = get(gameStore); diff --git a/src/lib/async/websockets/rooms/handler.ts b/src/lib/async/websockets/rooms/handler.ts index d81e24e..f5dfd3c 100644 --- a/src/lib/async/websockets/rooms/handler.ts +++ b/src/lib/async/websockets/rooms/handler.ts @@ -44,6 +44,7 @@ export const roomsEventHandler = (event: MessageEvent) => { break; } case 'move_applied': + case 'game_over': matchDeltaHandler(delta); break; } diff --git a/src/lib/async/websockets/types.ts b/src/lib/async/websockets/types.ts index 6c97c8a..5b05f37 100644 --- a/src/lib/async/websockets/types.ts +++ b/src/lib/async/websockets/types.ts @@ -19,13 +19,20 @@ export type RoomDelta = | { kind: 'game_started'; game: GameSummary }; // Deltas broadcast on the per-game MATCH: topic. -export type MatchDelta = { - kind: 'move_applied'; - from: Coord; - to: Coord; - turn: Side; - board: Record; - status: 'active' | 'finished'; - winnerId: string | null; - captured: PieceState | null; -}; +export type MatchDelta = + | { + kind: 'move_applied'; + from: Coord; + to: Coord; + turn: Side; + board: Record; + status: 'active' | 'finished'; + winnerId: string | null; + captured: PieceState | null; + } + | { + kind: 'game_over'; + status: 'finished'; + winnerId: string | null; + reason: 'resign'; + }; diff --git a/src/lib/components/App.svelte b/src/lib/components/App.svelte index 91ac591..022f446 100644 --- a/src/lib/components/App.svelte +++ b/src/lib/components/App.svelte @@ -1,22 +1,53 @@