Prepare game updates and single-process PM2 deployment

This commit is contained in:
2026-10-14 19:13:36 +09:00
parent 4f24129e29
commit 22792bc2b4
50 changed files with 1546 additions and 197 deletions
+231 -11
View File
@@ -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(); }
});