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
+109
View File
@@ -2,9 +2,118 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
import { MongoClient } from 'mongodb';
import type { Db } from 'mongodb';
import { createGameSessionStore } from '../src/server/game-session-store.js';
import { createRoomStore } from '../src/server/rooms.js';
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 () => {
const client = await new MongoClient(uri!, { serverSelectionTimeoutMS: 5000 }).connect();
const db = client.db(`dragon_ball_test_p2p_records_${randomUUID().replaceAll('-', '')}`);