feat: require ranked match acceptance with configurable timeout

This commit is contained in:
2026-10-15 18:59:52 +09:00
parent 3540f392b0
commit c744e8c2a1
14 changed files with 388 additions and 74 deletions
+2
View File
@@ -1,4 +1,6 @@
.lobby-tools { display: flex; gap: 10px; margin-top: 16px; }
.ranked-queue-actions { display: flex; justify-content: center; gap: 12px; }
.ranked-queue .accept-time { font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; }
.lobby-tools button { flex: 1; min-width: 0; min-height: 48px; padding: 10px; font-size: 17px; }
#home .home-actions > button:not(.primary) { min-height: 64px; font-size: 26px; }
#home .home-actions .split { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+50 -11
View File
@@ -29,7 +29,7 @@ export function createLobbyFeatures(options: LobbyOptions) {
tools.innerHTML = '<button id="leaderboard-open" disabled>리더보드</button><button id="appearance-open" disabled>캐릭터·장비</button>';
actions.after(tools);
const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true;
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><h1 role="status">랭킹대전 매칭 중…</h1><button id="ranked-cancel">매칭 취소</button>';
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><h1 role="status">랭킹대전 매칭 중…</h1><p class="accept-status" role="status" hidden></p><p class="accept-time" aria-label="수락 남은 시간" hidden></p><div class="ranked-queue-actions"><button id="ranked-accept" class="primary" hidden>수락</button><button id="ranked-cancel">매칭 취소</button></div>';
document.getElementById('waiting')!.after(queue);
const appearanceDialog = document.createElement('dialog'); appearanceDialog.id = 'appearance-dialog';
appearanceDialog.innerHTML = '<div class="dialog-top"><h2>캐릭터·장비</h2><button class="quiet" aria-label="캐릭터·장비 닫기">닫기</button></div><div class="appearance-layout"><div class="appearance-preview" aria-label="외형 미리보기"></div><div class="appearance-options"><h3 id="skin-label">피부</h3><div class="skin-options" role="group" aria-labelledby="skin-label"></div><h3 id="hair-label">머리</h3><div class="hair-options" role="group" aria-labelledby="hair-label"></div><h3 id="color-label">도복 색상</h3><div class="color-options" role="group" aria-labelledby="color-label"></div><p class="color-condition" role="status"></p><h3 id="equipment-label">장비</h3><div class="equipment-options" role="group" aria-labelledby="equipment-label"></div><h3 id="eyewear-label" class="eyewear-heading">안경</h3><div class="eyewear-options" role="group" aria-labelledby="eyewear-label"></div><button class="equip primary">장착</button></div></div><p class="profile-notice" role="status"></p>';
@@ -42,6 +42,10 @@ export function createLobbyFeatures(options: LobbyOptions) {
const appearanceOpen = document.getElementById('appearance-open') as HTMLButtonElement;
const leaderboardOpen = document.getElementById('leaderboard-open') as HTMLButtonElement;
const cancel = document.getElementById('ranked-cancel') as HTMLButtonElement;
const accept = document.getElementById('ranked-accept') as HTMLButtonElement;
const acceptStatus = select(queue, '.accept-status'), acceptTime = select(queue, '.accept-time');
let confirmationTimer: ReturnType<typeof setTimeout> | undefined;
let confirmationDeadline = 0;
let profile: PlayerProfile | undefined, preview: FighterAppearance = normalizeAppearance(undefined);
let latest: RankedStatus | undefined, active = false, busy = false, profileBusy = false, timer: ReturnType<typeof setTimeout> | undefined, generation = 0;
let profileRevision = 0;
@@ -177,11 +181,27 @@ export function createLobbyFeatures(options: LobbyOptions) {
if (status.match && latest?.match?.matchId === status.match.matchId && status.match.version < latest.match.version) return;
latest = status; updateProfile(status.profile);
active = status.status !== 'idle'; rankedStart.disabled = busy || active;
clearTimeout(confirmationTimer);
accept.hidden = acceptStatus.hidden = acceptTime.hidden = status.status !== 'confirming';
cancel.textContent = status.status === 'confirming' ? '취소' : '매칭 취소';
options.onAvailabilityChange();
if (status.status === 'queued') showQueue('랭킹대전 매칭 중…');
else if (status.status === 'confirming' && status.offer) {
confirmationDeadline = Date.now() + status.offer.expiresAt - status.offer.serverNow;
acceptStatus.textContent = status.offer.accepted ? '상대 수락 대기' : '';
showQueue('매칭 성공!'); updateConfirmation();
}
else if (status.match) options.onMatch(status.match);
else options.onHome();
}
function updateConfirmation() {
clearTimeout(confirmationTimer);
if (!latest?.offer) return;
const remaining = Math.max(0, confirmationDeadline - Date.now());
acceptTime.textContent = `${Math.ceil(remaining / 1000)}초`;
accept.disabled = busy || latest.offer.accepted || remaining === 0;
if (remaining > 0) confirmationTimer = setTimeout(updateConfirmation, RANKED_POLL_MS);
}
function schedule() {
clearTimeout(timer);
if (!active || latest?.match?.phase === 'finished') return;
@@ -197,24 +217,42 @@ export function createLobbyFeatures(options: LobbyOptions) {
}
async function start() {
if (busy || active) return;
busy = true; rankedStart.disabled = true; generation++;
busy = true; rankedStart.disabled = true; cancel.disabled = true;
const currentGeneration = ++generation;
options.onAvailabilityChange();
showQueue('랭킹대전 매칭 중…');
try { consume(await api.request<RankedStatus>('/api/ranked/queue', 'POST', {})); schedule(); }
catch (error) { options.onHome(); options.onError(error); }
finally { busy = false; options.onAvailabilityChange(); }
try {
const status = await api.request<RankedStatus>('/api/ranked/queue', 'POST', {});
if (currentGeneration === generation) { consume(status); schedule(); }
}
catch (error) { if (currentGeneration === generation) { options.onHome(); options.onError(error); } }
finally { if (currentGeneration === generation) { busy = false; cancel.disabled = false; updateConfirmation(); options.onAvailabilityChange(); } }
}
async function leave() {
if (busy) return;
busy = true; cancel.disabled = true; generation++; clearTimeout(timer);
busy = true; cancel.disabled = true; accept.disabled = true; clearTimeout(timer);
const currentGeneration = ++generation;
try {
const status = latest?.match
? await api.request<RankedStatus>('/api/ranked/leave', 'POST', { matchId: latest.match.matchId })
: await api.request<RankedStatus>('/api/ranked/cancel', 'POST', {});
consume(status);
} catch (error) { options.onError(error); }
finally { busy = false; cancel.disabled = false; options.onAvailabilityChange(); schedule(); }
: await api.request<RankedStatus>('/api/ranked/cancel', 'POST', latest?.offer ? { matchId: latest.offer.matchId } : {});
if (currentGeneration === generation) consume(status);
} catch (error) { if (currentGeneration === generation) options.onError(error); }
finally { if (currentGeneration === generation) { busy = false; cancel.disabled = false; updateConfirmation(); options.onAvailabilityChange(); schedule(); } }
}
accept.onclick = async () => {
if (busy || !latest?.offer || latest.offer.accepted || Date.now() >= confirmationDeadline) return;
const matchId = latest.offer.matchId;
busy = true; accept.disabled = true; cancel.disabled = true; clearTimeout(timer);
const currentGeneration = ++generation;
options.onAvailabilityChange();
try {
const status = await api.request<RankedStatus>('/api/ranked/accept', 'POST', { matchId });
if (currentGeneration === generation) consume(status);
}
catch (error) { if (currentGeneration === generation) options.onError(error); }
finally { if (currentGeneration === generation) { busy = false; cancel.disabled = false; updateConfirmation(); options.onAvailabilityChange(); schedule(); } }
};
rankedStart.onclick = () => { void start(); };
cancel.onclick = () => { void leave(); };
return {
@@ -222,7 +260,8 @@ export function createLobbyFeatures(options: LobbyOptions) {
get busy() { return busy; },
setReady(ready: boolean) { rankedStart.disabled = !ready || !profile || busy || active; appearanceOpen.disabled = !ready || !profile || busy || active; leaderboardOpen.disabled = !ready || !profile || busy || active; },
async initialize(restoreRanked: boolean) {
generation++; clearTimeout(timer);
generation++; clearTimeout(timer); clearTimeout(confirmationTimer);
busy = false; cancel.disabled = false;
if (!restoreRanked) { updateProfile(await api.request<PlayerProfile>('/api/profile')); return false; }
const status = await api.request<RankedStatus>('/api/ranked/status');
updateProfile(status.profile); latest = status; active = status.status !== 'idle';
+2 -2
View File
@@ -321,8 +321,8 @@ const invitation = new URLSearchParams(location.search).get('room');
if (invitation && /^[A-Z0-9]{6}$/i.test(invitation)) el<HTMLInputElement>('room-code').value = invitation.toUpperCase();
lobby = createLobbyFeatures({
api,
onQueue: message => { setScreen('ranked-queue'); connection(message, true); },
onMatch: state => showMatch(state),
onQueue: message => { setScreen('ranked-queue'); connection(message, true); notice(); el('reconnect').hidden = true; },
onMatch: state => { el('reconnect').hidden = true; showMatch(state); },
onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; },
onAppearance: appearance => {
el('waiting-fighter').innerHTML = fighterArt(0, false, appearance);
+2 -2
View File
@@ -16,7 +16,7 @@ export function createGlobalSettingsStore(db: Db) {
return {
async initialize() {
await collection.updateOne({ _id: 'global' }, { $setOnInsert: DEFAULT_GLOBAL_SETTINGS }, { upsert: true });
for (const key of ['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs'] as const) {
for (const key of ['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs', 'rankedAcceptTimeoutMs'] as const) {
await collection.updateOne({ _id: 'global', [key]: { $exists: false } }, { $set: { [key]: DEFAULT_GLOBAL_SETTINGS[key] }, $inc: { revision: 1 } });
}
},
@@ -24,7 +24,7 @@ export function createGlobalSettingsStore(db: Db) {
async update(input: unknown) {
if (!isGlobalSettings(input) || Object.keys(input).some(key => !Object.hasOwn(DEFAULT_GLOBAL_SETTINGS, key))) throw new ApiError(400, 'INVALID_SETTINGS');
const doc = await collection.findOneAndUpdate({ _id: 'global', revision: input.revision }, {
$set: { turnDurationMs: input.turnDurationMs, finishCutsceneSpeed: input.finishCutsceneSpeed, matchingDelayMs: input.matchingDelayMs, matchSuccessDelayMs: input.matchSuccessDelayMs, matchPreparationDelayMs: input.matchPreparationDelayMs }, $inc: { revision: 1 },
$set: { turnDurationMs: input.turnDurationMs, finishCutsceneSpeed: input.finishCutsceneSpeed, matchingDelayMs: input.matchingDelayMs, matchSuccessDelayMs: input.matchSuccessDelayMs, matchPreparationDelayMs: input.matchPreparationDelayMs, rankedAcceptTimeoutMs: input.rankedAcceptTimeoutMs }, $inc: { revision: 1 },
}, { returnDocument: 'after' });
if (!doc) throw new ApiError(409, 'SETTINGS_REVISION_CONFLICT');
const { _id, ...settings } = doc;
+9 -1
View File
@@ -17,12 +17,20 @@ export function registerRankedApi(app: FastifyInstance, sessions: GameSessionSto
return service.updateProfile(playerId, request.body as { appearance?: FighterAppearance });
});
gameRoute(app, 'GET', '/api/ranked/leaderboard', async request => service.leaderboard((await identity(request)).playerId));
for (const [url, method] of [['status', 'GET'], ['queue', 'POST'], ['cancel', 'POST']] as const) {
for (const [url, method] of [['status', 'GET'], ['queue', 'POST']] as const) {
gameRoute(app, method, `/api/ranked/${url}`, async request => {
const { playerId, gameSessionId } = await identity(request);
return service[url === 'queue' ? 'join' : url](playerId, gameSessionId);
});
}
gameRoute(app, 'POST', '/api/ranked/cancel', async request => {
const { playerId, gameSessionId } = await identity(request);
return service.cancel(playerId, gameSessionId, (request.body as { matchId?: string } | undefined)?.matchId);
});
gameRoute(app, 'POST', '/api/ranked/accept', async request => {
const { playerId, gameSessionId } = await identity(request);
return service.accept(playerId, gameSessionId, (request.body as { matchId: string } | undefined)?.matchId!);
});
gameRoute(app, 'POST', '/api/ranked/action', async request => {
const { playerId, gameSessionId } = await identity(request);
const body = request.body as { matchId: string; turnId: number; action: ActionId } | undefined;
+77 -20
View File
@@ -15,7 +15,6 @@ import type { ActionId, ModeStats, Pair } from '../shared/types.js';
import { systemClock, type Clock } from './clock.js';
import { ApiError, validId } from './game-session-store.js';
import { createGlobalSettingsStore } from './global-settings.js';
import { matchIntroductionDuration } from '../shared/match-introduction.js';
import { createEmotes } from '../shared/emotes.js';
interface ProfileDocument {
@@ -25,8 +24,9 @@ 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 QueueEntry { playerId: string; gameSessionId: string; expiresAt: number; previousMatchId?: string }
interface RankedMatch {
confirmation?: { expiresAt: number; accepted: Pair<boolean>; sessions: Pair<string> };
emotes: ReturnType<typeof createEmotes>;
emoteVersion: number;
id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; engine: MatchEngine;
@@ -181,7 +181,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
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
const at = match.confirmation ? match.confirmation.expiresAt : 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;
@@ -195,6 +195,11 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
}
async function advance(match: RankedMatch) {
try {
if (!matches.has(match)) return;
if (match.confirmation) {
if (clock.now() >= match.confirmation.expiresAt) await cancelConfirmation(match);
return;
}
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) {
@@ -211,11 +216,15 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
} finally { schedule(match); }
}
async function statusView(playerId: string): Promise<RankedStatus> {
const match = byPlayer.get(playerId);
let match = byPlayer.get(playerId);
if (match) await advance(match);
match = byPlayer.get(playerId);
const profile = await readProfile(playerId);
if (!match) return { status: queue.has(playerId) ? 'queued' : 'idle', profile };
const selfIndex = match.playerIds[0] === playerId ? 0 : 1;
if (match.confirmation) return { status: 'confirming', profile, offer: {
matchId: match.id, expiresAt: match.confirmation.expiresAt, serverNow: clock.now(), accepted: match.confirmation.accepted[selfIndex],
} };
const state = match.engine.view();
if (state.finished) {
state.finished.stats.friend = structuredClone(profile.stats);
@@ -238,6 +247,40 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
if (!match || match.id !== matchId) throw new ApiError(409, 'NO_ACTIVE_MATCH');
return match;
}
async function pairQueued() {
expireQueue();
let candidate: QueueEntry | undefined;
for (const entry of queue.values()) {
try { await identity(entry.playerId, entry.gameSessionId); await freeForRanked(entry.playerId); }
catch (error) {
if (!(error instanceof ApiError)) throw error;
queue.delete(entry.playerId); continue;
}
if (!candidate) { candidate = entry; continue; }
const first = await readProfile(candidate.playerId), second = await readProfile(entry.playerId);
const settings = await createGlobalSettingsStore(db).get();
const id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock, settings });
const match: RankedMatch = { id, engine, emotes: createEmotes(), emoteVersion: 0, playerIds: [candidate.playerId, entry.playerId],
appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false,
confirmation: { expiresAt: clock.now() + settings.rankedAcceptTimeoutMs, accepted: [false, false], sessions: [candidate.gameSessionId, entry.gameSessionId] } };
matches.add(match);
for (const playerId of match.playerIds) { queue.delete(playerId); byPlayer.set(playerId, match); }
schedule(match); candidate = undefined;
}
}
async function cancelConfirmation(match: RankedMatch, cancelledBy?: string) {
const confirmation = match.confirmation!;
if (match.timer !== undefined) clock.clearTimeout(match.timer);
matches.delete(match);
for (const slot of [0, 1] as const) {
const playerId = match.playerIds[slot];
byPlayer.delete(playerId);
if (confirmation.accepted[slot] && playerId !== cancelledBy) queue.set(playerId, {
playerId, gameSessionId: confirmation.sessions[slot], expiresAt: clock.now() + RANKED_QUEUE_TTL_MS, previousMatchId: match.id,
});
}
await pairQueued();
}
return {
async profile(playerId: string) {
if (closed) throw new ApiError(503, 'RANKED_CLOSED');
@@ -319,28 +362,38 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
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, settings: await createGlobalSettingsStore(db).get() });
const match: RankedMatch = { id, engine, emotes: createEmotes(), emoteVersion: 0, playerIds: [candidate.playerId, playerId],
appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false };
engine.start(clock.now() + matchIntroductionDuration(engine.view().settings, true)); matches.add(match);
for (const id of match.playerIds) { queue.delete(id); byPlayer.set(id, match); }
schedule(match); break;
await pairQueued();
return statusView(playerId);
});
},
accept(playerId: string, gameSessionId: string, matchId: string): Promise<RankedStatus> {
return serial(async () => {
await identity(playerId, gameSessionId);
if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
const match = byPlayer.get(playerId);
if (!match || match.id !== matchId) return statusView(playerId);
await advance(match);
if (byPlayer.get(playerId) !== match || !match.confirmation) return statusView(playerId);
const slot = match.playerIds[0] === playerId ? 0 : 1;
match.confirmation.accepted[slot] = true;
match.confirmation.sessions[slot] = gameSessionId;
if (match.confirmation.accepted.every(Boolean)) {
match.confirmation = undefined;
match.engine.start(clock.now()); schedule(match);
}
return statusView(playerId);
});
},
cancel(playerId: string, gameSessionId: string): Promise<RankedStatus> {
cancel(playerId: string, gameSessionId: string, matchId?: string): Promise<RankedStatus> {
return serial(async () => {
await identity(playerId, gameSessionId); expireQueue();
if (byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING');
if (matchId !== undefined && !validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
const match = byPlayer.get(playerId);
if (matchId && match?.id !== matchId && queue.get(playerId)?.previousMatchId !== matchId) return statusView(playerId);
if (match) {
if (match.confirmation) await cancelConfirmation(match, playerId);
return statusView(playerId);
}
queue.delete(playerId); return statusView(playerId);
});
},
@@ -348,6 +401,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
return serial(async () => {
await identity(playerId, gameSessionId);
const match = memberMatch(playerId, matchId); await advance(match);
if (match.confirmation || byPlayer.get(playerId) !== match) throw new ApiError(409, 'NO_ACTIVE_MATCH');
if (match.engine.view().phase === 'finished' || match.departed.size) throw new ApiError(409, 'NO_ACTIVE_MATCH');
const slot = match.playerIds[0] === playerId ? 0 : 1;
if (!match.emotes.send(slot, id, clock.now())) throw new ApiError(400, 'INVALID_REQUEST');
@@ -359,6 +413,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
return serial(async () => {
await identity(playerId, gameSessionId);
const match = memberMatch(playerId, matchId); await advance(match);
if (match.confirmation || byPlayer.get(playerId) !== match) throw new ApiError(409, 'NO_ACTIVE_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');
@@ -373,6 +428,8 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
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 (byPlayer.get(playerId) !== match) return statusView(playerId);
if (match.confirmation) { await cancelConfirmation(match, playerId); return statusView(playerId); }
if (match.engine.view().phase !== 'finished') {
match.engine.finish(match.playerIds[0] === playerId ? 1 : 0, 'forfeit');
await advance(match);
+2 -2
View File
@@ -1,11 +1,11 @@
import { GLOBAL_SETTINGS_LIMITS } from '../../constants/global-settings.js';
export interface GlobalSettings { revision: number; turnDurationMs: number; finishCutsceneSpeed: number; matchingDelayMs: number; matchSuccessDelayMs: number; matchPreparationDelayMs: number }
export interface GlobalSettings { revision: number; turnDurationMs: number; finishCutsceneSpeed: number; matchingDelayMs: number; matchSuccessDelayMs: number; matchPreparationDelayMs: number; rankedAcceptTimeoutMs: number }
export function isGlobalSettings(value: unknown): value is GlobalSettings {
if (!value || typeof value !== 'object') return false;
const s = value as GlobalSettings, limits = GLOBAL_SETTINGS_LIMITS;
return Number.isSafeInteger(s.revision) && s.revision >= 1
&& Number.isSafeInteger(s.turnDurationMs) && s.turnDurationMs >= limits.turnDurationMs.min && s.turnDurationMs <= limits.turnDurationMs.max
&& Number.isFinite(s.finishCutsceneSpeed) && s.finishCutsceneSpeed >= limits.finishCutsceneSpeed.min && s.finishCutsceneSpeed <= limits.finishCutsceneSpeed.max
&& (['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs'] as const).every(key => Number.isSafeInteger(s[key]) && s[key] >= limits[key].min && s[key] <= limits[key].max);
&& (['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs', 'rankedAcceptTimeoutMs'] as const).every(key => Number.isSafeInteger(s[key]) && s[key] >= limits[key].min && s[key] <= limits[key].max);
}
+2 -1
View File
@@ -12,8 +12,9 @@ export interface PlayerProfile {
unlockedEquipment: EquipmentId[];
}
export interface RankedStatus {
status: 'idle' | 'queued' | 'matched';
status: 'idle' | 'queued' | 'confirming' | 'matched';
profile: PlayerProfile;
offer?: { matchId: string; expiresAt: number; serverNow: number; accepted: boolean };
match?: LocalMatchState;
}
export interface LeaderboardEntry {