Clean up abandoned matches and fix analytics validation

This commit is contained in:
2026-10-19 15:23:16 +09:00
parent 65c3941e29
commit 6f44ec0d15
14 changed files with 232 additions and 10 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ export const RETRY_INTERVAL_MS = 5000;
export const MAX_EVENTS_PER_MINUTE = 120; export const MAX_EVENTS_PER_MINUTE = 120;
export const RETENTION_SWEEP_MS = 60 * 60 * 1000; export const RETENTION_SWEEP_MS = 60 * 60 * 1000;
export const CLIENT_EVENTS = ['page_view', 'screen_view', 'ui_action', 'client_error', 'game_started', 'game_finished'] as const; export const CLIENT_EVENTS = ['page_view', 'screen_view', 'ui_action', 'client_error', 'game_started', 'game_finished'] as const;
export const SCREENS = ['home', 'join', 'waiting', 'match', 'result'] as const; export const SCREENS = ['home', 'join', 'waiting', 'match', 'result', 'ranked-queue'] as const;
export const UI_ACTIONS = ['bot_start', 'room_create', 'room_join', 'rematch'] as const; export const UI_ACTIONS = ['bot_start', 'room_create', 'room_join', 'rematch'] as const;
export const CLIENT_ERROR_CODES = ['storage_unavailable', 'connection_failed', 'renderer_failed'] as const; export const CLIENT_ERROR_CODES = ['storage_unavailable', 'connection_failed', 'renderer_failed'] as const;
export const CAMPAIGN_VALUES = ['friend', 'organic', 'social', 'email', 'launch'] as const; export const CAMPAIGN_VALUES = ['friend', 'organic', 'social', 'email', 'launch'] as const;
+2
View File
@@ -1,2 +1,4 @@
export const PRESENCE_AUTH_REFRESH_MS = 60000; export const PRESENCE_AUTH_REFRESH_MS = 60000;
export const PRESENCE_RETRY_MS = 5000; export const PRESENCE_RETRY_MS = 5000;
export const MATCH_PRESENCE_GRACE_MS = 120000;
export const MATCH_CLEANUP_INTERVAL_MS = 30000;
+1 -1
View File
@@ -67,7 +67,7 @@ export class Analytics {
if (data.eventType === 'client_error' && !CLIENT_ERROR_CODES.includes(data.code as any)) reject('INVALID_ANALYTICS_EVENT', '오류 코드를 확인하세요.'); if (data.eventType === 'client_error' && !CLIENT_ERROR_CODES.includes(data.code as any)) reject('INVALID_ANALYTICS_EVENT', '오류 코드를 확인하세요.');
const gameSummary = data.eventType === 'game_started' || data.eventType === 'game_finished'; const gameSummary = data.eventType === 'game_started' || data.eventType === 'game_finished';
if (gameSummary && (!validId(data.matchId) || !['bot', 'friend'].includes(String(data.mode)))) reject('INVALID_ANALYTICS_EVENT', '대전을 확인하세요.'); if (gameSummary && (!validId(data.matchId) || !['bot', 'friend'].includes(String(data.mode)))) reject('INVALID_ANALYTICS_EVENT', '대전을 확인하세요.');
if (data.eventType === 'game_finished' && (!['win', 'loss', 'draw', 'aborted'].includes(String(data.outcome)) || !['hit', 'timeout', 'double_timeout', 'forfeit', 'connection_failed', 'protocol_error'].includes(String(data.reason)))) reject('INVALID_ANALYTICS_EVENT', '결과를 확인하세요.'); if (data.eventType === 'game_finished' && (!['win', 'loss', 'draw', 'aborted'].includes(String(data.outcome)) || !['hit', 'timeout', 'double_timeout', 'forfeit', 'disconnect', 'double_disconnect', 'connection_failed', 'protocol_error'].includes(String(data.reason)))) reject('INVALID_ANALYTICS_EVENT', '결과를 확인하세요.');
const state = this.activityState(p); const state = this.activityState(p);
if (data.eventType === 'page_view' && !state.visit?.metadataReady) state.metadata = normalizeMetadata(data.metadata); if (data.eventType === 'page_view' && !state.visit?.metadataReady) state.metadata = normalizeMetadata(data.metadata);
const id = gameSummary && p.gameSessionId ? `game:${p.gameSessionId}:${data.matchId}:${data.eventType}` : `${p.playerId}:${data.eventId}`; const id = gameSummary && p.gameSessionId ? `game:${p.gameSessionId}:${data.matchId}:${data.eventType}` : `${p.playerId}:${data.eventId}`;
+10
View File
@@ -18,6 +18,7 @@ import { registerRankedApi } from './ranked-api.js';
import { createGlobalSettingsStore } from './global-settings.js'; import { createGlobalSettingsStore } from './global-settings.js';
import { gameRoute } from './game-api.js'; import { gameRoute } from './game-api.js';
import { createHttpOptions, registerHttpOperations, logSafeError } from './http-operations.js'; import { createHttpOptions, registerHttpOperations, logSafeError } from './http-operations.js';
import { MATCH_CLEANUP_INTERVAL_MS } from '../../constants/presence.js';
export interface ServerOptions { mongoUri: string; databaseName: string; clientDirectory: string; clock?: Clock; adminUser?: string; adminPassword?: string; environment?: 'production' | 'test'; rtc?: RtcServerOptions; trustProxy?: false | string[]; logLevel?: string } export interface ServerOptions { mongoUri: string; databaseName: string; clientDirectory: string; clock?: Clock; adminUser?: string; adminPassword?: string; environment?: 'production' | 'test'; rtc?: RtcServerOptions; trustProxy?: false | string[]; logLevel?: string }
export async function createGameServer(options: ServerOptions): Promise<FastifyInstance> { export async function createGameServer(options: ServerOptions): Promise<FastifyInstance> {
const store = await createStatsStore(options); const store = await createStatsStore(options);
@@ -33,6 +34,14 @@ export async function createGameServer(options: ServerOptions): Promise<FastifyI
? { revision: current.revision } : { revision: current.revision, settings: current }; ? { revision: current.revision } : { revision: current.revision, settings: current };
}); });
const gameSessions = await createGameSessionStore(store.db, () => clock.now(), options.environment ?? 'production'); const gameSessions = await createGameSessionStore(store.db, () => clock.now(), options.environment ?? 'production');
let cleanupClosed = false, cleanupTask: Promise<void> | undefined;
const cleanup = () => {
if (cleanupClosed) return;
cleanupTask = gameSessions.cleanupDisconnectedMatches()
.catch(error => logSafeError(app.log, 'match_cleanup', error))
.finally(() => { if (!cleanupClosed) cleanupTimer = clock.setTimeout(cleanup, MATCH_CLEANUP_INTERVAL_MS); });
};
let cleanupTimer = clock.setTimeout(cleanup, MATCH_CLEANUP_INTERVAL_MS);
registerPresence(io, gameSessions); registerPresence(io, gameSessions);
const peerRooms = await createRoomStore(store.db, () => clock.now()); const peerRooms = await createRoomStore(store.db, () => clock.now());
const ranked = await createRankedService(store.db, clock, options.environment ?? 'production', () => logSafeError(app.log, 'ranked_background', undefined)); const ranked = await createRankedService(store.db, clock, options.environment ?? 'production', () => logSafeError(app.log, 'ranked_background', undefined));
@@ -58,6 +67,7 @@ export async function createGameServer(options: ServerOptions): Promise<FastifyI
} }
registerAdmin(app, store, analytics, clock, { user: options.adminUser, password: options.adminPassword }, directory, settings); registerAdmin(app, store, analytics, clock, { user: options.adminUser, password: options.adminPassword }, directory, settings);
app.addHook('preClose', async () => { app.addHook('preClose', async () => {
cleanupClosed = true; clock.clearTimeout(cleanupTimer); await cleanupTask;
await ranked.close(); await ranked.close();
await analytics.close(); await analytics.close();
await new Promise<void>(done => io.close(() => done())); await new Promise<void>(done => io.close(() => done()));
+46 -2
View File
@@ -8,6 +8,7 @@ import type { FighterAppearance } from '../shared/cosmetics.js';
import type { Pair } from '../shared/types.js'; import type { Pair } from '../shared/types.js';
import { createGlobalSettingsStore } from './global-settings.js'; import { createGlobalSettingsStore } from './global-settings.js';
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js'; import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
import { MATCH_PRESENCE_GRACE_MS } from '../../constants/presence.js';
export class ApiError extends Error { export class ApiError extends Error {
constructor(public statusCode: number, public code: string) { super(code); } constructor(public statusCode: number, public code: string) { super(code); }
@@ -17,14 +18,17 @@ const hash = (value: string) => createHash('sha256').update(value).digest('hex')
interface SessionDocument { interface SessionDocument {
_id: string; tokenHash: string; playerId?: string; environment: string; createdAt: Date; updatedAt: Date; expiresAt: Date; _id: string; tokenHash: string; playerId?: string; environment: string; createdAt: Date; updatedAt: Date; expiresAt: Date;
stats: PlayerStats; lastAppliedSeq: number; stats: PlayerStats; lastAppliedSeq: number;
presenceUntil?: Date;
activeMatch?: ActiveMatch & { registerRequestId: string }; activeMatch?: ActiveMatch & { registerRequestId: string };
lastResult?: { matchId: string; seq: number; fingerprint: string; outcome: string; appliedAt: Date }; lastResult?: { matchId: string; seq: number; fingerprint: string; outcome: string; appliedAt: Date; abandoned?: boolean };
} }
export interface RegisterMatch extends Omit<ActiveMatch, 'seq'> { requestId: string; expectedPreviousSeq: number } export interface RegisterMatch extends Omit<ActiveMatch, 'seq'> { requestId: string; expectedPreviousSeq: number }
export async function createGameSessionStore(db: Db, now: () => number = Date.now, environment = 'production') { export async function createGameSessionStore(db: Db, now: () => number = Date.now, environment = 'production') {
const sessions = db.collection<SessionDocument>('game_sessions'); const sessions = db.collection<SessionDocument>('game_sessions');
const recoverUntil = now() + MATCH_PRESENCE_GRACE_MS;
await sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); await sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
await sessions.createIndex({ playerId: 1, updatedAt: -1 }); await sessions.createIndex({ playerId: 1, updatedAt: -1 });
await sessions.createIndex({ presenceUntil: 1 });
const authFilter = (auth: GameSessionAuth) => { const authFilter = (auth: GameSessionAuth) => {
if (!auth || !validId(auth.gameSessionId) || typeof auth.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(auth.token)) throw new ApiError(401, 'INVALID_SESSION'); if (!auth || !validId(auth.gameSessionId) || typeof auth.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(auth.token)) throw new ApiError(401, 'INVALID_SESSION');
return { _id: auth.gameSessionId, tokenHash: hash(auth.token), expiresAt: { $gt: new Date(now()) } }; return { _id: auth.gameSessionId, tokenHash: hash(auth.token), expiresAt: { $gt: new Date(now()) } };
@@ -61,6 +65,45 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no
return publicState(await read({ gameSessionId, token }), { gameSessionId, token }); return publicState(await read({ gameSessionId, token }), { gameSessionId, token });
}, },
async get(auth: GameSessionAuth) { return publicState(await read(auth), auth); }, async get(auth: GameSessionAuth) { return publicState(await read(auth), auth); },
async touchPresence(auth: GameSessionAuth) {
const doc = await sessions.findOneAndUpdate(authFilter(auth), {
$max: { presenceUntil: new Date(now() + MATCH_PRESENCE_GRACE_MS) },
}, { returnDocument: 'after' });
if (!doc) throw new ApiError(401, 'INVALID_SESSION');
return publicState(doc, auth);
},
async cleanupDisconnectedMatches() {
// Give existing tabs time to reconnect after a server restart or deployment.
if (now() < recoverUntil) return;
const at = new Date(now());
const expired = { $or: [
{ presenceUntil: { $lte: at } },
{ presenceUntil: { $exists: false }, updatedAt: { $lte: new Date(now() - MATCH_PRESENCE_GRACE_MS) } },
] };
for await (const candidate of sessions.find({ ...expired, activeMatch: { $exists: true } })) {
// Recheck the lease and match in the write: reconnects and result saves may have won the race.
await sessions.updateOne({ ...expired, _id: candidate._id, 'activeMatch.matchId': candidate.activeMatch!.matchId,
'activeMatch.seq': candidate.activeMatch!.seq }, [
{ $set: { lastAppliedSeq: '$activeMatch.seq', updatedAt: at,
lastResult: { matchId: '$activeMatch.matchId', seq: '$activeMatch.seq',
fingerprint: '', outcome: 'aborted', abandoned: true, appliedAt: at } } },
{ $unset: 'activeMatch' },
]);
}
// Rooms may outlive already-saved results. Discover them independently so failures can be retried.
const rooms = db.collection<any>('rooms');
for await (const room of rooms.find({ open: true })) {
const protectedMember = await sessions.findOne({ _id: { $in: room.members }, $or: [
{ activeMatch: { $exists: true } },
{ presenceUntil: { $gt: at } },
{ presenceUntil: { $exists: false }, updatedAt: { $gt: new Date(+at - MATCH_PRESENCE_GRACE_MS) } },
] });
if (protectedMember) continue;
await rooms.updateOne({ _id: room._id, members: room.members, currentMatchId: room.currentMatchId,
round: room.round, status: room.status, open: true },
{ $set: { open: false, status: 'closed' } });
}
},
async register(auth: GameSessionAuth, request: RegisterMatch): Promise<ActiveMatch> { async register(auth: GameSessionAuth, request: RegisterMatch): Promise<ActiveMatch> {
if (!request || !validId(request.requestId) || !validId(request.matchId) || !['bot', 'friend'].includes(request.mode) || !Number.isSafeInteger(request.expectedPreviousSeq) || request.expectedPreviousSeq < 0) throw new ApiError(400, 'INVALID_REQUEST'); if (!request || !validId(request.requestId) || !validId(request.matchId) || !['bot', 'friend'].includes(request.mode) || !Number.isSafeInteger(request.expectedPreviousSeq) || request.expectedPreviousSeq < 0) throw new ApiError(400, 'INVALID_REQUEST');
const before = await read(auth); const before = await read(auth);
@@ -86,7 +129,7 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no
const match = { matchId: request.matchId, seq: request.expectedPreviousSeq + 1, mode: request.mode, const match = { matchId: request.matchId, seq: request.expectedPreviousSeq + 1, mode: request.mode,
...(request.roomId ? { roomId: request.roomId } : {}), appearances, settings, registerRequestId: request.requestId }; ...(request.roomId ? { roomId: request.roomId } : {}), appearances, settings, registerRequestId: request.requestId };
const doc = await sessions.findOneAndUpdate({ ...authFilter(auth), lastAppliedSeq: request.expectedPreviousSeq, activeMatch: { $exists: false } }, { const doc = await sessions.findOneAndUpdate({ ...authFilter(auth), lastAppliedSeq: request.expectedPreviousSeq, activeMatch: { $exists: false } }, {
$set: { activeMatch: match, updatedAt: new Date(now()), expiresAt: new Date(now() + GAME_SESSION_TTL_MS) }, $set: { activeMatch: match, presenceUntil: new Date(now() + MATCH_PRESENCE_GRACE_MS), updatedAt: new Date(now()), expiresAt: new Date(now() + GAME_SESSION_TTL_MS) },
}, { returnDocument: 'after' }); }, { returnDocument: 'after' });
if (doc) return active(doc)!; if (doc) return active(doc)!;
const current = await read(auth); const current = await read(auth);
@@ -116,6 +159,7 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no
const current = await read(auth); const current = await read(auth);
if (input.seq < current.lastAppliedSeq) return { status: 'stale', stats: current.stats }; if (input.seq < current.lastAppliedSeq) return { status: 'stale', stats: current.stats };
if (input.seq === current.lastAppliedSeq && current.lastResult?.matchId === input.matchId) { if (input.seq === current.lastAppliedSeq && current.lastResult?.matchId === input.matchId) {
if (current.lastResult.abandoned) return { status: 'stale', stats: current.stats };
if (current.lastResult.fingerprint !== fingerprint) throw new ApiError(409, 'RESULT_CONFLICT'); if (current.lastResult.fingerprint !== fingerprint) throw new ApiError(409, 'RESULT_CONFLICT');
return { status: 'duplicate', stats: current.stats }; return { status: 'duplicate', stats: current.stats };
} }
+1 -1
View File
@@ -36,7 +36,7 @@ export function createHttpOptions(options: { environment?: 'production' | 'test'
}; };
} }
type ErrorOperation = 'http_request' | 'readiness' | 'startup' | 'shutdown' | 'ranked_background'; type ErrorOperation = 'http_request' | 'readiness' | 'startup' | 'shutdown' | 'ranked_background' | 'match_cleanup';
// Deliberately exclude error message, stack, cause, arbitrary name/code and request input. // Deliberately exclude error message, stack, cause, arbitrary name/code and request input.
export function logSafeError(logger: Pick<FastifyBaseLogger, 'error'>, operation: ErrorOperation, error: unknown, request?: FastifyRequest) { export function logSafeError(logger: Pick<FastifyBaseLogger, 'error'>, operation: ErrorOperation, error: unknown, request?: FastifyRequest) {
const name = error instanceof Error ? error.name : undefined; const name = error instanceof Error ? error.name : undefined;
+2 -2
View File
@@ -12,7 +12,7 @@ export function registerPresence(io: Server, sessions: GameSessionStore) {
} }
namespace.use((socket, next) => { namespace.use((socket, next) => {
const { gameSessionId, token } = socket.handshake.auth; const { gameSessionId, token } = socket.handshake.auth;
void sessions.get({ gameSessionId, token }).then(session => { void sessions.touchPresence({ gameSessionId, token }).then(session => {
socket.data.playerId = session.playerId ?? session.gameSessionId; socket.data.playerId = session.playerId ?? session.gameSessionId;
next(); next();
}, error => next(new Error(error instanceof ApiError && error.statusCode === 401 ? 'INVALID_SESSION' : 'PRESENCE_UNAVAILABLE'))); }, error => next(new Error(error instanceof ApiError && error.statusCode === 401 ? 'INVALID_SESSION' : 'PRESENCE_UNAVAILABLE')));
@@ -25,7 +25,7 @@ export function registerPresence(io: Server, sessions: GameSessionStore) {
if (checking) return; if (checking) return;
checking = true; checking = true;
const { gameSessionId, token } = socket.handshake.auth; const { gameSessionId, token } = socket.handshake.auth;
void sessions.get({ gameSessionId, token }).catch(error => { void sessions.touchPresence({ gameSessionId, token }).catch(error => {
if (error instanceof ApiError && error.statusCode === 401) socket.disconnect(true); if (error instanceof ApiError && error.statusCode === 401) socket.disconnect(true);
else socket.conn.close(); // Transport closure permits Socket.IO to reconnect after a transient outage. else socket.conn.close(); // Transport closure permits Socket.IO to reconnect after a transient outage.
}).finally(() => { checking = false; }); }).finally(() => { checking = false; });
+4
View File
@@ -162,12 +162,16 @@ function viewHarness(t: import('node:test').TestContext) {
const actors = [new Element(), new Element()]; const actors = [new Element(), new Element()];
const container = new Element(); const container = new Element();
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window'); const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: { addEventListener() {}, removeEventListener() {} } });
Object.defineProperty(globalThis, 'window', { configurable: true, value: { Object.defineProperty(globalThis, 'window', { configurable: true, value: {
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
} }); } });
const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {}); const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {});
t.after(() => { t.after(() => {
view.destroy(); view.destroy();
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument);
else Reflect.deleteProperty(globalThis, 'document');
if (previous) Object.defineProperty(globalThis, 'window', previous); if (previous) Object.defineProperty(globalThis, 'window', previous);
else Reflect.deleteProperty(globalThis, 'window'); else Reflect.deleteProperty(globalThis, 'window');
}); });
+1 -1
View File
@@ -252,7 +252,7 @@ function domHarness(t: import('node:test').TestContext, reducedMotion = true) {
} }
const actors = [new Element(), new Element()], appended: Element[] = []; const actors = [new Element(), new Element()], appended: Element[] = [];
const container = new Element(); const container = new Element();
const doc = { body: new Element(), activeElement: new Element(), createElement: () => new Element() }; const doc = { body: new Element(), activeElement: new Element(), createElement: () => new Element(), addEventListener() {}, removeEventListener() {} };
const globals = { document: doc, HTMLElement: Element, window: { const globals = { document: doc, HTMLElement: Element, window: {
matchMedia: () => ({ matches: reducedMotion, addEventListener() {}, removeEventListener() {} }), matchMedia: () => ({ matches: reducedMotion, addEventListener() {}, removeEventListener() {} }),
} }; } };
+109
View File
@@ -2,9 +2,118 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto'; import { randomBytes, randomUUID } from 'node:crypto';
import { MongoClient } from 'mongodb'; import { MongoClient } from 'mongodb';
import type { Db } from 'mongodb';
import { createGameSessionStore } from '../src/server/game-session-store.js'; import { createGameSessionStore } from '../src/server/game-session-store.js';
import { createRoomStore } from '../src/server/rooms.js';
const uri = process.env.TEST_MONGODB_URI; const uri = process.env.TEST_MONGODB_URI;
test('orphan friend cleanup releases its room without discarding the connected opponent result', { skip: !uri }, async () => {
const client = await new MongoClient(uri!).connect();
const db = client.db(`dragon_ball_test_orphan_room_${randomUUID().replaceAll('-', '')}`);
try {
let now = Date.now();
const store = await createGameSessionStore(db, () => now), rooms = await createRoomStore(db, () => now);
const a = await store.create(randomBytes(32).toString('base64url'));
const b = await store.create(randomBytes(32).toString('base64url'));
const waiting = await rooms.create(a.gameSessionId, 'room');
const room = await rooms.join(b.gameSessionId, waiting.code);
const request = { requestId: 'match', matchId: room.currentMatchId, roomId: room.id, mode: 'friend' as const, expectedPreviousSeq: 0 };
await store.register(a, request);
const match = await store.register(b, request);
now += 90000; await store.touchPresence(b);
now += 31000; await store.cleanupDisconnectedMatches();
assert.equal((await rooms.get(room.id, b.gameSessionId)).status, 'paired', 'a connected participant keeps the room');
assert.equal((await store.get(a)).activeMatch, undefined);
assert.equal((await store.get(b)).activeMatch?.matchId, match.matchId);
assert.equal((await store.record(b, { ...match, outcome: 'win', reason: 'disconnect' })).status, 'applied');
assert.deepEqual((await store.get(a)).stats, a.stats);
now += 90000; await store.cleanupDisconnectedMatches();
assert.equal((await rooms.get(room.id, b.gameSessionId)).status, 'closed');
} finally { await db.dropDatabase(); await client.close(); }
});
test('closed browsers release waiting rooms and rooms with both results already saved', { skip: !uri }, async () => {
const client = await new MongoClient(uri!).connect();
const db = client.db(`dragon_ball_test_saved_room_${randomUUID().replaceAll('-', '')}`);
try {
let now = Date.now();
const store = await createGameSessionStore(db, () => now), rooms = await createRoomStore(db, () => now);
const a = await store.create(randomBytes(32).toString('base64url'));
const b = await store.create(randomBytes(32).toString('base64url'));
const c = await store.create(randomBytes(32).toString('base64url'));
const waiting = await rooms.create(a.gameSessionId, 'paired-room');
const room = await rooms.join(b.gameSessionId, waiting.code);
const idle = await rooms.create(c.gameSessionId, 'waiting-room');
for (const session of [a, b]) {
const match = await store.register(session, { requestId: 'match', matchId: room.currentMatchId, roomId: room.id, mode: 'friend', expectedPreviousSeq: 0 });
await store.record(session, { ...match, outcome: 'draw', reason: 'double_timeout' });
}
now += 120001; await store.cleanupDisconnectedMatches();
assert.equal((await rooms.get(room.id, a.gameSessionId)).status, 'closed');
assert.equal((await rooms.get(idle.id, c.gameSessionId)).status, 'closed');
assert.equal((await store.get(a)).stats.friend.draws, 1);
} finally { await db.dropDatabase(); await client.close(); }
});
test('restart grace and a reconnect during cleanup protect the current match', { skip: !uri }, async () => {
const client = await new MongoClient(uri!).connect();
const db = client.db(`dragon_ball_test_reconnect_${randomUUID().replaceAll('-', '')}`);
try {
let now = Date.now();
const store = await createGameSessionStore(db, () => now);
const session = await store.create(randomBytes(32).toString('base64url'));
const match = await store.register(session, { requestId: 'request', matchId: 'match', mode: 'bot', expectedPreviousSeq: 0 });
// Legacy registrations from before lease tracking are protected on server startup too.
await db.collection('game_sessions').updateOne({ _id: session.gameSessionId as any }, { $unset: { presenceUntil: '' } });
now += 300000;
let reconnect = false;
const collection = db.collection('game_sessions');
const wrapped = new Proxy(db, { get(target, key) {
if (key === 'collection') return (name: string) => name !== 'game_sessions' ? target.collection(name) : new Proxy(collection, { get(c, method) {
if (method === 'updateOne') return async (...args: Parameters<typeof collection.updateOne>) => {
if (reconnect) { reconnect = false; await store.touchPresence(session); }
return c.updateOne(...args);
};
const value = Reflect.get(c, method); return typeof value === 'function' ? value.bind(c) : value;
} });
const value = Reflect.get(target, key); return typeof value === 'function' ? value.bind(target) : value;
} }) as Db;
const restarted = await createGameSessionStore(wrapped, () => now);
await restarted.cleanupDisconnectedMatches();
assert.equal((await store.get(session)).activeMatch?.matchId, match.matchId);
now += 120001; reconnect = true;
await restarted.cleanupDisconnectedMatches();
assert.equal(reconnect, false, 'exercise a reconnect between candidate selection and atomic cleanup');
assert.equal((await store.get(session)).activeMatch?.matchId, match.matchId);
now += 120001; await restarted.cleanupDisconnectedMatches();
assert.equal((await store.get(session)).activeMatch, undefined);
} finally { await db.dropDatabase(); await client.close(); }
});
test('disconnected matches expire without changing stats or cancelling a live tab', { skip: !uri }, async () => {
const client = await new MongoClient(uri!).connect();
const db = client.db(`dragon_ball_test_orphans_${randomUUID().replaceAll('-', '')}`);
try {
let now = Date.now();
const store = await createGameSessionStore(db, () => now);
const offline = await store.create(randomBytes(32).toString('base64url'), 'same-player');
const online = await store.create(randomBytes(32).toString('base64url'), 'same-player');
const register = (auth: typeof offline) => store.register(auth, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 0 });
const abandoned = await register(offline), live = await register(online);
now += 90000;
await store.touchPresence(online);
await store.cleanupDisconnectedMatches();
assert.equal((await store.get(offline)).activeMatch?.matchId, abandoned.matchId, 'reconnection grace');
now += 31000;
await store.cleanupDisconnectedMatches();
assert.equal((await store.get(offline)).activeMatch, undefined);
assert.equal((await store.get(online)).activeMatch?.matchId, live.matchId);
assert.deepEqual((await store.get(offline)).stats, offline.stats);
assert.equal((await store.get(offline)).lastAppliedSeq, 1);
assert.equal((await store.record(offline, { ...abandoned, outcome: 'win', reason: 'hit' })).status, 'stale');
assert.deepEqual((await store.get(offline)).stats, offline.stats);
await store.register(offline, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 1 });
await store.cleanupDisconnectedMatches();
assert.ok((await store.get(offline)).activeMatch, 'a new match must not inherit an expired lease');
} finally { await db.dropDatabase(); await client.close(); }
});
test('Standalone session counters apply concurrent and late results exactly once', { skip: !uri }, async () => { test('Standalone session counters apply concurrent and late results exactly once', { skip: !uri }, async () => {
const client = await new MongoClient(uri!, { serverSelectionTimeoutMS: 5000 }).connect(); const client = await new MongoClient(uri!, { serverSelectionTimeoutMS: 5000 }).connect();
const db = client.db(`dragon_ball_test_p2p_records_${randomUUID().replaceAll('-', '')}`); const db = client.db(`dragon_ball_test_p2p_records_${randomUUID().replaceAll('-', '')}`);
+5
View File
@@ -42,6 +42,11 @@ test('HTTP analytics batches deduplicate, throttle activity writes and keep clie
assert.equal(await stats.db.collection('matches').countDocuments(), 0); assert.equal(await stats.db.collection('matches').countDocuments(), 0);
assert.equal((await post('/api/analytics/events', { events: Array(21).fill(events[0]) })).statusCode, 400); assert.equal((await post('/api/analytics/events', { events: Array(21).fill(events[0]) })).statusCode, 400);
assert.equal((await post('/api/analytics/events', { events: [{ eventId: 'fake', eventType: 'match_started' }] })).statusCode, 400); assert.equal((await post('/api/analytics/events', { events: [{ eventId: 'fake', eventType: 'match_started' }] })).statusCode, 400);
assert.equal((await post('/api/analytics/events', { events: [
{ eventId: 'ranked-screen', eventType: 'screen_view', screen: 'ranked-queue' },
{ eventId: 'disconnect-summary', eventType: 'game_finished', matchId: 'disconnected', mode: 'friend', outcome: 'aborted', reason: 'disconnect' },
{ eventId: 'double-disconnect-summary', eventType: 'game_finished', matchId: 'both-disconnected', mode: 'friend', outcome: 'aborted', reason: 'double_disconnect' },
] })).statusCode, 200);
assert.equal((await app.inject({ method: 'POST', url: '/api/analytics/activity', payload: { visible: true } })).statusCode, 401); assert.equal((await app.inject({ method: 'POST', url: '/api/analytics/activity', payload: { visible: true } })).statusCode, 401);
} finally { await app.close(); await analytics.close(); try { await stats.db.dropDatabase(); } finally { await stats.close(); } } } finally { await app.close(); await analytics.close(); try { await stats.db.dropDatabase(); } finally { await stats.close(); } }
}); });
+2 -2
View File
@@ -9,7 +9,7 @@ import { PRESENCE_AUTH_REFRESH_MS } from '../constants/presence.js';
test('presence counts authenticated players once across tabs and removes disconnected players', async () => { test('presence counts authenticated players once across tabs and removes disconnected players', async () => {
const http = createServer(), io = new Server(http); const http = createServer(), io = new Server(http);
registerPresence(io, { async get(auth: { token: string; gameSessionId: string }) { registerPresence(io, { async touchPresence(auth: { token: string; gameSessionId: string }) {
if (auth.token !== 'valid') throw new ApiError(401, 'INVALID_SESSION'); if (auth.token !== 'valid') throw new ApiError(401, 'INVALID_SESSION');
return { playerId: auth.gameSessionId.startsWith('a') ? 'a' : 'b' }; return { playerId: auth.gameSessionId.startsWith('a') ? 'a' : 'b' };
} } as unknown as GameSessionStore); } } as unknown as GameSessionStore);
@@ -45,7 +45,7 @@ test('temporary revalidation errors allow reconnect, invalid sessions remain dis
}) as typeof setInterval); }) as typeof setInterval);
let failure: 'temporary' | 'invalid' | undefined; let failure: 'temporary' | 'invalid' | undefined;
const http = createServer(), io = new Server(http); const http = createServer(), io = new Server(http);
registerPresence(io, { async get() { registerPresence(io, { async touchPresence() {
if (failure === 'invalid') throw new ApiError(401, 'INVALID_SESSION'); if (failure === 'invalid') throw new ApiError(401, 'INVALID_SESSION');
if (failure === 'temporary') { failure = undefined; throw new Error('DB_UNAVAILABLE'); } if (failure === 'temporary') { failure = undefined; throw new Error('DB_UNAVAILABLE'); }
return { playerId: 'one' }; return { playerId: 'one' };
+14
View File
@@ -17,6 +17,20 @@ import { EQUIPMENT_IDS } from '../constants/cosmetics.js';
import { createRoomStore } from '../src/server/rooms.js'; import { createRoomStore } from '../src/server/rooms.js';
const integration = { skip: !process.env.TEST_MONGODB_URI }; const integration = { skip: !process.env.TEST_MONGODB_URI };
test('orphan cleanup unblocks the same player without blocking independent browsers or live tabs', integration, async () => {
const f = await fixture();
try {
await f.sessions.register(f.a, { requestId: 'bot', matchId: 'bot', mode: 'bot', expectedPreviousSeq: 0 });
assert.equal((await f.service.join('player-b', f.b.gameSessionId)).status, 'queued');
await f.service.cancel('player-b', f.b.gameSessionId);
await assert.rejects(f.service.join('player-a', f.a2.gameSessionId), /MATCH_PENDING/);
f.clock.time += 90000; await f.sessions.touchPresence(f.a);
f.clock.time += 31000; await f.sessions.cleanupDisconnectedMatches();
await assert.rejects(f.service.join('player-a', f.a2.gameSessionId), /MATCH_PENDING/);
f.clock.time += 90000; await f.sessions.cleanupDisconnectedMatches();
assert.equal((await f.service.join('player-a', f.a2.gameSessionId)).status, 'queued');
} finally { await f.close(); }
});
async function pairAndAccept(service: Awaited<ReturnType<typeof createRankedService>>, sessionA: string, sessionB: string) { async function pairAndAccept(service: Awaited<ReturnType<typeof createRankedService>>, sessionA: string, sessionB: string) {
const paired = await service.join('player-b', sessionB); const paired = await service.join('player-b', sessionB);
assert.equal(paired.status, 'confirming'); assert.equal(paired.status, 'confirming');
+34
View File
@@ -6,6 +6,40 @@ import { io } from 'socket.io-client';
import { createGameServer } from '../src/server/create-server.js'; import { createGameServer } from '../src/server/create-server.js';
import { ManualClock } from './helpers.js'; import { ManualClock } from './helpers.js';
const uri = process.env.TEST_MONGODB_URI; const uri = process.env.TEST_MONGODB_URI;
test('server cleanup releases a closed browser while an authenticated presence connection protects another tab', { skip: !uri }, async () => {
const databaseName = 'dragon_ball_test_cleanup_' + randomUUID().replaceAll('-', ''), clock = new ManualClock();
const app = await createGameServer({ mongoUri: uri!, databaseName, clientDirectory: 'dist/client', clock });
const client = await new MongoClient(uri!).connect();
let socket: ReturnType<typeof io> | undefined;
try {
const url = await app.listen({ host: '127.0.0.1', port: 0 });
const create = async (guestToken?: string) => (await app.inject({ method: 'POST', url: '/api/game-sessions',
payload: { createRequestId: randomBytes(32).toString('base64url'), guestToken } })).json().data;
const old = await create(), live = await create(old.guestToken), otherBrowser = await create();
assert.equal(old.playerId, live.playerId);
assert.notEqual(old.playerId, otherBrowser.playerId);
const headers = (s: typeof old) => ({ authorization: 'Bearer ' + s.token, 'x-game-session': s.gameSessionId });
for (const session of [old, live]) {
assert.equal((await app.inject({ method: 'POST', url: '/api/game-session/matches', headers: headers(session),
payload: { requestId: 'bot', matchId: session.gameSessionId, mode: 'bot', expectedPreviousSeq: 0 } })).statusCode, 200);
}
assert.equal((await app.inject({ method: 'POST', url: '/api/ranked/queue', headers: headers(otherBrowser), payload: {} })).statusCode, 200);
await clock.advance(90000);
socket = io(url + '/presence', { autoConnect: false, reconnection: false, auth: { gameSessionId: live.gameSessionId, token: live.token } });
const connected = new Promise<void>((resolve, reject) => { socket!.once('connect', resolve); socket!.once('connect_error', reject); });
socket.connect(); await connected;
await clock.advance(60000);
const get = async (s: typeof old) => (await app.inject({ url: '/api/game-session', headers: headers(s) })).json().data;
assert.equal((await get(old)).activeMatch, undefined);
assert.equal((await get(old)).stats.bot.losses, 0);
assert.equal((await get(live)).activeMatch.matchId, live.gameSessionId);
assert.equal((await app.inject({ method: 'POST', url: '/api/ranked/queue', headers: headers(old), payload: {} })).json().code, 'MATCH_PENDING');
socket.disconnect();
await clock.advance(90000);
assert.equal((await get(live)).activeMatch, undefined);
assert.equal((await app.inject({ method: 'POST', url: '/api/ranked/queue', headers: headers(old), payload: {} })).statusCode, 200);
} finally { socket?.disconnect(); await app.close(); await client.db(databaseName).dropDatabase(); await client.close(); }
});
test('HTTP game registration survives server restart without game timers and old gameplay sockets are rejected', { skip: !uri }, async () => { test('HTTP game registration survives server restart without game timers and old gameplay sockets are rejected', { skip: !uri }, async () => {
const databaseName = 'dragon_ball_test_server_' + randomUUID().replaceAll('-', ''), clock = new ManualClock(); const databaseName = 'dragon_ball_test_server_' + randomUUID().replaceAll('-', ''), clock = new ManualClock();