Exclude round one records from match history and statistics
This commit is contained in:
@@ -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
@@ -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. |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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')!;
|
||||||
|
|||||||
Reference in New Issue
Block a user