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
+2 -2
View File
@@ -140,7 +140,7 @@ Requirements on a qualifying server are ANDed:
| Requirement | Evidence | | Requirement | Evidence |
| --- | --- | | --- | --- |
| Reach round X | A round change, heartbeat, or game end reports round >= X with the required player count | | Reach round X | A round change, heartbeat, or game end reports round >= X with the required player count |
| Complete qualifying matches | Game end, optionally qualified by round, player count, and duration | | Complete qualifying matches | Game end at round 2 or later, optionally qualified by a higher round, player count, and duration |
| Populated minutes | Time between consecutive game reports with enough players at both ends | | Populated minutes | Time between consecutive game reports with enough players at both ends |
| Player-hours | Populated minutes multiplied by the lower reported player count, divided by 60 | | Player-hours | Populated minutes multiplied by the lower reported player count, divided by 60 |
| Reach player count | Player array length in a game report | | Reach player count | Player array length in a game report |
@@ -206,4 +206,4 @@ copied into a beta; later edits do not change an active plan.
Internal progress records support attempts and recovery cycles. The reporter Internal progress records support attempts and recovery cycles. The reporter
does not know about or supply these records. Legacy betas without a test plan does not know about or supply these records. Legacy betas without a test plan
also use `game_end` for their match-completion gate. also use `game_end` at round 2 or later for their match-completion gate.
+6 -2
View File
@@ -3,7 +3,11 @@
Open **Leaderboard** in the main navigation to compare players across the fleet, Open **Leaderboard** in the main navigation to compare players across the fleet,
or filter by game and map. Choose a ranking, search a name or GUID, and share the or filter by game and map. Choose a ranking, search a name or GUID, and share the
page URL with its filters intact. Results refresh while the page is visible. page URL with its filters intact. Results refresh while the page is visible.
Active games contribute as soon as a round report arrives. Matches only appear in server/global history and leaderboard map options once
they reach round 2. Player records only contribute to leaderboards, summaries,
profiles and player history once that player is observed at round 2 or later.
This also excludes existing round 1 records. Early telemetry is retained so
qualifying games keep their start time, roster and cumulative counters.
Click a player's name to open their shareable profile at Click a player's name to open their shareable profile at
`/leaderboard/players/[game]/[guid]`. Profiles show all ten lifetime statistics, `/leaderboard/players/[game]/[guid]`. Profiles show all ten lifetime statistics,
@@ -26,7 +30,7 @@ identical names with different GUIDs remain separate. GUIDs stay strings.
| Highest round | Highest round reported with that player present, including players who joined mid-game. | | Highest round | Highest round reported with that player present, including players who joined mid-game. |
| Total kills, headshots, revives, downs | Sum of each player's highest reported counter in each game. Round snapshots are cumulative, so they are never added together within a game. | | Total kills, headshots, revives, downs | Sum of each player's highest reported counter in each game. Round snapshots are cumulative, so they are never added together within a game. |
| Total rounds | Round progression during observed participation, added across games. A player observed at game start and then round 8 receives seven rounds. A player first seen at round 8 gets a highest-round record of 8, but zero total rounds; reaching round 9 then adds one. | | Total rounds | Round progression during observed participation, added across games. A player observed at game start and then round 8 receives seven rounds. A player first seen at round 8 gets a highest-round record of 8, but zero total rounds; reaching round 9 then adds one. |
| Games played | Games in which at least one stats report contains the player. A start-only roster does not count yet. | | Games played | Games in which a stats report at round 2 or later contains the player. Start-only and round 1 participation do not count. |
| Games completed | Games whose `game_end` roster contains the player. Disconnected players retain earlier stats without receiving completion credit. | | Games completed | Games whose `game_end` roster contains the player. Disconnected players retain earlier stats without receiving completion credit. |
| Best game kills | Highest kill count recorded in any one game. | | Best game kills | Highest kill count recorded in any one game. |
| Highest reported score | Highest observed score balance. Spending points can lower the score, so this is not cumulative points earned. | | Highest reported score | Highest observed score balance. Spending points can lower the score, so this is not cumulative points earned. |
+3
View File
@@ -1,3 +1,6 @@
// Reset/abandoned games at round 1 do not qualify as gameplay records.
export const MIN_RECORDED_ROUND = 2;
export interface ServerMatch { export interface ServerMatch {
id: number; id: number;
server: string; server: string;
+2
View File
@@ -1,5 +1,6 @@
import type { BetaPlan, RequirementProgress } from '../../beta-plan.js'; import type { BetaPlan, RequirementProgress } from '../../beta-plan.js';
import type { GameEvent } from '../../telemetry.js'; import type { GameEvent } from '../../telemetry.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js';
export interface MatchEvidence { export interface MatchEvidence {
map: string; map: string;
@@ -46,6 +47,7 @@ export function applyGameEvent(plan: BetaPlan, metrics: TestMetrics, event: Game
if (r.kind === 'round') metrics.values[r.id] = Math.max(current, event.round); if (r.kind === 'round') metrics.values[r.id] = Math.max(current, event.round);
} }
if (event.event === 'game_end' && r.kind === 'matches' if (event.event === 'game_end' && r.kind === 'matches'
&& event.round >= MIN_RECORDED_ROUND
&& (match.rounds[r.id] ?? -1) >= r.minRound && (match.rounds[r.id] ?? -1) >= r.minRound
&& (r.minMinutes === 0 || (match.started !== undefined && now - match.started >= r.minMinutes * 60_000))) { && (r.minMinutes === 0 || (match.started !== undefined && now - match.started >= r.minMinutes * 60_000))) {
metrics.values[r.id] = current + 1; metrics.values[r.id] = current + 1;
+5 -3
View File
@@ -1,4 +1,5 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric, import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric,
type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js'; type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js';
@@ -15,7 +16,8 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
const metric = options.metric && isLeaderboardMetric(options.metric) ? options.metric : 'highestRound'; const metric = options.metric && isLeaderboardMetric(options.metric) ? options.metric : 'highestRound';
const limit = Number.isSafeInteger(options.limit) ? Math.min(100, Math.max(1, options.limit!)) : 50; const limit = Number.isSafeInteger(options.limit) ? Math.min(100, Math.max(1, options.limit!)) : 50;
const offset = Number.isSafeInteger(options.offset) ? Math.min(MAX_LEADERBOARD_OFFSET, Math.max(0, options.offset!)) : 0; const offset = Number.isSafeInteger(options.offset) ? Math.min(MAX_LEADERBOARD_OFFSET, Math.max(0, options.offset!)) : 0;
const conditions = ['mp.has_stats = 1']; const conditions = ['mp.has_stats = 1', `m.highest_round >= ${MIN_RECORDED_ROUND}`,
`mp.highest_round >= ${MIN_RECORDED_ROUND}`];
const parameters: string[] = []; const parameters: string[] = [];
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); } if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
if (options.map) { conditions.push('m.map_key = ?'); parameters.push(options.map); } if (options.map) { conditions.push('m.map_key = ?'); parameters.push(options.map); }
@@ -48,9 +50,9 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
COALESCE(MAX(mp.highest_round), 0) AS highestRound, COALESCE(MAX(mp.highest_round), 0) AS highestRound,
MIN(m.started_at) AS firstTrackedAt, MAX(m.last_received_at) AS updatedAt MIN(m.started_at) AS firstTrackedAt, MAX(m.last_received_at) AS updatedAt
${scoped}`).get(...parameters, ...parameters) as LeaderboardResponse['summary']; ${scoped}`).get(...parameters, ...parameters) as LeaderboardResponse['summary'];
// Keep all recorded map variants available, even after their server is removed. // Keep qualifying map variants available, even after their server is removed.
const maps = handle.prepare(`SELECT map_key AS key, game, map, map_name AS name const maps = handle.prepare(`SELECT map_key AS key, game, map, map_name AS name
FROM leaderboard_matches ${options.game ? 'WHERE game = ?' : ''} FROM leaderboard_matches WHERE highest_round >= ${MIN_RECORDED_ROUND}${options.game ? ' AND game = ?' : ''}
GROUP BY game, map_key HAVING id = MAX(id) GROUP BY game, map_key HAVING id = MAX(id)
ORDER BY game, name COLLATE NOCASE, map_key`) ORDER BY game, name COLLATE NOCASE, map_key`)
.all(...(options.game ? [options.game] : [])) as LeaderboardMap[]; .all(...(options.game ? [options.game] : [])) as LeaderboardMap[];
+2 -2
View File
@@ -1,9 +1,9 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import type { ServerMatch } from '../../match-history.js'; import { MIN_RECORDED_ROUND, type ServerMatch } from '../../match-history.js';
export function serverMatchHistory(server?: string, limit = 25): { matches: ServerMatch[]; total: number } { export function serverMatchHistory(server?: string, limit = 25): { matches: ServerMatch[]; total: number } {
const handle = db(); const handle = db();
const where = server === undefined ? '' : 'WHERE m.server = ?'; const where = `WHERE m.highest_round >= ${MIN_RECORDED_ROUND}${server === undefined ? '' : ' AND m.server = ?'}`;
const parameters = server === undefined ? [] : [server]; const parameters = server === undefined ? [] : [server];
const total = (handle.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`).get(...parameters) as { total: number }).total; const total = (handle.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`).get(...parameters) as { total: number }).total;
const matches = handle.prepare(`SELECT m.id, m.server, m.map_name AS map, m.started_at AS startedAt, m.status, const matches = handle.prepare(`SELECT m.id, m.server, m.map_name AS map, m.started_at AS startedAt, m.status,
+3 -1
View File
@@ -1,4 +1,5 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js';
import type { PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js'; import type { PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills, const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills,
@@ -6,7 +7,8 @@ const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills,
SUM(mp.revives) AS revives, SUM(mp.downs) AS downs, COUNT(*) AS gamesPlayed, SUM(mp.revives) AS revives, SUM(mp.downs) AS downs, COUNT(*) AS gamesPlayed,
SUM(mp.completed) AS gamesCompleted, MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore`; SUM(mp.completed) AS gamesCompleted, MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore`;
const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id
WHERE m.game = ? AND mp.guid = ? AND mp.has_stats = 1`; WHERE m.game = ? AND mp.guid = ? AND mp.has_stats = 1
AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}`;
export function getPlayerProfile(game: string, guid: string, requestedOffset = 0): PlayerProfile | null { export function getPlayerProfile(game: string, guid: string, requestedOffset = 0): PlayerProfile | null {
const handle = db(); const handle = db();
+2 -1
View File
@@ -1,5 +1,6 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import type { GameTelemetry } from '../../telemetry.js'; import type { GameTelemetry } from '../../telemetry.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { recordLeaderboardTelemetry } from './leaderboard-ingest.js'; import { recordLeaderboardTelemetry } from './leaderboard-ingest.js';
export function listTelemetry(): GameTelemetry[] { export function listTelemetry(): GameTelemetry[] {
@@ -36,7 +37,7 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
} else if (report.event === 'game_end') { } else if (report.event === 'game_end') {
handle.prepare('UPDATE servers SET game_ended_at = ? WHERE name = ?').run(report.receivedAt, report.server); handle.prepare('UPDATE servers SET game_ended_at = ? WHERE name = ?').run(report.receivedAt, report.server);
// Legacy betas also take match completion directly from the script. // Legacy betas also take match completion directly from the script.
handle.prepare(`UPDATE beta_servers SET match_seen_at = ? WHERE server = ? AND stage = 'awaiting-match' if (report.round >= MIN_RECORDED_ROUND) handle.prepare(`UPDATE beta_servers SET match_seen_at = ? WHERE server = ? AND stage = 'awaiting-match'
AND healthy_at <= ? AND beta_id IN (SELECT id FROM betas WHERE state = 'running')`) AND healthy_at <= ? AND beta_id IN (SELECT id FROM betas WHERE state = 'running')`)
.run(report.receivedAt, report.server, report.receivedAt); .run(report.receivedAt, report.server, report.receivedAt);
} }
+3
View File
@@ -62,6 +62,9 @@ describe('beta requirement evaluation', () => {
expect(m.values).toMatchObject({ r0: 25, r1: 1 }); expect(m.values).toMatchObject({ r0: 25, r1: 1 });
applyGameEvent(plan, m, gameEvent('game_start'), 2000); applyGameEvent(plan, m, gameEvent('game_start'), 2000);
applyGameEvent(plan, m, gameEvent('game_end', 1), 3000); 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); 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); 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 { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta, getBeta, listBetaServers } = await import('../../src/lib/server/fleet/beta.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() {}') }); 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 appendFile(join(fixture.t5, names[0], 'logs/games_sp.log'), 'ExitLevel: executed\n');
await tick(); await tick();
expect(getBeta(betaId)?.state).toBe('running'); 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_end'));
await postTelemetry(gameEvent('game_start')); await postTelemetry(gameEvent('game_start'));
await tick(); 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 () => { it('records server match counts, unique rejoins, duration, and final round durably', async () => {
await report('game_start', 0, [player('a'), player('b')]); await report('game_start', 0, [player('a'), player('b')]);
await report('round_change', 2, [player('b'), player('c')]); 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('game_start', 0, []);
await report('round_change', 2); await report('round_change', 2);
await report('game_start'); await report('game_start');
await report('round_change', 2);
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js'); const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
expect(serverMatchHistory(server).matches).toEqual([ expect(serverMatchHistory(server).matches).toEqual([
expect.objectContaining({ status: 'active', startPlayers: 1, endPlayers: null, durationMs: null, finalRound: null, endedAt: null }), 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_start');
await report('game_end', 8); await report('game_end', 8);
await report('game_start', 0, [player()], 't6_zombie_town_1', 'zm_transit'); 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 { GET } = await import('../../src/routes/api/matches/+server.js');
const all = await GET({ url: new URL('http://localhost/api/matches') }).json(); const all = await GET({ url: new URL('http://localhost/api/matches') }).json();
expect(all.total).toBe(2); 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 { db } = await import('../../src/lib/server/db/index.js');
const handle = db(); const handle = db();
const match = handle.prepare(`INSERT INTO leaderboard_matches 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) (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')`); VALUES (?, 'removed-server', ?, ?, ?, ?, ?, ?, ?, ?, 'round_change', 'test')`);
const player = handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at) 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')`); VALUES (?, ?, ?, '2026-09-01T00:00:00.000Z', '2026-09-05T00:00:00.000Z')`);
const record = handle.prepare(`INSERT INTO leaderboard_match_players 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) completed, has_stats, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '2026-09-01T00:00:00.000Z', ?)`); VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '2026-09-01T00:00:00.000Z', ?)`);
const iso = (day: number) => `2026-09-0${day}T00: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(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'); 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'); 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'); 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'); 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'); 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', '101', 'Ace');
player.run('t5', '202', 'Blake'); player.run('t5', '202', 'Blake');
player.run('t5', '303', 'Casey'); player.run('t5', '303', 'Casey');
@@ -44,6 +44,42 @@ async function seedRecords() {
} }
describe('persisted leaderboard queries', () => { 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 () => { it('sums counters across matches, retains personal bests and separates games for the same GUID', async () => {
await seedRecords(); await seedRecords();
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js'); const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
@@ -129,11 +165,11 @@ describe('player profiles', () => {
const handle = await seedRecords(); const handle = await seedRecords();
for (let id = 10; id < 40; id++) { for (let id = 10; id < 40; id++) {
handle.prepare(`INSERT INTO leaderboard_matches handle.prepare(`INSERT INTO leaderboard_matches
(id, 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, 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); 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) handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, has_stats, highest_round, first_seen_at, last_seen_at)
VALUES (?, '101', 1, '2026-09-01', '2026-09-01')`).run(id); VALUES (?, '101', 1, 2, '2026-09-01', '2026-09-01')`).run(id);
} }
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js'); const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const first = getPlayerProfile('t5', '101')!; const first = getPlayerProfile('t5', '101')!;