320 lines
18 KiB
TypeScript
320 lines
18 KiB
TypeScript
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<string>; appearances: Pair<FighterAppearance>; 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<string>; appearances: Pair<FighterAppearance>; engine: MatchEngine;
|
|
actions: Pair<ActionId | null>; departed: Set<string>; timer?: ReturnType<Clock['setTimeout']>;
|
|
terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair<number>;
|
|
}
|
|
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<ProfileDocument>('player_profiles');
|
|
const results = db.collection<TerminalMatch>('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<string, QueueEntry>();
|
|
const byPlayer = new Map<string, RankedMatch>();
|
|
const matches = new Set<RankedMatch>();
|
|
let tail: Promise<unknown> = Promise.resolve(), closed = false;
|
|
function serial<T>(work: () => Promise<T>): Promise<T> {
|
|
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<any>('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<any>('game_sessions').find({ playerId, expiresAt: { $gt: new Date(clock.now()) } }).toArray();
|
|
if (sessions.some(session => session.activeMatch) || await db.collection<any>('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<PlayerProfile> {
|
|
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<RankedStatus> {
|
|
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<PlayerProfile> {
|
|
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<ProfileDocument> = {};
|
|
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<Leaderboard> {
|
|
if (closed) throw new ApiError(503, 'RANKED_CLOSED');
|
|
await readProfile(playerId);
|
|
const totals = new Map<string, ModeStats>();
|
|
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<void> {
|
|
return serial(async () => { expireQueue(); if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING'); });
|
|
},
|
|
withAvailable<T>(playerId: string, work: () => Promise<T>): Promise<T> {
|
|
return serial(async () => {
|
|
if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING');
|
|
return work();
|
|
});
|
|
},
|
|
status(playerId: string, gameSessionId: string): Promise<RankedStatus> {
|
|
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<RankedStatus> {
|
|
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<RankedStatus> {
|
|
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<RankedStatus> {
|
|
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<RankedStatus> {
|
|
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<void> {
|
|
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<ReturnType<typeof createRankedService>>;
|