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 -2
View File
@@ -39,9 +39,9 @@ Fastify HTTP API·페이지·Socket.IO `/signal`은 한 앱 포트와 MongoDB
### 글로벌 설정 ### 글로벌 설정
MongoDB `global_settings`의 `_id: "global"` 문서에 `revision`, `turnDurationMs`(기본 3000), `finishCutsceneSpeed`(기본 1), `matchingDelayMs`(기본 1000), `matchSuccessDelayMs`(기본 1000), `matchPreparationDelayMs`(기본 3000)를 저장한다. 랭킹은 매칭 중 → 성공 → 준비 중, 친구전은 성공 → 준비 중 순서로 표시한다. 준비 화면은 `matchPreparationDelayMs`만큼 표시하며 안내 지연이 첫 턴 선택 시간을 줄이지 않는다. 컷씬 배속 2는 장면 전환과 애니메이션을 두 배 빠르게 재생한다. 컷씬 종료 후 결과 화면으로 자동 전환하며, 최소 결과 공개 시간(1.2초)을 지킨다. MongoDB `global_settings`의 `_id: "global"` 문서에 `revision`, `turnDurationMs`(기본 3000), `finishCutsceneSpeed`(기본 1), `matchingDelayMs`(기본 1000), `matchSuccessDelayMs`(기본 1000), `matchPreparationDelayMs`(기본 3000), `rankedAcceptTimeoutMs`(기본 10000)를 저장한다. 랭킹은 매칭 중 → 수락 대기 → 양쪽 수락 시 준비 중, 친구전은 성공 → 준비 중 순서로 표시한다. 랭킹 수락 대기는 매칭 시점의 설정값을 사용한다. 취소·시간 초과 시 승패 기록 없이 매칭을 해제하고, 수락한 상대만 자동 재매칭한다. 준비 화면은 `matchPreparationDelayMs`만큼 표시하며 안내 지연이 첫 턴 선택 시간을 줄이지 않는다. 컷씬 배속 2는 장면 전환과 애니메이션을 두 배 빠르게 재생한다. 컷씬 종료 후 결과 화면으로 자동 전환하며, 최소 결과 공개 시간(1.2초)을 지킨다.
기존 관리자 Basic 인증으로 `GET /api/admin/global-settings`를 조회하고, 현재 `revision`과 설정값 전체을 `POST /api/admin/global-settings`에 보낸다. 예: `{"revision":1,"turnDurationMs":3000,"finishCutsceneSpeed":2,"matchingDelayMs":1000,"matchSuccessDelayMs":1000,"matchPreparationDelayMs":3000}`. 저장 시 리비전이 자동 증가하며, 다른 관리자가 먼저 변경했다면 409를 반환한다. 선택 시간은 500~60000ms 정수, 컷씬 배속은 0.25~4 범위다. 매칭 중·성공·준비 지연은 각각 0~10000ms 정수다. 기존 문서의 누락 필드는 서버 시작 시 기본값으로 추가된다. DB에서 직접 변경할 때도 값의 범위를 지키고 같은 원자적 업데이트에서 `revision`을 1 증가시킨다. 기존 관리자 Basic 인증으로 `GET /api/admin/global-settings`를 조회하고, 현재 `revision`과 설정값 전체을 `POST /api/admin/global-settings`에 보낸다. 예: `{"revision":1,"turnDurationMs":3000,"finishCutsceneSpeed":2,"matchingDelayMs":1000,"matchSuccessDelayMs":1000,"matchPreparationDelayMs":3000,"rankedAcceptTimeoutMs":10000}`. 저장 시 리비전이 자동 증가하며, 다른 관리자가 먼저 변경했다면 409를 반환한다. 선택 시간은 500~60000ms 정수, 컷씬 배속은 0.25~4 범위다. 매칭 중·성공·준비 지연은 각각 0~10000ms 정수다. 랭킹 수락 대기는 1000~60000ms 정수다. 기존 문서의 누락 필드는 서버 시작 시 기본값으로 추가된다. DB에서 직접 변경할 때도 값의 범위를 지키고 같은 원자적 업데이트에서 `revision`을 1 증가시킨다.
설정 변경은 서버 재시작 없이 다음 대전에 적용된다. 친구전은 페어링·재대전 시 같은 설정을 방에 저장하고, 봇·랭킹 대전도 생성 시 설정을 확정한다. 진행 중 대전과 새로고침 복구는 기존 대전의 설정을 유지한다. 브라우저는 최초 로딩·창 포커스/가시성 복귀·대전 시작 시 `GET /api/global-settings?revision=...`로 확인하고, 변경된 경우만 `localStorage` 캐시를 갱신한다. 조회 실패 시 유효한 캐시 또는 기본값을 유지하며 실제 대전에는 서버가 확정한 설정을 사용한다. 설정 변경은 서버 재시작 없이 다음 대전에 적용된다. 친구전은 페어링·재대전 시 같은 설정을 방에 저장하고, 봇·랭킹 대전도 생성 시 설정을 확정한다. 진행 중 대전과 새로고침 복구는 기존 대전의 설정을 유지한다. 브라우저는 최초 로딩·창 포커스/가시성 복귀·대전 시작 시 `GET /api/global-settings?revision=...`로 확인하고, 변경된 경우만 `localStorage` 캐시를 갱신한다. 조회 실패 시 유효한 캐시 또는 기본값을 유지하며 실제 대전에는 서버가 확정한 설정을 사용한다.
+2 -2
View File
@@ -1,4 +1,4 @@
export const GLOBAL_SETTINGS_CACHE_KEY = 'dragon-ball:global-settings:v1'; export const GLOBAL_SETTINGS_CACHE_KEY = 'dragon-ball:global-settings:v1';
export const GLOBAL_SETTINGS_REQUEST_TIMEOUT_MS = 5000; export const GLOBAL_SETTINGS_REQUEST_TIMEOUT_MS = 5000;
export const DEFAULT_GLOBAL_SETTINGS = { revision: 1, turnDurationMs: 3000, finishCutsceneSpeed: 1, matchingDelayMs: 1000, matchSuccessDelayMs: 1000, matchPreparationDelayMs: 3000 }; export const DEFAULT_GLOBAL_SETTINGS = { revision: 1, turnDurationMs: 3000, finishCutsceneSpeed: 1, matchingDelayMs: 1000, matchSuccessDelayMs: 1000, matchPreparationDelayMs: 3000, rankedAcceptTimeoutMs: 10000 };
export const GLOBAL_SETTINGS_LIMITS = { turnDurationMs: { min: 500, max: 60000 }, finishCutsceneSpeed: { min: 0.25, max: 4 }, matchingDelayMs: { min: 0, max: 10000 }, matchSuccessDelayMs: { min: 0, max: 10000 }, matchPreparationDelayMs: { min: 0, max: 10000 } }; export const GLOBAL_SETTINGS_LIMITS = { turnDurationMs: { min: 500, max: 60000 }, finishCutsceneSpeed: { min: 0.25, max: 4 }, matchingDelayMs: { min: 0, max: 10000 }, matchSuccessDelayMs: { min: 0, max: 10000 }, matchPreparationDelayMs: { min: 0, max: 10000 }, rankedAcceptTimeoutMs: { min: 1000, max: 60000 } };
+2
View File
@@ -1,4 +1,6 @@
.lobby-tools { display: flex; gap: 10px; margin-top: 16px; } .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; } .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 > button:not(.primary) { min-height: 64px; font-size: 26px; }
#home .home-actions .split { grid-template-columns: repeat(2, minmax(0, 1fr)); } #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>'; tools.innerHTML = '<button id="leaderboard-open" disabled>리더보드</button><button id="appearance-open" disabled>캐릭터·장비</button>';
actions.after(tools); actions.after(tools);
const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true; 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); document.getElementById('waiting')!.after(queue);
const appearanceDialog = document.createElement('dialog'); appearanceDialog.id = 'appearance-dialog'; 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>'; 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 appearanceOpen = document.getElementById('appearance-open') as HTMLButtonElement;
const leaderboardOpen = document.getElementById('leaderboard-open') as HTMLButtonElement; const leaderboardOpen = document.getElementById('leaderboard-open') as HTMLButtonElement;
const cancel = document.getElementById('ranked-cancel') 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 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 latest: RankedStatus | undefined, active = false, busy = false, profileBusy = false, timer: ReturnType<typeof setTimeout> | undefined, generation = 0;
let profileRevision = 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; if (status.match && latest?.match?.matchId === status.match.matchId && status.match.version < latest.match.version) return;
latest = status; updateProfile(status.profile); latest = status; updateProfile(status.profile);
active = status.status !== 'idle'; rankedStart.disabled = busy || active; 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(); options.onAvailabilityChange();
if (status.status === 'queued') showQueue('랭킹대전 매칭 중…'); 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 if (status.match) options.onMatch(status.match);
else options.onHome(); 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() { function schedule() {
clearTimeout(timer); clearTimeout(timer);
if (!active || latest?.match?.phase === 'finished') return; if (!active || latest?.match?.phase === 'finished') return;
@@ -197,24 +217,42 @@ export function createLobbyFeatures(options: LobbyOptions) {
} }
async function start() { async function start() {
if (busy || active) return; if (busy || active) return;
busy = true; rankedStart.disabled = true; generation++; busy = true; rankedStart.disabled = true; cancel.disabled = true;
const currentGeneration = ++generation;
options.onAvailabilityChange(); options.onAvailabilityChange();
showQueue('랭킹대전 매칭 중…'); showQueue('랭킹대전 매칭 중…');
try { consume(await api.request<RankedStatus>('/api/ranked/queue', 'POST', {})); schedule(); } try {
catch (error) { options.onHome(); options.onError(error); } const status = await api.request<RankedStatus>('/api/ranked/queue', 'POST', {});
finally { busy = false; options.onAvailabilityChange(); } 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() { async function leave() {
if (busy) return; 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 { try {
const status = latest?.match const status = latest?.match
? await api.request<RankedStatus>('/api/ranked/leave', 'POST', { matchId: latest.match.matchId }) ? await api.request<RankedStatus>('/api/ranked/leave', 'POST', { matchId: latest.match.matchId })
: await api.request<RankedStatus>('/api/ranked/cancel', 'POST', {}); : await api.request<RankedStatus>('/api/ranked/cancel', 'POST', latest?.offer ? { matchId: latest.offer.matchId } : {});
consume(status); if (currentGeneration === generation) consume(status);
} catch (error) { options.onError(error); } } catch (error) { if (currentGeneration === generation) options.onError(error); }
finally { busy = false; cancel.disabled = false; options.onAvailabilityChange(); schedule(); } 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(); }; rankedStart.onclick = () => { void start(); };
cancel.onclick = () => { void leave(); }; cancel.onclick = () => { void leave(); };
return { return {
@@ -222,7 +260,8 @@ export function createLobbyFeatures(options: LobbyOptions) {
get busy() { return busy; }, 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; }, 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) { 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; } if (!restoreRanked) { updateProfile(await api.request<PlayerProfile>('/api/profile')); return false; }
const status = await api.request<RankedStatus>('/api/ranked/status'); const status = await api.request<RankedStatus>('/api/ranked/status');
updateProfile(status.profile); latest = status; active = status.status !== 'idle'; 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(); if (invitation && /^[A-Z0-9]{6}$/i.test(invitation)) el<HTMLInputElement>('room-code').value = invitation.toUpperCase();
lobby = createLobbyFeatures({ lobby = createLobbyFeatures({
api, api,
onQueue: message => { setScreen('ranked-queue'); connection(message, true); }, onQueue: message => { setScreen('ranked-queue'); connection(message, true); notice(); el('reconnect').hidden = true; },
onMatch: state => showMatch(state), onMatch: state => { el('reconnect').hidden = true; showMatch(state); },
onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; }, onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; },
onAppearance: appearance => { onAppearance: appearance => {
el('waiting-fighter').innerHTML = fighterArt(0, false, appearance); el('waiting-fighter').innerHTML = fighterArt(0, false, appearance);
+2 -2
View File
@@ -16,7 +16,7 @@ export function createGlobalSettingsStore(db: Db) {
return { return {
async initialize() { async initialize() {
await collection.updateOne({ _id: 'global' }, { $setOnInsert: DEFAULT_GLOBAL_SETTINGS }, { upsert: true }); 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 } }); 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) { async update(input: unknown) {
if (!isGlobalSettings(input) || Object.keys(input).some(key => !Object.hasOwn(DEFAULT_GLOBAL_SETTINGS, key))) throw new ApiError(400, 'INVALID_SETTINGS'); 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 }, { 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' }); }, { returnDocument: 'after' });
if (!doc) throw new ApiError(409, 'SETTINGS_REVISION_CONFLICT'); if (!doc) throw new ApiError(409, 'SETTINGS_REVISION_CONFLICT');
const { _id, ...settings } = doc; 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 }); return service.updateProfile(playerId, request.body as { appearance?: FighterAppearance });
}); });
gameRoute(app, 'GET', '/api/ranked/leaderboard', async request => service.leaderboard((await identity(request)).playerId)); 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 => { gameRoute(app, method, `/api/ranked/${url}`, async request => {
const { playerId, gameSessionId } = await identity(request); const { playerId, gameSessionId } = await identity(request);
return service[url === 'queue' ? 'join' : url](playerId, gameSessionId); 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 => { gameRoute(app, 'POST', '/api/ranked/action', async request => {
const { playerId, gameSessionId } = await identity(request); const { playerId, gameSessionId } = await identity(request);
const body = request.body as { matchId: string; turnId: number; action: ActionId } | undefined; 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 { systemClock, type Clock } from './clock.js';
import { ApiError, validId } from './game-session-store.js'; import { ApiError, validId } from './game-session-store.js';
import { createGlobalSettingsStore } from './global-settings.js'; import { createGlobalSettingsStore } from './global-settings.js';
import { matchIntroductionDuration } from '../shared/match-introduction.js';
import { createEmotes } from '../shared/emotes.js'; import { createEmotes } from '../shared/emotes.js';
interface ProfileDocument { interface ProfileDocument {
@@ -25,8 +24,9 @@ interface TerminalMatch {
_id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; winnerIndex: 0 | 1 | null; _id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; winnerIndex: 0 | 1 | null;
reason: LocalEndReason; order: number; completedAt: Date; environment: string; 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 { interface RankedMatch {
confirmation?: { expiresAt: number; accepted: Pair<boolean>; sessions: Pair<string> };
emotes: ReturnType<typeof createEmotes>; emotes: ReturnType<typeof createEmotes>;
emoteVersion: number; emoteVersion: number;
id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; engine: MatchEngine; 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(); const state = match.engine.view();
if (state.aborted) return; if (state.aborted) return;
if (state.phase === 'finished' && match.saved && match.departed.size === 2) 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 : state.phase === 'revealing' ? state.reveal!.revealEndsAt : match.saved
? Math.min(...match.playerIds.map((id, slot) => match.departed.has(id) ? Infinity : match.finishedExpiresAt![slot])) ? Math.min(...match.playerIds.map((id, slot) => match.departed.has(id) ? Infinity : match.finishedExpiresAt![slot]))
: clock.now() + RANKED_SAVE_RETRY_MS; : 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) { async function advance(match: RankedMatch) {
try { try {
if (!matches.has(match)) return;
if (match.confirmation) {
if (clock.now() >= match.confirmation.expiresAt) await cancelConfirmation(match);
return;
}
let state = match.engine.view(); let state = match.engine.view();
if (state.phase === 'countdown' && clock.now() >= state.startsAt) match.engine.beginTurn(state.startsAt); if (state.phase === 'countdown' && clock.now() >= state.startsAt) match.engine.beginTurn(state.startsAt);
if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) { 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); } } finally { schedule(match); }
} }
async function statusView(playerId: string): Promise<RankedStatus> { async function statusView(playerId: string): Promise<RankedStatus> {
const match = byPlayer.get(playerId); let match = byPlayer.get(playerId);
if (match) await advance(match); if (match) await advance(match);
match = byPlayer.get(playerId);
const profile = await readProfile(playerId); const profile = await readProfile(playerId);
if (!match) return { status: queue.has(playerId) ? 'queued' : 'idle', profile }; if (!match) return { status: queue.has(playerId) ? 'queued' : 'idle', profile };
const selfIndex = match.playerIds[0] === playerId ? 0 : 1; 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(); const state = match.engine.view();
if (state.finished) { if (state.finished) {
state.finished.stats.friend = structuredClone(profile.stats); 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'); if (!match || match.id !== matchId) throw new ApiError(409, 'NO_ACTIVE_MATCH');
return 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 { return {
async profile(playerId: string) { async profile(playerId: string) {
if (closed) throw new ApiError(503, 'RANKED_CLOSED'); 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); if (byPlayer.has(playerId)) return statusView(playerId);
await freeForRanked(playerId); await readProfile(playerId); await freeForRanked(playerId); await readProfile(playerId);
queue.set(playerId, { playerId, gameSessionId, expiresAt: clock.now() + RANKED_QUEUE_TTL_MS }); queue.set(playerId, { playerId, gameSessionId, expiresAt: clock.now() + RANKED_QUEUE_TTL_MS });
for (const candidate of queue.values()) { await pairQueued();
if (candidate.playerId === playerId) continue; return statusView(playerId);
try { await identity(candidate.playerId, candidate.gameSessionId); await freeForRanked(candidate.playerId); } });
catch (error) { },
if (!(error instanceof ApiError)) throw error; accept(playerId: string, gameSessionId: string, matchId: string): Promise<RankedStatus> {
queue.delete(candidate.playerId); continue; return serial(async () => {
} await identity(playerId, gameSessionId);
const first = await readProfile(candidate.playerId), second = await readProfile(playerId); if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
const id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock, settings: await createGlobalSettingsStore(db).get() }); const match = byPlayer.get(playerId);
const match: RankedMatch = { id, engine, emotes: createEmotes(), emoteVersion: 0, playerIds: [candidate.playerId, playerId], if (!match || match.id !== matchId) return statusView(playerId);
appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false }; await advance(match);
engine.start(clock.now() + matchIntroductionDuration(engine.view().settings, true)); matches.add(match); if (byPlayer.get(playerId) !== match || !match.confirmation) return statusView(playerId);
for (const id of match.playerIds) { queue.delete(id); byPlayer.set(id, match); } const slot = match.playerIds[0] === playerId ? 0 : 1;
schedule(match); break; 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); return statusView(playerId);
}); });
}, },
cancel(playerId: string, gameSessionId: string): Promise<RankedStatus> { cancel(playerId: string, gameSessionId: string, matchId?: string): Promise<RankedStatus> {
return serial(async () => { return serial(async () => {
await identity(playerId, gameSessionId); expireQueue(); 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); queue.delete(playerId); return statusView(playerId);
}); });
}, },
@@ -348,6 +401,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
return serial(async () => { return serial(async () => {
await identity(playerId, gameSessionId); await identity(playerId, gameSessionId);
const match = memberMatch(playerId, matchId); await advance(match); 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'); if (match.engine.view().phase === 'finished' || match.departed.size) throw new ApiError(409, 'NO_ACTIVE_MATCH');
const slot = match.playerIds[0] === playerId ? 0 : 1; const slot = match.playerIds[0] === playerId ? 0 : 1;
if (!match.emotes.send(slot, id, clock.now())) throw new ApiError(400, 'INVALID_REQUEST'); 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 () => { return serial(async () => {
await identity(playerId, gameSessionId); await identity(playerId, gameSessionId);
const match = memberMatch(playerId, matchId); await advance(match); 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; const state = match.engine.view(), slot = match.playerIds[0] === playerId ? 0 : 1;
if (!Number.isSafeInteger(turnId) || state.phase !== 'selecting' || turnId !== state.turnId 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'); || 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 (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
if (!byPlayer.has(playerId) && await results.findOne({ _id: matchId, playerIds: playerId })) return statusView(playerId); if (!byPlayer.has(playerId) && await results.findOne({ _id: matchId, playerIds: playerId })) return statusView(playerId);
const match = memberMatch(playerId, matchId); await advance(match); 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') { if (match.engine.view().phase !== 'finished') {
match.engine.finish(match.playerIds[0] === playerId ? 1 : 0, 'forfeit'); match.engine.finish(match.playerIds[0] === playerId ? 1 : 0, 'forfeit');
await advance(match); await advance(match);
+2 -2
View File
@@ -1,11 +1,11 @@
import { GLOBAL_SETTINGS_LIMITS } from '../../constants/global-settings.js'; 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 { export function isGlobalSettings(value: unknown): value is GlobalSettings {
if (!value || typeof value !== 'object') return false; if (!value || typeof value !== 'object') return false;
const s = value as GlobalSettings, limits = GLOBAL_SETTINGS_LIMITS; const s = value as GlobalSettings, limits = GLOBAL_SETTINGS_LIMITS;
return Number.isSafeInteger(s.revision) && s.revision >= 1 return Number.isSafeInteger(s.revision) && s.revision >= 1
&& Number.isSafeInteger(s.turnDurationMs) && s.turnDurationMs >= limits.turnDurationMs.min && s.turnDurationMs <= limits.turnDurationMs.max && 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 && 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[]; unlockedEquipment: EquipmentId[];
} }
export interface RankedStatus { export interface RankedStatus {
status: 'idle' | 'queued' | 'matched'; status: 'idle' | 'queued' | 'confirming' | 'matched';
profile: PlayerProfile; profile: PlayerProfile;
offer?: { matchId: string; expiresAt: number; serverNow: number; accepted: boolean };
match?: LocalMatchState; match?: LocalMatchState;
} }
export interface LeaderboardEntry { export interface LeaderboardEntry {
+13 -2
View File
@@ -9,6 +9,15 @@ import { createGlobalSettingsStore } from '../src/server/global-settings.js';
import { createGameSessionStore } from '../src/server/game-session-store.js'; import { createGameSessionStore } from '../src/server/game-session-store.js';
import { createRoomStore } from '../src/server/rooms.js'; import { createRoomStore } from '../src/server/rooms.js';
import { createMatchEngine } from '../src/shared/match-engine.js'; import { createMatchEngine } from '../src/shared/match-engine.js';
import { isGlobalSettings } from '../src/shared/global-settings.js';
test('ranked acceptance timeout is a configurable positive duration', () => {
assert.equal(DEFAULT_GLOBAL_SETTINGS.rankedAcceptTimeoutMs, 10000);
assert.equal(isGlobalSettings({ ...DEFAULT_GLOBAL_SETTINGS, rankedAcceptTimeoutMs: 5000 }), true);
for (const rankedAcceptTimeoutMs of [0, -1, 60001, 1500.5]) {
assert.equal(isGlobalSettings({ ...DEFAULT_GLOBAL_SETTINGS, rankedAcceptTimeoutMs }), false);
}
});
test('browser caches settings, checks revision and retains valid settings through failures', async () => { test('browser caches settings, checks revision and retains valid settings through failures', async () => {
const memory = new Map<string, string>(), urls: string[] = []; const memory = new Map<string, string>(), urls: string[] = [];
@@ -71,17 +80,19 @@ test('DB settings update without restart and friend matches share an immutable s
const c = await sessions.create(randomBytes(32).toString('base64url')); const c = await sessions.create(randomBytes(32).toString('base64url'));
const bot = await sessions.register(c, { requestId: 'bot', matchId: 'bot', mode: 'bot', expectedPreviousSeq: 0 }); const bot = await sessions.register(c, { requestId: 'bot', matchId: 'bot', mode: 'bot', expectedPreviousSeq: 0 });
assert.equal(bot.settings?.revision, 3); assert.equal(bot.settings?.finishCutsceneSpeed, 3); assert.equal(bot.settings?.revision, 3); assert.equal(bot.settings?.finishCutsceneSpeed, 3);
await db.collection('global_settings').updateOne({ _id: 'global' as any }, { $unset: { matchingDelayMs: '', matchSuccessDelayMs: '', matchPreparationDelayMs: '' } }); await db.collection('global_settings').updateOne({ _id: 'global' as any }, { $unset: { matchingDelayMs: '', matchSuccessDelayMs: '', matchPreparationDelayMs: '', rankedAcceptTimeoutMs: '' } });
await store.initialize(); await store.initialize();
const migrated = await store.get(); const migrated = await store.get();
assert.equal(migrated.matchingDelayMs, 1000); assert.equal(migrated.matchSuccessDelayMs, 1000); assert.equal(migrated.matchingDelayMs, 1000); assert.equal(migrated.matchSuccessDelayMs, 1000);
assert.equal(migrated.matchPreparationDelayMs, 3000); assert.equal(migrated.matchPreparationDelayMs, 3000);
assert.equal(migrated.rankedAcceptTimeoutMs, 10000);
assert.equal(migrated.finishCutsceneSpeed, 3); assert.equal(migrated.turnDurationMs, 2000); assert.equal(migrated.finishCutsceneSpeed, 3); assert.equal(migrated.turnDurationMs, 2000);
assert.ok(migrated.revision > 3); assert.ok(migrated.revision > 3);
await store.initialize(); assert.deepEqual(await store.get(), migrated); await store.initialize(); assert.deepEqual(await store.get(), migrated);
const changed = await store.update({ ...migrated, matchingDelayMs: 4000, matchSuccessDelayMs: 2000, matchPreparationDelayMs: 5000 }); const changed = await store.update({ ...migrated, matchingDelayMs: 4000, matchSuccessDelayMs: 2000, matchPreparationDelayMs: 5000, rankedAcceptTimeoutMs: 7000 });
assert.equal(changed.matchingDelayMs, 4000); assert.equal(changed.matchSuccessDelayMs, 2000); assert.equal(changed.matchingDelayMs, 4000); assert.equal(changed.matchSuccessDelayMs, 2000);
assert.equal((await store.get()).matchPreparationDelayMs, 5000); assert.equal((await store.get()).matchPreparationDelayMs, 5000);
assert.equal((await store.get()).rankedAcceptTimeoutMs, 7000);
await assert.rejects(store.update({ ...changed, matchPreparationDelayMs: -1 }), /INVALID_SETTINGS/); await assert.rejects(store.update({ ...changed, matchPreparationDelayMs: -1 }), /INVALID_SETTINGS/);
await assert.rejects(store.update({ ...changed, matchingDelayMs: -1 }), /INVALID_SETTINGS/); await assert.rejects(store.update({ ...changed, matchingDelayMs: -1 }), /INVALID_SETTINGS/);
} finally { await app.close(); await db.dropDatabase(); await client.close(); } } finally { await app.close(); await db.dropDatabase(); await client.close(); }
+82
View File
@@ -38,6 +38,88 @@ function documentSurface() {
return document; return document;
} }
test('ranked confirmation shows accept, waits for opponent and cancels by offer id', async t => {
const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
t.mock.timers.enable({ apis: ['setTimeout'] });
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', appearance: normalizeAppearance(undefined),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
let accepted = false, screen = '', cancelBody: unknown;
const api = { async request(path: string, method?: string, body?: unknown) {
if (path === '/api/ranked/accept') { assert.deepEqual(body, { matchId: 'offer-a' }); accepted = true; }
if (path === '/api/ranked/cancel') { cancelBody = body; return { status: 'idle', profile }; }
return { status: 'confirming', profile, offer: { matchId: 'offer-a', expiresAt: 10000, serverNow: 0, accepted } };
} } as unknown as GameApi;
const lobby = createLobbyFeatures({ api, onQueue(message) { screen = message; }, onMatch() { assert.fail('must wait for both accepts'); },
onHome() { screen = 'home'; }, onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; } });
await lobby.initialize(true);
assert.equal(screen, '매칭 성공!');
const accept = document.getElementById('ranked-accept');
assert.equal(accept.disabled, false);
await accept.onclick!();
assert.equal(accept.disabled, true);
assert.equal(document.getElementById('ranked-queue').querySelector('.accept-status').textContent, '상대 수락 대기');
await lobby.leave();
assert.deepEqual(cancelBody, { matchId: 'offer-a' });
assert.equal(screen, 'home');
});
test('a delayed accept response cannot overwrite a restored newer offer', async t => {
const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
t.mock.timers.enable({ apis: ['setTimeout'] });
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', appearance: normalizeAppearance(undefined),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
let currentId = 'old', finishAccept!: (value: unknown) => void;
const pendingAccept = new Promise(resolve => { finishAccept = resolve; });
const status = (matchId: string, accepted = false) => ({ status: 'confirming', profile, offer: { matchId, accepted, expiresAt: 10000, serverNow: 0 } });
const bodies: unknown[] = [];
const api = { async request(path: string, method?: string, body?: unknown) {
if (path === '/api/ranked/accept') return pendingAccept;
if (path === '/api/ranked/cancel') { bodies.push(body); return { status: 'idle', profile }; }
return status(currentId);
} } as unknown as GameApi;
const lobby = createLobbyFeatures({ api, onQueue() {}, onMatch() {}, onHome() {}, onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; } });
await lobby.initialize(true);
const accepting = document.getElementById('ranked-accept').onclick!();
currentId = 'new'; await lobby.initialize(true);
finishAccept(status('old', true)); await accepting;
assert.equal(document.getElementById('ranked-accept').disabled, false);
await lobby.leave();
assert.deepEqual(bodies, [{ matchId: 'new' }]);
});
test('an old confirmation poll cannot restore the prompt after accepting starts preparation', async t => {
const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
t.mock.timers.enable({ apis: ['setTimeout'] });
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', appearance: normalizeAppearance(undefined),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
const confirming = { status: 'confirming', profile, offer: { matchId: 'offer-a', expiresAt: 10000, serverNow: 0, accepted: false } };
const engine = createMatchEngine({ matchId: 'offer-a', mode: 'friend', selfIndex: 0, clock: { now: () => 0 } });
engine.start();
let reads = 0, screen = '', finishPoll!: (value: unknown) => void;
const oldPoll = new Promise(resolve => { finishPoll = resolve; });
const api = { async request(path: string) {
if (path === '/api/ranked/accept') return { status: 'matched', profile, match: { ...engine.view(), ranked: true } };
if (path === '/api/ranked/leave') return { status: 'idle', profile };
return ++reads === 1 ? confirming : oldPoll;
} } as unknown as GameApi;
const lobby = createLobbyFeatures({ api, onQueue(message) { screen = message; }, onMatch() { screen = 'preparing'; },
onHome() { screen = 'home'; }, onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; } });
await lobby.initialize(true);
t.mock.timers.tick(RANKED_POLL_MS);
await document.getElementById('ranked-accept').onclick!();
assert.equal(screen, 'preparing');
finishPoll(confirming);
for (let i = 0; i < 5; i++) await Promise.resolve();
assert.equal(screen, 'preparing');
await lobby.leave();
});
for (const initiallyQueued of [false, true]) test(`ranked introduction follows server countdown (queued: ${initiallyQueued})`, async t => { for (const initiallyQueued of [false, true]) test(`ranked introduction follows server countdown (queued: ${initiallyQueued})`, async t => {
const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document'); const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: document }); Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
+109 -1
View File
@@ -8,6 +8,7 @@ import { createRankedService } from '../src/server/ranked-service.js';
import type { RoomStore } from '../src/server/rooms.js'; import type { RoomStore } from '../src/server/rooms.js';
import type { Signaling } from '../src/server/signaling.js'; import type { Signaling } from '../src/server/signaling.js';
import { ManualClock } from './helpers.js'; import { ManualClock } from './helpers.js';
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
function deferred() { function deferred() {
let resolve!: () => void; let resolve!: () => void;
@@ -23,7 +24,7 @@ function memoryDatabase(now: number) {
{ _id: 'session-a', playerId: 'player-a', expiresAt: new Date(now + 60000) }, { _id: 'session-a', playerId: 'player-a', expiresAt: new Date(now + 60000) },
{ _id: 'session-b', playerId: 'player-b', expiresAt: new Date(now + 60000) }, { _id: 'session-b', playerId: 'player-b', expiresAt: new Date(now + 60000) },
], ],
player_profiles: [], ranked_matches: [], rooms: [], player_profiles: [], ranked_matches: [], rooms: [], global_settings: [],
}; };
const matches = (doc: Record<string, any>, query: Record<string, any> = {}) => const matches = (doc: Record<string, any>, query: Record<string, any> = {}) =>
Object.entries(query).every(([key, value]) => { Object.entries(query).every(([key, value]) => {
@@ -56,6 +57,113 @@ function memoryDatabase(now: number) {
return { db, records }; return { db, records };
} }
test('ranked pairing waits for both accepts and then starts preparation', async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
const service = await createRankedService(db, clock, 'test');
try {
await service.join('player-a', 'session-a');
const paired = await service.join('player-b', 'session-b');
assert.equal(paired.status, 'confirming');
assert.equal(paired.match, undefined);
assert.equal(paired.offer!.expiresAt - paired.offer!.serverNow, 10000);
const id = paired.offer!.matchId;
await assert.rejects(service.accept('player-a', 'session-b', id), /INVALID_SESSION/);
await assert.rejects(service.action('player-a', 'session-a', id, 1, 'charge'), /NO_ACTIVE_MATCH/);
const first = await service.accept('player-a', 'session-a', id);
assert.equal(first.offer!.accepted, true);
assert.equal(first.status, 'confirming');
const started = await service.accept('player-b', 'session-b', id);
assert.equal(started.status, 'matched');
assert.equal(started.match!.phase, 'countdown');
assert.equal(started.match!.startsAt - clock.now(), started.match!.settings!.matchPreparationDelayMs);
assert.equal((await service.accept('player-b', 'session-b', id)).match!.startsAt, started.match!.startsAt);
assert.equal(records.ranked_matches.length, 0);
} finally { await service.close(); }
});
test('two simultaneous accepts start once and a delayed cancel cannot forfeit a started match', async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
const service = await createRankedService(db, clock, 'test');
try {
await service.join('player-a', 'session-a');
const offer = (await service.join('player-b', 'session-b')).offer!;
await Promise.all([service.accept('player-a', 'session-a', offer.matchId), service.accept('player-b', 'session-b', offer.matchId)]);
const state = await service.cancel('player-a', 'session-a', offer.matchId);
assert.equal(state.status, 'matched');
assert.equal(state.match!.phase, 'countdown');
assert.equal(records.ranked_matches.length, 0);
await clock.advance(state.match!.startsAt - clock.now());
assert.equal((await service.status('player-a', 'session-a')).match!.turnId, 1);
} finally { await service.close(); }
});
for (const timeout of [false, true]) test(`ranked ${timeout ? 'timeout' : 'cancel'} requeues only the accepting player without awards`, async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
const service = await createRankedService(db, clock, 'test');
try {
await service.join('player-a', 'session-a');
const paired = await service.join('player-b', 'session-b');
assert.equal(paired.status, 'confirming');
const id = paired.offer!.matchId;
await service.accept('player-a', 'session-a', id);
if (timeout) await clock.advance(10000);
else assert.equal((await service.cancel('player-b', 'session-b')).status, 'idle');
assert.equal((await service.status('player-a', 'session-a')).status, 'queued');
assert.equal((await service.status('player-b', 'session-b')).status, 'idle');
assert.equal((await service.accept('player-b', 'session-b', id)).status, 'idle');
assert.equal(records.ranked_matches.length, 0);
assert.equal((await service.profile('player-a')).stats.wins, 0);
} finally { await service.close(); }
});
test('ranked confirmation snapshots global settings and expires at its server deadline', async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
records.global_settings.push({ _id: 'global', ...DEFAULT_GLOBAL_SETTINGS, rankedAcceptTimeoutMs: 5000 });
const service = await createRankedService(db, clock, 'test');
try {
await service.join('player-a', 'session-a');
const paired = await service.join('player-b', 'session-b');
assert.equal(paired.offer!.expiresAt - clock.now(), 5000);
records.global_settings[0].rankedAcceptTimeoutMs = 20000;
await clock.advance(4999);
assert.equal((await service.status('player-a', 'session-a')).status, 'confirming');
clock.time++;
assert.equal((await service.accept('player-a', 'session-a', paired.offer!.matchId)).status, 'idle');
assert.equal((await service.status('player-b', 'session-b')).status, 'idle');
assert.equal(records.ranked_matches.length, 0);
} finally { await service.close(); }
});
test('cancelling an expired offer also cancels its automatic requeue', async () => {
const clock = new ManualClock(), { db } = memoryDatabase(clock.now());
const service = await createRankedService(db, clock, 'test');
try {
await service.join('player-a', 'session-a');
const offer = (await service.join('player-b', 'session-b')).offer!;
await service.accept('player-a', 'session-a', offer.matchId);
await clock.advance(10000);
assert.equal((await service.cancel('player-a', 'session-a', offer.matchId)).status, 'idle');
} finally { await service.close(); }
});
test('cancel racing with accept releases the offer and stale requests cannot cancel the next offer', async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
records.game_sessions.push({ _id: 'session-c', playerId: 'player-c', expiresAt: new Date(clock.now() + 60000) });
const service = await createRankedService(db, clock, 'test');
try {
await service.join('player-a', 'session-a');
const offer = (await service.join('player-b', 'session-b')).offer!;
await service.join('player-c', 'session-c');
await Promise.all([service.accept('player-a', 'session-a', offer.matchId), service.cancel('player-b', 'session-b', offer.matchId)]);
const next = await service.status('player-a', 'session-a');
assert.equal(next.status, 'confirming');
assert.notEqual(next.offer!.matchId, offer.matchId);
assert.equal((await service.cancel('player-a', 'session-a', offer.matchId)).offer!.matchId, next.offer!.matchId);
assert.equal((await service.accept('player-b', 'session-b', offer.matchId)).status, 'idle');
assert.equal(records.ranked_matches.length, 0);
} finally { await service.close(); }
});
for (const url of ['/api/game-session/matches', '/api/rooms', '/api/rooms/join']) { for (const url of ['/api/game-session/matches', '/api/rooms', '/api/rooms/join']) {
test(`${url} reserves the player until its write completes before ranked pairing`, { timeout: 5000 }, async () => { test(`${url} reserves the player until its write completes before ranked pairing`, { timeout: 5000 }, async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now()); const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
+34 -28
View File
@@ -17,11 +17,17 @@ import { EQUIPMENT_IDS } from '../constants/cosmetics.js';
import { createRoomStore } from '../src/server/rooms.js'; import { createRoomStore } from '../src/server/rooms.js';
const integration = { skip: !process.env.TEST_MONGODB_URI }; const integration = { skip: !process.env.TEST_MONGODB_URI };
async function pairAndAccept(service: Awaited<ReturnType<typeof createRankedService>>, sessionA: string, sessionB: string) {
const paired = await service.join('player-b', sessionB);
assert.equal(paired.status, 'confirming');
await service.accept('player-a', sessionA, paired.offer!.matchId);
return service.accept('player-b', sessionB, paired.offer!.matchId);
}
test('ranked emotes authenticate membership, reach both players and preserve hidden actions', integration, async () => { test('ranked emotes authenticate membership, reach both players and preserve hidden actions', integration, async () => {
const f = await fixture(); const f = await fixture();
try { try {
await f.service.join('player-a', f.a.gameSessionId); await f.service.join('player-a', f.a.gameSessionId);
const paired = await f.service.join('player-b', f.b.gameSessionId); const paired = await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId);
const matchId = paired.match!.matchId; const matchId = paired.match!.matchId;
await f.clock.advance(paired.match!.startsAt - f.clock.now()); await f.clock.advance(paired.match!.startsAt - f.clock.now());
await f.service.action('player-a', f.a.gameSessionId, matchId, 1, 'charge'); await f.service.action('player-a', f.a.gameSessionId, matchId, 1, 'charge');
@@ -84,13 +90,13 @@ test('ranked pairing is serialized by player identity across tabs and survives r
const first = await Promise.all([s.join('player-a', a.gameSessionId), s.join('player-a', a2.gameSessionId)]); const first = await Promise.all([s.join('player-a', a.gameSessionId), s.join('player-a', a2.gameSessionId)]);
assert.ok(first.every(value => value.status === 'queued')); assert.ok(first.every(value => value.status === 'queued'));
await assert.rejects(s.assertAvailable('player-a'), /MATCH_PENDING/); await assert.rejects(s.assertAvailable('player-a'), /MATCH_PENDING/);
const paired = await s.join('player-b', b.gameSessionId); const paired = await pairAndAccept(s, a.gameSessionId, b.gameSessionId);
const restored = await s.status('player-a', a2.gameSessionId); const restored = await s.status('player-a', a2.gameSessionId);
assert.equal(paired.match?.matchId, restored.match?.matchId); assert.equal(paired.match?.matchId, restored.match?.matchId);
assert.notEqual(paired.match?.selfIndex, restored.match?.selfIndex); assert.notEqual(paired.match?.selfIndex, restored.match?.selfIndex);
assert.equal(restored.match?.mode, 'friend'); assert.equal(restored.match?.ranked, true); assert.equal(restored.match?.mode, 'friend'); assert.equal(restored.match?.ranked, true);
assert.equal((await s.join('player-a', a.gameSessionId)).match?.matchId, paired.match?.matchId); assert.equal((await s.join('player-a', a.gameSessionId)).match?.matchId, paired.match?.matchId);
await assert.rejects(s.cancel('player-a', a.gameSessionId), /MATCH_PENDING/); assert.equal((await s.cancel('player-a', a.gameSessionId)).status, 'matched');
await assert.rejects(s.status('player-a', b.gameSessionId), /INVALID_SESSION/); await assert.rejects(s.status('player-a', b.gameSessionId), /INVALID_SESSION/);
assert.equal((await s.profile('player-a')).stats.wins, 0); assert.equal((await s.profile('player-a')).stats.wins, 0);
} finally { await f.close(); } } finally { await f.close(); }
@@ -100,7 +106,7 @@ test('bot and friend wins never change ranked standings, profile stats or cosmet
const f = await fixture(); const f = await fixture();
try { try {
await f.service.join('player-a', f.a.gameSessionId); await f.service.join('player-a', f.a.gameSessionId);
const rankedId = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId; const rankedId = (await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.service.leave('player-b', f.b.gameSessionId, rankedId); await f.service.leave('player-b', f.b.gameSessionId, rankedId);
await f.service.leave('player-a', f.a.gameSessionId, rankedId); await f.service.leave('player-a', f.a.gameSessionId, rankedId);
const before = await f.service.profile('player-a'); const before = await f.service.profile('player-a');
@@ -152,7 +158,7 @@ test('profiles keep generated names and validate unlocked appearances, persisten
} }
const selected = await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } }); const selected = await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } });
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const paired = await s.join('player-b', f.b.gameSessionId); const paired = await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await s.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } }); await s.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } });
assert.deepEqual(paired.match?.appearances?.[0], selected.appearance); assert.deepEqual(paired.match?.appearances?.[0], selected.appearance);
assert.equal((await f.db.collection('player_profiles').findOne({ _id: 'player-a' as any }))?.displayName, original.displayName); assert.equal((await f.db.collection('player_profiles').findOne({ _id: 'player-a' as any }))?.displayName, original.displayName);
@@ -171,7 +177,7 @@ test('startup migrates old custom and default names including nonvisiting leader
try { try {
await f.service.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } }); await f.service.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } });
await f.service.join('player-a', f.a.gameSessionId); await f.service.join('player-a', f.a.gameSessionId);
const id = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId; const id = (await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.service.leave('player-b', f.b.gameSessionId, id); await f.service.leave('player-b', f.b.gameSessionId, id);
const expectedStats = (await f.service.profile('player-a')).stats; const expectedStats = (await f.service.profile('player-a')).stats;
const records = await f.db.collection('ranked_matches').find().toArray(); const records = await f.db.collection('ranked_matches').find().toArray();
@@ -241,7 +247,7 @@ test('equipment rewards use canonical thresholds, toggle independently, persist
const basicEquipment = ['shoes', 'wristbands', 'headband'] as const; const basicEquipment = ['shoes', 'wristbands', 'headband'] as const;
for (let wins = 1; wins <= 20; wins++) { for (let wins = 1; wins <= 20; wins++) {
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId; const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await s.leave('player-b', f.b.gameSessionId, id); await s.leave('player-b', f.b.gameSessionId, id);
await s.leave('player-a', f.a.gameSessionId, id); await s.leave('player-a', f.a.gameSessionId, id);
const profile = await s.profile('player-a'); const profile = await s.profile('player-a');
@@ -259,7 +265,7 @@ test('equipment rewards use canonical thresholds, toggle independently, persist
const stored = await f.db.collection<any>('player_profiles').findOne({ _id: 'player-a' }); const stored = await f.db.collection<any>('player_profiles').findOne({ _id: 'player-a' });
assert.deepEqual(stored.appearance, equipped.appearance); assert.deepEqual(stored.appearance, equipped.appearance);
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const paired = await s.join('player-b', f.b.gameSessionId); const paired = await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await s.updateProfile('player-a', { appearance: { ...base, hairStyle: 'default', equipment: [] } }); await s.updateProfile('player-a', { appearance: { ...base, hairStyle: 'default', equipment: [] } });
assert.deepEqual(paired.match!.appearances![0], equipped.appearance); assert.deepEqual(paired.match!.appearances![0], equipped.appearance);
assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance); assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance);
@@ -305,7 +311,7 @@ test('skin and independent white hair validate, preserve legacy profiles, persis
} }); } });
assert.deepEqual((await f.service.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } })).appearance, selected.appearance); assert.deepEqual((await f.service.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } })).appearance, selected.appearance);
await f.service.join('player-a', f.a.gameSessionId); await f.service.join('player-a', f.a.gameSessionId);
const paired = await f.service.join('player-b', f.b.gameSessionId); const paired = await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId);
await f.service.updateProfile('player-a', { appearance: { ...selected.appearance, skinTone: 'light', hairStyle: 'black' } }); await f.service.updateProfile('player-a', { appearance: { ...selected.appearance, skinTone: 'light', hairStyle: 'black' } });
assert.deepEqual(paired.match!.appearances![0], selected.appearance); assert.deepEqual(paired.match!.appearances![0], selected.appearance);
assert.deepEqual((await f.service.status('player-a', f.a2.gameSessionId)).match!.appearances![0], selected.appearance); assert.deepEqual((await f.service.status('player-a', f.a2.gameSessionId)).match!.appearances![0], selected.appearance);
@@ -352,7 +358,7 @@ test('ranked eyewear persists after streak loss, rejects locked or dual eyewear,
assert.equal(equipped.displayName, original.displayName); assert.equal(equipped.displayName, original.displayName);
assert.deepEqual((await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } })).appearance, equipped.appearance); assert.deepEqual((await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } })).appearance, equipped.appearance);
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const paired = await s.join('player-b', f.b.gameSessionId); const paired = await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await s.updateProfile('player-a', { appearance: { ...base, equipment: ['glasses'] } }); await s.updateProfile('player-a', { appearance: { ...base, equipment: ['glasses'] } });
assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance); assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance);
assert.deepEqual(paired.match!.appearances![0], equipped.appearance); assert.deepEqual(paired.match!.appearances![0], equipped.appearance);
@@ -370,9 +376,9 @@ test('server hides choices, checks costs and deadlines, resolves once and awards
try { try {
const { service: s, a, a2, b, clock } = f; const { service: s, a, a2, b, clock } = f;
await s.join('player-a', a.gameSessionId); await s.join('player-a', a.gameSessionId);
const id = (await s.join('player-b', b.gameSessionId)).match!.matchId; const id = (await pairAndAccept(s, a.gameSessionId, b.gameSessionId)).match!.matchId;
await assert.rejects(s.action('player-a', a.gameSessionId, id, 0, 'charge'), /INVALID_ACTION/); await assert.rejects(s.action('player-a', a.gameSessionId, id, 0, 'charge'), /INVALID_ACTION/);
await clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS); await clock.advance(MATCH_COUNTDOWN_MS);
await assert.rejects(s.action('player-a', a.gameSessionId, id, 1, 'beam'), /INVALID_ACTION/); await assert.rejects(s.action('player-a', a.gameSessionId, id, 1, 'beam'), /INVALID_ACTION/);
const version = (await s.status('player-a', a.gameSessionId)).match!.version; const version = (await s.status('player-a', a.gameSessionId)).match!.version;
const locked = await s.action('player-a', a.gameSessionId, id, 1, 'charge'); const locked = await s.action('player-a', a.gameSessionId, id, 1, 'charge');
@@ -413,8 +419,8 @@ test('finished matches retain results on reload then expire when abandoned', int
const f = await fixture(); const f = await fixture();
try { try {
await f.service.join('player-a', f.a.gameSessionId); await f.service.join('player-a', f.a.gameSessionId);
const id = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId; const id = (await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3); await f.clock.advance(MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.finished?.reason, 'double_timeout'); assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.finished?.reason, 'double_timeout');
await f.clock.advance(RANKED_FINISHED_TTL_MS - 1); await f.clock.advance(RANKED_FINISHED_TTL_MS - 1);
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.matchId, id); assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.matchId, id);
@@ -453,8 +459,8 @@ test('deadline rejects late actions and duplicate terminal writes survive an amb
const s = await createRankedService(db, f.clock, 'test'); const s = await createRankedService(db, f.clock, 'test');
try { try {
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId; const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS); await f.clock.advance(MATCH_COUNTDOWN_MS);
await s.action('player-a', f.a.gameSessionId, id, 1, 'guard'); await s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
assert.equal((await s.leaderboard('player-a')).self, undefined); assert.equal((await s.leaderboard('player-a')).self, undefined);
// Jump to the deadline without executing timers: the request itself must settle it. // Jump to the deadline without executing timers: the request itself must settle it.
@@ -496,7 +502,7 @@ test('another authenticated tab keeps the player queue alive after the original
await f.service.join('player-a', f.a.gameSessionId); await f.service.join('player-a', f.a.gameSessionId);
await f.db.collection<any>('game_sessions').updateOne({ _id: f.a.gameSessionId }, { $set: { expiresAt: new Date(f.clock.now() - 1) } }); await f.db.collection<any>('game_sessions').updateOne({ _id: f.a.gameSessionId }, { $set: { expiresAt: new Date(f.clock.now() - 1) } });
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).status, 'queued'); assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).status, 'queued');
assert.equal((await f.service.join('player-b', f.b.gameSessionId)).status, 'matched'); assert.equal((await f.service.join('player-b', f.b.gameSessionId)).status, 'confirming');
} finally { await f.close(); } } finally { await f.close(); }
}); });
@@ -506,8 +512,8 @@ test('timeouts progress without polling, unlock colors only from authoritative r
const { service: s, a, b, clock } = f; const { service: s, a, b, clock } = f;
for (let n = 0; n < 5; n++) { for (let n = 0; n < 5; n++) {
await s.join('player-a', a.gameSessionId); await s.join('player-a', a.gameSessionId);
const id = (await s.join('player-b', b.gameSessionId)).match!.matchId; const id = (await pairAndAccept(s, a.gameSessionId, b.gameSessionId)).match!.matchId;
await clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS); await clock.advance(MATCH_COUNTDOWN_MS);
await s.action('player-a', a.gameSessionId, id, 1, 'guard'); await s.action('player-a', a.gameSessionId, id, 1, 'guard');
await clock.advance(TURN_DURATION_MS * 3); await clock.advance(TURN_DURATION_MS * 3);
assert.equal((await s.status('player-b', b.gameSessionId)).match?.finished?.reason, 'timeout'); assert.equal((await s.status('player-b', b.gameSessionId)).match?.finished?.reason, 'timeout');
@@ -517,7 +523,7 @@ test('timeouts progress without polling, unlock colors only from authoritative r
assert.equal(profile.stats.wins, 5); assert.equal(profile.stats.best, 5); assert.equal(profile.stats.wins, 5); assert.equal(profile.stats.best, 5);
assert.ok(profile.unlockedColors.length > 1); assert.ok(profile.unlockedColors.length > 1);
await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: profile.unlockedColors.find(id => id !== 'white')! } }); await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: profile.unlockedColors.find(id => id !== 'white')! } });
await s.join('player-a', a.gameSessionId); await s.join('player-b', b.gameSessionId); await s.join('player-a', a.gameSessionId); await pairAndAccept(s, a.gameSessionId, b.gameSessionId);
await s.close(); await s.close();
const restarted = await createRankedService(f.db, clock, 'test'); const restarted = await createRankedService(f.db, clock, 'test');
try { assert.equal((await restarted.profile('player-a')).stats.wins, 5); } try { assert.equal((await restarted.profile('player-a')).stats.wins, 5); }
@@ -546,7 +552,7 @@ test('ranked HTTP authenticates identity and ignores client results for ranked a
const profile = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { playerId: 'player-b', stats: { wins: 500 } } }); const profile = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { playerId: 'player-b', stats: { wins: 500 } } });
assert.equal(profile.statusCode, 200); assert.equal(profile.json().data.playerId, 'player-a'); assert.equal(profile.statusCode, 200); assert.equal(profile.json().data.playerId, 'player-a');
await app.inject({ method: 'POST', url: '/api/ranked/queue', headers }); await app.inject({ method: 'POST', url: '/api/ranked/queue', headers });
const paired = await f.service.join('player-b', f.b.gameSessionId); const paired = await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId);
const report = await app.inject({ method: 'POST', url: '/api/game-session/results', headers, payload: { const report = await app.inject({ method: 'POST', url: '/api/game-session/results', headers, payload: {
matchId: paired.match!.matchId, seq: 1, mode: 'friend', outcome: 'win', reason: 'hit', matchId: paired.match!.matchId, seq: 1, mode: 'friend', outcome: 'win', reason: 'hit',
} }); } });
@@ -585,8 +591,8 @@ test('a slow leaderboard scan does not block match actions or deadline processin
const s = await createRankedService(db, f.clock, 'test'); const s = await createRankedService(db, f.clock, 'test');
try { try {
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId; const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS); await f.clock.advance(MATCH_COUNTDOWN_MS);
const board = s.leaderboard('player-a'); await started; const board = s.leaderboard('player-a'); await started;
const action = s.action('player-a', f.a.gameSessionId, id, 1, 'guard'); const action = s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
@@ -628,7 +634,7 @@ test('ranked polls reuse stats and concurrent leaderboard requests share a scan
await Promise.all(Array.from({ length: 8 }, () => s.leaderboard('player-a'))); await Promise.all(Array.from({ length: 8 }, () => s.leaderboard('player-a')));
assert.equal(boards, 1); assert.equal(boards, 1);
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId; const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await s.leave('player-b', f.b.gameSessionId, id); await s.leave('player-b', f.b.gameSessionId, id);
const profile = await s.profile('player-a'); const profile = await s.profile('player-a');
assert.equal(profile.stats.wins, 1); assert.equal(profile.stats.wins, 1);
@@ -673,7 +679,7 @@ test('save during stalled ranked reads returns one snapshot without retrying or
const s = await createRankedService(db, f.clock, 'test'); const s = await createRankedService(db, f.clock, 'test');
try { try {
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId; const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
hold = true; hold = true;
const board = s.leaderboard('player-a'), spectator = s.profile('spectator'); const board = s.leaderboard('player-a'), spectator = s.profile('spectator');
await started; await started;
@@ -737,7 +743,7 @@ test('a personal history read overlapping its own result save cannot repopulate
const s = await createRankedService(db, f.clock, 'test'); const s = await createRankedService(db, f.clock, 'test');
try { try {
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId; const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
// Expire the cached stats without running timers; leave settles the overdue draw. // Expire the cached stats without running timers; leave settles the overdue draw.
f.clock.time += RANKED_STATS_CACHE_TTL_MS; f.clock.time += RANKED_STATS_CACHE_TTL_MS;
hold = true; hold = true;
@@ -777,8 +783,8 @@ test('ranked timer reports only a safe failure code and retries persistence even
}); });
try { try {
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
await s.join('player-b', f.b.gameSessionId); await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3); await f.clock.advance(MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
await logged; await logged;
assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']); assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']);
assert.equal(await records.countDocuments(), 0); assert.equal(await records.countDocuments(), 0);