feat: require ranked match acceptance with configurable timeout
This commit is contained in:
@@ -9,6 +9,15 @@ import { createGlobalSettingsStore } from '../src/server/global-settings.js';
|
||||
import { createGameSessionStore } from '../src/server/game-session-store.js';
|
||||
import { createRoomStore } from '../src/server/rooms.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 () => {
|
||||
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 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);
|
||||
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();
|
||||
const migrated = await store.get();
|
||||
assert.equal(migrated.matchingDelayMs, 1000); assert.equal(migrated.matchSuccessDelayMs, 1000);
|
||||
assert.equal(migrated.matchPreparationDelayMs, 3000);
|
||||
assert.equal(migrated.rankedAcceptTimeoutMs, 10000);
|
||||
assert.equal(migrated.finishCutsceneSpeed, 3); assert.equal(migrated.turnDurationMs, 2000);
|
||||
assert.ok(migrated.revision > 3);
|
||||
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((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, matchingDelayMs: -1 }), /INVALID_SETTINGS/);
|
||||
} finally { await app.close(); await db.dropDatabase(); await client.close(); }
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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());
|
||||
|
||||
+34
-28
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user