feat: require ranked match acceptance with configurable timeout
This commit is contained in:
@@ -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)); }
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user