From 30d3aa077b03e5367ebece31bfb7368611136cb1 Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 12:05:05 +0200 Subject: [PATCH 01/52] fix(ranking): make Elo rounding symmetric --- packages/common/src/utils/elo.test.ts | 23 ++++++++++++++++++++++- packages/common/src/utils/elo.ts | 7 ++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/common/src/utils/elo.test.ts b/packages/common/src/utils/elo.test.ts index 07337fdd..5f1b82a0 100644 --- a/packages/common/src/utils/elo.test.ts +++ b/packages/common/src/utils/elo.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { calculateEloRatings } from './elo' +import { calculateEloRatings, roundEloChange } from './elo' describe('calculateEloRatings', () => { it('moves sixteen points between equally rated players after a decisive game', () => { @@ -22,4 +22,25 @@ describe('calculateEloRatings', () => { expect(update.playerOne.change).toBeGreaterThan(16) expect(update.playerOne.change + update.playerTwo.change).toBe(0) }) + + it('produces the same changes when player seats are swapped', () => { + const original = calculateEloRatings(1000, 1600, 'player-one-win') + const swapped = calculateEloRatings(1600, 1000, 'player-two-win') + + expect(swapped.playerTwo.change).toBe(original.playerOne.change) + expect(swapped.playerOne.change).toBe(original.playerTwo.change) + }) +}) + +describe('roundEloChange', () => { + it('rounds positive and negative halves symmetrically', () => { + expect(roundEloChange(4.5)).toBe(5) + expect(roundEloChange(-4.5)).toBe(-5) + }) + + it('returns positive zero for changes below half a point', () => { + expect(roundEloChange(0.49)).toBe(0) + expect(roundEloChange(-0.49)).toBe(0) + expect(Object.is(roundEloChange(-0.49), -0)).toBe(false) + }) }) diff --git a/packages/common/src/utils/elo.ts b/packages/common/src/utils/elo.ts index b9f78645..5b6ef0a8 100644 --- a/packages/common/src/utils/elo.ts +++ b/packages/common/src/utils/elo.ts @@ -11,7 +11,7 @@ export function calculateEloRatings( ): EloRatingUpdate { const playerOneExpectedScore = expectedScore(playerOneRating, playerTwoRating) const playerOneScore = getPlayerOneScore(result) - const playerOneChange = Math.round( + const playerOneChange = roundEloChange( ELO_K_FACTOR * (playerOneScore - playerOneExpectedScore) ) const playerTwoChange = playerOneChange === 0 ? 0 : -playerOneChange @@ -30,6 +30,11 @@ export function calculateEloRatings( } } +export function roundEloChange(change: number): number { + const roundedMagnitude = Math.round(Math.abs(change)) + return roundedMagnitude === 0 ? 0 : Math.sign(change) * roundedMagnitude +} + function expectedScore(rating: number, opponentRating: number): number { return 1 / (1 + 10 ** ((opponentRating - rating) / 400)) } From 87413bf6a2504ebd9dead4d8a963204e047dc06b Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 12:11:58 +0200 Subject: [PATCH 02/52] feat(ranking): add ranked match persistence --- .../migrations/0007_create_ranked_matches.sql | 129 ++++++++++++++++++ apps/worker/test/worker.integration.test.ts | 115 ++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 apps/worker/migrations/0007_create_ranked_matches.sql diff --git a/apps/worker/migrations/0007_create_ranked_matches.sql b/apps/worker/migrations/0007_create_ranked_matches.sql new file mode 100644 index 00000000..08c52b32 --- /dev/null +++ b/apps/worker/migrations/0007_create_ranked_matches.sql @@ -0,0 +1,129 @@ +CREATE TABLE active_ranked_matches ( + match_id TEXT PRIMARY KEY NOT NULL CHECK (length(match_id) = 36), + room_key TEXT NOT NULL UNIQUE CHECK (length(room_key) = 36), + queue_key TEXT NOT NULL, + rating_pool TEXT NOT NULL, + format TEXT NOT NULL CHECK (format IN ('bo1')), + player_one_id TEXT NOT NULL, + player_two_id TEXT NOT NULL, + player_one_rating INTEGER NOT NULL, + player_two_rating INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('assigned', 'active')), + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + activated_at INTEGER, + FOREIGN KEY (player_one_id) REFERENCES players(player_id) ON DELETE CASCADE, + FOREIGN KEY (player_two_id) REFERENCES players(player_id) ON DELETE CASCADE, + CHECK (player_one_id <> player_two_id), + CHECK (expires_at > created_at), + CHECK ( + (state = 'assigned' AND activated_at IS NULL) OR + (state = 'active' AND activated_at IS NOT NULL) + ) +); + +CREATE INDEX active_ranked_matches_player_one + ON active_ranked_matches (player_one_id); + +CREATE INDEX active_ranked_matches_player_two + ON active_ranked_matches (player_two_id); + +CREATE TRIGGER active_ranked_matches_unique_players +BEFORE INSERT ON active_ranked_matches +WHEN EXISTS ( + SELECT 1 + FROM active_ranked_matches + WHERE player_one_id IN (NEW.player_one_id, NEW.player_two_id) + OR player_two_id IN (NEW.player_one_id, NEW.player_two_id) +) +BEGIN + SELECT RAISE(ABORT, 'PLAYER_ALREADY_IN_RANKED_MATCH'); +END; + +CREATE TRIGGER active_ranked_matches_players_immutable +BEFORE UPDATE OF player_one_id, player_two_id ON active_ranked_matches +BEGIN + SELECT RAISE(ABORT, 'RANKED_MATCH_PLAYERS_ARE_IMMUTABLE'); +END; + +CREATE TABLE rated_matches ( + match_id TEXT PRIMARY KEY NOT NULL CHECK (length(match_id) = 36), + room_key TEXT NOT NULL UNIQUE CHECK (length(room_key) = 36), + queue_key TEXT NOT NULL, + rating_pool TEXT NOT NULL, + format TEXT NOT NULL CHECK (format IN ('bo1')), + player_one_id TEXT NOT NULL, + player_two_id TEXT NOT NULL, + result TEXT NOT NULL CHECK ( + result IN ('player-one-win', 'draw', 'player-two-win', 'no-contest') + ), + finish_reason TEXT NOT NULL CHECK ( + finish_reason IN ('completed', 'forfeit', 'no-contest') + ), + player_one_rating_before INTEGER NOT NULL, + player_two_rating_before INTEGER NOT NULL, + player_one_rating_after INTEGER NOT NULL, + player_two_rating_after INTEGER NOT NULL, + rating_delta INTEGER NOT NULL, + created_at INTEGER NOT NULL, + activated_at INTEGER NOT NULL, + finished_at INTEGER NOT NULL, + settled_at INTEGER NOT NULL, + FOREIGN KEY (player_one_id) REFERENCES players(player_id), + FOREIGN KEY (player_two_id) REFERENCES players(player_id), + CHECK (player_one_id <> player_two_id), + CHECK (activated_at >= created_at), + CHECK (finished_at >= activated_at), + CHECK (settled_at >= finished_at), + CHECK ( + (result = 'no-contest' AND finish_reason = 'no-contest' AND + rating_delta = 0 AND + player_one_rating_after = player_one_rating_before AND + player_two_rating_after = player_two_rating_before) OR + (result <> 'no-contest' AND finish_reason <> 'no-contest' AND + player_one_rating_after = player_one_rating_before + rating_delta AND + player_two_rating_after = player_two_rating_before - rating_delta) + ) +); + +CREATE INDEX rated_matches_player_one_history + ON rated_matches (player_one_id, settled_at DESC); + +CREATE INDEX rated_matches_player_two_history + ON rated_matches (player_two_id, settled_at DESC); + +CREATE TRIGGER rated_matches_validate_active_match +BEFORE INSERT ON rated_matches +WHEN NOT EXISTS ( + SELECT 1 + FROM active_ranked_matches + WHERE match_id = NEW.match_id + AND room_key = NEW.room_key + AND queue_key = NEW.queue_key + AND rating_pool = NEW.rating_pool + AND format = NEW.format + AND player_one_id = NEW.player_one_id + AND player_two_id = NEW.player_two_id + AND player_one_rating = NEW.player_one_rating_before + AND player_two_rating = NEW.player_two_rating_before + AND state = 'active' + AND created_at = NEW.created_at + AND activated_at = NEW.activated_at +) +OR NOT EXISTS ( + SELECT 1 + FROM player_ratings + WHERE player_id = NEW.player_one_id + AND rating_pool = NEW.rating_pool + AND rating = NEW.player_one_rating_before +) +OR NOT EXISTS ( + SELECT 1 + FROM player_ratings + WHERE player_id = NEW.player_two_id + AND rating_pool = NEW.rating_pool + AND rating = NEW.player_two_rating_before +) +BEGIN + SELECT RAISE(ABORT, 'INVALID_RANKED_MATCH_SETTLEMENT'); +END; diff --git a/apps/worker/test/worker.integration.test.ts b/apps/worker/test/worker.integration.test.ts index a546ce10..0fce4222 100644 --- a/apps/worker/test/worker.integration.test.ts +++ b/apps/worker/test/worker.integration.test.ts @@ -1006,6 +1006,121 @@ describe('WebSocket tickets', () => { }) }) +describe('ranked match persistence', () => { + const insertActiveMatch = async ({ + matchId, + roomKey, + playerOne, + playerTwo, + state = 'assigned' + }: { + matchId: string + roomKey: string + playerOne: PlayerCredentials + playerTwo: PlayerCredentials + state?: 'assigned' | 'active' + }) => { + const environment = await server.getWorker().getEnv() + const createdAt = Date.now() + const activatedAt = state === 'active' ? createdAt + 1 : null + await environment.PLAYERS_DB.prepare( + `INSERT INTO active_ranked_matches ( + match_id, room_key, queue_key, rating_pool, format, + player_one_id, player_two_id, + player_one_rating, player_two_rating, + state, created_at, expires_at, activated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + matchId, + roomKey, + 'classic:bo1', + 'classic', + 'bo1', + playerOne.playerId, + playerTwo.playerId, + 1200, + 1200, + state, + createdAt, + createdAt + 15_000, + activatedAt + ) + .run() + return { createdAt, activatedAt } + } + + it('allows only one active ranked assignment per player', async () => { + const playerOne = await createPlayer() + const playerTwo = await createPlayer() + const playerThree = await createPlayer() + + await insertActiveMatch({ + matchId: crypto.randomUUID(), + roomKey: crypto.randomUUID(), + playerOne, + playerTwo + }) + + await expect( + insertActiveMatch({ + matchId: crypto.randomUUID(), + roomKey: crypto.randomUUID(), + playerOne: playerThree, + playerTwo + }) + ).rejects.toThrow(/PLAYER_ALREADY_IN_RANKED_MATCH/) + }) + + it('rejects a result that does not match its active assignment', async () => { + const playerOne = await createPlayer() + const playerTwo = await createPlayer() + const environment = await server.getWorker().getEnv() + const matchId = crypto.randomUUID() + const roomKey = crypto.randomUUID() + const { createdAt, activatedAt } = await insertActiveMatch({ + matchId, + roomKey, + playerOne, + playerTwo, + state: 'active' + }) + + const settlement = environment.PLAYERS_DB.prepare( + `INSERT INTO rated_matches ( + match_id, room_key, queue_key, rating_pool, format, + player_one_id, player_two_id, result, finish_reason, + player_one_rating_before, player_two_rating_before, + player_one_rating_after, player_two_rating_after, rating_delta, + created_at, activated_at, finished_at, settled_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).bind( + matchId, + roomKey, + 'classic:bo1', + 'classic', + 'bo1', + playerOne.playerId, + playerTwo.playerId, + 'player-one-win', + 'completed', + 1199, + 1200, + 1215, + 1184, + 16, + createdAt, + activatedAt, + activatedAt! + 1, + activatedAt! + 2 + ) + + await expect(settlement.run()).rejects.toThrow( + /INVALID_RANKED_MATCH_SETTLEMENT/ + ) + }) +}) + describe('ranked matchmaking', () => { const joinQueue = (player: PlayerCredentials) => request('/v1/matchmaking/join', { From 5dbc598dc06efcf5fe95cc7320195c8d136af292 Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 12:15:40 +0200 Subject: [PATCH 03/52] feat(matchmaking): reserve ranked assignments --- .../MatchmakingDurableObject.ts | 53 +++++++- apps/worker/src/endpoints/matchmaking.ts | 9 +- apps/worker/src/utils/rankedMatches.ts | 126 ++++++++++++++++++ apps/worker/test/worker.integration.test.ts | 25 +++- packages/common/src/schemas/matchmaking.ts | 9 +- packages/common/src/types/matchmaking.ts | 8 +- 6 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 apps/worker/src/utils/rankedMatches.ts diff --git a/apps/worker/src/durable-objects/MatchmakingDurableObject.ts b/apps/worker/src/durable-objects/MatchmakingDurableObject.ts index b5ab4924..28452747 100644 --- a/apps/worker/src/durable-objects/MatchmakingDurableObject.ts +++ b/apps/worker/src/durable-objects/MatchmakingDurableObject.ts @@ -5,15 +5,21 @@ import { matchmakingStatusSchema, playerIdSchema, RANKED_MATCH_FORMAT, + RANKED_QUEUE_KEY, type RankedMatchAssignment } from '@knucklebones/common' import { type CloudflareEnvironment } from '../types/cloudflareEnvironment' import { apiError } from '../utils/http' +import { + getActiveRankedMatchForPlayer, + releaseRankedMatch, + reserveRankedMatch +} from '../utils/rankedMatches' const MATCHMAKING_STATE_KEY = 'matchmaking-state' const RATING_SELECTION_WINDOW_MS = 750 const QUEUE_ENTRY_TTL_MS = 15_000 -const MATCH_ASSIGNMENT_TTL_MS = 5 * 60 * 1000 +const MATCH_ASSIGNMENT_TTL_MS = 15_000 interface QueueEntry { playerId: string @@ -106,6 +112,19 @@ export class MatchmakingDurableObject { private async join(playerId: string, rating: number): Promise { const now = Date.now() + const activeMatch = await getActiveRankedMatchForPlayer( + this.cloudflareEnvironment.PLAYERS_DB, + playerId, + now + ) + if (activeMatch !== undefined) { + await this.configureRankedRoom(activeMatch.assignment) + return this.statusResponse({ + status: 'matched', + match: activeMatch.assignment + }) + } + const state = await this.getActiveState(now) const assignment = state.assignments[playerId] @@ -133,6 +152,19 @@ export class MatchmakingDurableObject { private async getStatus(playerId: string): Promise { const now = Date.now() + const activeMatch = await getActiveRankedMatchForPlayer( + this.cloudflareEnvironment.PLAYERS_DB, + playerId, + now + ) + if (activeMatch !== undefined) { + await this.configureRankedRoom(activeMatch.assignment) + return this.statusResponse({ + status: 'matched', + match: activeMatch.assignment + }) + } + const state = await this.getActiveState(now) const assignment = state.assignments[playerId] if (assignment !== undefined) { @@ -168,7 +200,7 @@ export class MatchmakingDurableObject { ) state.assignments = Object.fromEntries( Object.entries(state.assignments).filter( - ([, match]) => match.createdAt > now - MATCH_ASSIGNMENT_TTL_MS + ([, match]) => match.expiresAt > now ) ) return state @@ -214,14 +246,27 @@ export class MatchmakingDurableObject { const match: RankedMatchAssignment = { matchId: crypto.randomUUID(), roomKey: crypto.randomUUID(), + queueKey: RANKED_QUEUE_KEY, ratingPool: DEFAULT_RATING_POOL, format: RANKED_MATCH_FORMAT, playerOneId: entry.playerId, playerTwoId: opponent.playerId, - createdAt: now + playerOneRating: entry.rating, + playerTwoRating: opponent.rating, + createdAt: now, + expiresAt: now + MATCH_ASSIGNMENT_TTL_MS } - await this.configureRankedRoom(match) + await reserveRankedMatch(this.cloudflareEnvironment.PLAYERS_DB, match) + try { + await this.configureRankedRoom(match) + } catch (error) { + await releaseRankedMatch( + this.cloudflareEnvironment.PLAYERS_DB, + match.matchId + ) + throw error + } state.waiting = state.waiting.filter( (candidate) => diff --git a/apps/worker/src/endpoints/matchmaking.ts b/apps/worker/src/endpoints/matchmaking.ts index 6892f9cb..8e0fe5f8 100644 --- a/apps/worker/src/endpoints/matchmaking.ts +++ b/apps/worker/src/endpoints/matchmaking.ts @@ -1,4 +1,4 @@ -import { DEFAULT_RATING_POOL } from '@knucklebones/common' +import { DEFAULT_RATING_POOL, RANKED_QUEUE_KEY } from '@knucklebones/common' import { type CloudflareEnvironment } from '../types/cloudflareEnvironment' import { type AuthenticatedRequestWithProps } from '../types/itty' import { apiError } from '../utils/http' @@ -77,9 +77,10 @@ async function fetchMatchmakingObject( path: string, init?: RequestInit ): Promise { - const id = cloudflareEnvironment.MATCHMAKING_DURABLE_OBJECT.idFromName( - `${DEFAULT_RATING_POOL}:bo1` - ) + const id = + cloudflareEnvironment.MATCHMAKING_DURABLE_OBJECT.idFromName( + RANKED_QUEUE_KEY + ) const matchmaking = cloudflareEnvironment.MATCHMAKING_DURABLE_OBJECT.get(id) const headers = new Headers(init?.headers) headers.set('X-Player-Id', playerId) diff --git a/apps/worker/src/utils/rankedMatches.ts b/apps/worker/src/utils/rankedMatches.ts new file mode 100644 index 00000000..b5b6716e --- /dev/null +++ b/apps/worker/src/utils/rankedMatches.ts @@ -0,0 +1,126 @@ +import { + type RankedMatchAssignment, + rankedMatchAssignmentSchema +} from '@knucklebones/common' + +interface ActiveRankedMatchRow { + match_id: string + room_key: string + queue_key: string + rating_pool: string + format: string + player_one_id: string + player_two_id: string + player_one_rating: number + player_two_rating: number + state: 'assigned' | 'active' + created_at: number + expires_at: number + activated_at: number | null +} + +export interface ActiveRankedMatch { + assignment: RankedMatchAssignment + state: ActiveRankedMatchRow['state'] + activatedAt?: number +} + +export async function getActiveRankedMatchForPlayer( + database: D1Database, + playerId: string, + now = Date.now() +): Promise { + const row = await database + .prepare( + `SELECT match_id, room_key, queue_key, rating_pool, format, + player_one_id, player_two_id, + player_one_rating, player_two_rating, + state, created_at, expires_at, activated_at + FROM active_ranked_matches + WHERE player_one_id = ? OR player_two_id = ? + LIMIT 1` + ) + .bind(playerId, playerId) + .first() + + if (row === null) { + return + } + + if (row.state === 'assigned' && row.expires_at <= now) { + await database + .prepare( + `DELETE FROM active_ranked_matches + WHERE match_id = ? AND state = 'assigned' AND expires_at <= ?` + ) + .bind(row.match_id, now) + .run() + return + } + + return { + assignment: rankedMatchAssignmentSchema.parse({ + matchId: row.match_id, + roomKey: row.room_key, + queueKey: row.queue_key, + ratingPool: row.rating_pool, + format: row.format, + playerOneId: row.player_one_id, + playerTwoId: row.player_two_id, + playerOneRating: row.player_one_rating, + playerTwoRating: row.player_two_rating, + createdAt: row.created_at, + expiresAt: row.expires_at + }), + state: row.state, + ...(row.activated_at !== null && { activatedAt: row.activated_at }) + } +} + +export async function reserveRankedMatch( + database: D1Database, + assignment: RankedMatchAssignment +): Promise { + const parsedAssignment = rankedMatchAssignmentSchema.parse(assignment) + if (parsedAssignment.expiresAt <= parsedAssignment.createdAt) { + throw new Error('The ranked assignment expiry is invalid.') + } + + const result = await database + .prepare( + `INSERT INTO active_ranked_matches ( + match_id, room_key, queue_key, rating_pool, format, + player_one_id, player_two_id, + player_one_rating, player_two_rating, + state, created_at, expires_at, activated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'assigned', ?, ?, NULL)` + ) + .bind( + parsedAssignment.matchId, + parsedAssignment.roomKey, + parsedAssignment.queueKey, + parsedAssignment.ratingPool, + parsedAssignment.format, + parsedAssignment.playerOneId, + parsedAssignment.playerTwoId, + parsedAssignment.playerOneRating, + parsedAssignment.playerTwoRating, + parsedAssignment.createdAt, + parsedAssignment.expiresAt + ) + .run() + + if (result.meta.changes !== 1) { + throw new Error('The ranked assignment was not reserved.') + } +} + +export async function releaseRankedMatch( + database: D1Database, + matchId: string +): Promise { + await database + .prepare('DELETE FROM active_ranked_matches WHERE match_id = ?') + .bind(matchId) + .run() +} diff --git a/apps/worker/test/worker.integration.test.ts b/apps/worker/test/worker.integration.test.ts index 0fce4222..2b3102eb 100644 --- a/apps/worker/test/worker.integration.test.ts +++ b/apps/worker/test/worker.integration.test.ts @@ -1199,13 +1199,36 @@ describe('ranked matchmaking', () => { } expect(playerOneMatch.match).toEqual(playerTwoMatch.match) expect(playerOneMatch.match).toMatchObject({ + queueKey: 'classic:bo1', ratingPool: 'classic', format: 'bo1', playerOneId: playerOne.playerId, - playerTwoId: playerTwo.playerId + playerTwoId: playerTwo.playerId, + playerOneRating: 1200, + playerTwoRating: 1200, + expiresAt: expect.any(Number) }) const environment = await server.getWorker().getEnv() + const activeMatch = await environment.PLAYERS_DB.prepare( + `SELECT match_id, player_one_id, player_two_id, state + FROM active_ranked_matches + WHERE match_id = ?` + ) + .bind(playerOneMatch.match.matchId) + .first() + expect(activeMatch).toEqual({ + match_id: playerOneMatch.match.matchId, + player_one_id: playerOne.playerId, + player_two_id: playerTwo.playerId, + state: 'assigned' + }) + + const duplicateJoin = matchmakingStatusSchema.parse( + await (await joinQueue(playerOne)).json() + ) + expect(duplicateJoin).toEqual(playerOneMatch) + const roomId = environment.GAME_STATE_DURABLE_OBJECT.idFromName( playerOneMatch.match.roomKey ) diff --git a/packages/common/src/schemas/matchmaking.ts b/packages/common/src/schemas/matchmaking.ts index ae462eb5..ba786180 100644 --- a/packages/common/src/schemas/matchmaking.ts +++ b/packages/common/src/schemas/matchmaking.ts @@ -2,18 +2,23 @@ import { z } from 'zod/mini' import { DEFAULT_RATING_POOL, type MatchmakingStatus, - RANKED_MATCH_FORMAT + RANKED_MATCH_FORMAT, + RANKED_QUEUE_KEY } from '../types' import { matchIdSchema, playerIdSchema, roomKeySchema } from './identifiers' export const rankedMatchAssignmentSchema = z.object({ matchId: matchIdSchema, roomKey: roomKeySchema, + queueKey: z.literal(RANKED_QUEUE_KEY), ratingPool: z.literal(DEFAULT_RATING_POOL), format: z.literal(RANKED_MATCH_FORMAT), playerOneId: playerIdSchema, playerTwoId: playerIdSchema, - createdAt: z.int().check(z.minimum(0)) + playerOneRating: z.int(), + playerTwoRating: z.int(), + createdAt: z.int().check(z.minimum(0)), + expiresAt: z.int().check(z.minimum(0)) }) export const matchmakingStatusSchema = z.union([ diff --git a/packages/common/src/types/matchmaking.ts b/packages/common/src/types/matchmaking.ts index 7be78ac4..40bd496a 100644 --- a/packages/common/src/types/matchmaking.ts +++ b/packages/common/src/types/matchmaking.ts @@ -1,17 +1,23 @@ -import { type RatingPool } from './ranking' +import { DEFAULT_RATING_POOL, type RatingPool } from './ranking' export const RANKED_MATCH_FORMAT = 'bo1' +export const RANKED_QUEUE_KEY = `${DEFAULT_RATING_POOL}:${RANKED_MATCH_FORMAT}` export type RankedMatchFormat = typeof RANKED_MATCH_FORMAT +export type RankedQueueKey = typeof RANKED_QUEUE_KEY export interface RankedMatchAssignment { matchId: string roomKey: string + queueKey: RankedQueueKey ratingPool: RatingPool format: RankedMatchFormat playerOneId: string playerTwoId: string + playerOneRating: number + playerTwoRating: number createdAt: number + expiresAt: number } export type MatchmakingStatus = From 127803d68efec3d1d26cb18241ab4b772736be40 Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 12:19:21 +0200 Subject: [PATCH 04/52] feat(matchmaking): activate ranked assignments --- .../durable-objects/GameStateDurableObject.ts | 73 ++++++++++++++++--- apps/worker/src/endpoints/init.ts | 9 +++ apps/worker/src/utils/rankedMatches.ts | 66 +++++++++++++++++ apps/worker/test/worker.integration.test.ts | 62 ++++++++++++++++ packages/common/src/schemas/durableObject.ts | 7 +- packages/common/src/types/durableObject.ts | 6 +- 6 files changed, 211 insertions(+), 12 deletions(-) diff --git a/apps/worker/src/durable-objects/GameStateDurableObject.ts b/apps/worker/src/durable-objects/GameStateDurableObject.ts index 05192d01..f0901ffb 100644 --- a/apps/worker/src/durable-objects/GameStateDurableObject.ts +++ b/apps/worker/src/durable-objects/GameStateDurableObject.ts @@ -34,6 +34,7 @@ import { import { type CloudflareEnvironment } from '../types/cloudflareEnvironment' import { type IttyDurableObjectNamespace } from '../types/itty' import { applyPlayCommand } from '../utils/authoritativeGame' +import { activateRankedMatch } from '../utils/rankedMatches' interface ProcessedMutation { fingerprint: string @@ -55,6 +56,7 @@ interface DisconnectPolicy { export class GameStateDurableObject extends createDurable({ autoPersist: true }) { + cloudflareEnvironment: CloudflareEnvironment lobby: ILobby gameState?: IGameState processedMutations: ProcessedMutations @@ -70,6 +72,7 @@ export class GameStateDurableObject extends createDurable({ cloudflareEnvironment: CloudflareEnvironment ) { super(state, cloudflareEnvironment) + this.cloudflareEnvironment = cloudflareEnvironment this.lobby = new Lobby().toJson() this.processedMutations = {} this.connectedPlayers = {} @@ -242,13 +245,15 @@ export class GameStateDurableObject extends createDurable({ } } - initializeGame({ + async initializeGame({ mutationId, playerId, displayName, difficulty, boType - }: InitializeGameCommand): IdempotentMutationResult { + }: InitializeGameCommand): Promise< + IdempotentMutationResult + > { const command = initializeGameCommandSchema.parse({ mutationId, playerId, @@ -257,7 +262,7 @@ export class GameStateDurableObject extends createDurable({ boType }) - return this.runIdempotently( + return await this.runIdempotentlyAsync( command.mutationId, 'initialize-game', { @@ -267,8 +272,8 @@ export class GameStateDurableObject extends createDurable({ boType: command.boType }, initializeGameResultSchema, - () => - this.applyInitializeGame({ + async () => + await this.applyInitializeGame({ playerId: command.playerId, displayName: command.displayName, difficulty: command.difficulty, @@ -277,12 +282,12 @@ export class GameStateDurableObject extends createDurable({ ) } - private applyInitializeGame({ + private async applyInitializeGame({ playerId, displayName, difficulty, boType - }: Omit): InitializeGameResult { + }: Omit): Promise { if (this.gameState !== undefined) { const gameState = GameState.fromJson( gameStateSchema.parse(this.gameState) @@ -297,7 +302,7 @@ export class GameStateDurableObject extends createDurable({ } if (this.rankedMatch !== undefined) { - return this.applyRankedInitializeGame({ + return await this.applyRankedInitializeGame({ playerId, displayName, difficulty, @@ -325,12 +330,12 @@ export class GameStateDurableObject extends createDurable({ return { status: 'created', gameState } } - private applyRankedInitializeGame({ + private async applyRankedInitializeGame({ playerId, displayName, difficulty, boType - }: Omit): InitializeGameResult { + }: Omit): Promise { const assignment = this.rankedMatch! if ( playerId !== assignment.playerOneId && @@ -353,6 +358,15 @@ export class GameStateDurableObject extends createDurable({ return { status: 'waiting' } } + const activationStatus = await activateRankedMatch( + this.cloudflareEnvironment.PLAYERS_DB, + assignment + ) + if (activationStatus === 'expired') { + this.rankedPlayerClaims = {} + return { status: 'ranked-assignment-expired' } + } + const gameState = new GameState({ playerOne: Player.fromJson(playerOne), playerTwo: Player.fromJson(playerTwo), @@ -610,6 +624,45 @@ export class GameStateDurableObject extends createDurable({ return { idempotencyStatus: 'applied', value } } + + private async runIdempotentlyAsync( + mutationId: string, + operation: string, + payload: unknown, + resultSchema: { parse(value: unknown): T }, + mutation: () => Promise + ): Promise> { + const fingerprint = JSON.stringify({ operation, payload }) + const processedMutation = this.processedMutations[mutationId] + + if (processedMutation !== undefined) { + if (processedMutation.fingerprint !== fingerprint) { + return { idempotencyStatus: 'conflict' } + } + + return { + idempotencyStatus: 'replayed', + value: resultSchema.parse(processedMutation.value) + } + } + + const value = await mutation() + const processedAt = Date.now() + const activeMutations = Object.entries(this.processedMutations) + .filter( + ([, processed]) => + processed.processedAt > processedAt - PROCESSED_MUTATION_TTL_MS + ) + .sort(([, left], [, right]) => right.processedAt - left.processedAt) + .slice(0, MAX_PROCESSED_MUTATIONS - 1) + + this.processedMutations = Object.fromEntries([ + [mutationId, { fingerprint, processedAt, value }], + ...activeMutations + ]) + + return { idempotencyStatus: 'applied', value } + } } export interface GameStateDurableObjectProps { diff --git a/apps/worker/src/endpoints/init.ts b/apps/worker/src/endpoints/init.ts index b70093bf..c75cdf34 100644 --- a/apps/worker/src/endpoints/init.ts +++ b/apps/worker/src/endpoints/init.ts @@ -145,6 +145,15 @@ async function executeInitializeGame( }) } + if (mutation.status === 'ranked-assignment-expired') { + return apiError({ + status: 409, + code: 'RANKED_ASSIGNMENT_EXPIRED', + message: 'The ranked match assignment has expired.', + requestId: request.requestId + }) + } + if (mutation.status === 'created' || mutation.status === 'existing') { const gameState = GameState.fromJson(mutation.gameState) await broadcastGameState(mutation.gameState, request, cloudflareEnvironment) diff --git a/apps/worker/src/utils/rankedMatches.ts b/apps/worker/src/utils/rankedMatches.ts index b5b6716e..22722330 100644 --- a/apps/worker/src/utils/rankedMatches.ts +++ b/apps/worker/src/utils/rankedMatches.ts @@ -25,6 +25,9 @@ export interface ActiveRankedMatch { activatedAt?: number } +export type RankedMatchActivationStatus = + 'activated' | 'already-active' | 'expired' + export async function getActiveRankedMatchForPlayer( database: D1Database, playerId: string, @@ -115,6 +118,69 @@ export async function reserveRankedMatch( } } +export async function activateRankedMatch( + database: D1Database, + assignment: RankedMatchAssignment, + activatedAt = Date.now() +): Promise { + const parsedAssignment = rankedMatchAssignmentSchema.parse(assignment) + const result = await database + .prepare( + `UPDATE active_ranked_matches + SET state = 'active', activated_at = ? + WHERE match_id = ? + AND room_key = ? + AND queue_key = ? + AND rating_pool = ? + AND format = ? + AND player_one_id = ? + AND player_two_id = ? + AND player_one_rating = ? + AND player_two_rating = ? + AND created_at = ? + AND expires_at = ? + AND state = 'assigned' + AND expires_at > ?` + ) + .bind( + activatedAt, + parsedAssignment.matchId, + parsedAssignment.roomKey, + parsedAssignment.queueKey, + parsedAssignment.ratingPool, + parsedAssignment.format, + parsedAssignment.playerOneId, + parsedAssignment.playerTwoId, + parsedAssignment.playerOneRating, + parsedAssignment.playerTwoRating, + parsedAssignment.createdAt, + parsedAssignment.expiresAt, + activatedAt + ) + .run() + + if (result.meta.changes === 1) { + return 'activated' + } + + const activeMatch = await getActiveRankedMatchForPlayer( + database, + parsedAssignment.playerOneId, + activatedAt + ) + if (activeMatch === undefined) { + return 'expired' + } + if ( + activeMatch.state === 'active' && + JSON.stringify(activeMatch.assignment) === JSON.stringify(parsedAssignment) + ) { + return 'already-active' + } + + throw new Error('The ranked assignment does not match its reservation.') +} + export async function releaseRankedMatch( database: D1Database, matchId: string diff --git a/apps/worker/test/worker.integration.test.ts b/apps/worker/test/worker.integration.test.ts index 2b3102eb..71e82671 100644 --- a/apps/worker/test/worker.integration.test.ts +++ b/apps/worker/test/worker.integration.test.ts @@ -1293,6 +1293,18 @@ describe('ranked matchmaking', () => { expect(playerTwoClaim.status).toBe(200) expect(playerOneClaim.status).toBe(200) + const activatedMatch = await environment.PLAYERS_DB.prepare( + `SELECT state, activated_at + FROM active_ranked_matches + WHERE match_id = ?` + ) + .bind(playerOneMatch.match.matchId) + .first<{ state: string; activated_at: number | null }>() + expect(activatedMatch).toEqual({ + state: 'active', + activated_at: expect.any(Number) + }) + const existing = idempotentInitializeGameResultSchema.parse( await callRoom('initializeGame', [ { @@ -1379,6 +1391,56 @@ describe('ranked matchmaking', () => { ) }) + it('rejects initialization after the ranked assignment expires', async () => { + const playerOne = await createPlayer() + const playerTwo = await createPlayer() + + await joinQueue(playerOne) + await new Promise((resolve) => setTimeout(resolve, 800)) + const status = matchmakingStatusSchema.parse( + await (await joinQueue(playerTwo)).json() + ) + if (status.status !== 'matched') { + throw new Error('Expected both players to be matched.') + } + + const initialize = (player: PlayerCredentials) => + request(`/v1/rooms/${status.match.roomKey}/init`, { + method: 'POST', + headers: { + ...authorization(player), + 'Content-Type': 'application/json', + 'Idempotency-Key': crypto.randomUUID() + }, + body: JSON.stringify({ playerType: 'human', boType: 1 }) + }) + + expect((await initialize(playerOne)).status).toBe(200) + + const environment = await server.getWorker().getEnv() + const expiredAt = Date.now() - 1 + await environment.PLAYERS_DB.prepare( + `UPDATE active_ranked_matches + SET created_at = ?, expires_at = ? + WHERE match_id = ?` + ) + .bind(expiredAt - 1, expiredAt, status.match.matchId) + .run() + + const expired = await initialize(playerTwo) + expect(expired.status).toBe(409) + await expect(expired.json()).resolves.toMatchObject({ + error: { code: 'RANKED_ASSIGNMENT_EXPIRED' } + }) + await expect( + environment.PLAYERS_DB.prepare( + 'SELECT match_id FROM active_ranked_matches WHERE match_id = ?' + ) + .bind(status.match.matchId) + .first() + ).resolves.toBeNull() + }) + it('chooses the closest rating after the selection window', async () => { const player = await createPlayer() const distantOpponent = await createPlayer() diff --git a/packages/common/src/schemas/durableObject.ts b/packages/common/src/schemas/durableObject.ts index 5c2e3eca..638576ef 100644 --- a/packages/common/src/schemas/durableObject.ts +++ b/packages/common/src/schemas/durableObject.ts @@ -56,7 +56,12 @@ const updatedGameStateResultSchema = z.object({ export const initializeGameResultSchema = z.union([ z.object({ - status: z.enum(['waiting', 'not-assigned', 'invalid-ranked-settings']) + status: z.enum([ + 'waiting', + 'not-assigned', + 'invalid-ranked-settings', + 'ranked-assignment-expired' + ]) }), z.object({ status: z.enum(['created', 'existing']), diff --git a/packages/common/src/types/durableObject.ts b/packages/common/src/types/durableObject.ts index 27749fa4..592e124a 100644 --- a/packages/common/src/types/durableObject.ts +++ b/packages/common/src/types/durableObject.ts @@ -41,7 +41,11 @@ export type PresenceUpdateResult = export type InitializeGameResult = | { - status: 'waiting' | 'not-assigned' | 'invalid-ranked-settings' + status: + | 'waiting' + | 'not-assigned' + | 'invalid-ranked-settings' + | 'ranked-assignment-expired' } | { status: 'created' | 'existing'; gameState: IGameState } From 6b8475df1b2d510412f96643a560809d5feedbad Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 12:25:40 +0200 Subject: [PATCH 05/52] feat(ranking): settle ranked results once --- .../durable-objects/GameStateDurableObject.ts | 30 +- apps/worker/src/endpoints/play.ts | 13 +- apps/worker/src/utils/rankedMatches.ts | 311 +++++++++++++++++- apps/worker/test/worker.integration.test.ts | 61 ++++ packages/common/src/schemas/matchmaking.ts | 34 +- packages/common/src/types/matchmaking.ts | 32 +- 6 files changed, 475 insertions(+), 6 deletions(-) diff --git a/apps/worker/src/durable-objects/GameStateDurableObject.ts b/apps/worker/src/durable-objects/GameStateDurableObject.ts index f0901ffb..e24de102 100644 --- a/apps/worker/src/durable-objects/GameStateDurableObject.ts +++ b/apps/worker/src/durable-objects/GameStateDurableObject.ts @@ -24,6 +24,8 @@ import { rematchGameCommandSchema, rematchGameResultSchema, type RankedMatchAssignment, + type RankedMatchSettlement, + type RankedMatchSettlementResult, rankedMatchAssignmentSchema, roomKeySchema, toGameStateMessage, @@ -34,7 +36,10 @@ import { import { type CloudflareEnvironment } from '../types/cloudflareEnvironment' import { type IttyDurableObjectNamespace } from '../types/itty' import { applyPlayCommand } from '../utils/authoritativeGame' -import { activateRankedMatch } from '../utils/rankedMatches' +import { + activateRankedMatch, + settleRankedMatch as settleRankedMatchInDatabase +} from '../utils/rankedMatches' interface ProcessedMutation { fingerprint: string @@ -65,6 +70,7 @@ export class GameStateDurableObject extends createDurable({ reconnectDeadlines: Record pendingDisconnectBroadcast?: IGameState rankedMatch?: RankedMatchAssignment + rankedSettlement?: RankedMatchSettlement rankedPlayerClaims: Record constructor( @@ -239,6 +245,8 @@ export class GameStateDurableObject extends createDurable({ await this.persist() if (this.pendingDisconnectBroadcast !== undefined) { + await this.settleRankedResult() + await this.persist() await this.broadcastAlarmResult(this.pendingDisconnectBroadcast) this.pendingDisconnectBroadcast = undefined await this.persist() @@ -282,6 +290,26 @@ export class GameStateDurableObject extends createDurable({ ) } + async settleRankedResult(): Promise { + if (this.rankedMatch === undefined) { + return { status: 'not-ranked' } + } + if ( + this.gameState === undefined || + this.gameState.outcome !== 'game-ended' + ) { + return { status: 'not-finished' } + } + + const result = await settleRankedMatchInDatabase( + this.cloudflareEnvironment.PLAYERS_DB, + this.rankedMatch, + this.gameState + ) + this.rankedSettlement = result.settlement + return result + } + private async applyInitializeGame({ playerId, displayName, diff --git a/apps/worker/src/endpoints/play.ts b/apps/worker/src/endpoints/play.ts index 56c2f39c..9f78ae87 100644 --- a/apps/worker/src/endpoints/play.ts +++ b/apps/worker/src/endpoints/play.ts @@ -3,7 +3,8 @@ import { GameState, playIntentSchema, playRouteParamsSchema, - type PlayIntentRejectionReason + type PlayIntentRejectionReason, + rankedMatchSettlementResultSchema } from '@knucklebones/common' import { type CloudflareEnvironment } from '../types/cloudflareEnvironment' import { @@ -11,7 +12,10 @@ import { type MutationRequestWithProps } from '../types/itty' import { makeAiPlay } from '../utils/ai' -import { broadcastGameState } from '../utils/endpoints' +import { + broadcastGameState, + getGameStateDurableObject +} from '../utils/endpoints' import { apiError } from '../utils/http' import { idempotencyConflict } from '../utils/idempotency' import { applyAuthoritativePlay } from '../utils/play' @@ -91,6 +95,11 @@ async function executePlayIntent( } const gameState = GameState.fromJson(mutation.gameState) + if (gameState.outcome === 'game-ended') { + rankedMatchSettlementResultSchema.parse( + await getGameStateDurableObject(request).settleRankedResult() + ) + } await broadcastGameState(mutation.gameState, request, cloudflareEnvironment) if ( diff --git a/apps/worker/src/utils/rankedMatches.ts b/apps/worker/src/utils/rankedMatches.ts index 22722330..eb1c66ac 100644 --- a/apps/worker/src/utils/rankedMatches.ts +++ b/apps/worker/src/utils/rankedMatches.ts @@ -1,6 +1,13 @@ import { + calculateEloRatings, + gameStateSchema, + type IGameState, type RankedMatchAssignment, - rankedMatchAssignmentSchema + rankedMatchAssignmentSchema, + type RankedMatchResult, + type RankedMatchSettlement, + rankedMatchSettlementSchema, + type RankedMatchSettlementResult } from '@knucklebones/common' interface ActiveRankedMatchRow { @@ -19,6 +26,28 @@ interface ActiveRankedMatchRow { activated_at: number | null } +interface RatedMatchRow { + match_id: string + room_key: string + queue_key: string + rating_pool: string + format: string + player_one_id: string + player_two_id: string + result: string + finish_reason: string + player_one_rating_before: number + player_two_rating_before: number + player_one_rating_after: number + player_two_rating_after: number + settled_at: number +} + +type CompletedRankedMatchSettlementResult = Extract< + RankedMatchSettlementResult, + { settlement: RankedMatchSettlement } +> + export interface ActiveRankedMatch { assignment: RankedMatchAssignment state: ActiveRankedMatchRow['state'] @@ -181,6 +210,171 @@ export async function activateRankedMatch( throw new Error('The ranked assignment does not match its reservation.') } +export async function settleRankedMatch( + database: D1Database, + assignment: RankedMatchAssignment, + gameState: IGameState, + settledAt = Date.now() +): Promise { + const parsedAssignment = rankedMatchAssignmentSchema.parse(assignment) + const parsedGameState = gameStateSchema.parse(gameState) + if ( + parsedGameState.outcome !== 'game-ended' || + parsedGameState.finishReason === undefined + ) { + throw new Error('The ranked game is not finished.') + } + if ( + parsedGameState.playerOne.id !== parsedAssignment.playerOneId || + parsedGameState.playerTwo.id !== parsedAssignment.playerTwoId + ) { + throw new Error('The ranked game players do not match the assignment.') + } + + const result = getRankedMatchResult(parsedGameState, parsedAssignment) + const ratingUpdate = + result === 'no-contest' + ? { + playerOne: { + before: parsedAssignment.playerOneRating, + after: parsedAssignment.playerOneRating, + change: 0 + }, + playerTwo: { + before: parsedAssignment.playerTwoRating, + after: parsedAssignment.playerTwoRating, + change: 0 + } + } + : calculateEloRatings( + parsedAssignment.playerOneRating, + parsedAssignment.playerTwoRating, + result + ) + const createSettlement = (settlementTime: number) => + rankedMatchSettlementSchema.parse({ + matchId: parsedAssignment.matchId, + roomKey: parsedAssignment.roomKey, + queueKey: parsedAssignment.queueKey, + ratingPool: parsedAssignment.ratingPool, + format: parsedAssignment.format, + playerOneId: parsedAssignment.playerOneId, + playerTwoId: parsedAssignment.playerTwoId, + result, + finishReason: parsedGameState.finishReason, + playerOne: ratingUpdate.playerOne, + playerTwo: ratingUpdate.playerTwo, + settledAt: settlementTime + }) + const existingSettlement = await getRankedMatchSettlement( + database, + parsedAssignment.matchId + ) + if (existingSettlement !== undefined) { + assertSameSettlement( + existingSettlement, + createSettlement(existingSettlement.settledAt) + ) + return { status: 'already-settled', settlement: existingSettlement } + } + + const activeMatch = await getActiveRankedMatchForPlayer( + database, + parsedAssignment.playerOneId, + settledAt + ) + if ( + activeMatch?.state !== 'active' || + activeMatch.activatedAt === undefined || + JSON.stringify(activeMatch.assignment) !== JSON.stringify(parsedAssignment) + ) { + throw new Error('The ranked match is not active.') + } + + const settlement = createSettlement(settledAt) + + const insert = database + .prepare( + `INSERT INTO rated_matches ( + match_id, room_key, queue_key, rating_pool, format, + player_one_id, player_two_id, result, finish_reason, + player_one_rating_before, player_two_rating_before, + player_one_rating_after, player_two_rating_after, rating_delta, + created_at, activated_at, finished_at, settled_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + settlement.matchId, + settlement.roomKey, + settlement.queueKey, + settlement.ratingPool, + settlement.format, + settlement.playerOneId, + settlement.playerTwoId, + settlement.result, + settlement.finishReason, + settlement.playerOne.before, + settlement.playerTwo.before, + settlement.playerOne.after, + settlement.playerTwo.after, + settlement.playerOne.change, + parsedAssignment.createdAt, + activeMatch.activatedAt, + settledAt, + settledAt + ) + const removeActiveMatch = database + .prepare('DELETE FROM active_ranked_matches WHERE match_id = ?') + .bind(settlement.matchId) + const statements = [insert] + + if (result !== 'no-contest') { + statements.push( + createRatingUpdateStatement( + database, + settlement.playerOneId, + settlement.ratingPool, + settlement.playerOne.before, + settlement.playerOne.after, + getPlayerStatistics(result, 'player-one'), + settledAt + ), + createRatingUpdateStatement( + database, + settlement.playerTwoId, + settlement.ratingPool, + settlement.playerTwo.before, + settlement.playerTwo.after, + getPlayerStatistics(result, 'player-two'), + settledAt + ) + ) + } + statements.push(removeActiveMatch) + + try { + const results = await database.batch(statements) + if (results.some((batchResult) => !batchResult.success)) { + throw new Error('The ranked settlement batch was not successful.') + } + } catch (error) { + const concurrentSettlement = await getRankedMatchSettlement( + database, + parsedAssignment.matchId + ) + if (concurrentSettlement !== undefined) { + assertSameSettlement(concurrentSettlement, settlement) + return { + status: 'already-settled', + settlement: concurrentSettlement + } + } + throw error + } + + return { status: 'settled', settlement } +} + export async function releaseRankedMatch( database: D1Database, matchId: string @@ -190,3 +384,118 @@ export async function releaseRankedMatch( .bind(matchId) .run() } + +async function getRankedMatchSettlement( + database: D1Database, + matchId: string +): Promise { + const row = await database + .prepare( + `SELECT match_id, room_key, queue_key, rating_pool, format, + player_one_id, player_two_id, result, finish_reason, + player_one_rating_before, player_two_rating_before, + player_one_rating_after, player_two_rating_after, settled_at + FROM rated_matches + WHERE match_id = ?` + ) + .bind(matchId) + .first() + + if (row === null) { + return + } + + return rankedMatchSettlementSchema.parse({ + matchId: row.match_id, + roomKey: row.room_key, + queueKey: row.queue_key, + ratingPool: row.rating_pool, + format: row.format, + playerOneId: row.player_one_id, + playerTwoId: row.player_two_id, + result: row.result, + finishReason: row.finish_reason, + playerOne: { + before: row.player_one_rating_before, + after: row.player_one_rating_after, + change: row.player_one_rating_after - row.player_one_rating_before + }, + playerTwo: { + before: row.player_two_rating_before, + after: row.player_two_rating_after, + change: row.player_two_rating_after - row.player_two_rating_before + }, + settledAt: row.settled_at + }) +} + +function getRankedMatchResult( + gameState: IGameState, + assignment: RankedMatchAssignment +): RankedMatchResult { + if (gameState.finishReason === 'no-contest') { + return 'no-contest' + } + if (gameState.winnerId === assignment.playerOneId) { + return 'player-one-win' + } + if (gameState.winnerId === assignment.playerTwoId) { + return 'player-two-win' + } + if (gameState.finishReason === 'completed') { + return 'draw' + } + throw new Error('The ranked result has no valid winner.') +} + +function createRatingUpdateStatement( + database: D1Database, + playerId: string, + ratingPool: string, + ratingBefore: number, + ratingAfter: number, + statistics: { wins: number; draws: number; losses: number }, + updatedAt: number +): D1PreparedStatement { + return database + .prepare( + `UPDATE player_ratings + SET rating = ?, + games_played = games_played + 1, + wins = wins + ?, + draws = draws + ?, + losses = losses + ?, + updated_at = ? + WHERE player_id = ? AND rating_pool = ? AND rating = ?` + ) + .bind( + ratingAfter, + statistics.wins, + statistics.draws, + statistics.losses, + updatedAt, + playerId, + ratingPool, + ratingBefore + ) +} + +function getPlayerStatistics( + result: Exclude, + player: 'player-one' | 'player-two' +): { wins: number; draws: number; losses: number } { + if (result === 'draw') { + return { wins: 0, draws: 1, losses: 0 } + } + const won = result === `${player}-win` + return { wins: won ? 1 : 0, draws: 0, losses: won ? 0 : 1 } +} + +function assertSameSettlement( + actual: RankedMatchSettlement, + expected: RankedMatchSettlement +): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error('The ranked match has a conflicting settled result.') + } +} diff --git a/apps/worker/test/worker.integration.test.ts b/apps/worker/test/worker.integration.test.ts index 71e82671..33147591 100644 --- a/apps/worker/test/worker.integration.test.ts +++ b/apps/worker/test/worker.integration.test.ts @@ -10,6 +10,7 @@ import { playerCredentialsSchema, playerIdentityBootstrapSchema, type PresenceUpdateResult, + rankedMatchSettlementResultSchema, rankedProfileSchema, webSocketTicketSchema, type PlayerCredentials @@ -1353,6 +1354,66 @@ describe('ranked matchmaking', () => { } }) + const settlement = rankedMatchSettlementResultSchema.parse( + await callRoom('settleRankedResult', []) + ) + expect(settlement).toMatchObject({ + status: 'settled', + settlement: { + matchId: playerOneMatch.match.matchId, + result: 'player-two-win', + finishReason: 'forfeit', + playerOne: { before: 1200, after: 1184, change: -16 }, + playerTwo: { before: 1200, after: 1216, change: 16 } + } + }) + if (settlement.status !== 'settled') { + throw new Error('Expected the ranked result to settle.') + } + const repeatedSettlement = rankedMatchSettlementResultSchema.parse( + await callRoom('settleRankedResult', []) + ) + expect(repeatedSettlement).toMatchObject({ + status: 'already-settled', + settlement: settlement.settlement + }) + + const profiles = await environment.PLAYERS_DB.prepare( + `SELECT player_id, rating, games_played, wins, draws, losses + FROM player_ratings + WHERE player_id IN (?, ?) + ORDER BY player_id` + ) + .bind(playerOne.playerId, playerTwo.playerId) + .all() + expect(profiles.results).toEqual( + [ + { + player_id: playerOne.playerId, + rating: 1184, + games_played: 1, + wins: 0, + draws: 0, + losses: 1 + }, + { + player_id: playerTwo.playerId, + rating: 1216, + games_played: 1, + wins: 1, + draws: 0, + losses: 0 + } + ].sort((left, right) => left.player_id.localeCompare(right.player_id)) + ) + await expect( + environment.PLAYERS_DB.prepare( + 'SELECT match_id FROM active_ranked_matches WHERE match_id = ?' + ) + .bind(playerOneMatch.match.matchId) + .first() + ).resolves.toBeNull() + const rematch = await request( `/v1/rooms/${playerOneMatch.match.roomKey}/rematch`, { diff --git a/packages/common/src/schemas/matchmaking.ts b/packages/common/src/schemas/matchmaking.ts index ba786180..62b678f2 100644 --- a/packages/common/src/schemas/matchmaking.ts +++ b/packages/common/src/schemas/matchmaking.ts @@ -3,8 +3,11 @@ import { DEFAULT_RATING_POOL, type MatchmakingStatus, RANKED_MATCH_FORMAT, - RANKED_QUEUE_KEY + RANKED_QUEUE_KEY, + type RankedMatchSettlement, + type RankedMatchSettlementResult } from '../types' +import { gameFinishReasonSchema } from './gameState' import { matchIdSchema, playerIdSchema, roomKeySchema } from './identifiers' export const rankedMatchAssignmentSchema = z.object({ @@ -32,3 +35,32 @@ export const matchmakingStatusSchema = z.union([ match: rankedMatchAssignmentSchema }) ]) satisfies z.ZodMiniType + +const eloRatingChangeSchema = z.object({ + before: z.int(), + after: z.int(), + change: z.int() +}) + +export const rankedMatchSettlementSchema = z.object({ + matchId: matchIdSchema, + roomKey: roomKeySchema, + queueKey: z.literal(RANKED_QUEUE_KEY), + ratingPool: z.literal(DEFAULT_RATING_POOL), + format: z.literal(RANKED_MATCH_FORMAT), + playerOneId: playerIdSchema, + playerTwoId: playerIdSchema, + result: z.enum(['player-one-win', 'draw', 'player-two-win', 'no-contest']), + finishReason: gameFinishReasonSchema, + playerOne: eloRatingChangeSchema, + playerTwo: eloRatingChangeSchema, + settledAt: z.int().check(z.minimum(0)) +}) satisfies z.ZodMiniType + +export const rankedMatchSettlementResultSchema = z.union([ + z.object({ status: z.enum(['not-ranked', 'not-finished']) }), + z.object({ + status: z.enum(['settled', 'already-settled']), + settlement: rankedMatchSettlementSchema + }) +]) satisfies z.ZodMiniType diff --git a/packages/common/src/types/matchmaking.ts b/packages/common/src/types/matchmaking.ts index 40bd496a..eef7f270 100644 --- a/packages/common/src/types/matchmaking.ts +++ b/packages/common/src/types/matchmaking.ts @@ -1,4 +1,10 @@ -import { DEFAULT_RATING_POOL, type RatingPool } from './ranking' +import type { GameFinishReason } from './outcome' +import { + DEFAULT_RATING_POOL, + type EloMatchResult, + type EloRatingChange, + type RatingPool +} from './ranking' export const RANKED_MATCH_FORMAT = 'bo1' export const RANKED_QUEUE_KEY = `${DEFAULT_RATING_POOL}:${RANKED_MATCH_FORMAT}` @@ -24,3 +30,27 @@ export type MatchmakingStatus = | { status: 'idle' } | { status: 'waiting'; joinedAt: number } | { status: 'matched'; match: RankedMatchAssignment } + +export type RankedMatchResult = EloMatchResult | 'no-contest' + +export interface RankedMatchSettlement { + matchId: string + roomKey: string + queueKey: RankedQueueKey + ratingPool: RatingPool + format: RankedMatchFormat + playerOneId: string + playerTwoId: string + result: RankedMatchResult + finishReason: GameFinishReason + playerOne: EloRatingChange + playerTwo: EloRatingChange + settledAt: number +} + +export type RankedMatchSettlementResult = + | { status: 'not-ranked' | 'not-finished' } + | { + status: 'settled' | 'already-settled' + settlement: RankedMatchSettlement + } From 099444011177d3b0471f736fb97d92696c647e9d Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 12:43:12 +0200 Subject: [PATCH 06/52] feat(matchmaking): add ranked queue experience --- apps/front/src/components/GameOutcome.tsx | 82 ++++++++- apps/front/src/components/HomePage.tsx | 3 + .../src/components/RankedMatchmaking.test.tsx | 101 ++++++++++++ .../src/components/RankedMatchmaking.tsx | 155 ++++++++++++++++++ apps/front/src/components/Router.tsx | 2 + apps/front/src/translations/resources/en.json | 22 +++ apps/front/src/translations/resources/fr.json | 22 +++ .../src/translations/resources/zh-tw.json | 22 +++ apps/front/src/utils/api.test.ts | 43 +++++ apps/front/src/utils/api.ts | 41 +++++ apps/front/src/utils/rankedMatchStorage.ts | 42 +++++ tests/e2e/game.spec.ts | 51 ++++++ 12 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 apps/front/src/components/RankedMatchmaking.test.tsx create mode 100644 apps/front/src/components/RankedMatchmaking.tsx create mode 100644 apps/front/src/utils/rankedMatchStorage.ts diff --git a/apps/front/src/components/GameOutcome.tsx b/apps/front/src/components/GameOutcome.tsx index fe1a5f21..086f8b62 100644 --- a/apps/front/src/components/GameOutcome.tsx +++ b/apps/front/src/components/GameOutcome.tsx @@ -1,7 +1,13 @@ +import * as React from 'react' import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' import { PlayIcon } from '@heroicons/react/24/outline' import { t } from 'i18next' import { useIsOnDesktop } from '../hooks/detectDevice' +import { useRoomKey } from '../hooks/useRoomKey' +import { getRankedProfile } from '../utils/api' +import { getStoredPlayerId } from '../utils/identityStorage' +import { getStoredRankedMatchAssignment } from '../utils/rankedMatchStorage' import { Button } from './Button' import { useGame, type InGameContext } from './GameContext' import { ShortcutModal } from './ShortcutModal' @@ -84,6 +90,34 @@ export function GameOutcome() { const hasVoted = rematchVote === playerOne.id const isOnDesktop = useIsOnDesktop() const { t } = useTranslation() + const roomKey = useRoomKey() + const rankedAssignment = React.useMemo( + () => getStoredRankedMatchAssignment(roomKey), + [roomKey] + ) + const [rankedRating, setRankedRating] = React.useState() + const [rankedRatingError, setRankedRatingError] = React.useState(false) + + React.useEffect(() => { + let cancelled = false + if (outcome === 'game-ended' && rankedAssignment !== undefined) { + void getRankedProfile() + .then((profile) => { + if (!cancelled) { + setRankedRating(profile.rating) + setRankedRatingError(false) + } + }) + .catch(() => { + if (!cancelled) { + setRankedRatingError(true) + } + }) + } + return () => { + cancelled = true + } + }, [outcome, rankedAssignment]) if (outcome === 'ongoing') { // On peut mettre un VS semi-transparent dans le fond de la partie @@ -95,7 +129,13 @@ export function GameOutcome() { const content = (

{getWinMessage({ outcome, winner })}

- {!isSpectator && ( + {!isSpectator && rankedAssignment !== undefined ? ( + + ) : !isSpectator ? ( - )} + ) : null} {!isSpectator && + rankedAssignment === undefined && (hasVoted ? (

{t('game.waiting-rematch', { player: playerTwo.inGameName })}

) : ( @@ -139,3 +180,40 @@ export function GameOutcome() { ) } + +function RankedResultRating({ + assignment, + hasError, + rating +}: { + assignment: NonNullable> + hasError: boolean + rating?: number +}) { + const { t } = useTranslation() + const playerId = getStoredPlayerId() + const previousRating = + playerId === assignment.playerOneId + ? assignment.playerOneRating + : assignment.playerTwoRating + const change = rating === undefined ? undefined : rating - previousRating + + return ( +
+

+ {hasError + ? t('ranked.result.rating-error') + : rating === undefined || change === undefined + ? t('ranked.rating-loading') + : t('ranked.result.rating-change', { + before: previousRating, + after: rating, + change: change > 0 ? `+${change}` : String(change) + })} +

+ +
+ ) +} diff --git a/apps/front/src/components/HomePage.tsx b/apps/front/src/components/HomePage.tsx index a292f837..83275541 100644 --- a/apps/front/src/components/HomePage.tsx +++ b/apps/front/src/components/HomePage.tsx @@ -31,6 +31,9 @@ export function HomePage() {
+ + + ) +} diff --git a/apps/front/src/components/Router.tsx b/apps/front/src/components/Router.tsx index 8aa32769..547a41e7 100644 --- a/apps/front/src/components/Router.tsx +++ b/apps/front/src/components/Router.tsx @@ -3,6 +3,7 @@ import { Game } from './Game' import { GameProvider } from './GameContext' import { HomePage } from './HomePage' import { HowToPlayPage } from './HowToPlay' +import { RankedMatchmaking } from './RankedMatchmaking' export function Router() { return ( @@ -17,6 +18,7 @@ export function Router() { } /> } /> + } /> {/* Handle 404 */} ) diff --git a/apps/front/src/translations/resources/en.json b/apps/front/src/translations/resources/en.json index bd736cb1..3e2c87db 100644 --- a/apps/front/src/translations/resources/en.json +++ b/apps/front/src/translations/resources/en.json @@ -2,11 +2,33 @@ "language": "English", "home": { "play": { + "ranked": "Play ranked", "friend": "Play against someone", "ai": "Play against an AI" }, "footer": "The original Knucklebones game in Cult of the Lamb was created by Massive Monster.\nThis is a fan-site and not an official implementation by Massive Monster.\nYou can find the original game on the <0>Cult of the Lamb website." }, + "ranked": { + "identity-warning": "Ranked progress belongs to this browser identity. Save your recovery phrase before clearing browser data.", + "rating": "Rating: {{rating}}", + "rating-loading": "Loading rating…", + "result": { + "rating-change": "Rating: {{before}} → {{after}} ({{change}})", + "rating-error": "Your new rating couldn't be loaded.", + "play-again": "Find another ranked match" + }, + "queue": { + "title": "Ranked matchmaking", + "joining": "Joining the queue…", + "waiting": "Looking for an opponent… {{seconds}}s", + "best-effort": "Close ratings are preferred, but finding a match quickly comes first.", + "cancel": "Cancel matchmaking", + "cancelling": "Cancelling…", + "join-error": "We couldn't join ranked matchmaking. Retrying…", + "connection-error": "The matchmaking connection was interrupted. Reconnecting…", + "cancel-error": "We couldn't cancel matchmaking. Try again." + } + }, "guide": { "label": "How to play", "goal": "Each turn, you get a random dice to place in a column.\nThe goal is to score more points than your opponent when the game ends, which happens when one of the boards is full.", diff --git a/apps/front/src/translations/resources/fr.json b/apps/front/src/translations/resources/fr.json index 4ea74320..4bca5240 100644 --- a/apps/front/src/translations/resources/fr.json +++ b/apps/front/src/translations/resources/fr.json @@ -2,11 +2,33 @@ "language": "Français", "home": { "play": { + "ranked": "Jouer en classé", "friend": "Jouer contre quelqu'un", "ai": "Jouer contre une IA" }, "footer": "Le jeu Knucklebones est une création originale de l'entité Massive Monster.\nLe présent site est une réalisation de fans et ne constitue en aucun cas une implémentation officielle de la part de Massive Monster.\nPour accéder au jeu original, nous vous invitons à consulter le site internet officiel de <0>Cult of the Lamb." }, + "ranked": { + "identity-warning": "Votre progression classée appartient à l'identité de ce navigateur. Sauvegardez votre phrase de récupération avant d'effacer ses données.", + "rating": "Classement : {{rating}}", + "rating-loading": "Chargement du classement…", + "result": { + "rating-change": "Classement : {{before}} → {{after}} ({{change}})", + "rating-error": "Impossible de charger votre nouveau classement.", + "play-again": "Rechercher une autre partie classée" + }, + "queue": { + "title": "Recherche de partie classée", + "joining": "Connexion à la file…", + "waiting": "Recherche d'un adversaire… {{seconds}} s", + "best-effort": "Les classements proches sont privilégiés, mais trouver rapidement une partie reste prioritaire.", + "cancel": "Annuler la recherche", + "cancelling": "Annulation…", + "join-error": "Impossible de rejoindre la file classée. Nouvelle tentative…", + "connection-error": "La connexion à la file a été interrompue. Reconnexion…", + "cancel-error": "Impossible d'annuler la recherche. Réessayez." + } + }, "guide": { "label": "Comment jouer", "goal": "Chaque tour, vous obtenez un dé aléatoire à placer dans une colonne.\nLe but est d'avoir plus de points que votre adversaire quand la partie se termine, c'est-à-dire quand l'un des 2 plateaux est plein.", diff --git a/apps/front/src/translations/resources/zh-tw.json b/apps/front/src/translations/resources/zh-tw.json index a024dbed..3556017d 100644 --- a/apps/front/src/translations/resources/zh-tw.json +++ b/apps/front/src/translations/resources/zh-tw.json @@ -2,11 +2,33 @@ "language": "正體中文(臺灣)", "home": { "play": { + "ranked": "進行排名對戰", "friend": "與他人對戰", "ai": "與 AI 對戰" }, "footer": "《進擊羔羊傳說》中的原版 Knucklebones 遊戲由 Massive Monster 創作。\n這是一個粉絲網站,並非 Massive Monster 的官方實現。\n您可以在 <0>《進擊羔羊傳說》 網站上找到原版遊戲。" }, + "ranked": { + "identity-warning": "排名進度綁定於此瀏覽器的玩家身分。清除瀏覽器資料前,請先保存復原短語。", + "rating": "評分:{{rating}}", + "rating-loading": "正在載入評分…", + "result": { + "rating-change": "評分:{{before}} → {{after}}({{change}})", + "rating-error": "無法載入您的新評分。", + "play-again": "尋找另一場排名對戰" + }, + "queue": { + "title": "排名配對", + "joining": "正在加入配對佇列…", + "waiting": "正在尋找對手… {{seconds}} 秒", + "best-effort": "系統會優先配對評分相近的玩家,同時盡量縮短等待時間。", + "cancel": "取消配對", + "cancelling": "正在取消…", + "join-error": "無法加入排名配對,正在重試…", + "connection-error": "配對連線中斷,正在重新連線…", + "cancel-error": "無法取消配對,請再試一次。" + } + }, "guide": { "label": "遊戲說明", "goal": "每回合,您會獲得一個隨機骰子放置在一列中。\n目標是在遊戲結束時獲得比對手更多的分數,當其中一個棋盤滿了時遊戲結束。", diff --git a/apps/front/src/utils/api.test.ts b/apps/front/src/utils/api.test.ts index 56f210d1..75caa1ea 100644 --- a/apps/front/src/utils/api.test.ts +++ b/apps/front/src/utils/api.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createWebSocketTicket, + getMatchmakingStatus, + getRankedProfile, initGame, + joinMatchmaking, + leaveMatchmaking, play, updateDisplayName, voteRematch @@ -147,4 +151,43 @@ describe('mutation requests', () => { ) expect(fetchMock).toHaveBeenCalledTimes(2) }) + + it('uses authenticated ranked profile and matchmaking endpoints', async () => { + const joinedAt = Date.now() + fetchMock + .mockResolvedValueOnce( + Response.json({ + playerId: room.playerId, + ratingPool: 'classic', + rating: 1200, + gamesPlayed: 0, + wins: 0, + draws: 0, + losses: 0 + }) + ) + .mockResolvedValueOnce(Response.json({ status: 'waiting', joinedAt })) + .mockResolvedValueOnce(Response.json({ status: 'waiting', joinedAt })) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + + await expect(getRankedProfile()).resolves.toMatchObject({ rating: 1200 }) + await expect(joinMatchmaking()).resolves.toEqual({ + status: 'waiting', + joinedAt + }) + await expect(getMatchmakingStatus()).resolves.toEqual({ + status: 'waiting', + joinedAt + }) + await leaveMatchmaking() + + expect( + fetchMock.mock.calls.map(([url, init]) => [url, init?.method]) + ).toEqual([ + [expect.stringContaining('/v1/ranked/profile'), 'GET'], + [expect.stringContaining('/v1/matchmaking/join'), 'POST'], + [expect.stringContaining('/v1/matchmaking/status'), 'GET'], + [expect.stringContaining('/v1/matchmaking/queue'), 'DELETE'] + ]) + }) }) diff --git a/apps/front/src/utils/api.ts b/apps/front/src/utils/api.ts index 57812ab3..6efafb85 100644 --- a/apps/front/src/utils/api.ts +++ b/apps/front/src/utils/api.ts @@ -8,10 +8,14 @@ import { identityRecoverySchema, type IdentityTransfer, identityTransferSchema, + type MatchmakingStatus, + matchmakingStatusSchema, type PlayerCredentials, playerCredentialsSchema, type PlayerIdentityBootstrap, playerIdentityBootstrapSchema, + type RankedProfile, + rankedProfileSchema, type WebSocketTicket, webSocketTicketSchema } from '@knucklebones/common' @@ -126,6 +130,43 @@ export async function createWebSocketTicket({ return result.data } +export async function getRankedProfile(): Promise { + const response = await sendApiRequest('/v1/ranked/profile', 'GET') + const result = rankedProfileSchema.safeParse(await response.json()) + + if (!result.success) { + throw new Error('The server returned an invalid ranked profile.') + } + + return result.data +} + +export async function joinMatchmaking(): Promise { + return await getMatchmakingResponse('/v1/matchmaking/join', 'POST') +} + +export async function getMatchmakingStatus(): Promise { + return await getMatchmakingResponse('/v1/matchmaking/status', 'GET') +} + +export async function leaveMatchmaking(): Promise { + await sendApiRequest('/v1/matchmaking/queue', 'DELETE') +} + +async function getMatchmakingResponse( + path: string, + method: 'GET' | 'POST' +): Promise { + const response = await sendApiRequest(path, method) + const result = matchmakingStatusSchema.safeParse(await response.json()) + + if (!result.success) { + throw new Error('The server returned an invalid matchmaking status.') + } + + return result.data +} + // À synchroniser avec les types de requêtes côté back interface InitGameRequestParams extends Omit { boType?: GameSettings['boType'] diff --git a/apps/front/src/utils/rankedMatchStorage.ts b/apps/front/src/utils/rankedMatchStorage.ts new file mode 100644 index 00000000..cebf8049 --- /dev/null +++ b/apps/front/src/utils/rankedMatchStorage.ts @@ -0,0 +1,42 @@ +import { + type RankedMatchAssignment, + rankedMatchAssignmentSchema +} from '@knucklebones/common' + +const RANKED_MATCH_STORAGE_PREFIX = 'knucklebones.ranked-match.v1.' + +export function storeRankedMatchAssignment( + assignment: RankedMatchAssignment +): void { + const parsedAssignment = rankedMatchAssignmentSchema.parse(assignment) + sessionStorage.setItem( + `${RANKED_MATCH_STORAGE_PREFIX}${parsedAssignment.roomKey}`, + JSON.stringify(parsedAssignment) + ) +} + +export function getStoredRankedMatchAssignment( + roomKey: string +): RankedMatchAssignment | undefined { + const storedAssignment = sessionStorage.getItem( + `${RANKED_MATCH_STORAGE_PREFIX}${roomKey}` + ) + if (storedAssignment === null) { + return + } + + let storedValue: unknown + try { + storedValue = JSON.parse(storedAssignment) + } catch { + sessionStorage.removeItem(`${RANKED_MATCH_STORAGE_PREFIX}${roomKey}`) + return + } + + const parsedAssignment = rankedMatchAssignmentSchema.safeParse(storedValue) + if (!parsedAssignment.success || parsedAssignment.data.roomKey !== roomKey) { + sessionStorage.removeItem(`${RANKED_MATCH_STORAGE_PREFIX}${roomKey}`) + return + } + return parsedAssignment.data +} diff --git a/tests/e2e/game.spec.ts b/tests/e2e/game.spec.ts index dcd0a163..09171ac6 100644 --- a/tests/e2e/game.spec.ts +++ b/tests/e2e/game.spec.ts @@ -207,6 +207,57 @@ test('synchronizes a human game across independent browser identities', async ({ } }) +test('matches two ranked identities and starts their assigned BO1 room', async ({ + browser +}) => { + const firstContext = await browser.newContext() + const secondContext = await browser.newContext() + const firstPlayer = await firstContext.newPage() + const secondPlayer = await secondContext.newPage() + + try { + await Promise.all([waitForHome(firstPlayer), waitForHome(secondPlayer)]) + await Promise.all([ + firstPlayer.getByRole('link', { name: 'Play ranked' }).click(), + secondPlayer.getByRole('link', { name: 'Play ranked' }).click() + ]) + + await Promise.all([ + firstPlayer.waitForURL(/\/room\/[0-9a-f-]+$/), + secondPlayer.waitForURL(/\/room\/[0-9a-f-]+$/) + ]) + expect(new URL(firstPlayer.url()).pathname).toBe( + new URL(secondPlayer.url()).pathname + ) + + for (const player of [firstPlayer, secondPlayer]) { + await expect(player.getByText('Round 1 of 1')).toBeVisible() + await expect( + player.getByText('Waiting for game to start...') + ).toHaveCount(0) + } + + const firstColumns = firstPlayer.locator('div[role="button"]') + const secondColumns = secondPlayer.locator('div[role="button"]') + await expect + .poll( + async () => (await firstColumns.count()) + (await secondColumns.count()) + ) + .toBe(3) + + const currentPlayer = + (await firstColumns.count()) === 3 ? firstPlayer : secondPlayer + const nextPlayer = + currentPlayer === firstPlayer ? secondPlayer : firstPlayer + await currentPlayer.locator('div[role="button"]').first().click() + await expect(currentPlayer.locator('div[role="button"]')).toHaveCount(0) + await expect(nextPlayer.locator('div[role="button"]')).toHaveCount(3) + } finally { + await firstContext.close() + await secondContext.close() + } +}) + test('keeps a spectator read-only while synchronizing a human move', async ({ browser }) => { From 9a5110020b5fe51a57daa9997b41f9c2ae9b4ae1 Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 12:47:14 +0200 Subject: [PATCH 07/52] feat(matchmaking): apply rating-aware queue timing --- .../MatchmakingDurableObject.ts | 157 +++++++++++++++--- apps/worker/src/utils/rankedMatches.ts | 14 ++ apps/worker/test/worker.integration.test.ts | 30 +++- 3 files changed, 179 insertions(+), 22 deletions(-) diff --git a/apps/worker/src/durable-objects/MatchmakingDurableObject.ts b/apps/worker/src/durable-objects/MatchmakingDurableObject.ts index 28452747..bd485bf1 100644 --- a/apps/worker/src/durable-objects/MatchmakingDurableObject.ts +++ b/apps/worker/src/durable-objects/MatchmakingDurableObject.ts @@ -11,13 +11,16 @@ import { import { type CloudflareEnvironment } from '../types/cloudflareEnvironment' import { apiError } from '../utils/http' import { + expireRankedAssignment, getActiveRankedMatchForPlayer, releaseRankedMatch, reserveRankedMatch } from '../utils/rankedMatches' const MATCHMAKING_STATE_KEY = 'matchmaking-state' -const RATING_SELECTION_WINDOW_MS = 750 +const RATING_SELECTION_WINDOW_MS = 500 +const PREFERRED_RATING_DIFFERENCE = 100 +const DISTANT_OPPONENT_WAIT_MS = 3_000 const QUEUE_ENTRY_TTL_MS = 15_000 const MATCH_ASSIGNMENT_TTL_MS = 15_000 @@ -110,6 +113,13 @@ export class MatchmakingDurableObject { } } + async alarm(): Promise { + const now = Date.now() + const state = await this.getActiveState(now) + await this.matchEligiblePlayers(state, now) + await this.persistState(state, now) + } + private async join(playerId: string, rating: number): Promise { const now = Date.now() const activeMatch = await getActiveRankedMatchForPlayer( @@ -144,8 +154,8 @@ export class MatchmakingDurableObject { existingEntry.lastSeenAt = now } - await this.matchOldestEligiblePlayer(state, now) - await this.state.storage.put(MATCHMAKING_STATE_KEY, state) + await this.matchEligiblePlayers(state, now) + await this.persistState(state, now) return this.getPlayerStatus(state, playerId) } @@ -176,17 +186,19 @@ export class MatchmakingDurableObject { if (entry !== undefined) { entry.lastSeenAt = now - await this.matchPlayerIfEligible(state, entry, now) } - await this.state.storage.put(MATCHMAKING_STATE_KEY, state) + await this.matchEligiblePlayers(state, now) + await this.persistState(state, now) return this.getPlayerStatus(state, playerId) } private async leave(playerId: string): Promise { - const state = await this.getActiveState(Date.now()) + const now = Date.now() + const state = await this.getActiveState(now) state.waiting = state.waiting.filter((entry) => entry.playerId !== playerId) - await this.state.storage.put(MATCHMAKING_STATE_KEY, state) + await this.matchEligiblePlayers(state, now) + await this.persistState(state, now) return new Response(null, { status: 204 }) } @@ -198,6 +210,20 @@ export class MatchmakingDurableObject { state.waiting = state.waiting.filter( (entry) => entry.lastSeenAt > now - QUEUE_ENTRY_TTL_MS ) + const expiredAssignments = new Map( + Object.values(state.assignments) + .filter((match) => match.expiresAt <= now) + .map((match) => [match.matchId, match]) + ) + await Promise.all( + [...expiredAssignments.values()].map(async (match) => + expireRankedAssignment( + this.cloudflareEnvironment.PLAYERS_DB, + match.matchId, + now + ) + ) + ) state.assignments = Object.fromEntries( Object.entries(state.assignments).filter( ([, match]) => match.expiresAt > now @@ -206,43 +232,78 @@ export class MatchmakingDurableObject { return state } - private async matchOldestEligiblePlayer( + private async matchEligiblePlayers( state: MatchmakingState, now: number - ) { - const entry = [...state.waiting] - .sort((left, right) => left.joinedAt - right.joinedAt) - .find( - (candidate) => now - candidate.joinedAt >= RATING_SELECTION_WINDOW_MS + ): Promise { + while (true) { + const entries = [...state.waiting].sort( + (left, right) => left.joinedAt - right.joinedAt + ) + const matchableEntry = entries.find( + (entry) => this.getEligibleOpponent(state, entry, now) !== undefined ) + if (matchableEntry === undefined) { + return + } - if (entry !== undefined) { - await this.matchPlayerIfEligible(state, entry, now) + const opponent = this.getEligibleOpponent(state, matchableEntry, now) + if (opponent === undefined) { + return + } + await this.createMatch(state, matchableEntry, opponent, now) } } - private async matchPlayerIfEligible( + private getEligibleOpponent( state: MatchmakingState, entry: QueueEntry, now: number - ) { + ): QueueEntry | undefined { if (now - entry.joinedAt < RATING_SELECTION_WINDOW_MS) { return } - const opponent = state.waiting + const opponents = state.waiting .filter((candidate) => candidate.playerId !== entry.playerId) .sort((left, right) => { const ratingDifference = Math.abs(left.rating - entry.rating) - Math.abs(right.rating - entry.rating) return ratingDifference || left.joinedAt - right.joinedAt - })[0] + }) + + const preferredOpponent = opponents.find( + (opponent) => + Math.abs(opponent.rating - entry.rating) <= PREFERRED_RATING_DIFFERENCE + ) + if (preferredOpponent !== undefined) { + return preferredOpponent + } - if (opponent === undefined) { + const firstDistantOpponentAt = opponents.reduce( + (oldest, opponent) => + oldest === undefined + ? opponent.joinedAt + : Math.min(oldest, opponent.joinedAt), + undefined + ) + if ( + firstDistantOpponentAt === undefined || + now - Math.max(entry.joinedAt, firstDistantOpponentAt) < + DISTANT_OPPONENT_WAIT_MS + ) { return } + return opponents[0] + } + private async createMatch( + state: MatchmakingState, + entry: QueueEntry, + opponent: QueueEntry, + now: number + ): Promise { const match: RankedMatchAssignment = { matchId: crypto.randomUUID(), roomKey: crypto.randomUUID(), @@ -277,6 +338,62 @@ export class MatchmakingDurableObject { state.assignments[opponent.playerId] = match } + private async persistState( + state: MatchmakingState, + now: number + ): Promise { + await this.state.storage.put(MATCHMAKING_STATE_KEY, state) + const nextAlarmAt = this.getNextAlarmAt(state, now) + if (nextAlarmAt === undefined) { + await this.state.storage.deleteAlarm() + } else { + await this.state.storage.setAlarm(nextAlarmAt) + } + } + + private getNextAlarmAt( + state: MatchmakingState, + now: number + ): number | undefined { + const deadlines = [ + ...state.waiting.map((entry) => entry.lastSeenAt + QUEUE_ENTRY_TTL_MS), + ...Object.values(state.assignments).map((match) => match.expiresAt) + ] + + for (const entry of state.waiting) { + const opponents = state.waiting.filter( + (candidate) => candidate.playerId !== entry.playerId + ) + if (opponents.length === 0) { + continue + } + + const hasPreferredOpponent = opponents.some( + (opponent) => + Math.abs(opponent.rating - entry.rating) <= + PREFERRED_RATING_DIFFERENCE + ) + if (hasPreferredOpponent) { + deadlines.push(entry.joinedAt + RATING_SELECTION_WINDOW_MS) + } else { + const firstDistantOpponentAt = Math.min( + ...opponents.map((opponent) => opponent.joinedAt) + ) + deadlines.push( + Math.max( + entry.joinedAt + RATING_SELECTION_WINDOW_MS, + Math.max(entry.joinedAt, firstDistantOpponentAt) + + DISTANT_OPPONENT_WAIT_MS + ) + ) + } + } + + return deadlines + .filter((deadline) => deadline > now) + .sort((left, right) => left - right)[0] + } + private async configureRankedRoom( assignment: RankedMatchAssignment ): Promise { diff --git a/apps/worker/src/utils/rankedMatches.ts b/apps/worker/src/utils/rankedMatches.ts index eb1c66ac..e3b3f92d 100644 --- a/apps/worker/src/utils/rankedMatches.ts +++ b/apps/worker/src/utils/rankedMatches.ts @@ -385,6 +385,20 @@ export async function releaseRankedMatch( .run() } +export async function expireRankedAssignment( + database: D1Database, + matchId: string, + now = Date.now() +): Promise { + await database + .prepare( + `DELETE FROM active_ranked_matches + WHERE match_id = ? AND state = 'assigned' AND expires_at <= ?` + ) + .bind(matchId, now) + .run() +} + async function getRankedMatchSettlement( database: D1Database, matchId: string diff --git a/apps/worker/test/worker.integration.test.ts b/apps/worker/test/worker.integration.test.ts index 33147591..6845b40c 100644 --- a/apps/worker/test/worker.integration.test.ts +++ b/apps/worker/test/worker.integration.test.ts @@ -1138,7 +1138,7 @@ describe('ranked matchmaking', () => { headers: authorization(player) }) - it('keeps both players waiting during the fast selection window', async () => { + it('matches compatible players after the fast selection window', async () => { const playerOne = await createPlayer() const playerTwo = await createPlayer() @@ -1153,6 +1153,26 @@ describe('ranked matchmaking', () => { expect( matchmakingStatusSchema.parse(await playerTwoJoin.json()).status ).toBe('waiting') + + await new Promise((resolve) => setTimeout(resolve, 650)) + + const environment = await server.getWorker().getEnv() + const activeMatch = await environment.PLAYERS_DB.prepare( + `SELECT player_one_id, player_two_id + FROM active_ranked_matches + WHERE player_one_id IN (?, ?) OR player_two_id IN (?, ?)` + ) + .bind( + playerOne.playerId, + playerTwo.playerId, + playerOne.playerId, + playerTwo.playerId + ) + .first<{ player_one_id: string; player_two_id: string }>() + expect(activeMatch).toEqual({ + player_one_id: playerOne.playerId, + player_two_id: playerTwo.playerId + }) }) it('does not reset the selection window when a player joins twice', async () => { @@ -1535,7 +1555,7 @@ describe('ranked matchmaking', () => { ]) }) - it('falls back to a distant opponent instead of waiting indefinitely', async () => { + it('waits briefly before falling back to a distant opponent', async () => { const player = await createPlayer() const opponent = await createPlayer() await setRating(player.playerId, 800) @@ -1546,6 +1566,12 @@ describe('ranked matchmaking', () => { ) await new Promise((resolve) => setTimeout(resolve, 800)) + const waiting = await getQueueStatus(player) + expect(matchmakingStatusSchema.parse(await waiting.json()).status).toBe( + 'waiting' + ) + + await new Promise((resolve) => setTimeout(resolve, 2_400)) const response = await getQueueStatus(player) expect(matchmakingStatusSchema.parse(await response.json()).status).toBe( From c869a3eb79e5b8a1e4d218e43c4d2162b7665946 Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 13:42:26 +0200 Subject: [PATCH 08/52] feat(matchmaking): recover expired assignments --- .../components/GameContext/useGameSetup.ts | 26 ++- .../durable-objects/GameStateDurableObject.ts | 34 ++++ .../MatchmakingDurableObject.ts | 154 ++++++++++++++++-- apps/worker/src/utils/rankedMatches.ts | 5 +- apps/worker/test/worker.integration.test.ts | 67 ++++++++ .../src/schemas/gameStateMessage.test.ts | 12 ++ .../common/src/schemas/gameStateMessage.ts | 10 ++ tests/e2e/game.spec.ts | 46 ++++++ 8 files changed, 337 insertions(+), 17 deletions(-) diff --git a/apps/front/src/components/GameContext/useGameSetup.ts b/apps/front/src/components/GameContext/useGameSetup.ts index 8b2a44c2..7804d4b3 100644 --- a/apps/front/src/components/GameContext/useGameSetup.ts +++ b/apps/front/src/components/GameContext/useGameSetup.ts @@ -1,6 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import useWebSocketImport, { ReadyState } from 'react-use-websocket' import { AI_PLAYER_ID, @@ -25,6 +25,7 @@ import { } from '../../utils/api' import { getStoredPlayerId } from '../../utils/identityStorage' import { getPlayerFromId, getPlayerSide } from '../../utils/player' +import { getStoredRankedMatchAssignment } from '../../utils/rankedMatchStorage' import { getWebSocketUrl, preparePlayers } from './utils' // react-use-websocket 4.13 publishes a CommonJS object containing its default @@ -38,6 +39,7 @@ const useWebSocket = export function useGameSetup() { const { t } = useTranslation() + const navigate = useNavigate() const [gameState, setGameState] = React.useState(null) const [isLoading, setIsLoading] = React.useState(true) const [errorMessage, setErrorMessage] = React.useState(null) @@ -47,6 +49,10 @@ export function useGameSetup() { const [reconnectDeadlineByPlayerId, setReconnectDeadlineByPlayerId] = React.useState>({}) const roomKey = useRoomKey() + const rankedAssignment = React.useMemo( + () => getStoredRankedMatchAssignment(roomKey), + [roomKey] + ) const latestRevision = React.useRef({ roomKey, value: -1 }) const state = useLocation().state as GameSettings | undefined const playerId = getStoredPlayerId()! @@ -117,6 +123,10 @@ export function useGameSetup() { return nextDeadlines }) } else if (serverEvent.data.type === 'game.error') { + if (serverEvent.data.payload.code === 'RANKED_ASSIGNMENT_EXPIRED') { + navigate('/ranked', { replace: true }) + return + } setErrorMessage(serverEvent.data.payload.message) } return @@ -168,13 +178,25 @@ export function useGameSetup() { setIsLoading(false) setErrorMessage(null) } - }, [lastJsonMessage, roomKey, t]) + }, [lastJsonMessage, navigate, roomKey, t]) React.useEffect(() => { setPresenceByPlayerId({}) setReconnectDeadlineByPlayerId({}) }, [roomKey]) + React.useEffect(() => { + if (gameState !== null || rankedAssignment === undefined) { + return + } + + const timeout = setTimeout( + () => navigate('/ranked', { replace: true }), + Math.max(0, rankedAssignment.expiresAt - Date.now()) + 500 + ) + return () => clearTimeout(timeout) + }, [gameState, navigate, rankedAssignment]) + React.useEffect(() => { if (readyState === ReadyState.OPEN) { initGame( diff --git a/apps/worker/src/durable-objects/GameStateDurableObject.ts b/apps/worker/src/durable-objects/GameStateDurableObject.ts index e24de102..c2b157bb 100644 --- a/apps/worker/src/durable-objects/GameStateDurableObject.ts +++ b/apps/worker/src/durable-objects/GameStateDurableObject.ts @@ -86,6 +86,12 @@ export class GameStateDurableObject extends createDurable({ this.rankedPlayerClaims = {} } + getPersistable(): Record { + const persistable = super.getPersistable() as Record + delete persistable.cloudflareEnvironment + return persistable + } + configureRankedMatch(assignment: RankedMatchAssignment): void { const parsedAssignment = rankedMatchAssignmentSchema.parse(assignment) if (this.rankedMatch !== undefined) { @@ -141,6 +147,7 @@ export class GameStateDurableObject extends createDurable({ const changed = this.connectedPlayers[parsedPlayerId] !== connected this.connectedPlayers[parsedPlayerId] = connected + await this.reportRankedAssignmentPresence(parsedPlayerId, connected) return changed ? { status: 'updated', @@ -577,6 +584,33 @@ export class GameStateDurableObject extends createDurable({ await this.setAlarm(Math.min(...deadlines)) } + private async reportRankedAssignmentPresence( + playerId: string, + connected: boolean + ): Promise { + const assignment = this.rankedMatch + if (assignment === undefined) { + return + } + + const id = this.cloudflareEnvironment.MATCHMAKING_DURABLE_OBJECT.idFromName( + assignment.queueKey + ) + const matchmaking = + this.cloudflareEnvironment.MATCHMAKING_DURABLE_OBJECT.get(id) + const response = await matchmaking.fetch('https://dummy-url/presence', { + method: 'POST', + headers: { + 'X-Player-Id': playerId, + 'X-Match-Id': assignment.matchId, + 'X-Connected': String(connected) + } + }) + if (!response.ok) { + throw new Error('The matchmaker rejected a ranked presence update.') + } + } + private async broadcastAlarmResult(gameState: IGameState): Promise { const roomKey = this.disconnectPolicy?.roomKey if (roomKey === undefined) { diff --git a/apps/worker/src/durable-objects/MatchmakingDurableObject.ts b/apps/worker/src/durable-objects/MatchmakingDurableObject.ts index bd485bf1..dca70e55 100644 --- a/apps/worker/src/durable-objects/MatchmakingDurableObject.ts +++ b/apps/worker/src/durable-objects/MatchmakingDurableObject.ts @@ -3,10 +3,13 @@ import { DEFAULT_RATING_POOL, type MatchmakingStatus, matchmakingStatusSchema, + matchIdSchema, playerIdSchema, + requestIdSchema, RANKED_MATCH_FORMAT, RANKED_QUEUE_KEY, - type RankedMatchAssignment + type RankedMatchAssignment, + toGameErrorMessage } from '@knucklebones/common' import { type CloudflareEnvironment } from '../types/cloudflareEnvironment' import { apiError } from '../utils/http' @@ -34,6 +37,11 @@ interface QueueEntry { interface MatchmakingState { waiting: QueueEntry[] assignments: Record + assignmentEntries: Record< + string, + { playerOne: QueueEntry; playerTwo: QueueEntry } + > + assignmentPresence: Record } export class MatchmakingDurableObject { @@ -92,6 +100,28 @@ export class MatchmakingDurableObject { return await this.getStatus(parsedPlayerId.data) case 'DELETE /queue': return await this.leave(parsedPlayerId.data) + case 'POST /presence': { + const matchId = matchIdSchema.safeParse( + request.headers.get('X-Match-Id') + ) + const connected = request.headers.get('X-Connected') + if ( + !matchId.success || + !['true', 'false'].includes(connected ?? '') + ) { + return apiError({ + status: 400, + code: 'INVALID_MATCHMAKING_PRESENCE', + message: 'The matchmaking presence update is invalid.', + requestId + }) + } + return await this.updateAssignmentPresence( + parsedPlayerId.data, + matchId.data, + connected === 'true' + ) + } default: return apiError({ status: 404, @@ -122,12 +152,14 @@ export class MatchmakingDurableObject { private async join(playerId: string, rating: number): Promise { const now = Date.now() + const state = await this.getActiveState(now) const activeMatch = await getActiveRankedMatchForPlayer( this.cloudflareEnvironment.PLAYERS_DB, playerId, now ) if (activeMatch !== undefined) { + await this.persistState(state, now) await this.configureRankedRoom(activeMatch.assignment) return this.statusResponse({ status: 'matched', @@ -135,7 +167,6 @@ export class MatchmakingDurableObject { }) } - const state = await this.getActiveState(now) const assignment = state.assignments[playerId] if (assignment !== undefined) { @@ -162,12 +193,14 @@ export class MatchmakingDurableObject { private async getStatus(playerId: string): Promise { const now = Date.now() + const state = await this.getActiveState(now) const activeMatch = await getActiveRankedMatchForPlayer( this.cloudflareEnvironment.PLAYERS_DB, playerId, now ) if (activeMatch !== undefined) { + await this.persistState(state, now) await this.configureRankedRoom(activeMatch.assignment) return this.statusResponse({ status: 'matched', @@ -175,7 +208,6 @@ export class MatchmakingDurableObject { }) } - const state = await this.getActiveState(now) const assignment = state.assignments[playerId] if (assignment !== undefined) { await this.configureRankedRoom(assignment) @@ -202,11 +234,55 @@ export class MatchmakingDurableObject { return new Response(null, { status: 204 }) } + private async updateAssignmentPresence( + playerId: string, + matchId: string, + connected: boolean + ): Promise { + const now = Date.now() + const state = await this.loadState() + const assignment = state.assignments[playerId] + if (assignment?.matchId === matchId) { + const connectedPlayerIds = new Set( + state.assignmentPresence[matchId] ?? [] + ) + if (connected) { + connectedPlayerIds.add(playerId) + } else { + connectedPlayerIds.delete(playerId) + } + state.assignmentPresence[matchId] = [...connectedPlayerIds] + } + await this.removeInactiveState(state, now, false) + await this.persistState(state, now) + return new Response(null, { status: 204 }) + } + private async getActiveState(now: number): Promise { + const state = await this.loadState() + await this.removeInactiveState(state, now, true) + return state + } + + private async loadState(): Promise { const state = (await this.state.storage.get( MATCHMAKING_STATE_KEY - )) ?? { waiting: [], assignments: {} } + )) ?? { + waiting: [], + assignments: {}, + assignmentEntries: {}, + assignmentPresence: {} + } + state.assignmentEntries ??= {} + state.assignmentPresence ??= {} + return state + } + private async removeInactiveState( + state: MatchmakingState, + now: number, + broadcastExpirations: boolean + ): Promise { state.waiting = state.waiting.filter( (entry) => entry.lastSeenAt > now - QUEUE_ENTRY_TTL_MS ) @@ -215,21 +291,43 @@ export class MatchmakingDurableObject { .filter((match) => match.expiresAt <= now) .map((match) => [match.matchId, match]) ) - await Promise.all( - [...expiredAssignments.values()].map(async (match) => - expireRankedAssignment( - this.cloudflareEnvironment.PLAYERS_DB, - match.matchId, - now - ) + for (const match of expiredAssignments.values()) { + const connectedPlayerIds = state.assignmentPresence[match.matchId] ?? [] + const expired = await expireRankedAssignment( + this.cloudflareEnvironment.PLAYERS_DB, + match.matchId, + now ) - ) + if (expired) { + const entries = state.assignmentEntries[match.matchId] + if (entries !== undefined) { + for (const entry of [entries.playerOne, entries.playerTwo]) { + if ( + connectedPlayerIds.includes(entry.playerId) && + !state.waiting.some( + (waitingEntry) => waitingEntry.playerId === entry.playerId + ) + ) { + state.waiting.push({ ...entry, lastSeenAt: now }) + } + } + } + if (broadcastExpirations && connectedPlayerIds.length > 0) { + try { + await this.broadcastAssignmentExpired(match) + } catch (error) { + this.sentry.captureException(error) + } + } + } + delete state.assignmentEntries[match.matchId] + delete state.assignmentPresence[match.matchId] + } state.assignments = Object.fromEntries( Object.entries(state.assignments).filter( ([, match]) => match.expiresAt > now ) ) - return state } private async matchEligiblePlayers( @@ -336,6 +434,36 @@ export class MatchmakingDurableObject { ) state.assignments[entry.playerId] = match state.assignments[opponent.playerId] = match + state.assignmentEntries[match.matchId] = { + playerOne: entry, + playerTwo: opponent + } + state.assignmentPresence[match.matchId] = [] + } + + private async broadcastAssignmentExpired( + assignment: RankedMatchAssignment + ): Promise { + const requestId = requestIdSchema.parse(crypto.randomUUID()) + const id = this.cloudflareEnvironment.WEB_SOCKET_DURABLE_OBJECT.idFromName( + assignment.roomKey + ) + const room = this.cloudflareEnvironment.WEB_SOCKET_DURABLE_OBJECT.get(id) + const response = await room.fetch('https://dummy-url/broadcast', { + method: 'POST', + headers: { 'X-Request-Id': requestId }, + body: JSON.stringify( + toGameErrorMessage({ + code: 'RANKED_ASSIGNMENT_EXPIRED', + message: 'The opponent did not connect. Returning to matchmaking.', + requestId, + retryable: true + }) + ) + }) + if (!response.ok) { + throw new Error('The WebSocket room rejected the assignment expiry.') + } } private async persistState( diff --git a/apps/worker/src/utils/rankedMatches.ts b/apps/worker/src/utils/rankedMatches.ts index e3b3f92d..f71de40f 100644 --- a/apps/worker/src/utils/rankedMatches.ts +++ b/apps/worker/src/utils/rankedMatches.ts @@ -389,14 +389,15 @@ export async function expireRankedAssignment( database: D1Database, matchId: string, now = Date.now() -): Promise { - await database +): Promise { + const result = await database .prepare( `DELETE FROM active_ranked_matches WHERE match_id = ? AND state = 'assigned' AND expires_at <= ?` ) .bind(matchId, now) .run() + return result.meta.changes === 1 } async function getRankedMatchSettlement( diff --git a/apps/worker/test/worker.integration.test.ts b/apps/worker/test/worker.integration.test.ts index 6845b40c..4f495582 100644 --- a/apps/worker/test/worker.integration.test.ts +++ b/apps/worker/test/worker.integration.test.ts @@ -813,6 +813,14 @@ describe('ranked disconnect adjudication', () => { return { callGame, playerOne, playerTwo } } + it('excludes runtime bindings from persisted room state', async () => { + const { callGame } = await createActiveRoom() + + expect( + await callGame>('getPersistable', []) + ).not.toHaveProperty('cloudflareEnvironment') + }) + it('cancels a deadline on reconnect and forfeits only after a later expiry', async () => { const { callGame, playerOne, playerTwo } = await createActiveRoom() const now = Date.now() + 60_000 @@ -1522,6 +1530,65 @@ describe('ranked matchmaking', () => { ).resolves.toBeNull() }) + it('requeues the connected player when an opponent never connects', async () => { + const playerOne = await createPlayer() + const playerTwo = await createPlayer() + + const firstJoin = matchmakingStatusSchema.parse( + await (await joinQueue(playerOne)).json() + ) + if (firstJoin.status !== 'waiting') { + throw new Error('Expected the first player to be waiting.') + } + + await new Promise((resolve) => setTimeout(resolve, 550)) + const match = matchmakingStatusSchema.parse( + await (await joinQueue(playerTwo)).json() + ) + if (match.status !== 'matched') { + throw new Error('Expected the players to be matched.') + } + + const environment = await server.getWorker().getEnv() + const roomId = environment.GAME_STATE_DURABLE_OBJECT.idFromName( + match.match.roomKey + ) + const room = environment.GAME_STATE_DURABLE_OBJECT.get(roomId) + const presence = await room.fetch( + 'https://itty-durable/do/call/updatePresence', + { + headers: { + 'do-name': match.match.roomKey, + 'do-content': JSON.stringify([playerOne.playerId, true]) + } + } + ) + expect(presence.ok).toBe(true) + + await new Promise((resolve) => + setTimeout(resolve, Math.max(0, match.match.expiresAt - Date.now()) + 750) + ) + + const playerOneStatus = matchmakingStatusSchema.parse( + await (await getQueueStatus(playerOne)).json() + ) + const playerTwoStatus = matchmakingStatusSchema.parse( + await (await getQueueStatus(playerTwo)).json() + ) + expect(playerOneStatus).toEqual({ + status: 'waiting', + joinedAt: firstJoin.joinedAt + }) + expect(playerTwoStatus).toEqual({ status: 'idle' }) + await expect( + environment.PLAYERS_DB.prepare( + 'SELECT match_id FROM active_ranked_matches WHERE match_id = ?' + ) + .bind(match.match.matchId) + .first() + ).resolves.toBeNull() + }, 25_000) + it('chooses the closest rating after the selection window', async () => { const player = await createPlayer() const distantOpponent = await createPlayer() diff --git a/packages/common/src/schemas/gameStateMessage.test.ts b/packages/common/src/schemas/gameStateMessage.test.ts index 2a26ac50..1891d84f 100644 --- a/packages/common/src/schemas/gameStateMessage.test.ts +++ b/packages/common/src/schemas/gameStateMessage.test.ts @@ -5,6 +5,7 @@ import { compatibleGameStateMessageSchema, gameServerEventSchema, getGameStateMessagePayload, + toGameErrorMessage, toGameStateMessage } from './gameStateMessage' @@ -109,4 +110,15 @@ describe('gameServerEventSchema', () => { }).success ).toBe(false) }) + + it('creates a versioned game error from API error details', () => { + const message = toGameErrorMessage({ + code: 'RANKED_ASSIGNMENT_EXPIRED', + message: 'Returning to matchmaking.', + requestId, + retryable: true + }) + + expect(gameServerEventSchema.parse(message)).toEqual(message) + }) }) diff --git a/packages/common/src/schemas/gameStateMessage.ts b/packages/common/src/schemas/gameStateMessage.ts index 140d84a7..64274e0e 100644 --- a/packages/common/src/schemas/gameStateMessage.ts +++ b/packages/common/src/schemas/gameStateMessage.ts @@ -1,6 +1,7 @@ import { z } from 'zod/mini' import { type IGameState } from '../interfaces' import { + type ApiErrorDetails, type GameErrorEvent, type GamePresenceEvent, type GameReconnectDeadlineEvent, @@ -109,6 +110,15 @@ export function toGameStateMessage( } } +export function toGameErrorMessage(payload: ApiErrorDetails): GameErrorEvent { + return { + version: PROTOCOL_VERSION, + type: 'game.error', + requestId: payload.requestId, + payload + } +} + export function toGamePresenceMessage( roomKey: string, playerId: string, diff --git a/tests/e2e/game.spec.ts b/tests/e2e/game.spec.ts index 09171ac6..ac89abd0 100644 --- a/tests/e2e/game.spec.ts +++ b/tests/e2e/game.spec.ts @@ -258,6 +258,52 @@ test('matches two ranked identities and starts their assigned BO1 room', async ( } }) +test('returns a connected ranked player to the queue when the opponent never connects', async ({ + browser +}) => { + const playerContext = await browser.newContext() + const opponentContext = await browser.newContext() + const player = await playerContext.newPage() + const opponent = await opponentContext.newPage() + + try { + await Promise.all([waitForHome(player), waitForHome(opponent)]) + const opponentIdentity = await readIdentity(opponent) + + await player.getByRole('link', { name: 'Play ranked' }).click() + await expect( + player.getByRole('heading', { name: 'Ranked matchmaking' }) + ).toBeVisible() + + const opponentJoinStatus = await opponent.evaluate( + async ({ credential }) => { + const response = await fetch( + 'http://localhost:8787/v1/matchmaking/join', + { + method: 'POST', + headers: { Authorization: `Bearer ${credential}` } + } + ) + return response.status + }, + { credential: opponentIdentity.playerCredential } + ) + expect(opponentJoinStatus).toBe(200) + + await player.waitForURL(/\/room\/[0-9a-f-]+$/) + await player.waitForURL((url) => url.pathname === '/ranked', { + timeout: 20_000 + }) + await expect( + player.getByRole('heading', { name: 'Ranked matchmaking' }) + ).toBeVisible() + await expect(player.getByText(/Looking for an opponent/)).toBeVisible() + } finally { + await playerContext.close() + await opponentContext.close() + } +}) + test('keeps a spectator read-only while synchronizing a human move', async ({ browser }) => { From c15b791761a852333a03e6517809fb3ce1f23575 Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 13:48:56 +0200 Subject: [PATCH 09/52] feat(ranking): add explicit resignation --- apps/front/src/components/Game.tsx | 2 + .../components/GameContext/useGameSetup.ts | 14 ++++ apps/front/src/components/ResignGame.tsx | 49 +++++++++++ apps/front/src/translations/resources/en.json | 8 ++ apps/front/src/translations/resources/fr.json | 8 ++ .../src/translations/resources/zh-tw.json | 8 ++ apps/front/src/utils/api.test.ts | 5 ++ apps/front/src/utils/api.ts | 7 ++ .../durable-objects/GameStateDurableObject.ts | 42 ++++++++++ apps/worker/src/endpoints/index.ts | 1 + apps/worker/src/endpoints/resign.ts | 83 +++++++++++++++++++ apps/worker/src/workers/index.ts | 2 + apps/worker/test/worker.integration.test.ts | 67 +++++++++++++++ .../common/src/schemas/durableObject.test.ts | 22 ++++- packages/common/src/schemas/durableObject.ts | 22 +++++ packages/common/src/types/durableObject.ts | 13 +++ tests/e2e/game.spec.ts | 11 +++ 17 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 apps/front/src/components/ResignGame.tsx create mode 100644 apps/worker/src/endpoints/resign.ts diff --git a/apps/front/src/components/Game.tsx b/apps/front/src/components/Game.tsx index 9fc7de1d..de93679e 100644 --- a/apps/front/src/components/Game.tsx +++ b/apps/front/src/components/Game.tsx @@ -9,6 +9,7 @@ import { OutcomeHistory } from './OutcomeHistory' import { PlayerOneBoard, PlayerTwoBoard } from './PlayerBoard' import { QRCodeModal } from './QRCode' import { ReconnectNotice } from './ReconnectNotice' +import { ResignGame } from './ResignGame' import { SideBarActions } from './SideBar' import { WarningToast } from './WarningToast' @@ -33,6 +34,7 @@ export function Game() { + {isOnMobile && gameOutcome}
diff --git a/apps/front/src/components/GameContext/useGameSetup.ts b/apps/front/src/components/GameContext/useGameSetup.ts index 7804d4b3..e1b37e55 100644 --- a/apps/front/src/components/GameContext/useGameSetup.ts +++ b/apps/front/src/components/GameContext/useGameSetup.ts @@ -21,6 +21,7 @@ import { initGame, play, reportClientProtocolDiagnostic, + resignGame, voteRematch } from '../../utils/api' import { getStoredPlayerId } from '../../utils/identityStorage' @@ -261,6 +262,18 @@ export function useGameSetup() { }) } + async function resign(): Promise { + try { + await resignGame({ roomKey }) + return true + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : t('ranked.resign.error') + ) + return false + } + } + async function voteContinueBo() { await voteRematch({ roomKey, playerId }).catch((error) => { setErrorMessage(error.message) @@ -311,6 +324,7 @@ export function useGameSetup() { voteContinueBo, voteContinueIndefinitely, voteRematch: _voteRematch, + resign, updateDisplayName: _updateDisplayName } } diff --git a/apps/front/src/components/ResignGame.tsx b/apps/front/src/components/ResignGame.tsx new file mode 100644 index 00000000..7889ce9c --- /dev/null +++ b/apps/front/src/components/ResignGame.tsx @@ -0,0 +1,49 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { FlagIcon } from '@heroicons/react/24/outline' +import { useRoomKey } from '../hooks/useRoomKey' +import { getStoredRankedMatchAssignment } from '../utils/rankedMatchStorage' +import { Button } from './Button' +import { useGame } from './GameContext' +import { ShortcutModal } from './ShortcutModal' + +export function ResignGame() { + const { t } = useTranslation() + const roomKey = useRoomKey() + const { outcome, playerSide, resign } = useGame() + const [isResigning, setIsResigning] = React.useState(false) + const rankedAssignment = React.useMemo( + () => getStoredRankedMatchAssignment(roomKey), + [roomKey] + ) + + if ( + rankedAssignment === undefined || + outcome !== 'ongoing' || + playerSide === 'spectator' + ) { + return null + } + + return ( + } label={t('ranked.resign.action')}> +
+

+ {t('ranked.resign.title')} +

+

{t('ranked.resign.warning')}

+ +
+
+ ) +} diff --git a/apps/front/src/translations/resources/en.json b/apps/front/src/translations/resources/en.json index 3e2c87db..9108251e 100644 --- a/apps/front/src/translations/resources/en.json +++ b/apps/front/src/translations/resources/en.json @@ -17,6 +17,14 @@ "rating-error": "Your new rating couldn't be loaded.", "play-again": "Find another ranked match" }, + "resign": { + "action": "Resign", + "title": "Resign this ranked match?", + "warning": "Resigning ends the game immediately and counts as a rated loss.", + "confirm": "Confirm resignation", + "resigning": "Resigning…", + "error": "We couldn't resign from this match. Try again." + }, "queue": { "title": "Ranked matchmaking", "joining": "Joining the queue…", diff --git a/apps/front/src/translations/resources/fr.json b/apps/front/src/translations/resources/fr.json index 4bca5240..3c40b6be 100644 --- a/apps/front/src/translations/resources/fr.json +++ b/apps/front/src/translations/resources/fr.json @@ -17,6 +17,14 @@ "rating-error": "Impossible de charger votre nouveau classement.", "play-again": "Rechercher une autre partie classée" }, + "resign": { + "action": "Abandonner", + "title": "Abandonner cette partie classée ?", + "warning": "L'abandon termine immédiatement la partie et compte comme une défaite classée.", + "confirm": "Confirmer l'abandon", + "resigning": "Abandon en cours…", + "error": "Impossible d'abandonner cette partie. Réessayez." + }, "queue": { "title": "Recherche de partie classée", "joining": "Connexion à la file…", diff --git a/apps/front/src/translations/resources/zh-tw.json b/apps/front/src/translations/resources/zh-tw.json index 3556017d..04569fa5 100644 --- a/apps/front/src/translations/resources/zh-tw.json +++ b/apps/front/src/translations/resources/zh-tw.json @@ -17,6 +17,14 @@ "rating-error": "無法載入您的新評分。", "play-again": "尋找另一場排名對戰" }, + "resign": { + "action": "認輸", + "title": "要在這場排名對戰中認輸嗎?", + "warning": "認輸會立即結束對局,並計為一場排名敗局。", + "confirm": "確認認輸", + "resigning": "正在認輸…", + "error": "無法在這場對局中認輸,請再試一次。" + }, "queue": { "title": "排名配對", "joining": "正在加入配對佇列…", diff --git a/apps/front/src/utils/api.test.ts b/apps/front/src/utils/api.test.ts index 75caa1ea..1468b981 100644 --- a/apps/front/src/utils/api.test.ts +++ b/apps/front/src/utils/api.test.ts @@ -7,6 +7,7 @@ import { joinMatchmaking, leaveMatchmaking, play, + resignGame, updateDisplayName, voteRematch } from './api' @@ -67,6 +68,7 @@ describe('mutation requests', () => { .mockResolvedValueOnce(new Response(null, { status: 200 })) .mockResolvedValueOnce(new Response(null, { status: 200 })) .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })) .mockResolvedValueOnce( Response.json({ ticket: 'a'.repeat(64), @@ -78,12 +80,14 @@ describe('mutation requests', () => { await initGame(room, { playerType: 'human', boType: 1 }) await voteRematch(room, { boType: 3 }) await updateDisplayName(room, { displayName: 'A/B ? Player' }) + await resignGame(room) await createWebSocketTicket(room) expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ expect.stringContaining(`/v1/rooms/${room.roomKey}/init`), expect.stringContaining(`/v1/rooms/${room.roomKey}/rematch`), expect.stringContaining(`/v1/rooms/${room.roomKey}/display-name`), + expect.stringContaining(`/v1/rooms/${room.roomKey}/resign`), expect.stringContaining(`/v1/rooms/${room.roomKey}/websocket-ticket`) ]) expect( @@ -98,6 +102,7 @@ describe('mutation requests', () => { expect(fetchMock.mock.calls[2][1]?.body).toBe( '{"displayName":"A/B ? Player"}' ) + expect(fetchMock.mock.calls[3][1]?.body).toBeUndefined() }) it('reports a validated API error code and message', async () => { diff --git a/apps/front/src/utils/api.ts b/apps/front/src/utils/api.ts index 6efafb85..83b3769c 100644 --- a/apps/front/src/utils/api.ts +++ b/apps/front/src/utils/api.ts @@ -210,6 +210,13 @@ export async function play( const path = `/v1/rooms/${roomKey}/play` await sendMutationRequest(path, 'POST', { column }) } + +export async function resignGame({ + roomKey +}: Pick): Promise { + await sendMutationRequest(`/v1/rooms/${roomKey}/resign`, 'POST') +} + interface UpdateDisplayNameRequestParams { displayName: string } diff --git a/apps/worker/src/durable-objects/GameStateDurableObject.ts b/apps/worker/src/durable-objects/GameStateDurableObject.ts index c2b157bb..34727386 100644 --- a/apps/worker/src/durable-objects/GameStateDurableObject.ts +++ b/apps/worker/src/durable-objects/GameStateDurableObject.ts @@ -23,6 +23,10 @@ import { type RematchGameResult, rematchGameCommandSchema, rematchGameResultSchema, + type ResignGameCommand, + type ResignGameResult, + resignGameCommandSchema, + resignGameResultSchema, type RankedMatchAssignment, type RankedMatchSettlement, type RankedMatchSettlementResult, @@ -518,6 +522,44 @@ export class GameStateDurableObject extends createDurable({ return { status: 'unchanged' } } + resign( + command: ResignGameCommand + ): IdempotentMutationResult { + const parsedCommand = resignGameCommandSchema.parse(command) + + return this.runIdempotently( + parsedCommand.mutationId, + 'resign', + { playerId: parsedCommand.playerId }, + resignGameResultSchema, + () => this.applyResign(parsedCommand.playerId) + ) + } + + private applyResign(playerId: string): ResignGameResult { + if (this.gameState === undefined) { + return { status: 'game-not-initialized' } + } + + const gameState = GameState.fromJson(gameStateSchema.parse(this.gameState)) + if (gameState.outcome !== 'ongoing') { + return { status: 'game-ended' } + } + if ( + playerId !== gameState.playerOne.id && + playerId !== gameState.playerTwo.id + ) { + return { status: 'unknown-player' } + } + if (this.rankedMatch === undefined) { + return { status: 'not-ranked' } + } + + gameState.finishByForfeit(playerId) + this.reconnectDeadlines = {} + return { status: 'updated', gameState: this.commitGameState(gameState) } + } + updateDisplayName( mutationId: string, playerId: string, diff --git a/apps/worker/src/endpoints/index.ts b/apps/worker/src/endpoints/index.ts index ca71e4dc..75edbc87 100644 --- a/apps/worker/src/endpoints/index.ts +++ b/apps/worker/src/endpoints/index.ts @@ -6,6 +6,7 @@ export * from './init' export * from './matchmaking' export * from './play' export * from './rematch' +export * from './resign' export * from './webSocket' export * from './webSocketTicket' export * from './verifyPlayer' diff --git a/apps/worker/src/endpoints/resign.ts b/apps/worker/src/endpoints/resign.ts new file mode 100644 index 00000000..0d5a7fd0 --- /dev/null +++ b/apps/worker/src/endpoints/resign.ts @@ -0,0 +1,83 @@ +import { status } from 'itty-router' +import { + idempotentResignGameResultSchema, + rankedMatchSettlementResultSchema +} from '@knucklebones/common' +import { type CloudflareEnvironment } from '../types/cloudflareEnvironment' +import { type AuthenticatedMutationRoomRequestWithProps } from '../types/itty' +import { + broadcastGameState, + getGameStateDurableObject +} from '../utils/endpoints' +import { apiError } from '../utils/http' +import { idempotencyConflict } from '../utils/idempotency' + +export async function resignRoom( + request: Request & AuthenticatedMutationRoomRequestWithProps, + cloudflareEnvironment: CloudflareEnvironment +): Promise { + const gameStateStore = getGameStateDurableObject(request) + const result = idempotentResignGameResultSchema.parse( + await gameStateStore.resign({ + mutationId: request.mutationId, + playerId: request.principal.playerId + }) + ) + + if (result.idempotencyStatus === 'conflict') { + return idempotencyConflict(request.requestId) + } + + const mutation = result.value + if (mutation.status !== 'updated') { + return resignError(mutation.status, request.requestId) + } + + rankedMatchSettlementResultSchema.parse( + await gameStateStore.settleRankedResult() + ) + await broadcastGameState(mutation.gameState, request, cloudflareEnvironment) + return status(200) +} + +function resignError( + reason: Exclude< + Extract< + ReturnType, + { idempotencyStatus: 'applied' | 'replayed' } + >['value'], + { status: 'updated' } + >['status'], + requestId: string +): Response { + switch (reason) { + case 'game-not-initialized': + return apiError({ + status: 409, + code: 'GAME_NOT_INITIALIZED', + message: 'The game has not started yet.', + requestId + }) + case 'game-ended': + return apiError({ + status: 409, + code: 'GAME_ALREADY_FINISHED', + message: 'The game has already ended.', + requestId + }) + case 'unknown-player': + return apiError({ + status: 403, + code: 'NOT_A_PLAYER', + message: 'Only a player in this game can resign.', + requestId + }) + case 'not-ranked': + return apiError({ + status: 409, + code: 'NOT_A_RANKED_MATCH', + message: 'Only ranked matches support resignation.', + requestId + }) + } +} diff --git a/apps/worker/src/workers/index.ts b/apps/worker/src/workers/index.ts index c8ca570b..5d2432b9 100644 --- a/apps/worker/src/workers/index.ts +++ b/apps/worker/src/workers/index.ts @@ -20,6 +20,7 @@ import { reportClientProtocolDiagnostic, rematch, rematchRoom, + resignRoom, redeemIdentityTransfer, redeemIdentityRecovery, revokeDeviceCredential, @@ -90,6 +91,7 @@ router .post('/v1/rooms/:roomKey/init', withAuthenticatedMutationId, initializeRoom) .post('/v1/rooms/:roomKey/play', withAuthenticatedMutationId, playIntent) .post('/v1/rooms/:roomKey/rematch', withAuthenticatedMutationId, rematchRoom) + .post('/v1/rooms/:roomKey/resign', withAuthenticatedMutationId, resignRoom) .post( '/v1/rooms/:roomKey/display-name', withAuthenticatedMutationId, diff --git a/apps/worker/test/worker.integration.test.ts b/apps/worker/test/worker.integration.test.ts index 4f495582..d5bbe430 100644 --- a/apps/worker/test/worker.integration.test.ts +++ b/apps/worker/test/worker.integration.test.ts @@ -1460,6 +1460,73 @@ describe('ranked matchmaking', () => { }) }) + it('settles an authenticated ranked resignation exactly once', async () => { + const playerOne = await createPlayer() + const playerTwo = await createPlayer() + + await joinQueue(playerOne) + await new Promise((resolve) => setTimeout(resolve, 550)) + const match = matchmakingStatusSchema.parse( + await (await joinQueue(playerTwo)).json() + ) + if (match.status !== 'matched') { + throw new Error('Expected the players to be matched.') + } + + for (const player of [playerOne, playerTwo]) { + const initialize = await request( + `/v1/rooms/${match.match.roomKey}/init`, + { + method: 'POST', + headers: { + ...authorization(player), + 'Content-Type': 'application/json', + 'Idempotency-Key': crypto.randomUUID() + }, + body: JSON.stringify({ playerType: 'human', boType: 1 }) + } + ) + expect(initialize.status).toBe(200) + } + + const mutationId = crypto.randomUUID() + const resign = () => + request(`/v1/rooms/${match.match.roomKey}/resign`, { + method: 'POST', + headers: { + ...authorization(playerOne), + 'Idempotency-Key': mutationId + } + }) + expect((await resign()).status).toBe(200) + expect((await resign()).status).toBe(200) + + const environment = await server.getWorker().getEnv() + const settlement = await environment.PLAYERS_DB.prepare( + `SELECT result, finish_reason, + player_one_rating_before, player_one_rating_after, + player_two_rating_before, player_two_rating_after + FROM rated_matches + WHERE match_id = ?` + ) + .bind(match.match.matchId) + .first() + expect(settlement).toEqual({ + result: 'player-two-win', + finish_reason: 'forfeit', + player_one_rating_before: 1200, + player_one_rating_after: 1184, + player_two_rating_before: 1200, + player_two_rating_after: 1216 + }) + const settledMatches = await environment.PLAYERS_DB.prepare( + 'SELECT COUNT(*) AS count FROM rated_matches WHERE match_id = ?' + ) + .bind(match.match.matchId) + .first<{ count: number }>() + expect(settledMatches?.count).toBe(1) + }) + it('removes a waiting player from the queue', async () => { const player = await createPlayer() diff --git a/packages/common/src/schemas/durableObject.test.ts b/packages/common/src/schemas/durableObject.test.ts index b0608d53..379b9300 100644 --- a/packages/common/src/schemas/durableObject.test.ts +++ b/packages/common/src/schemas/durableObject.test.ts @@ -4,7 +4,9 @@ import { Player } from '../classes/Player' import { idempotentInitializeGameResultSchema, idempotentPlayGameResultSchema, - playGameCommandSchema + idempotentResignGameResultSchema, + playGameCommandSchema, + resignGameCommandSchema } from './durableObject' function createSerializedGameState() { @@ -39,6 +41,15 @@ describe('Durable Object result contracts', () => { expectedRevision: -1 }).success ).toBe(false) + + expect( + resignGameCommandSchema.parse({ + mutationId: '11111111-1111-4111-8111-111111111111', + playerId: '22222222-2222-4222-8222-222222222222' + }) + ).toMatchObject({ + playerId: '22222222-2222-4222-8222-222222222222' + }) }) it('accepts applied, replayed, and conflicting mutation results', () => { @@ -67,6 +78,15 @@ describe('Durable Object result contracts', () => { idempotencyStatus: 'conflict' }) ).toEqual({ idempotencyStatus: 'conflict' }) + expect( + idempotentResignGameResultSchema.parse({ + idempotencyStatus: 'applied', + value: { status: 'updated', gameState } + }) + ).toMatchObject({ + idempotencyStatus: 'applied', + value: { status: 'updated' } + }) }) it('rejects malformed command results', () => { diff --git a/packages/common/src/schemas/durableObject.ts b/packages/common/src/schemas/durableObject.ts index 638576ef..9af5b9cd 100644 --- a/packages/common/src/schemas/durableObject.ts +++ b/packages/common/src/schemas/durableObject.ts @@ -6,6 +6,7 @@ import { type PlayGameResult, type PresenceUpdateResult, type RematchGameResult, + type ResignGameResult, type UpdateDisplayNameResult } from '../types' import { gameStateSchema } from './gameState' @@ -43,6 +44,11 @@ export const rematchGameCommandSchema = z.object({ gameSettings: z.optional(gameSettingsCommandSchema) }) +export const resignGameCommandSchema = z.object({ + mutationId: mutationIdSchema, + playerId: playerIdSchema +}) + export const updateDisplayNameCommandSchema = z.object({ mutationId: mutationIdSchema, playerId: playerIdSchema, @@ -81,6 +87,18 @@ export const rematchGameResultSchema = z.union([ updatedGameStateResultSchema ]) satisfies z.ZodMiniType +export const resignGameResultSchema = z.union([ + z.object({ + status: z.enum([ + 'game-not-initialized', + 'game-ended', + 'unknown-player', + 'not-ranked' + ]) + }), + updatedGameStateResultSchema +]) satisfies z.ZodMiniType + export const updateDisplayNameResultSchema = z.union([ z.object({ status: z.literal('unknown-player') }), updatedGameStateResultSchema @@ -120,6 +138,7 @@ export const presenceUpdateResultSchema = z.union([ export const gameStateMutationResultSchema = z.union([ initializeGameResultSchema, rematchGameResultSchema, + resignGameResultSchema, updateDisplayNameResultSchema, playGameResultSchema ]) satisfies z.ZodMiniType @@ -140,6 +159,9 @@ export const idempotentInitializeGameResultSchema = idempotentResultSchema( export const idempotentRematchGameResultSchema = idempotentResultSchema( rematchGameResultSchema ) +export const idempotentResignGameResultSchema = idempotentResultSchema( + resignGameResultSchema +) export const idempotentUpdateDisplayNameResultSchema = idempotentResultSchema( updateDisplayNameResultSchema ) diff --git a/packages/common/src/types/durableObject.ts b/packages/common/src/types/durableObject.ts index 592e124a..99bc0484 100644 --- a/packages/common/src/types/durableObject.ts +++ b/packages/common/src/types/durableObject.ts @@ -23,6 +23,11 @@ export interface RematchGameCommand { gameSettings?: Partial> } +export interface ResignGameCommand { + mutationId: string + playerId: string +} + export interface UpdateDisplayNameCommand { mutationId: string playerId: string @@ -59,6 +64,13 @@ export type RematchGameResult = } | { status: 'updated'; gameState: IGameState } +export type ResignGameResult = + | { + status: + 'game-not-initialized' | 'game-ended' | 'unknown-player' | 'not-ranked' + } + | { status: 'updated'; gameState: IGameState } + export type UpdateDisplayNameResult = { status: 'unknown-player' } | { status: 'updated'; gameState: IGameState } @@ -69,6 +81,7 @@ export type PlayGameResult = export type GameStateMutationResult = | InitializeGameResult | RematchGameResult + | ResignGameResult | UpdateDisplayNameResult | PlayGameResult diff --git a/tests/e2e/game.spec.ts b/tests/e2e/game.spec.ts index ac89abd0..b4b264d6 100644 --- a/tests/e2e/game.spec.ts +++ b/tests/e2e/game.spec.ts @@ -252,6 +252,17 @@ test('matches two ranked identities and starts their assigned BO1 room', async ( await currentPlayer.locator('div[role="button"]').first().click() await expect(currentPlayer.locator('div[role="button"]')).toHaveCount(0) await expect(nextPlayer.locator('div[role="button"]')).toHaveCount(3) + + await firstPlayer.getByRole('button', { name: 'Resign' }).click() + await firstPlayer + .getByRole('button', { name: 'Confirm resignation' }) + .click() + for (const player of [firstPlayer, secondPlayer]) { + await expect( + player.getByRole('link', { name: 'Find another ranked match' }) + ).toBeVisible() + await expect(player.getByText(/Rating: 1200 →/)).toBeVisible() + } } finally { await firstContext.close() await secondContext.close() From c86e0b7a7b24e8448ef29086a647ad78193de845 Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 13:51:04 +0200 Subject: [PATCH 10/52] perf(matchmaking): avoid database reads while queued --- .../src/components/RankedMatchmaking.tsx | 2 +- .../MatchmakingDurableObject.ts | 60 ++++++++++--------- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/apps/front/src/components/RankedMatchmaking.tsx b/apps/front/src/components/RankedMatchmaking.tsx index b9aa4f5d..68ee10a9 100644 --- a/apps/front/src/components/RankedMatchmaking.tsx +++ b/apps/front/src/components/RankedMatchmaking.tsx @@ -14,7 +14,7 @@ import { import { storeRankedMatchAssignment } from '../utils/rankedMatchStorage' import { Button } from './Button' -const MATCHMAKING_POLL_MS = 300 +const MATCHMAKING_POLL_MS = 500 const MATCHMAKING_RETRY_MS = 1_000 export function RankedMatchmaking() { diff --git a/apps/worker/src/durable-objects/MatchmakingDurableObject.ts b/apps/worker/src/durable-objects/MatchmakingDurableObject.ts index dca70e55..7393719c 100644 --- a/apps/worker/src/durable-objects/MatchmakingDurableObject.ts +++ b/apps/worker/src/durable-objects/MatchmakingDurableObject.ts @@ -153,23 +153,10 @@ export class MatchmakingDurableObject { private async join(playerId: string, rating: number): Promise { const now = Date.now() const state = await this.getActiveState(now) - const activeMatch = await getActiveRankedMatchForPlayer( - this.cloudflareEnvironment.PLAYERS_DB, - playerId, - now - ) - if (activeMatch !== undefined) { - await this.persistState(state, now) - await this.configureRankedRoom(activeMatch.assignment) - return this.statusResponse({ - status: 'matched', - match: activeMatch.assignment - }) - } - const assignment = state.assignments[playerId] if (assignment !== undefined) { + await this.persistState(state, now) await this.configureRankedRoom(assignment) return this.statusResponse({ status: 'matched', match: assignment }) } @@ -179,6 +166,19 @@ export class MatchmakingDurableObject { ) if (existingEntry === undefined) { + const activeMatch = await getActiveRankedMatchForPlayer( + this.cloudflareEnvironment.PLAYERS_DB, + playerId, + now + ) + if (activeMatch !== undefined) { + await this.persistState(state, now) + await this.configureRankedRoom(activeMatch.assignment) + return this.statusResponse({ + status: 'matched', + match: activeMatch.assignment + }) + } state.waiting.push({ playerId, rating, joinedAt: now, lastSeenAt: now }) } else { existingEntry.rating = rating @@ -194,6 +194,23 @@ export class MatchmakingDurableObject { private async getStatus(playerId: string): Promise { const now = Date.now() const state = await this.getActiveState(now) + const assignment = state.assignments[playerId] + if (assignment !== undefined) { + await this.configureRankedRoom(assignment) + await this.persistState(state, now) + return this.statusResponse({ status: 'matched', match: assignment }) + } + const entry = state.waiting.find( + (candidate) => candidate.playerId === playerId + ) + + if (entry !== undefined) { + entry.lastSeenAt = now + await this.matchEligiblePlayers(state, now) + await this.persistState(state, now) + return this.getPlayerStatus(state, playerId) + } + const activeMatch = await getActiveRankedMatchForPlayer( this.cloudflareEnvironment.PLAYERS_DB, playerId, @@ -208,21 +225,8 @@ export class MatchmakingDurableObject { }) } - const assignment = state.assignments[playerId] - if (assignment !== undefined) { - await this.configureRankedRoom(assignment) - } - const entry = state.waiting.find( - (candidate) => candidate.playerId === playerId - ) - - if (entry !== undefined) { - entry.lastSeenAt = now - } - - await this.matchEligiblePlayers(state, now) await this.persistState(state, now) - return this.getPlayerStatus(state, playerId) + return this.statusResponse({ status: 'idle' }) } private async leave(playerId: string): Promise { From d94941479c8f00997451820f0efff8f26298df4d Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 13:52:55 +0200 Subject: [PATCH 11/52] feat(ranking): record lifecycle metrics --- .../durable-objects/GameStateDurableObject.ts | 9 ++++ .../MatchmakingDurableObject.ts | 45 +++++++++++++++++++ apps/worker/src/endpoints/resign.ts | 5 +++ 3 files changed, 59 insertions(+) diff --git a/apps/worker/src/durable-objects/GameStateDurableObject.ts b/apps/worker/src/durable-objects/GameStateDurableObject.ts index 34727386..5ecd18d2 100644 --- a/apps/worker/src/durable-objects/GameStateDurableObject.ts +++ b/apps/worker/src/durable-objects/GameStateDurableObject.ts @@ -40,6 +40,7 @@ import { import { type CloudflareEnvironment } from '../types/cloudflareEnvironment' import { type IttyDurableObjectNamespace } from '../types/itty' import { applyPlayCommand } from '../utils/authoritativeGame' +import { recordOperationalEvent } from '../utils/observability' import { activateRankedMatch, settleRankedMatch as settleRankedMatchInDatabase @@ -318,6 +319,14 @@ export class GameStateDurableObject extends createDurable({ this.gameState ) this.rankedSettlement = result.settlement + recordOperationalEvent(this.cloudflareEnvironment.ENVIRONMENT, { + event: 'ranked.settlement', + outcome: result.status, + queue_key: result.settlement.queueKey, + result: result.settlement.result, + finish_reason: result.settlement.finishReason, + absolute_rating_change: Math.abs(result.settlement.playerOne.change) + }) return result } diff --git a/apps/worker/src/durable-objects/MatchmakingDurableObject.ts b/apps/worker/src/durable-objects/MatchmakingDurableObject.ts index 7393719c..55a74522 100644 --- a/apps/worker/src/durable-objects/MatchmakingDurableObject.ts +++ b/apps/worker/src/durable-objects/MatchmakingDurableObject.ts @@ -13,6 +13,7 @@ import { } from '@knucklebones/common' import { type CloudflareEnvironment } from '../types/cloudflareEnvironment' import { apiError } from '../utils/http' +import { recordOperationalEvent } from '../utils/observability' import { expireRankedAssignment, getActiveRankedMatchForPlayer, @@ -180,6 +181,12 @@ export class MatchmakingDurableObject { }) } state.waiting.push({ playerId, rating, joinedAt: now, lastSeenAt: now }) + recordOperationalEvent(this.cloudflareEnvironment.ENVIRONMENT, { + event: 'matchmaking.queue', + outcome: 'joined', + queue_key: RANKED_QUEUE_KEY, + queue_size: state.waiting.length + }) } else { existingEntry.rating = rating existingEntry.lastSeenAt = now @@ -232,7 +239,15 @@ export class MatchmakingDurableObject { private async leave(playerId: string): Promise { const now = Date.now() const state = await this.getActiveState(now) + const previousQueueSize = state.waiting.length state.waiting = state.waiting.filter((entry) => entry.playerId !== playerId) + recordOperationalEvent(this.cloudflareEnvironment.ENVIRONMENT, { + event: 'matchmaking.queue', + outcome: + state.waiting.length < previousQueueSize ? 'cancelled' : 'not-found', + queue_key: RANKED_QUEUE_KEY, + queue_size: state.waiting.length + }) await this.matchEligiblePlayers(state, now) await this.persistState(state, now) return new Response(null, { status: 204 }) @@ -287,9 +302,20 @@ export class MatchmakingDurableObject { now: number, broadcastExpirations: boolean ): Promise { + const previousQueueSize = state.waiting.length state.waiting = state.waiting.filter( (entry) => entry.lastSeenAt > now - QUEUE_ENTRY_TTL_MS ) + const expiredQueueEntries = previousQueueSize - state.waiting.length + if (expiredQueueEntries > 0) { + recordOperationalEvent(this.cloudflareEnvironment.ENVIRONMENT, { + event: 'matchmaking.queue', + outcome: 'expired', + queue_key: RANKED_QUEUE_KEY, + expired_count: expiredQueueEntries, + queue_size: state.waiting.length + }) + } const expiredAssignments = new Map( Object.values(state.assignments) .filter((match) => match.expiresAt <= now) @@ -323,6 +349,15 @@ export class MatchmakingDurableObject { this.sentry.captureException(error) } } + recordOperationalEvent(this.cloudflareEnvironment.ENVIRONMENT, { + event: 'matchmaking.assignment', + outcome: 'expired', + queue_key: match.queueKey, + connected_players: connectedPlayerIds.length, + requeued_players: state.waiting.filter((entry) => + connectedPlayerIds.includes(entry.playerId) + ).length + }) } delete state.assignmentEntries[match.matchId] delete state.assignmentPresence[match.matchId] @@ -443,6 +478,16 @@ export class MatchmakingDurableObject { playerTwo: opponent } state.assignmentPresence[match.matchId] = [] + const ratingDifference = Math.abs(entry.rating - opponent.rating) + recordOperationalEvent(this.cloudflareEnvironment.ENVIRONMENT, { + event: 'matchmaking.assignment', + outcome: 'created', + queue_key: match.queueKey, + wait_ms: Math.max(now - entry.joinedAt, now - opponent.joinedAt), + rating_difference: ratingDifference, + preferred: ratingDifference <= PREFERRED_RATING_DIFFERENCE, + queue_size: state.waiting.length + }) } private async broadcastAssignmentExpired( diff --git a/apps/worker/src/endpoints/resign.ts b/apps/worker/src/endpoints/resign.ts index 0d5a7fd0..cfaa2aaf 100644 --- a/apps/worker/src/endpoints/resign.ts +++ b/apps/worker/src/endpoints/resign.ts @@ -11,6 +11,7 @@ import { } from '../utils/endpoints' import { apiError } from '../utils/http' import { idempotencyConflict } from '../utils/idempotency' +import { recordOperationalEvent } from '../utils/observability' export async function resignRoom( request: Request & AuthenticatedMutationRoomRequestWithProps, @@ -36,6 +37,10 @@ export async function resignRoom( rankedMatchSettlementResultSchema.parse( await gameStateStore.settleRankedResult() ) + recordOperationalEvent(cloudflareEnvironment.ENVIRONMENT, { + event: 'ranked.resignation', + outcome: result.idempotencyStatus === 'replayed' ? 'replayed' : 'accepted' + }) await broadcastGameState(mutation.gameState, request, cloudflareEnvironment) return status(200) } From bc2e3dd55a02b2a835688dc5a11c3c79092cd502 Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 13:54:07 +0200 Subject: [PATCH 12/52] feat(ranking): label ranked game rooms --- apps/front/src/components/Game.tsx | 2 ++ apps/front/src/components/RankedMatchInfo.tsx | 36 +++++++++++++++++++ apps/front/src/translations/resources/en.json | 4 +++ apps/front/src/translations/resources/fr.json | 4 +++ .../src/translations/resources/zh-tw.json | 4 +++ tests/e2e/game.spec.ts | 2 ++ 6 files changed, 52 insertions(+) create mode 100644 apps/front/src/components/RankedMatchInfo.tsx diff --git a/apps/front/src/components/Game.tsx b/apps/front/src/components/Game.tsx index de93679e..338980a9 100644 --- a/apps/front/src/components/Game.tsx +++ b/apps/front/src/components/Game.tsx @@ -8,6 +8,7 @@ import { Loading } from './Loading' import { OutcomeHistory } from './OutcomeHistory' import { PlayerOneBoard, PlayerTwoBoard } from './PlayerBoard' import { QRCodeModal } from './QRCode' +import { RankedMatchInfo } from './RankedMatchInfo' import { ReconnectNotice } from './ReconnectNotice' import { ResignGame } from './ResignGame' import { SideBarActions } from './SideBar' @@ -31,6 +32,7 @@ export function Game() { return ( <> + diff --git a/apps/front/src/components/RankedMatchInfo.tsx b/apps/front/src/components/RankedMatchInfo.tsx new file mode 100644 index 00000000..0a5a93c0 --- /dev/null +++ b/apps/front/src/components/RankedMatchInfo.tsx @@ -0,0 +1,36 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useRoomKey } from '../hooks/useRoomKey' +import { getStoredPlayerId } from '../utils/identityStorage' +import { getStoredRankedMatchAssignment } from '../utils/rankedMatchStorage' + +export function RankedMatchInfo() { + const { t } = useTranslation() + const roomKey = useRoomKey() + const assignment = React.useMemo( + () => getStoredRankedMatchAssignment(roomKey), + [roomKey] + ) + const playerId = getStoredPlayerId() + + if (assignment === undefined || playerId === null) { + return null + } + + const opponentRating = + playerId === assignment.playerOneId + ? assignment.playerTwoRating + : playerId === assignment.playerTwoId + ? assignment.playerOneRating + : undefined + if (opponentRating === undefined) { + return null + } + + return ( +
+

{t('ranked.match.label')}

+

{t('ranked.match.opponent-rating', { rating: opponentRating })}

+
+ ) +} diff --git a/apps/front/src/translations/resources/en.json b/apps/front/src/translations/resources/en.json index 9108251e..feaf7f61 100644 --- a/apps/front/src/translations/resources/en.json +++ b/apps/front/src/translations/resources/en.json @@ -12,6 +12,10 @@ "identity-warning": "Ranked progress belongs to this browser identity. Save your recovery phrase before clearing browser data.", "rating": "Rating: {{rating}}", "rating-loading": "Loading rating…", + "match": { + "label": "Ranked · Best of 1", + "opponent-rating": "Opponent rating: {{rating}}" + }, "result": { "rating-change": "Rating: {{before}} → {{after}} ({{change}})", "rating-error": "Your new rating couldn't be loaded.", diff --git a/apps/front/src/translations/resources/fr.json b/apps/front/src/translations/resources/fr.json index 3c40b6be..50de321f 100644 --- a/apps/front/src/translations/resources/fr.json +++ b/apps/front/src/translations/resources/fr.json @@ -12,6 +12,10 @@ "identity-warning": "Votre progression classée appartient à l'identité de ce navigateur. Sauvegardez votre phrase de récupération avant d'effacer ses données.", "rating": "Classement : {{rating}}", "rating-loading": "Chargement du classement…", + "match": { + "label": "Classé · Une manche gagnante", + "opponent-rating": "Classement adverse : {{rating}}" + }, "result": { "rating-change": "Classement : {{before}} → {{after}} ({{change}})", "rating-error": "Impossible de charger votre nouveau classement.", diff --git a/apps/front/src/translations/resources/zh-tw.json b/apps/front/src/translations/resources/zh-tw.json index 04569fa5..7667b995 100644 --- a/apps/front/src/translations/resources/zh-tw.json +++ b/apps/front/src/translations/resources/zh-tw.json @@ -12,6 +12,10 @@ "identity-warning": "排名進度綁定於此瀏覽器的玩家身分。清除瀏覽器資料前,請先保存復原短語。", "rating": "評分:{{rating}}", "rating-loading": "正在載入評分…", + "match": { + "label": "排名對戰 · 一局定勝負", + "opponent-rating": "對手評分:{{rating}}" + }, "result": { "rating-change": "評分:{{before}} → {{after}}({{change}})", "rating-error": "無法載入您的新評分。", diff --git a/tests/e2e/game.spec.ts b/tests/e2e/game.spec.ts index b4b264d6..19225e12 100644 --- a/tests/e2e/game.spec.ts +++ b/tests/e2e/game.spec.ts @@ -232,6 +232,8 @@ test('matches two ranked identities and starts their assigned BO1 room', async ( for (const player of [firstPlayer, secondPlayer]) { await expect(player.getByText('Round 1 of 1')).toBeVisible() + await expect(player.getByText('Ranked · Best of 1')).toBeVisible() + await expect(player.getByText('Opponent rating: 1200')).toBeVisible() await expect( player.getByText('Waiting for game to start...') ).toHaveCount(0) From 553771e5271869c5f3a6a525942ca9f32793e48c Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 14:07:10 +0200 Subject: [PATCH 13/52] feat(ranking): gate matchmaking rollout --- apps/front/src/components/HomePage.tsx | 22 +++++++-- apps/front/src/utils/api.test.ts | 4 ++ apps/front/src/utils/api.ts | 13 ++++++ apps/worker/src/endpoints/index.ts | 1 + apps/worker/src/endpoints/matchmaking.ts | 10 +++++ .../src/endpoints/rankedAvailability.ts | 15 +++++++ .../worker/src/types/cloudflareEnvironment.ts | 1 + apps/worker/src/utils/rankedFeature.ts | 7 +++ apps/worker/src/workers/index.ts | 2 + apps/worker/test/rankedFeature.test.ts | 45 +++++++++++++++++++ apps/worker/test/worker.integration.test.ts | 5 +++ apps/worker/wrangler.toml | 6 +-- packages/common/src/schemas/ranking.ts | 10 ++++- packages/common/src/types/ranking.ts | 4 ++ 14 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 apps/worker/src/endpoints/rankedAvailability.ts create mode 100644 apps/worker/src/utils/rankedFeature.ts create mode 100644 apps/worker/test/rankedFeature.test.ts diff --git a/apps/front/src/components/HomePage.tsx b/apps/front/src/components/HomePage.tsx index 83275541..7365d5a3 100644 --- a/apps/front/src/components/HomePage.tsx +++ b/apps/front/src/components/HomePage.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import { type PlayerType } from '@knucklebones/common' import KnucklebonesLogo from '../svgs/logo.svg' +import { getRankedAvailability } from '../utils/api' import { Button } from './Button' import { Footer } from './Footer' import { GameSettingsModal } from './GameSettings' @@ -10,8 +11,21 @@ import { GameSettingsModal } from './GameSettings' export function HomePage() { const [playerType, setPlayerType] = React.useState() const [isEditingGameSettings, setEditingGameSettings] = React.useState(false) + const [isRankedEnabled, setIsRankedEnabled] = React.useState(false) const { t } = useTranslation() + React.useEffect(() => { + let disposed = false + void getRankedAvailability() + .then(({ enabled }) => { + if (!disposed) setIsRankedEnabled(enabled) + }) + .catch(() => undefined) + return () => { + disposed = true + } + }, []) + function openGameSettings(playerType: PlayerType) { setEditingGameSettings(true) setPlayerType(playerType) @@ -31,9 +45,11 @@ export function HomePage() {
- + {isRankedEnabled && ( + + )} -
- ) - } - - return children -} diff --git a/apps/front/src/utils/api.ts b/apps/front/src/utils/api.ts index 4db6810e..27ff9e8b 100644 --- a/apps/front/src/utils/api.ts +++ b/apps/front/src/utils/api.ts @@ -25,6 +25,7 @@ import { getStoredDeviceCredential, getStoredDisplayName } from './identityStorage' +import { ensurePlayerIdentity } from './playerIdentity' type Method = 'GET' | 'POST' | 'DELETE' @@ -34,7 +35,7 @@ interface IdentificationParams { } export async function createPlayer(): Promise { - const response = await sendApiRequest('/players', 'POST') + const response = await sendApiRequest('/players', 'POST', undefined, null) const result = playerIdentityBootstrapSchema.safeParse(await response.json()) if (!result.success) { @@ -259,9 +260,17 @@ async function sendApiRequest( path: string, method: Method, body?: unknown, - credential = getStoredDeviceCredential(), + credential?: string | null, mutationId?: string ) { + if (credential === undefined) { + credential = getStoredDeviceCredential() + if (credential === null) { + await ensurePlayerIdentity() + credential = getStoredDeviceCredential() + } + } + const headers = { Accept: 'application/json', ...(credential !== null && { diff --git a/apps/front/src/utils/playerIdentity.ts b/apps/front/src/utils/playerIdentity.ts index c1245405..997801b5 100644 --- a/apps/front/src/utils/playerIdentity.ts +++ b/apps/front/src/utils/playerIdentity.ts @@ -19,6 +19,8 @@ export { storePendingRecoveryPhrase } from './identityStorage' +let identityInitialization: Promise | undefined + export function getStoredPlayerCredentials(): PlayerCredentials | undefined { const result = playerCredentialsSchema.safeParse({ playerId: getStoredPlayerId(), @@ -31,7 +33,19 @@ export function storePlayerCredentials(credentials: PlayerCredentials): void { storeIdentity(credentials.playerId, credentials.credential) } -export async function ensurePlayerIdentity(): Promise { +export function ensurePlayerIdentity(): Promise { + if (identityInitialization !== undefined) { + return identityInitialization + } + + identityInitialization = initializePlayerIdentity().finally(() => { + identityInitialization = undefined + }) + + return identityInitialization +} + +async function initializePlayerIdentity(): Promise { const storedCredentials = getStoredPlayerCredentials() if (storedCredentials !== undefined) { ensurePlayerDisplayName() From e65a2d6e5feeb5ac8e5107a06c2980d0ad7cb5bd Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 17:14:08 +0200 Subject: [PATCH 18/52] fix(game): wait for identity before opening socket --- .../components/GameContext/useGameSetup.ts | 86 +++++++++++++------ 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/apps/front/src/components/GameContext/useGameSetup.ts b/apps/front/src/components/GameContext/useGameSetup.ts index e1b37e55..9cd7eea7 100644 --- a/apps/front/src/components/GameContext/useGameSetup.ts +++ b/apps/front/src/components/GameContext/useGameSetup.ts @@ -26,6 +26,7 @@ import { } from '../../utils/api' import { getStoredPlayerId } from '../../utils/identityStorage' import { getPlayerFromId, getPlayerSide } from '../../utils/player' +import { ensurePlayerIdentity } from '../../utils/playerIdentity' import { getStoredRankedMatchAssignment } from '../../utils/rankedMatchStorage' import { getWebSocketUrl, preparePlayers } from './utils' @@ -56,20 +57,50 @@ export function useGameSetup() { ) const latestRevision = React.useRef({ roomKey, value: -1 }) const state = useLocation().state as GameSettings | undefined - const playerId = getStoredPlayerId()! + const [playerId, setPlayerId] = React.useState( + () => getStoredPlayerId() ?? undefined + ) + React.useEffect(() => { + if (playerId !== undefined) { + return + } + + let disposed = false + void ensurePlayerIdentity() + .then(({ playerId: nextPlayerId }) => { + if (!disposed) { + setPlayerId(nextPlayerId) + } + }) + .catch((error) => { + if (!disposed) { + setErrorMessage( + error instanceof Error ? error.message : t('identity.error') + ) + } + }) + + return () => { + disposed = true + } + }, [playerId, t]) const getAuthenticatedWebSocketUrl = React.useCallback(async () => { - const { ticket } = await createWebSocketTicket({ roomKey, playerId }) + const { ticket } = await createWebSocketTicket({ + roomKey, + playerId: playerId! + }) return getWebSocketUrl(roomKey, ticket) }, [playerId, roomKey]) const { lastJsonMessage, readyState } = useWebSocket( - getAuthenticatedWebSocketUrl + playerId === undefined ? null : getAuthenticatedWebSocketUrl ) const isGameStateReady = gameState !== null - const playerSide = isGameStateReady - ? getPlayerSide(playerId, gameState) - : 'spectator' + const playerSide = + isGameStateReady && playerId !== undefined + ? getPlayerSide(playerId, gameState) + : 'spectator' const [playerOne, playerTwo] = isGameStateReady ? preparePlayers(playerSide, gameState) : [] @@ -199,7 +230,7 @@ export function useGameSetup() { }, [gameState, navigate, rankedAssignment]) React.useEffect(() => { - if (readyState === ReadyState.OPEN) { + if (readyState === ReadyState.OPEN && playerId !== undefined) { initGame( { roomKey, playerId }, { playerType: 'human', boType: state?.boType } @@ -231,7 +262,7 @@ export function useGameSetup() { const body = { column, dice, - author: playerId + author: playerId! } const previousGameState = gameState @@ -242,11 +273,13 @@ export function useGameSetup() { setGameState(mutatedGameState) - await play({ roomKey, playerId }, { column }).catch((error) => { - setErrorMessage(error.message) - setGameState(previousGameState) - setIsLoading(false) - }) + await play({ roomKey, playerId: playerId! }, { column }).catch( + (error) => { + setErrorMessage(error.message) + setGameState(previousGameState) + setIsLoading(false) + } + ) } } @@ -257,7 +290,7 @@ export function useGameSetup() { // Mouais à voir comment on peut repenser les options ici async function _voteRematch() { - await voteRematch({ roomKey, playerId }).catch((error) => { + await voteRematch({ roomKey, playerId: playerId! }).catch((error) => { setErrorMessage(error.message) }) } @@ -275,27 +308,30 @@ export function useGameSetup() { } async function voteContinueBo() { - await voteRematch({ roomKey, playerId }).catch((error) => { + await voteRematch({ roomKey, playerId: playerId! }).catch((error) => { setErrorMessage(error.message) }) } async function voteContinueIndefinitely() { - await voteRematch({ roomKey, playerId }, { boType: 'indefinite' }).catch( - (error) => { - setErrorMessage(error.message) - } - ) + await voteRematch( + { roomKey, playerId: playerId! }, + { boType: 'indefinite' } + ).catch((error) => { + setErrorMessage(error.message) + }) } async function _updateDisplayName(newDisplayName: string) { if (isEmptyOrBlank(newDisplayName)) { - await deleteDisplayName({ roomKey, playerId }).catch((error) => { - setErrorMessage(error.message) - }) + await deleteDisplayName({ roomKey, playerId: playerId! }).catch( + (error) => { + setErrorMessage(error.message) + } + ) } else { await updateDisplayName( - { roomKey, playerId }, + { roomKey, playerId: playerId! }, { displayName: newDisplayName } ).catch((error) => { setErrorMessage(error.message) @@ -304,7 +340,7 @@ export function useGameSetup() { } // Easy way to do a type guard - if (!isGameStateReady) { + if (!isGameStateReady || playerId === undefined) { return null } From e2f9aa450b61ad9117427f0a52ac070253505e26 Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 17:19:32 +0200 Subject: [PATCH 19/52] test(e2e): wait for background identity setup --- tests/e2e/game.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/e2e/game.spec.ts b/tests/e2e/game.spec.ts index 19225e12..f819ea4d 100644 --- a/tests/e2e/game.spec.ts +++ b/tests/e2e/game.spec.ts @@ -13,6 +13,15 @@ async function waitForHome(page: Page) { await expect( page.getByRole('button', { name: 'Play against an AI' }) ).toBeVisible() + await expect + .poll(() => + page.evaluate( + () => + localStorage.getItem('knucklebones.identity.v1.deviceCredential') !== + null + ) + ) + .toBe(true) const acknowledgeRecovery = page.getByRole('button', { name: "I've saved it" @@ -487,6 +496,7 @@ test('recovers an identity once and rotates its recovery phrase', async ({ target.waitForEvent('load'), target.getByRole('button', { name: 'Recover this identity' }).click() ]) + await target.getByRole('button', { name: 'Transfer identity' }).click() await expect( target.getByLabel('Player identity recovery phrase') ).toHaveValue(/^knucklebones-recovery-v1\./) From 39cb51ff75d0dd2187f3ee292a72fdd939df5068 Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 17:22:51 +0200 Subject: [PATCH 20/52] fix(home): prevent ranked button layout shift --- apps/front/src/components/HomePage.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/front/src/components/HomePage.tsx b/apps/front/src/components/HomePage.tsx index 7365d5a3..7d19ed4c 100644 --- a/apps/front/src/components/HomePage.tsx +++ b/apps/front/src/components/HomePage.tsx @@ -45,11 +45,18 @@ export function HomePage() {
- {isRankedEnabled && ( - - )} +
diff --git a/apps/front/src/components/GameSettings/GameSettingsModal.tsx b/apps/front/src/components/GameSettings/GameSettingsModal.tsx index 5bde88e1..d2fd7e89 100644 --- a/apps/front/src/components/GameSettings/GameSettingsModal.tsx +++ b/apps/front/src/components/GameSettings/GameSettingsModal.tsx @@ -7,6 +7,7 @@ import { type Difficulty, type PlayerType } from '@knucklebones/common' +import { useLocalizedPath } from '../../hooks/useLocalizedPath' import { Button } from '../Button' import { Modal, type ModalProps } from '../Modal' import { type Option, ToggleGroup } from '../ToggleGroup' @@ -59,6 +60,7 @@ export function GameSettingsModal({ const [difficulty, setDifficulty] = React.useState('medium') const [boType, setBoType] = React.useState('indefinite') const { t } = useTranslation() + const localizedPath = useLocalizedPath() return ( @@ -81,7 +83,7 @@ export function GameSettingsModal({ - diff --git a/apps/front/src/components/Language.tsx b/apps/front/src/components/Language.tsx index 11707adf..2ff28747 100644 --- a/apps/front/src/components/Language.tsx +++ b/apps/front/src/components/Language.tsx @@ -1,28 +1,48 @@ +import type * as React from 'react' import { useTranslation } from 'react-i18next' -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { GlobeAltIcon } from '@heroicons/react/24/outline' import { getPathWithoutLanguage, supportedLanguages } from '../translations' import { Button } from './Button' -function getNextLanguagePath(currentLanguage: string, pathname: string) { +function getNextLanguage(currentLanguage: string) { const currentIndex = supportedLanguages.findIndex(({ value }) => currentLanguage.startsWith(value) ) - const nextLang = - supportedLanguages[(currentIndex + 1) % supportedLanguages.length].value - return `/${nextLang}${getPathWithoutLanguage(pathname)}` + return supportedLanguages[(currentIndex + 1) % supportedLanguages.length] + .value } // https://ui.shadcn.com/docs/components/select ? export function Language() { const { t, i18n } = useTranslation() const { pathname } = useLocation() - const nextLanguagePath = getNextLanguagePath(i18n.language, pathname) + const navigate = useNavigate() + const nextLanguage = getNextLanguage(i18n.language) + const nextLanguagePath = `/${nextLanguage}${getPathWithoutLanguage(pathname)}` + + function changeLanguage(event: React.MouseEvent) { + if ( + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return + } + + event.preventDefault() + document.documentElement.lang = nextLanguage + void i18n.changeLanguage(nextLanguage) + void navigate(nextLanguagePath) + } return ( ), - isInitiallyOpen = false + isInitiallyOpen = false, + onOpen }: React.PropsWithChildren) { const [isModalOpen, setIsModalOpen] = React.useState(isInitiallyOpen) function openModal() { setIsModalOpen(true) + onOpen?.() } return ( diff --git a/tests/e2e/game.spec.ts b/tests/e2e/game.spec.ts index f626e080..8bd30c9e 100644 --- a/tests/e2e/game.spec.ts +++ b/tests/e2e/game.spec.ts @@ -464,6 +464,9 @@ test('transfers an identity between independent browsers', async ({ await waitForHome(source) const sourceIdentity = await readIdentity(source) await source.getByRole('button', { name: 'Transfer identity' }).click() + await expect( + source.getByLabel('Player identity transfer code') + ).toHaveValue(/^knucklebones-transfer-v1\./) await source.getByRole('button', { name: 'Show code' }).click() await expect( source.getByLabel('Player identity transfer code') From 3391eeb98cc89f786bb8884bb530a9e08c41a82c Mon Sep 17 00:00:00 2001 From: Paul Queruel Date: Sun, 2 Aug 2026 18:02:46 +0200 Subject: [PATCH 30/52] fix(identity): align revoke checkboxes --- apps/front/src/components/PlayerIdentityTransfer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/front/src/components/PlayerIdentityTransfer.tsx b/apps/front/src/components/PlayerIdentityTransfer.tsx index 53c2aab3..dd192bf5 100644 --- a/apps/front/src/components/PlayerIdentityTransfer.tsx +++ b/apps/front/src/components/PlayerIdentityTransfer.tsx @@ -260,9 +260,10 @@ export function PlayerIdentityTransfer() { {importError}

)} -