212 lines
11 KiB
TypeScript
212 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import Fastify from 'fastify';
|
|
import type { Db } from 'mongodb';
|
|
import { registerGameApi, registerRoomApi } from '../src/server/game-api.js';
|
|
import type { GameSessionStore } from '../src/server/game-session-store.js';
|
|
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;
|
|
const promise = new Promise<void>(done => { resolve = done; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
// Only the persistence boundary is fake. Queue ownership, serialization, pairing,
|
|
// availability checks and HTTP routing all run through the production code.
|
|
function memoryDatabase(now: number) {
|
|
const records: Record<string, Record<string, any>[]> = {
|
|
game_sessions: [
|
|
{ _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: [], global_settings: [],
|
|
};
|
|
const matches = (doc: Record<string, any>, query: Record<string, any> = {}) =>
|
|
Object.entries(query).every(([key, value]) => {
|
|
if (value && typeof value === 'object' && '$gt' in value) return doc[key] > value.$gt;
|
|
if (value && typeof value === 'object' && '$in' in value) {
|
|
return (Array.isArray(doc[key]) ? doc[key] : [doc[key]]).some(item => value.$in.includes(item));
|
|
}
|
|
return Array.isArray(doc[key]) ? doc[key].includes(value) : doc[key] === value;
|
|
});
|
|
const db = { collection(name: string) {
|
|
const data = records[name];
|
|
return {
|
|
async createIndex() {},
|
|
async findOne(query: Record<string, any>) { return data.find(doc => matches(doc, query)) ?? null; },
|
|
find(query?: Record<string, any>) {
|
|
const rows = data.filter(doc => matches(doc, query));
|
|
return {
|
|
sort() { return this; }, limit() { return this; },
|
|
async next() { return rows[0] ?? null; }, async toArray() { return rows; },
|
|
async *[Symbol.asyncIterator]() { yield* rows; },
|
|
};
|
|
},
|
|
async updateOne(query: Record<string, any>, update: Record<string, any>) {
|
|
const doc = data.find(row => matches(row, query));
|
|
if (doc) Object.assign(doc, update.$set ?? {});
|
|
else data.push({ ...query, ...update.$setOnInsert, ...update.$set });
|
|
},
|
|
};
|
|
} } as unknown as Db;
|
|
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());
|
|
const ranked = await createRankedService(db, clock, 'test'), app = Fastify();
|
|
const entered = deferred(), release = deferred();
|
|
const regularMatch = { matchId: 'bot-match', requestId: 'request-a', mode: 'bot', expectedPreviousSeq: 0 };
|
|
const room = { id: 'friend-room' };
|
|
const write = async () => {
|
|
entered.resolve();
|
|
await release.promise;
|
|
if (url === '/api/game-session/matches') records.game_sessions[0].activeMatch = regularMatch;
|
|
else records.rooms.push({ _id: room.id, members: ['session-a'], open: true, expiresAt: new Date(clock.now() + 60000) });
|
|
return url === '/api/game-session/matches' ? regularMatch : room;
|
|
};
|
|
const sessions = {
|
|
async get() { return { gameSessionId: 'session-a', playerId: 'player-a' }; },
|
|
register: write,
|
|
} as unknown as GameSessionStore;
|
|
const rooms = { create: write, join: write } as unknown as RoomStore;
|
|
registerGameApi(app, sessions, undefined, ranked);
|
|
registerRoomApi(app, sessions, rooms, { async notifyRoom() {} } as unknown as Signaling, ranked);
|
|
try {
|
|
await ranked.join('player-b', 'session-b');
|
|
const regular = app.inject({ method: 'POST', url,
|
|
headers: { authorization: `Bearer ${'a'.repeat(43)}`, 'x-game-session': 'session-a' },
|
|
payload: url === '/api/game-session/matches' ? regularMatch : { requestId: 'request-a', code: 'ABCDEF' },
|
|
}).then(response => response);
|
|
await entered.promise;
|
|
const pairing = ranked.join('player-a', 'session-a').then(
|
|
status => ({ status, error: undefined }), error => ({ status: undefined, error }),
|
|
);
|
|
// Drain queued work while the regular-mode write is explicitly held open.
|
|
// With the old check-then-write guard, ranked pairing completes in this gap.
|
|
await new Promise<void>(resolve => setImmediate(resolve));
|
|
release.resolve();
|
|
assert.equal((await regular).statusCode, 200);
|
|
const outcome = await pairing;
|
|
assert.equal(outcome.error?.code, 'MATCH_PENDING', `ranked entered during the write: ${outcome.status?.status}`);
|
|
assert.equal((await ranked.status('player-b', 'session-b')).status, 'queued');
|
|
} finally {
|
|
release.resolve();
|
|
await app.close(); await ranked.close();
|
|
}
|
|
});
|
|
}
|