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
+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 { Signaling } from '../src/server/signaling.js';
import { ManualClock } from './helpers.js';
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
function deferred() {
let resolve!: () => void;
@@ -23,7 +24,7 @@ function memoryDatabase(now: number) {
{ _id: 'session-a', playerId: 'player-a', 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> = {}) =>
Object.entries(query).every(([key, value]) => {
@@ -56,6 +57,113 @@ function memoryDatabase(now: number) {
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']) {
test(`${url} reserves the player until its write completes before ranked pairing`, { timeout: 5000 }, async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());