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
+34 -28
View File
@@ -17,11 +17,17 @@ import { EQUIPMENT_IDS } from '../constants/cosmetics.js';
import { createRoomStore } from '../src/server/rooms.js';
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 () => {
const f = await fixture();
try {
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;
await f.clock.advance(paired.match!.startsAt - f.clock.now());
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)]);
assert.ok(first.every(value => value.status === 'queued'));
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);
assert.equal(paired.match?.matchId, restored.match?.matchId);
assert.notEqual(paired.match?.selfIndex, restored.match?.selfIndex);
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);
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/);
assert.equal((await s.profile('player-a')).stats.wins, 0);
} 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();
try {
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-a', f.a.gameSessionId, rankedId);
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' } });
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' } });
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);
@@ -171,7 +177,7 @@ test('startup migrates old custom and default names including nonvisiting leader
try {
await f.service.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } });
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);
const expectedStats = (await f.service.profile('player-a')).stats;
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;
for (let wins = 1; wins <= 20; wins++) {
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-a', f.a.gameSessionId, id);
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' });
assert.deepEqual(stored.appearance, equipped.appearance);
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: [] } });
assert.deepEqual(paired.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);
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' } });
assert.deepEqual(paired.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.deepEqual((await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } })).appearance, equipped.appearance);
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'] } });
assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).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 {
const { service: s, a, a2, b, clock } = f;
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 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/);
const version = (await s.status('player-a', a.gameSessionId)).match!.version;
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();
try {
await f.service.join('player-a', f.a.gameSessionId);
const id = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId;
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
const id = (await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
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');
await f.clock.advance(RANKED_FINISHED_TTL_MS - 1);
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');
try {
await s.join('player-a', f.a.gameSessionId);
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId;
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS);
const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.clock.advance(MATCH_COUNTDOWN_MS);
await s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
assert.equal((await s.leaderboard('player-a')).self, undefined);
// 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.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.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(); }
});
@@ -506,8 +512,8 @@ test('timeouts progress without polling, unlock colors only from authoritative r
const { service: s, a, b, clock } = f;
for (let n = 0; n < 5; n++) {
await s.join('player-a', a.gameSessionId);
const id = (await s.join('player-b', b.gameSessionId)).match!.matchId;
await clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS);
const id = (await pairAndAccept(s, a.gameSessionId, b.gameSessionId)).match!.matchId;
await clock.advance(MATCH_COUNTDOWN_MS);
await s.action('player-a', a.gameSessionId, id, 1, 'guard');
await clock.advance(TURN_DURATION_MS * 3);
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.ok(profile.unlockedColors.length > 1);
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();
const restarted = await createRankedService(f.db, clock, 'test');
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 } } });
assert.equal(profile.statusCode, 200); assert.equal(profile.json().data.playerId, 'player-a');
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: {
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');
try {
await s.join('player-a', f.a.gameSessionId);
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId;
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS);
const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.clock.advance(MATCH_COUNTDOWN_MS);
const board = s.leaderboard('player-a'); await started;
const action = s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
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')));
assert.equal(boards, 1);
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);
const profile = await s.profile('player-a');
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');
try {
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;
const board = s.leaderboard('player-a'), spectator = s.profile('spectator');
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');
try {
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.
f.clock.time += RANKED_STATS_CACHE_TTL_MS;
hold = true;
@@ -777,8 +783,8 @@ test('ranked timer reports only a safe failure code and retries persistence even
});
try {
await s.join('player-a', f.a.gameSessionId);
await s.join('player-b', f.b.gameSessionId);
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await f.clock.advance(MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
await logged;
assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']);
assert.equal(await records.countDocuments(), 0);