Initial commit: ssessesse ki battle
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
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>>;
|
||||
Reference in New Issue
Block a user