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
+82
View File
@@ -38,6 +38,88 @@ function documentSurface() {
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 => {
const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: document });