Prepare game updates and single-process PM2 deployment
This commit is contained in:
+231
-11
@@ -1,10 +1,12 @@
|
||||
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import Fastify from 'fastify';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { MongoClient, type Db } from 'mongodb';
|
||||
import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS, TURN_DURATION_MS } from '../constants/gameplay.js';
|
||||
import { RANKED_FINISHED_TTL_MS, RANKED_QUEUE_TTL_MS } from '../constants/ranked.js';
|
||||
import { RANKED_FINISHED_TTL_MS, RANKED_QUEUE_TTL_MS, RANKED_STATS_CACHE_LIMIT, RANKED_STATS_CACHE_TTL_MS,
|
||||
RANKED_LEADERBOARD_CACHE_LIMIT, RANKED_LEADERBOARD_CACHE_TTL_MS } from '../constants/ranked.js';
|
||||
import { createRankedService } from '../src/server/ranked-service.js';
|
||||
import { registerRankedApi } from '../src/server/ranked-api.js';
|
||||
import { createGameSessionStore } from '../src/server/game-session-store.js';
|
||||
@@ -15,6 +17,30 @@ import { EQUIPMENT_IDS } from '../constants/cosmetics.js';
|
||||
import { createRoomStore } from '../src/server/rooms.js';
|
||||
|
||||
const integration = { skip: !process.env.TEST_MONGODB_URI };
|
||||
function observeResultReads(db: Db, afterRead: (filter?: Record<string, unknown>) => Promise<void>) {
|
||||
const records = db.collection('ranked_matches');
|
||||
return new Proxy(db, { get(target, key) {
|
||||
if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, {
|
||||
get(collection, method) {
|
||||
if (method === 'find') return (filter?: Record<string, unknown>) => {
|
||||
const cursor = collection.find(filter ?? {});
|
||||
const iterate = cursor[Symbol.asyncIterator].bind(cursor);
|
||||
cursor[Symbol.asyncIterator] = async function* () {
|
||||
const snapshot = [];
|
||||
for await (const result of iterate()) snapshot.push(result);
|
||||
await afterRead(filter);
|
||||
yield* snapshot;
|
||||
};
|
||||
return cursor;
|
||||
};
|
||||
const value = Reflect.get(collection, method);
|
||||
return typeof value === 'function' ? value.bind(collection) : value;
|
||||
},
|
||||
});
|
||||
const value = Reflect.get(target, key);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
} });
|
||||
}
|
||||
async function fixture() {
|
||||
const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect();
|
||||
const db = client.db(`dragon_ball_test_ranked_${randomUUID().replaceAll('-', '')}`);
|
||||
@@ -324,7 +350,7 @@ test('server hides choices, checks costs and deadlines, resolves once and awards
|
||||
await s.join('player-a', a.gameSessionId);
|
||||
const id = (await s.join('player-b', b.gameSessionId)).match!.matchId;
|
||||
await assert.rejects(s.action('player-a', a.gameSessionId, id, 0, 'charge'), /INVALID_ACTION/);
|
||||
await clock.advance(MATCH_COUNTDOWN_MS);
|
||||
await clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + 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');
|
||||
@@ -333,13 +359,13 @@ test('server hides choices, checks costs and deadlines, resolves once and awards
|
||||
const hidden = (await s.status('player-b', b.gameSessionId)).match!;
|
||||
assert.equal(hidden.ownAction, null); assert.equal(hidden.reveal, undefined);
|
||||
assert.equal(hidden.version, version);
|
||||
await s.action('player-b', b.gameSessionId, id, 1, 'charge');
|
||||
await clock.advance(TURN_DURATION_MS);
|
||||
const resolved = await s.action('player-b', b.gameSessionId, id, 1, 'charge');
|
||||
assert.equal(resolved.match?.phase, 'revealing');
|
||||
assert.deepEqual((await s.status('player-b', b.gameSessionId)).match?.reveal?.actions, ['charge', 'charge']);
|
||||
await clock.advance(REVEAL_DURATION_MS);
|
||||
await s.action('player-a', a.gameSessionId, id, 2, 'blast');
|
||||
await s.action('player-b', b.gameSessionId, id, 2, 'charge');
|
||||
await clock.advance(TURN_DURATION_MS);
|
||||
const finished = await s.action('player-b', b.gameSessionId, id, 2, 'charge');
|
||||
assert.equal(finished.match?.phase, 'finished');
|
||||
await Promise.all(Array.from({ length: 8 }, () => s.status('player-a', a.gameSessionId)));
|
||||
const ended = await s.status('player-b', b.gameSessionId);
|
||||
assert.equal(ended.status, 'matched'); assert.equal(ended.match?.phase, 'finished');
|
||||
@@ -366,7 +392,7 @@ test('finished matches retain results on reload then expire when abandoned', int
|
||||
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(MATCH_COUNTDOWN_MS + TURN_DURATION_MS);
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS);
|
||||
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);
|
||||
@@ -406,12 +432,14 @@ test('deadline rejects late actions and duplicate terminal writes survive an amb
|
||||
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(MATCH_COUNTDOWN_MS);
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + 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.
|
||||
f.clock.time += TURN_DURATION_MS;
|
||||
await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /response lost after write/);
|
||||
assert.equal((await s.profile('player-a')).stats.wins, 1);
|
||||
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
|
||||
await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /INVALID_ACTION/);
|
||||
assert.equal((await s.status('player-b', f.b.gameSessionId)).match?.finished?.reason, 'timeout');
|
||||
assert.equal(await collection.countDocuments(), 1);
|
||||
@@ -457,7 +485,7 @@ test('timeouts progress without polling, unlock colors only from authoritative r
|
||||
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(MATCH_COUNTDOWN_MS);
|
||||
await clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS);
|
||||
await s.action('player-a', a.gameSessionId, id, 1, 'guard');
|
||||
await clock.advance(TURN_DURATION_MS);
|
||||
assert.equal((await s.status('player-b', b.gameSessionId)).match?.finished?.reason, 'timeout');
|
||||
@@ -536,7 +564,7 @@ test('a slow leaderboard scan does not block match actions or deadline processin
|
||||
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(MATCH_COUNTDOWN_MS);
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + 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>;
|
||||
@@ -545,3 +573,195 @@ test('a slow leaderboard scan does not block match actions or deadline processin
|
||||
assert.equal(progressed, true, 'leaderboard must not own the gameplay operation lock');
|
||||
} finally { release(); await s.close(); await f.close(); }
|
||||
});
|
||||
|
||||
test('ranked polls reuse stats and concurrent leaderboard requests share a scan until a result is saved', integration, async () => {
|
||||
const f = await fixture();
|
||||
await f.service.close();
|
||||
const records = f.db.collection('ranked_matches');
|
||||
let histories = 0, boards = 0;
|
||||
const db = new Proxy(f.db, { get(target, key) {
|
||||
if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, {
|
||||
get(collection, method) {
|
||||
if (method === 'find') return (filter?: Record<string, unknown>) => {
|
||||
const cursor = collection.find(filter ?? {});
|
||||
const iterate = cursor[Symbol.asyncIterator].bind(cursor);
|
||||
cursor[Symbol.asyncIterator] = async function* () {
|
||||
if (filter?.playerIds) histories++; else boards++;
|
||||
yield* iterate();
|
||||
};
|
||||
return cursor;
|
||||
};
|
||||
const value = Reflect.get(collection, method);
|
||||
return typeof value === 'function' ? value.bind(collection) : value;
|
||||
},
|
||||
});
|
||||
const value = Reflect.get(target, key);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
} });
|
||||
const s = await createRankedService(db, f.clock, 'test');
|
||||
try {
|
||||
await Promise.all(Array.from({ length: 8 }, () => s.profile('player-a')));
|
||||
for (let n = 0; n < 8; n++) await s.status('player-a', f.a.gameSessionId);
|
||||
assert.equal(histories, 1);
|
||||
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;
|
||||
await s.leave('player-b', f.b.gameSessionId, id);
|
||||
const profile = await s.profile('player-a');
|
||||
assert.equal(profile.stats.wins, 1);
|
||||
profile.stats.wins = 999;
|
||||
assert.equal((await s.profile('player-a')).stats.wins, 1);
|
||||
const board = await s.leaderboard('player-a');
|
||||
assert.equal(board.self?.wins, 1);
|
||||
board.entries[0].wins = 999;
|
||||
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
|
||||
assert.equal((await s.leaderboard('player-b')).self?.rank, 2);
|
||||
assert.equal(boards, 2);
|
||||
assert.equal(histories, 4);
|
||||
f.clock.time += RANKED_LEADERBOARD_CACHE_TTL_MS;
|
||||
await s.leaderboard('player-a');
|
||||
assert.equal(boards, 3);
|
||||
assert.equal(histories, 4);
|
||||
f.clock.time += RANKED_STATS_CACHE_TTL_MS;
|
||||
await s.profile('player-a');
|
||||
assert.equal(histories, 5);
|
||||
// Exceed the retained-player budget, then revisit the evicted player.
|
||||
await Promise.all(Array.from({ length: RANKED_STATS_CACHE_LIMIT }, (_, n) => s.profile(`cache-player-${n}`)));
|
||||
const before = histories;
|
||||
await s.profile('player-a');
|
||||
assert.equal(histories, before + 1);
|
||||
} finally { await s.close(); await f.close(); }
|
||||
});
|
||||
|
||||
test('save during stalled ranked reads returns one snapshot without retrying or caching stale data', integration, async () => {
|
||||
const f = await fixture();
|
||||
await f.service.close();
|
||||
let release!: () => void, entered!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const started = new Promise<void>(resolve => { entered = resolve; });
|
||||
let hold = false, held = 0, histories = 0, boards = 0;
|
||||
const db = observeResultReads(f.db, async filter => {
|
||||
if (filter?.playerIds) histories++; else boards++;
|
||||
if (hold && (filter === undefined || filter.playerIds === 'spectator')) {
|
||||
if (++held === 2) entered();
|
||||
await gate;
|
||||
}
|
||||
});
|
||||
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;
|
||||
hold = true;
|
||||
const board = s.leaderboard('player-a'), spectator = s.profile('spectator');
|
||||
await started;
|
||||
await s.leave('player-b', f.b.gameSessionId, id);
|
||||
const before = histories;
|
||||
release();
|
||||
assert.equal((await board).entries.length, 0, 'overlapping request returns its snapshot');
|
||||
assert.equal((await spectator).stats.wins, 0);
|
||||
assert.equal(boards, 1, 'a save must not restart the leaderboard scan');
|
||||
assert.equal(histories, before, 'unrelated saves must not restart personal history reads');
|
||||
hold = false;
|
||||
await s.profile('spectator');
|
||||
assert.equal(histories, before, 'unrelated player retains its cache');
|
||||
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
|
||||
assert.equal(boards, 2, 'overlapping stale snapshot must not be cached');
|
||||
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
|
||||
assert.equal(boards, 2);
|
||||
} finally { release(); await s.close(); await f.close(); }
|
||||
});
|
||||
|
||||
test('bounded leaderboard cache preserves exact self ranks outside the top entries and recovers failed scans', integration, async () => {
|
||||
const f = await fixture();
|
||||
await f.service.close();
|
||||
const count = RANKED_LEADERBOARD_CACHE_LIMIT + 2;
|
||||
const players = Array.from({ length: count }, (_, n) => `rank-${String(n).padStart(4, '0')}`);
|
||||
await f.db.collection<any>('player_profiles').insertMany(players.map(_id => ({ _id, nameVersion: 1, displayName: _id })));
|
||||
await f.db.collection<any>('ranked_matches').insertMany(players.map((id, order) => ({
|
||||
_id: `result-${id}`, playerIds: [id, 'opponent'], winnerIndex: 0, order,
|
||||
})));
|
||||
let scans = 0, fail = true;
|
||||
const db = observeResultReads(f.db, async filter => {
|
||||
if (filter === undefined) { scans++; if (fail) throw new Error('scan failed'); }
|
||||
});
|
||||
const s = await createRankedService(db, f.clock, 'test');
|
||||
try {
|
||||
await assert.rejects(s.leaderboard(players.at(-1)!), /scan failed/);
|
||||
fail = false;
|
||||
const board = await s.leaderboard(players.at(-1)!);
|
||||
assert.equal(board.entries.length, 100);
|
||||
assert.equal(board.self?.rank, count);
|
||||
assert.equal(board.self?.wins, 1);
|
||||
assert.equal((await s.leaderboard(players.at(-1)!)).self?.rank, count);
|
||||
assert.equal(scans, 2, 'failed promise is discarded and successful self rank is cached');
|
||||
// The requested tail entry evicted rank 1 from the bounded self cache.
|
||||
assert.equal((await s.leaderboard(players[0])).self?.rank, 1);
|
||||
assert.equal(scans, 3);
|
||||
assert.equal((await s.leaderboard(players.at(-2)!)).self?.rank, count - 1);
|
||||
assert.equal(scans, 4);
|
||||
} finally { await s.close(); await f.close(); }
|
||||
});
|
||||
|
||||
test('a personal history read overlapping its own result save cannot repopulate stale stats', integration, async () => {
|
||||
const f = await fixture();
|
||||
await f.service.close();
|
||||
let release!: () => void, entered!: () => void, hold = false;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const started = new Promise<void>(resolve => { entered = resolve; });
|
||||
const db = observeResultReads(f.db, async filter => {
|
||||
if (hold && filter?.playerIds === 'player-a') { hold = false; entered(); await gate; }
|
||||
});
|
||||
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;
|
||||
// Expire the cached stats without running timers; leave settles the overdue draw.
|
||||
f.clock.time += RANKED_STATS_CACHE_TTL_MS;
|
||||
hold = true;
|
||||
const old = s.profile('player-a');
|
||||
await started;
|
||||
await s.leave('player-b', f.b.gameSessionId, id);
|
||||
release();
|
||||
assert.equal((await old).stats.draws, 0);
|
||||
assert.equal((await s.profile('player-a')).stats.draws, 1);
|
||||
assert.equal((await s.profile('player-a')).stats.draws, 1);
|
||||
} finally { release(); await s.close(); await f.close(); }
|
||||
});
|
||||
|
||||
test('ranked timer reports only a safe failure code and retries persistence even if logging throws', integration, async () => {
|
||||
const f = await fixture();
|
||||
await f.service.close();
|
||||
const records = f.db.collection('ranked_matches');
|
||||
let fail = true, reported!: () => void;
|
||||
const logged = new Promise<void>(resolve => { reported = resolve; });
|
||||
const codes: string[] = [];
|
||||
const db = new Proxy(f.db, { get(target, key) {
|
||||
if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, {
|
||||
get(collection, method) {
|
||||
if (method === 'updateOne') return async (...args: Parameters<typeof records.updateOne>) => {
|
||||
if (fail) { fail = false; throw new Error('mongodb://secret:password@host'); }
|
||||
return collection.updateOne(...args);
|
||||
};
|
||||
const value = Reflect.get(collection, method);
|
||||
return typeof value === 'function' ? value.bind(collection) : value;
|
||||
},
|
||||
});
|
||||
const value = Reflect.get(target, key);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
} });
|
||||
const s = await createRankedService(db, f.clock, 'test', code => {
|
||||
codes.push(code); reported(); throw new Error('logger failed');
|
||||
});
|
||||
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);
|
||||
await logged;
|
||||
assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']);
|
||||
assert.equal(await records.countDocuments(), 0);
|
||||
await f.clock.advance(1000);
|
||||
assert.equal((await s.status('player-a', f.a.gameSessionId)).profile.stats.draws, 1);
|
||||
assert.equal(await records.countDocuments(), 1);
|
||||
} finally { await s.close(); await f.close(); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user