Exclude round one records from match history and statistics

This commit is contained in:
2026-09-21 18:07:30 -04:00
parent 50c81456c4
commit 424d6cefb6
12 changed files with 120 additions and 24 deletions
+3
View File
@@ -62,6 +62,9 @@ describe('beta requirement evaluation', () => {
expect(m.values).toMatchObject({ r0: 25, r1: 1 });
applyGameEvent(plan, m, gameEvent('game_start'), 2000);
applyGameEvent(plan, m, gameEvent('game_end', 1), 3000);
expect(m.values.r1).toBe(1);
applyGameEvent(plan, m, gameEvent('game_start'), 4000);
applyGameEvent(plan, m, gameEvent('game_end', 2), 5000);
expect(m.values.r1).toBe(2);
});
+6 -1
View File
@@ -272,7 +272,7 @@ describe('durable beta test plans', () => {
expect(testView(id)?.servers[0].progress[0].current).toBe(25);
});
it('uses game_end for the legacy match gate without reading logs', async () => {
it('uses game_end at round 2 or later for the legacy match gate without reading logs', async () => {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta, getBeta, listBetaServers } = await import('../../src/lib/server/fleet/beta.js');
const { script, version } = createScript({ name: 'Legacy beta', targetRelPath: 'legacy.gsc', content: Buffer.from('main() {}') });
@@ -282,6 +282,11 @@ describe('durable beta test plans', () => {
await appendFile(join(fixture.t5, names[0], 'logs/games_sp.log'), 'ExitLevel: executed\n');
await tick();
expect(getBeta(betaId)?.state).toBe('running');
await postTelemetry(gameEvent('game_end', 1));
await tick();
expect(listBetaServers(betaId)[0].stage).toBe('awaiting-match');
expect(getBeta(betaId)?.state).toBe('running');
await postTelemetry(gameEvent('game_start'));
await postTelemetry(gameEvent('game_end'));
await postTelemetry(gameEvent('game_start'));
await tick();
+38
View File
@@ -34,6 +34,42 @@ async function matches() {
}>;
}
it('starts counting at round 2, preserves early stats, and excludes players who only stayed for round 1', async () => {
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
const departed = player('round-one-only', { kills: 5 });
await report('game_start', 0, [player(), departed]);
await report('round_change', 1, [player(undefined, { kills: 6, score: 1000 }), departed]);
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
expect(getPlayerProfile('t5', player().guid)).toBeNull();
expect(serverMatchHistory()).toEqual({ total: 0, matches: [] });
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
expect(getLeaderboard()).toMatchObject({ total: 1, entries: [expect.objectContaining({
highestRound: 2, kills: 6, bestScore: 1000, roundsSurvived: 1, gamesPlayed: 1, gamesCompleted: 0
})], summary: { players: 1, matches: 1, kills: 6 } });
expect(getPlayerProfile('t5', departed.guid)).toBeNull();
await report('game_end', 2, [player(undefined, { kills: 10 })]);
const board = getLeaderboard();
const profile = getPlayerProfile('t5', player().guid);
const history = serverMatchHistory();
expect(history).toMatchObject({ total: 1, matches: [{ startPlayers: 2, uniquePlayers: 2, durationMs: 3000, finalRound: 2 }] });
await report('game_start');
await report('game_end', 1, [player(undefined, { kills: 5000 })]);
await report('game_start', 0, []);
await report('game_end', 1, []);
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
expect(getLeaderboard()).toEqual(board);
// Identity timestamps still reflect live presence, but gameplay records do not change.
expect(getPlayerProfile('t5', player().guid)).toMatchObject({ stats: profile!.stats, maps: profile!.maps, matches: profile!.matches });
expect(serverMatchHistory()).toEqual(history);
const { GET } = await import('../../src/routes/api/matches/+server.js');
expect(await GET({ url: new URL('http://localhost/api/matches?limit=1') }).json()).toEqual(history);
const { GET: serverGET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
expect(await serverGET({ params: { name: server }, url: new URL('http://localhost/?limit=1') }).json()).toEqual(history);
});
it('records server match counts, unique rejoins, duration, and final round durably', async () => {
await report('game_start', 0, [player('a'), player('b')]);
await report('round_change', 2, [player('b'), player('c')]);
@@ -56,6 +92,7 @@ it('preserves unknown boundaries and distinguishes empty rosters from missing ob
await report('game_start', 0, []);
await report('round_change', 2);
await report('game_start');
await report('round_change', 2);
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
expect(serverMatchHistory(server).matches).toEqual([
expect.objectContaining({ status: 'active', startPlayers: 1, endPlayers: null, durationMs: null, finalRound: null, endedAt: null }),
@@ -74,6 +111,7 @@ it('lists global matches newest first across servers with totals and ending time
await report('game_start');
await report('game_end', 8);
await report('game_start', 0, [player()], 't6_zombie_town_1', 'zm_transit');
await report('round_change', 2, [player()], 't6_zombie_town_1', 'zm_transit');
const { GET } = await import('../../src/routes/api/matches/+server.js');
const all = await GET({ url: new URL('http://localhost/api/matches') }).json();
expect(all.total).toBe(2);
+48 -12
View File
@@ -10,8 +10,8 @@ async function seedRecords() {
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
const match = handle.prepare(`INSERT INTO leaderboard_matches
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, last_event, last_fingerprint)
VALUES (?, 'removed-server', ?, ?, ?, ?, ?, ?, ?, 'round_change', 'test')`);
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint)
VALUES (?, 'removed-server', ?, ?, ?, ?, ?, ?, ?, ?, 'round_change', 'test')`);
const player = handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
VALUES (?, ?, ?, '2026-09-01T00:00:00.000Z', '2026-09-05T00:00:00.000Z')`);
const record = handle.prepare(`INSERT INTO leaderboard_match_players
@@ -19,12 +19,12 @@ async function seedRecords() {
completed, has_stats, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '2026-09-01T00:00:00.000Z', ?)`);
const iso = (day: number) => `2026-09-0${day}T00:00:00.000Z`;
match.run(1, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(1), iso(2), 'completed');
match.run(2, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(2), iso(3), 'active');
match.run(3, 't5', 'zombie_coast', 't5:coast', 'Call of the Dead', iso(3), iso(4), 'completed');
match.run(4, 't6', 'zm_transit', 't6:town', 'Town Survival', iso(4), iso(5), 'completed');
match.run(5, 't6', 'zm_transit', 't6:farm', 'Farm Survival', iso(5), iso(6), 'interrupted');
match.run(6, 't5', 'custom_map', 't5:custom_map', 'Custom Map', iso(1), iso(7), 'active');
match.run(1, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(1), iso(2), 'completed', 10);
match.run(2, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(2), iso(3), 'active', 8);
match.run(3, 't5', 'zombie_coast', 't5:coast', 'Call of the Dead', iso(3), iso(4), 'completed', 4);
match.run(4, 't6', 'zm_transit', 't6:town', 'Town Survival', iso(4), iso(5), 'completed', 20);
match.run(5, 't6', 'zm_transit', 't6:farm', 'Farm Survival', iso(5), iso(6), 'interrupted', 15);
match.run(6, 't5', 'custom_map', 't5:custom_map', 'Custom Map', iso(1), iso(7), 'active', 2);
player.run('t5', '101', 'Ace');
player.run('t5', '202', 'Blake');
player.run('t5', '303', 'Casey');
@@ -44,6 +44,42 @@ async function seedRecords() {
}
describe('persisted leaderboard queries', () => {
it('excludes persisted round 0/1 matches from every history, total, ranking and map option', async () => {
const handle = await seedRecords();
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
const { leaderboardMetrics } = await import('../../src/lib/leaderboard.js');
const boards = leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }));
const profile = getPlayerProfile('t5', '101');
const history = serverMatchHistory(undefined, 2);
const scopedHistory = serverMatchHistory('removed-server', 2);
const scopedBoard = getLeaderboard({ game: 't5', map: 't5:theater' });
let id = 10;
for (const status of ['active', 'completed', 'interrupted']) {
for (const round of [0, 1]) {
handle.prepare(`INSERT INTO leaderboard_matches
(id, server, game, map, map_key, map_name, started_at, last_received_at,
status, highest_round, last_event, last_fingerprint)
VALUES (?, 'removed-server', 't5', 'reset_map', 't5:reset', 'Reset Map',
'2026-09-20', '2026-09-20', ?, ?, 'game_end', 'reset')`).run(id, status, round);
// A historical record with large counters must not change rankings or bests.
handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, highest_round, kills, headshots, revives, downs, best_score,
has_stats, completed, first_seen_at, last_seen_at)
VALUES (?, '101', ?, 99999, 99999, 99999, 99999, 99999, 1, 1, '2026-09-20', '2026-09-20')`)
.run(id++, round);
}
}
expect(leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }))).toEqual(boards);
expect(getLeaderboard({ game: 't5', map: 't5:theater' })).toEqual(scopedBoard);
expect(getLeaderboard({ map: 't5:reset' })).toMatchObject({ entries: [], total: 0,
summary: { players: 0, matches: 0, kills: 0, highestRound: 0, firstTrackedAt: null, updatedAt: null } });
expect(getPlayerProfile('t5', '101')).toEqual(profile);
expect(serverMatchHistory(undefined, 2)).toEqual(history);
expect(serverMatchHistory('removed-server', 2)).toEqual(scopedHistory);
});
it('sums counters across matches, retains personal bests and separates games for the same GUID', async () => {
await seedRecords();
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
@@ -129,11 +165,11 @@ describe('player profiles', () => {
const handle = await seedRecords();
for (let id = 10; id < 40; id++) {
handle.prepare(`INSERT INTO leaderboard_matches
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, last_event, last_fingerprint)
SELECT ?, server, game, map, map_key, map_name, started_at, last_received_at, status, last_event, last_fingerprint
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint)
SELECT ?, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint
FROM leaderboard_matches WHERE id = 1`).run(id);
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, has_stats, first_seen_at, last_seen_at)
VALUES (?, '101', 1, '2026-09-01', '2026-09-01')`).run(id);
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, has_stats, highest_round, first_seen_at, last_seen_at)
VALUES (?, '101', 1, 2, '2026-09-01', '2026-09-01')`).run(id);
}
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const first = getPlayerProfile('t5', '101')!;