Initial commit: ssessesse ki battle

This commit is contained in:
2026-10-13 11:15:26 +09:00
commit 4f24129e29
104 changed files with 10520 additions and 0 deletions
+319
View File
@@ -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>>;