import { randomInt, randomUUID } from 'node:crypto'; import type { Db } from 'mongodb'; import { COSMETIC_COLORS, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES } from '../../constants/cosmetics.js'; import { PROFILE_NAME_NOUNS, PROFILE_NAME_PREFIXES, PROFILE_NAME_SUFFIX_MAX, PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_VERSION, RANKED_FINISHED_TTL_MS, RANKED_LEADERBOARD_LIMIT, RANKED_QUEUE_TTL_MS, RANKED_SAVE_RETRY_MS, } from '../../constants/ranked.js'; import { availableColorIds, availableEquipmentIds, DEFAULT_APPEARANCE, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js'; import { createMatchEngine, type MatchEngine } from '../shared/match-engine.js'; import { emptyPlayerStats, type LocalEndReason } from '../shared/p2p-types.js'; import type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js'; import { isLegalAction } from '../shared/rules.js'; import type { ActionId, ModeStats, Pair } from '../shared/types.js'; import { systemClock, type Clock } from './clock.js'; import { ApiError, validId } from './game-session-store.js'; interface ProfileDocument { _id: string; displayName: string; nameVersion?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date; } interface TerminalMatch { _id: string; playerIds: Pair; appearances: Pair; winnerIndex: 0 | 1 | null; reason: LocalEndReason; order: number; completedAt: Date; environment: string; } interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number } interface RankedMatch { id: string; playerIds: Pair; appearances: Pair; engine: MatchEngine; actions: Pair; departed: Set; timer?: ReturnType; terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair; } const emptyStats = (): ModeStats => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 }); function generatedName(): string { return `${PROFILE_NAME_PREFIXES[randomInt(PROFILE_NAME_PREFIXES.length)]}${PROFILE_NAME_NOUNS[randomInt(PROFILE_NAME_NOUNS.length)]}-${randomInt(PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_SUFFIX_MAX)}`; } // One service owns the queue and all match clocks in the established single-process server. export async function createRankedService(db: Db, clock: Clock = systemClock, environment = 'production') { const profiles = db.collection('player_profiles'); const results = db.collection('ranked_matches'); // Finish migrating every legacy name before any profile or leaderboard can be served. // The version guard makes interrupted migrations/restarts preserve assigned names. for await (const profile of profiles.find({ nameVersion: { $ne: PROFILE_NAME_VERSION } })) { await profiles.updateOne({ _id: profile._id, nameVersion: { $ne: PROFILE_NAME_VERSION } }, { $set: { displayName: generatedName(), nameVersion: PROFILE_NAME_VERSION, updatedAt: new Date(clock.now()), } }); } await results.createIndex({ playerIds: 1, order: 1 }); let resultOrder = (await results.find().sort({ order: -1 }).limit(1).next())?.order ?? 0; const queue = new Map(); const byPlayer = new Map(); const matches = new Set(); let tail: Promise = Promise.resolve(), closed = false; function serial(work: () => Promise): Promise { const next = tail.then(() => { if (closed) throw new ApiError(503, 'RANKED_CLOSED'); expireQueue(); expireFinished(); return work(); }); tail = next.catch(() => {}); return next; } function expireQueue() { for (const [playerId, entry] of queue) if (entry.expiresAt <= clock.now()) queue.delete(playerId); } function expireFinished() { for (const match of matches) { if (!match.saved || !match.finishedExpiresAt) continue; for (const slot of [0, 1] as const) if (match.finishedExpiresAt[slot] <= clock.now()) { const playerId = match.playerIds[slot]; if (byPlayer.get(playerId) === match) byPlayer.delete(playerId); match.departed.add(playerId); } if (match.departed.size === 2) { if (match.timer !== undefined) clock.clearTimeout(match.timer); matches.delete(match); } } } async function identity(playerId: string, gameSessionId: string) { if (!validId(playerId) || !validId(gameSessionId) || !await db.collection('game_sessions').findOne({ _id: gameSessionId, playerId, expiresAt: { $gt: new Date(clock.now()) }, })) throw new ApiError(401, 'INVALID_SESSION'); } async function freeForRanked(playerId: string) { const sessions = await db.collection('game_sessions').find({ playerId, expiresAt: { $gt: new Date(clock.now()) } }).toArray(); if (sessions.some(session => session.activeMatch) || await db.collection('rooms').findOne({ members: { $in: sessions.map(session => session._id) }, open: true, expiresAt: { $gt: new Date(clock.now()) }, })) throw new ApiError(409, 'MATCH_PENDING'); } function addResult(stats: ModeStats, result: TerminalMatch, playerId: string) { if (result.winnerIndex === null) stats.draws++; else if (result.playerIds[result.winnerIndex] === playerId) { stats.wins++; stats.current++; stats.best = Math.max(stats.best, stats.current); } else { stats.losses++; stats.current = 0; } } async function readProfile(playerId: string): Promise { if (!validId(playerId)) throw new ApiError(400, 'INVALID_PROFILE'); const now = new Date(clock.now()); await profiles.updateOne({ _id: playerId }, { $setOnInsert: { displayName: generatedName(), nameVersion: PROFILE_NAME_VERSION, appearance: { ...DEFAULT_APPEARANCE }, environment, createdAt: now, updatedAt: now, } }, { upsert: true }); const doc = (await profiles.findOne({ _id: playerId }))!; const stats = emptyStats(); for await (const result of results.find({ playerIds: playerId }).sort({ order: 1, _id: 1 })) addResult(stats, result, playerId); return { playerId, displayName: doc.displayName, appearance: normalizeAppearance(doc.appearance), stats, unlockedColors: availableColorIds(stats), unlockedEquipment: availableEquipmentIds(stats) }; } async function persist(match: RankedMatch) { if (match.saved) return; const state = match.engine.view(); if (!state.finished || state.aborted) return; // Immutable per-match record is the only source of ranked stats/rewards. A retry after // an ambiguous Mongo response cannot award either player twice, even on standalone Mongo. match.terminal ??= { _id: match.id, playerIds: match.playerIds, appearances: match.appearances, winnerIndex: state.finished.winnerIndex, reason: state.finished.reason, order: ++resultOrder, completedAt: new Date(clock.now()), environment }; try { await results.updateOne({ _id: match.id }, { $setOnInsert: match.terminal }, { upsert: true }); match.saved = true; match.engine.saved(emptyPlayerStats()); } catch (error) { match.engine.saveFailed(); throw error; } } function schedule(match: RankedMatch) { if (match.timer !== undefined) clock.clearTimeout(match.timer); match.timer = undefined; if (closed || !matches.has(match)) return; const state = match.engine.view(); if (state.aborted) return; if (state.phase === 'finished' && match.saved && match.departed.size === 2) return; const at = state.phase === 'countdown' ? state.startsAt : state.phase === 'selecting' ? state.deadline : state.phase === 'revealing' ? state.reveal!.revealEndsAt : match.saved ? Math.min(...match.playerIds.map((id, slot) => match.departed.has(id) ? Infinity : match.finishedExpiresAt![slot])) : clock.now() + RANKED_SAVE_RETRY_MS; match.timer = clock.setTimeout(() => { match.timer = undefined; // advance schedules persistence retries itself; don't leak an unhandled timer rejection. void serial(() => advance(match)).catch(() => {}); }, Math.max(0, at - clock.now())); } async function advance(match: RankedMatch) { try { let state = match.engine.view(); if (state.phase === 'countdown' && clock.now() >= state.startsAt) match.engine.beginTurn(state.startsAt); if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) { match.actions = [null, null]; match.engine.beginTurn(state.reveal!.revealEndsAt); } state = match.engine.view(); if (state.phase === 'selecting' && clock.now() >= state.deadline) match.engine.resolve(match.actions); if (match.engine.view().phase === 'finished') { match.finishedExpiresAt ??= [clock.now() + RANKED_FINISHED_TTL_MS, clock.now() + RANKED_FINISHED_TTL_MS]; await persist(match); } } finally { schedule(match); } } async function statusView(playerId: string): Promise { const match = byPlayer.get(playerId); if (match) await advance(match); const profile = await readProfile(playerId); if (!match) return { status: queue.has(playerId) ? 'queued' : 'idle', profile }; const selfIndex = match.playerIds[0] === playerId ? 0 : 1; const state = match.engine.view(); if (state.finished) { state.finished.stats.friend = structuredClone(profile.stats); match.finishedExpiresAt![selfIndex] = clock.now() + RANKED_FINISHED_TTL_MS; schedule(match); } state.selfIndex = selfIndex; state.ownAction = match.actions[selfIndex]; // Own submissions change this participant's version without revealing whether the // opponent has locked a choice. Engine transitions always move past either value. state.version = state.version * 2 + (state.ownAction === null ? 0 : 1); state.ranked = true; state.appearances = structuredClone(match.appearances); state.opponentLeft = match.departed.has(match.playerIds[1 - selfIndex]); state.opponentPresent = !state.opponentLeft; return { status: 'matched', profile, match: state }; } function memberMatch(playerId: string, matchId: string) { if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST'); const match = byPlayer.get(playerId); if (!match || match.id !== matchId) throw new ApiError(409, 'NO_ACTIVE_MATCH'); return match; } return { async profile(playerId: string) { if (closed) throw new ApiError(503, 'RANKED_CLOSED'); return readProfile(playerId); }, updateProfile(playerId: string, input: { appearance?: FighterAppearance }): Promise { return serial(async () => { if (!input || typeof input !== 'object' || Array.isArray(input)) throw new ApiError(400, 'INVALID_PROFILE'); if (Object.hasOwn(input, 'displayName')) throw new ApiError(403, 'PROFILE_NAME_READONLY'); const profile = await readProfile(playerId); const update: Partial = {}; if (input.appearance !== undefined) { const appearance = input.appearance; if (!appearance || typeof appearance !== 'object' || Array.isArray(appearance) || (appearance.characterId !== 0 && appearance.characterId !== 1) || typeof appearance.colorId !== 'string' || !COSMETIC_COLORS.some(color => color.id === appearance.colorId)) throw new ApiError(400, 'INVALID_APPEARANCE'); if (Object.hasOwn(appearance, 'hairStyle') && appearance.hairStyle !== 'default' && !HAIR_STYLES.some(hair => hair.id === appearance.hairStyle)) throw new ApiError(400, 'INVALID_APPEARANCE'); if (Object.hasOwn(appearance, 'skinTone') && !SKIN_TONES.some(tone => tone.id === appearance.skinTone)) throw new ApiError(400, 'INVALID_APPEARANCE'); if (Object.hasOwn(appearance, 'equipment') && (!Array.isArray(appearance.equipment) || new Set(appearance.equipment).size !== appearance.equipment.length || [...appearance.equipment].some(id => !EQUIPMENT_IDS.includes(id)))) throw new ApiError(400, 'INVALID_APPEARANCE'); if (EYEWEAR_IDS.filter(id => appearance.equipment?.includes(id)).length > 1) throw new ApiError(400, 'INVALID_APPEARANCE'); if (!availableColorIds(profile.stats).includes(appearance.colorId)) throw new ApiError(403, 'COLOR_LOCKED'); // Older clients only send character/color; keep their current skin, hair and equipment. const next = normalizeAppearance({ ...profile.appearance, ...appearance }); const unlockedEquipment = availableEquipmentIds(profile.stats); if (next.equipment!.some(id => !unlockedEquipment.includes(id))) throw new ApiError(403, 'EQUIPMENT_LOCKED'); update.appearance = next; } if (Object.keys(update).length) await profiles.updateOne({ _id: playerId }, { $set: { ...update, updatedAt: new Date(clock.now()) } }); return readProfile(playerId); }); }, async leaderboard(playerId: string): Promise { if (closed) throw new ApiError(503, 'RANKED_CLOSED'); await readProfile(playerId); const totals = new Map(); for await (const result of results.find().sort({ order: 1, _id: 1 })) for (const id of result.playerIds) { const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats); } const entries = (await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(profile => ({ playerId: profile._id, displayName: profile.displayName, wins: totals.get(profile._id)?.wins ?? 0, best: totals.get(profile._id)?.best ?? 0, })).sort((a, b) => b.best - a.best || b.wins - a.wins || (a.playerId < b.playerId ? -1 : a.playerId > b.playerId ? 1 : 0)) .map((entry, index) => ({ rank: index + 1, ...entry })); return { entries: entries.slice(0, RANKED_LEADERBOARD_LIMIT), self: entries.find(entry => entry.playerId === playerId) }; }, assertAvailable(playerId: string): Promise { return serial(async () => { expireQueue(); if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING'); }); }, withAvailable(playerId: string, work: () => Promise): Promise { return serial(async () => { if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING'); return work(); }); }, status(playerId: string, gameSessionId: string): Promise { return serial(async () => { await identity(playerId, gameSessionId); expireQueue(); const entry = queue.get(playerId); if (entry) { entry.gameSessionId = gameSessionId; entry.expiresAt = clock.now() + RANKED_QUEUE_TTL_MS; } return statusView(playerId); }); }, join(playerId: string, gameSessionId: string): Promise { return serial(async () => { await identity(playerId, gameSessionId); expireQueue(); if (byPlayer.has(playerId)) return statusView(playerId); await freeForRanked(playerId); await readProfile(playerId); queue.set(playerId, { playerId, gameSessionId, expiresAt: clock.now() + RANKED_QUEUE_TTL_MS }); for (const candidate of queue.values()) { if (candidate.playerId === playerId) continue; try { await identity(candidate.playerId, candidate.gameSessionId); await freeForRanked(candidate.playerId); } catch (error) { if (!(error instanceof ApiError)) throw error; queue.delete(candidate.playerId); continue; } const first = await readProfile(candidate.playerId), second = await readProfile(playerId); const id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock }); const match: RankedMatch = { id, engine, playerIds: [candidate.playerId, playerId], appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false }; engine.start(); matches.add(match); for (const id of match.playerIds) { queue.delete(id); byPlayer.set(id, match); } schedule(match); break; } return statusView(playerId); }); }, cancel(playerId: string, gameSessionId: string): Promise { return serial(async () => { await identity(playerId, gameSessionId); expireQueue(); if (byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING'); queue.delete(playerId); return statusView(playerId); }); }, action(playerId: string, gameSessionId: string, matchId: string, turnId: number, action: ActionId): Promise { return serial(async () => { await identity(playerId, gameSessionId); const match = memberMatch(playerId, matchId); await advance(match); const state = match.engine.view(), slot = match.playerIds[0] === playerId ? 0 : 1; if (!Number.isSafeInteger(turnId) || state.phase !== 'selecting' || turnId !== state.turnId || clock.now() < state.startsAt || clock.now() >= state.deadline || !isLegalAction(action, state.energy[slot])) throw new ApiError(400, 'INVALID_ACTION'); if (match.actions[slot] !== null) throw new ApiError(409, 'ACTION_LOCKED'); match.actions[slot] = action; return statusView(playerId); }); }, leave(playerId: string, gameSessionId: string, matchId: string): Promise { return serial(async () => { await identity(playerId, gameSessionId); if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST'); if (!byPlayer.has(playerId) && await results.findOne({ _id: matchId, playerIds: playerId })) return statusView(playerId); const match = memberMatch(playerId, matchId); await advance(match); if (match.engine.view().phase !== 'finished') { match.engine.finish(match.playerIds[0] === playerId ? 1 : 0, 'forfeit'); await advance(match); } match.departed.add(playerId); byPlayer.delete(playerId); if (match.departed.size === 2) matches.delete(match); schedule(match); return statusView(playerId); }); }, async close(): Promise { if (closed) return; await serial(async () => { closed = true; queue.clear(); for (const match of matches) { if (match.timer !== undefined) clock.clearTimeout(match.timer); // Unfinished matches are deliberately not recovered or awarded after shutdown. if (match.engine.view().phase !== 'finished') match.engine.abort('connection_failed'); } byPlayer.clear(); matches.clear(); }); }, }; } export type RankedService = Awaited>;