From 8c350602516bac70ca2fda5ba12c2abb3ea37fc3 Mon Sep 17 00:00:00 2001 From: julano Date: Mon, 6 Jul 2026 23:15:14 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(resign):=20resign=20lifecycle=20?= =?UTF-8?q?=E2=80=94=20Escape=20now=20resigns=20with=20confirmation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service: resignGame (participant + active checks; opponent wins) → broadcasts a new game_over delta on MATCH: - route: POST /api/games/[id]/resign (401/403/404/409 mapping) - protocol: MatchDelta becomes move_applied | game_over(reason:'resign') - client: matchDeltaHandler handles game_over (won/resigned toasts); App.svelte Escape in an ACTIVE match opens a confirm modal ('leaving the board resigns the match') → resign → clear game → lobby; when no match or already finished, Escape just leaves and clears the store - e2e: match suite now resigns leftover actives at start (restores the strict no-active-game precondition) and covers the full scenario: resign 200, opponent gets game_over(resign) with the right winner, post-game moves/repeat resigns 409, /api/games/current null for both 54 unit tests + full e2e suite green; match e2e repeatable (leaves no active games behind). Co-Authored-By: Claude Fable 5 --- e2e/match.e2e.mjs | 38 ++++++++++++++++-- src/lib/async/websockets/match/handler.ts | 25 +++++++++++- src/lib/async/websockets/rooms/handler.ts | 1 + src/lib/async/websockets/types.ts | 27 ++++++++----- src/lib/components/App.svelte | 43 ++++++++++++++++++--- src/lib/server/game/service.ts | 22 +++++++++++ src/routes/api/games/[id]/resign/+server.ts | 17 ++++++++ 7 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 src/routes/api/games/[id]/resign/+server.ts 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/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 @@