Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/08-match-flow-next-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
38 changes: 35 additions & 3 deletions e2e/match.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -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');
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
25 changes: 24 additions & 1 deletion src/lib/async/websockets/match/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,21 @@ export async function enterMatchById(gameId: string): Promise<void> {
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,
Expand All @@ -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<boolean> {
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<void> {
const game = get(gameStore);
Expand Down
1 change: 1 addition & 0 deletions src/lib/async/websockets/rooms/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const roomsEventHandler = (event: MessageEvent) => {
break;
}
case 'move_applied':
case 'game_over':
matchDeltaHandler(delta);
break;
}
Expand Down
27 changes: 17 additions & 10 deletions src/lib/async/websockets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,20 @@ export type RoomDelta =
| { kind: 'game_started'; game: GameSummary };

// Deltas broadcast on the per-game MATCH:<gameId> topic.
export type MatchDelta = {
kind: 'move_applied';
from: Coord;
to: Coord;
turn: Side;
board: Record<string, PieceState>;
status: 'active' | 'finished';
winnerId: string | null;
captured: PieceState | null;
};
export type MatchDelta =
| {
kind: 'move_applied';
from: Coord;
to: Coord;
turn: Side;
board: Record<string, PieceState>;
status: 'active' | 'finished';
winnerId: string | null;
captured: PieceState | null;
}
| {
kind: 'game_over';
status: 'finished';
winnerId: string | null;
reason: 'resign';
};
43 changes: 37 additions & 6 deletions src/lib/components/App.svelte
Original file line number Diff line number Diff line change
@@ -1,22 +1,53 @@
<script lang="ts">
import { get } from 'svelte/store';
import { getModalStore, type ModalSettings } from '@skeletonlabs/skeleton';
import { decreaseOffsetY, increaseOffsetY } from '$lib/store/camera';
import userStore from '$lib/store/user';
import { gameStore, clearGame } from '$lib/store/game';
import { resignGame } from '$lib/async/websockets/match/handler';
import Table from './Table/Table.svelte';
import BorderWrapper from './borderWrapper/BorderWrapper.svelte';
import CreateRoom from './createRoom/CreateRoom.svelte';
import MyRooms from './myRooms/MyRooms.svelte';

const modalStore = getModalStore();

const leaveBoard = () => {
clearGame();
userStore.update((user) => ({ ...user, playing: false }));
};

const onEscape = () => {
const game = get(gameStore);

// no match, or the match is over: just leave the board view
if (!game || game.status !== 'active') {
leaveBoard();
return;
}

const modal: ModalSettings = {
type: 'confirm',
title: 'Resign?',
backdropClasses: 'glass black-glass',
modalClasses: 'bg-white',
body: 'Leaving the board resigns the match. Resign and return to the lobby?',
response: (confirmed: boolean) => {
if (!confirmed) return;
void resignGame().then((ok) => {
if (ok) leaveBoard();
});
}
};
modalStore.trigger(modal);
};

/** @param {KeyboardEvent} event */
function handleKeydown(event: KeyboardEvent) {
if (playing) {
switch (event.key) {
case 'Escape':
userStore.update((user) => {
return {
...user,
playing: false
};
});
onEscape();
break;

case 'w':
Expand Down
22 changes: 22 additions & 0 deletions src/lib/server/game/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,28 @@ export async function getGame(gameId: string): Promise<GameState | null> {
return game ? toState(game) : null;
}

/** Resign: the opponent wins immediately; both players get a game_over delta. */
export async function resignGame(gameId: string, userId: string): Promise<GameState> {
const game = await prisma.game.findUnique({ where: { id: gameId } });
if (!game) throw new Error('GAME_NOT_FOUND');
if (game.status !== 'active') throw new Error('GAME_OVER');

const isWhite = userId === game.whitePlayerId;
const isBlack = userId === game.blackPlayerId;
if (!isWhite && !isBlack) throw new Error('NOT_A_PARTICIPANT');

const winnerId = isWhite ? game.blackPlayerId : game.whitePlayerId;
const updated = await prisma.game.update({
where: { id: gameId },
data: { status: 'finished', winnerId }
});

const delta: MatchDelta = { kind: 'game_over', status: 'finished', winnerId, reason: 'resign' };
publish(matchTopic(gameId), JSON.stringify(delta));

return toState(updated);
}

/** The player's active game, if any — used to reconnect after a reload. */
export async function getCurrentGameFor(userId: string): Promise<GameState | null> {
const game = await prisma.game.findFirst({
Expand Down
17 changes: 17 additions & 0 deletions src/routes/api/games/[id]/resign/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { json, error } from '@sveltejs/kit';
import { resignGame } from '$lib/server/game/service';

export const POST = async ({ params, locals }) => {
if (!locals.user) throw error(401, 'Not authenticated');

try {
const game = await resignGame(params.id, locals.user.id);
return json({ game });
} catch (e) {
const msg = e instanceof Error ? e.message : 'UNKNOWN';
if (msg === 'GAME_NOT_FOUND') throw error(404, msg);
if (msg === 'NOT_A_PARTICIPANT') throw error(403, msg);
if (msg === 'GAME_OVER') throw error(409, msg);
throw error(500, msg);
}
};
Loading