Fix keyed telemetry match attribution (Refs #144)
CI / checks (push) Successful in 5m26s

This commit is contained in:
2026-10-12 00:58:05 -04:00
parent 4ea291d305
commit 785cc4616b
24 changed files with 1115 additions and 112 deletions
+43 -9
View File
@@ -73,6 +73,22 @@ without trimming or truncation. Keep GUIDs as strings to preserve their precisio
and leading zeros. Round and stat values are nonnegative JSON integers.
Each GUID must appear only once within a report's player array.
Every event also accepts these optional top-level fields:
| Field | Meaning |
| ------- | --------------------------------------------------------------------------------------------------- |
| `match` | Reporter-generated game identity: a nonblank string of at most 64 UTF-16 code units. |
| `seq` | Positive JSON safe integer, at most 9,007,199,254,740,991, identifying this report within the game. |
Send both fields for reliable match boundaries, ordering, and retry handling.
Choose a fresh `match` value for every game, including restarts on the same map,
and keep it unchanged throughout that game. Match keys are scoped to the exact
`server` name. Start `seq` at 1 and increment it once per event, including
heartbeats. Retry a report with its original `match`, `seq`, and payload; do not
allocate a new sequence number for a retry. Either field may be omitted for
compatibility, but sequence-based deduplication requires both fields. Existing
reporters without these fields remain valid.
Every event also accepts an optional per-player `ping`: the game server's current
latency measurement in milliseconds, as a nonnegative JSON integer (for example,
`"ping": 42`). Send `null` or omit the field when unavailable; do not substitute
@@ -95,6 +111,8 @@ Send when the game starts. Round and stats are not required.
{
"event": "game_start",
"server": "t6_zombie_town_1",
"match": "town-20261012-001",
"seq": 1,
"map": "zm_transit",
"players": [
{ "name": "Josh", "guid": "12345678901234567" },
@@ -111,6 +129,8 @@ Send the new round and each player's current stats.
{
"event": "round_change",
"server": "t6_zombie_town_1",
"match": "town-20261012-001",
"seq": 2,
"map": "zm_transit",
"round": 25,
"players": [
@@ -144,6 +164,8 @@ Send the final round and each player's final stats when the game ends.
{
"event": "game_end",
"server": "t6_zombie_town_1",
"match": "town-20261012-001",
"seq": 3,
"map": "zm_transit",
"round": 26,
"players": [
@@ -179,16 +201,28 @@ validation. Authenticated bodies over 1 MiB return 413 before JSON parsing.
Invalid JSON or fields return 400, an unknown/destroyed server 404, and a non-JSON
content type 415.
The script sends each event once, in gameplay order, directly to Electric Cherry.
There is no discovery request, beta token, boot/session ID, sequence number,
custom feature event, external helper, or separate beta acknowledgement.
Basic payload checks happen in the receiver.
Send events in gameplay order directly to Electric Cherry. There is no discovery
request, beta token, custom feature event, external helper, or separate beta
acknowledgement. Basic payload checks happen in the receiver.
Electric Cherry saves the latest complete report, including player stats, and
With `match` and `seq`, repeated sequences are ignored even after an application
restart. A lower sequence that arrives late can fill a missing-report gap without
rolling back the current round, roster, or counters. A delayed `game_start` can
record the starting roster without resetting the game's observed progression.
A known older match key cannot replace a newer game's live state. When an unseen
key starts a new game before the previous keyed game's `game_end` arrives,
Matches labels the previous game **End report missing**. A later `game_end`
completes its own match without changing the newer game. Missing sequence counts
are diagnostic: they do not invent absent rounds, rosters, or an ending.
Duplicate and older reports do not advance beta requirements or replace the
current scoreboard. A successful response acknowledges the submitted report;
it does not mean that report became the current game state.
Electric Cherry saves the latest accepted report, including player stats, and
streams it to connected clients. A new start clears the previous round display;
an end closes the match. Receipt order determines the latest report. This small
protocol does not attempt to identify delayed events from earlier games or
provide exactly-once delivery across games.
an end closes the match. Without a match key, legacy receipt-order and
map/round heuristics remain in effect. Those reports cannot reliably distinguish
delayed events from earlier games; send them once in order.
Match timing is independent of player count: an empty `game_start` still records
the start, and empty rosters or RCON polls do not erase it. Fleet can display a
@@ -198,7 +232,7 @@ Fleet clears the previous match's timing rather than carrying it into the new on
Reports also feed the persistent [Leaderboard](leaderboard.md), independently
of beta activity. That page documents cumulative stat accounting, round credit,
and the limits of identifying matches from this protocol.
and match identity and ordering behavior.
### Reporter deployment
+49 -24
View File
@@ -14,9 +14,10 @@ only under All squad sizes. The JSON API also defaults to all maps.
Matches only appear in server/global history once they reach round 2.
Leaderboards, summaries, map options, profile statistics and player history include only
completed matches with a received `game_end`. Active and interrupted matches do
not contribute. A player must also be observed at round 2 or later; players who
left before a completed game's end retain their recorded stats.
completed matches with a received `game_end`. Active, interrupted, and
**End report missing** matches do not contribute. A player must also be observed
at round 2 or later; players who left before a completed game's end retain their
recorded stats.
This also excludes existing round 1 records. Early telemetry is retained so
qualifying games keep their start time, roster and cumulative counters.
@@ -76,7 +77,9 @@ map and server, final round, start/end times, duration when the start was observ
player counts, and the full saved roster. Players missing from the final report
retain their earlier counters; players with no stats display dashes. Counters and
score are the highest reported values within the match, not necessarily its final
scoreboard. Active and interrupted matches remain listed without results links.
scoreboard. Active, interrupted, and **End report missing** matches remain listed
without results links. Matches can filter each status and show known gaps in the
reporter's sequence; a delayed report can reduce that missing-report count.
Match pages remain available after the original server is removed.
### Player identity
@@ -126,8 +129,9 @@ rate also requires 100 kills; kills per down requires five downs. The board show
the numerator, denominator and game count beside each ratio. Ratios are computed
from aggregate counters, not an average of individual match percentages.
Completion rate is the share of those ended games whose final report contained
the player; it is not a win rate. Active and interrupted games do not enter any
denominator. Zero-denominator ratios remain unknown and are not ranked.
the player; it is not a win rate. Games without a received `game_end`, including
**End report missing** games, do not enter any denominator. Zero-denominator ratios
remain unknown and are not ranked.
Qualification is applied before ranking, search and pagination. Summary cards
still describe all players in the game/map selection, including those below the
@@ -149,12 +153,29 @@ They persist across app restarts and server deletion, and are not subject to
campaign history retention. The latest scoreboard and leaderboard update in the
same transaction. Each match keeps player counters even after someone leaves.
A `game_start` opens a match; `game_end` completes it. A changed map, a lower round,
or a round report after a completed game can also establish a new match when the
start was missed. Superseded active matches remain in history as interrupted.
Repeated starts before any stats belong to the same match. Identical consecutive
payloads (ignoring receipt time and roster order) do not add records. Repeated
snapshots in a match cannot increase counters or rounds twice.
A `game_start` opens a match; `game_end` completes it. Reporters can send an optional
`match` key and per-game `seq` on every event; see the
[reporter protocol](beta-test-plans.md#event-fields) for field limits and retry rules.
A key identifies one game on one server, including when the start report was
missed. An unseen key opens a new game and labels the previous active keyed game
**End report missing**. This records missing evidence; it does not establish
whether the game finished or was interrupted. A late `game_end` can still complete its own match; old keys never
replace the newer game's live state.
With both fields present, each sequence is counted once, including across app
restarts. Lower sequences fill missing-report gaps without rolling back newer
rounds, counters, or rosters. A delayed `game_start` may fill the recorded starting
roster without resetting progression. The history API exposes the highest
sequence minus the count of unique received sequences as `missingReports`, or
`null` when sequencing is unavailable. A missing report count does not grant
credit for unobserved rounds or complete a game without `game_end`.
Without a match key, the legacy heuristics remain: a changed map, a lower round,
or a round report after a completed game can establish a new match when the
start was missed. Superseded active matches remain interrupted. Repeated starts
before any stats belong to the same match. Identical consecutive payloads
(ignoring receipt time and roster order) do not add records. Repeated snapshots
in a match cannot increase counters or rounds twice.
The first upgrade imports the latest stored full scoreboard from each server
once. That provides the available counters and highest round, but not discarded
@@ -167,14 +188,13 @@ explicitly identified instead of being labeled as a known selection. A match
retains the game/map identity recorded at ingestion even if the server is later
reconfigured or removed.
The current protocol sends events in gameplay order and has no match ID, event
ID, or source sequence. A late report from an older game cannot be reliably
distinguished from a new game. Likewise, identical starts before any stats are
indistinguishable from retries. Send reports once in order. Within a match,
counter decreases use the previous high-water mark; if a reconnect resets a
player's counters, subsequent activity may be undercounted until those counters
pass their earlier maxima. Exact replay handling and counter-reset accounting
would require additional reporter identity/ordering information.
Legacy reports without match identity cannot reliably distinguish a late report
from an older game from a new game. Likewise, identical starts before any stats
are indistinguishable from retries. Send those reports once in order. Within a
match, counter decreases use the previous high-water mark; if a reconnect resets
a player's counters, subsequent activity may be undercounted until those counters
pass their earlier maxima. Match keys and sequence numbers do not identify
player counter resets.
## Read API
@@ -349,10 +369,15 @@ an identity rename can change results without changing those timestamps.
### Related routes and current limits
`GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match
summaries as `{ "matches": [...], "total": number }`. These routes still require
session authentication. Both accept only `limit` (default 25, range 1–10,000); there is
no offset pagination. Results are newest first by first observation, then match
ID, and include active/interrupted matches that reached round 2. Their
summaries as `{ "matches": [...], "total": number, "page": number, "limit": number, "maps": [...] }`.
These routes still require session authentication. Both accept `limit` (default
25, range 1–10,000), `page`, `game`, `map`, `q` (server name), `days` (0, 7, 30, or
90), `status`, `sort` (`started`, `round`, `duration`, or `players`), and `direction`
(`asc` or `desc`). Results default to newest first by first observation, then
match ID, and include unfinished matches that reached round 2. Status values are
`active`, `completed`, `interrupted`, and `end-report-missing`; omit the filter for
all statuses. `reporterMatchKey` is the supplied match key or `null`;
`missingReports` is the known sequence-gap count or `null`. Their
[ServerMatch fields](../../src/lib/match-history.ts) differ from leaderboard entries
and do not include player counters or a roster. Unknown server names return an
empty list. URL-encode server names in paths.
+1 -1
View File
@@ -24,7 +24,7 @@ export interface ChatMatch {
startedAt: string;
endedAt: string | null;
lastReceivedAt: string;
status: 'active' | 'completed' | 'interrupted';
status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
startObserved: boolean;
}
+2 -2
View File
@@ -6,7 +6,7 @@ export interface MatchHistoryQuery {
map: string;
q: string;
days: number;
status: '' | 'active' | 'completed' | 'interrupted';
status: '' | 'active' | 'completed' | 'interrupted' | 'end-report-missing';
sort: MatchSort;
direction: 'asc' | 'desc';
page: number;
@@ -35,7 +35,7 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
map.length > 1024 ||
q.length > 100 ||
![0, 7, 30, 90].includes(days) ||
!['', 'active', 'completed', 'interrupted'].includes(status) ||
!['', 'active', 'completed', 'interrupted', 'end-report-missing'].includes(status) ||
!matchSorts.includes(sort as MatchSort) ||
!['asc', 'desc'].includes(direction) ||
!Number.isSafeInteger(page) ||
+4 -1
View File
@@ -13,7 +13,10 @@ export interface ServerMatch {
startedAt: string;
lastReportedAt: string | null;
endedAt: string | null;
status: 'active' | 'completed' | 'interrupted';
status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
reporterMatchKey: string | null;
// Unknown without sequence reports; otherwise gaps through the highest received sequence.
missingReports: number | null;
startPlayers: number | null;
uniquePlayers: number;
endPlayers: number | null;
+29
View File
@@ -0,0 +1,29 @@
import type Database from 'better-sqlite3';
/** Reporter identities and received sequences survive controller restarts with match history. */
export function migrateLeaderboardReports(handle: Database.Database): void {
const columns = new Set(
(handle.prepare('PRAGMA table_info(leaderboard_matches)').all() as { name: string }[]).map(
(column) => column.name
)
);
for (const [name, definition] of [
['reporter_match_key', 'TEXT'],
['last_seq', 'INTEGER NOT NULL DEFAULT 0'],
['report_count', 'INTEGER NOT NULL DEFAULT 0']
]) {
if (!columns.has(name))
handle.exec(`ALTER TABLE leaderboard_matches ADD COLUMN ${name} ${definition}`);
}
handle.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_reporter_match
ON leaderboard_matches(server, reporter_match_key)
WHERE reporter_match_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS leaderboard_match_reports (
match_id INTEGER NOT NULL REFERENCES leaderboard_matches(id) ON DELETE CASCADE,
seq INTEGER NOT NULL CHECK (typeof(seq) = 'integer' AND seq > 0),
applied INTEGER NOT NULL CHECK (applied IN (0,1)),
PRIMARY KEY (match_id, seq)
);
`);
}
+6
View File
@@ -16,6 +16,7 @@ import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'
import { migrateHostSchema } from '../hosts/schema.js';
import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js';
import { migrateGameFileSchema } from '../game-files/schema.js';
import { migrateLeaderboardReports } from './leaderboard-reports.js';
import type { Migration } from './migration-runner.js';
// Append only: never renumber entries or change an applied migration to add an
@@ -97,6 +98,11 @@ export const migrations: readonly Migration[] = [
name: 'super viewer authentication role',
mode: 'foreign-keys-off',
run: migrateSuperViewerRole
},
{
version: 18,
name: 'durable leaderboard report identity and sequences',
run: migrateLeaderboardReports
}
];
+216 -36
View File
@@ -7,10 +7,17 @@ interface Match {
id: number;
map_key: string;
game: string;
status: 'active' | 'completed' | 'interrupted';
status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
highest_round: number;
last_event: GameTelemetry['event'];
last_fingerprint: string;
start_observed: number;
started_at: string;
server_deleted_at: string | null;
}
interface KeyedMatch extends Match {
last_seq: number;
}
interface Participation {
@@ -51,7 +58,7 @@ function mapIdentity(handle: Database.Database, report: GameTelemetry) {
function fingerprint(report: GameTelemetry): string {
// Receipt timestamps and roster ordering do not identify new gameplay.
const { receivedAt: _receivedAt, timing: _timing, ...event } = report;
const { receivedAt: _receivedAt, timing: _timing, match: _match, seq: _seq, ...event } = report;
const players = [...event.players].sort((a, b) => a.guid.localeCompare(b.guid));
return createHash('sha256')
.update(JSON.stringify({ ...event, players }))
@@ -59,7 +66,16 @@ function fingerprint(report: GameTelemetry): string {
}
/** Called inside the same transaction as the latest scoreboard snapshot. */
export function recordLeaderboardTelemetry(handle: Database.Database, report: GameTelemetry): void {
export function recordLeaderboardTelemetry(
handle: Database.Database,
report: GameTelemetry
): { matchId: number; current: boolean } {
if (report.match !== undefined) return recordKeyedTelemetry(handle, report);
return { matchId: recordLegacyTelemetry(handle, report), current: true };
}
// Also used by the version-0 snapshot backfill, before keyed schema exists.
function recordLegacyTelemetry(handle: Database.Database, report: GameTelemetry): number {
const identity = mapIdentity(handle, report);
const signature = fingerprint(report);
let match = handle
@@ -74,7 +90,7 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
handle
.prepare('UPDATE leaderboard_matches SET last_received_at = ? WHERE id = ?')
.run(report.receivedAt, match.id);
if (match.status !== 'interrupted') return;
if (match.status !== 'interrupted') return match.id;
}
const isStart = report.event === 'game_start';
@@ -95,40 +111,55 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
)
.run(report.receivedAt, match.id);
}
const inserted = handle
.prepare(
`INSERT INTO leaderboard_matches
(server, game, map, map_key, map_name, started_at, last_received_at, start_observed, last_event, last_fingerprint)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
report.server,
identity.game,
identity.map,
identity.key,
identity.name,
report.receivedAt,
report.receivedAt,
Number(isStart),
report.event,
signature
);
match = {
id: Number(inserted.lastInsertRowid),
game: identity.game,
map_key: identity.key,
status: 'active',
highest_round: 0,
last_event: report.event,
last_fingerprint: signature
};
match = createMatch(handle, report, identity, signature);
}
if (!match) return;
// No prior match always creates one above.
if (!match) throw new Error('Could not create telemetry match');
applyReport(handle, report, match, signature);
return match.id;
}
function createMatch(
handle: Database.Database,
report: GameTelemetry,
identity: ReturnType<typeof mapIdentity>,
signature: string
): Match {
const inserted = handle
.prepare(
`INSERT INTO leaderboard_matches
(server, game, map, map_key, map_name, started_at, last_received_at, start_observed, last_event, last_fingerprint)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
report.server,
identity.game,
identity.map,
identity.key,
identity.name,
report.receivedAt,
report.receivedAt,
Number(report.event === 'game_start'),
report.event,
signature
);
return handle
.prepare('SELECT * FROM leaderboard_matches WHERE id = ?')
.get(inserted.lastInsertRowid) as Match;
}
function applyReport(
handle: Database.Database,
report: GameTelemetry,
match: Match,
signature: string
): void {
const isStart = report.event === 'game_start';
const round = isStart ? 0 : report.round;
if (isStart) {
handle
.prepare(
'UPDATE leaderboard_matches SET start_players = COALESCE(start_players, ?) WHERE id = ?'
'UPDATE leaderboard_matches SET start_observed = 1, start_players = COALESCE(start_players, ?) WHERE id = ?'
)
.run(report.players.length, match.id);
}
@@ -159,8 +190,10 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
rounds_survived = rounds_survived + excluded.rounds_survived,
kills = MAX(kills, excluded.kills), headshots = MAX(headshots, excluded.headshots),
downs = MAX(downs, excluded.downs), revives = MAX(revives, excluded.revives),
best_score = MAX(best_score, excluded.best_score), last_round = excluded.last_round,
best_score = MAX(best_score, excluded.best_score), last_round = MAX(last_round, excluded.last_round),
present = 1, has_stats = MAX(has_stats, excluded.has_stats),
first_observed_round = CASE WHEN excluded.present_at_start = 1 THEN 0 ELSE first_observed_round END,
present_at_start = CASE WHEN excluded.present_at_start = 1 THEN 1 ELSE present_at_start END,
completed = MAX(completed, excluded.completed), last_seen_at = excluded.last_seen_at`);
// Highest round includes late joins; total rounds only credits progression
@@ -172,7 +205,7 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
const survived =
!isStart && prior?.present ? Math.max(0, completedRounds - prior.last_round) : 0;
saveIdentity.run(
identity.game,
match.game,
player.guid,
player.name,
report.receivedAt,
@@ -217,6 +250,153 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
);
}
function recordKeyedTelemetry(
handle: Database.Database,
report: GameTelemetry
): { matchId: number; current: boolean } {
const signature = fingerprint(report);
let match = handle
.prepare('SELECT * FROM leaderboard_matches WHERE server = ? AND reporter_match_key = ?')
.get(report.server, report.match) as KeyedMatch | undefined;
if (!match) {
// A key is a durable identity. Only a previously unseen key supersedes
// the current game; a retry for a known old key cannot close a new one.
handle
.prepare(
`UPDATE leaderboard_matches SET
status = CASE WHEN reporter_match_key IS NULL THEN 'interrupted' ELSE 'end-report-missing' END,
ended_at = ? WHERE server = ? AND server_deleted_at IS NULL AND status = 'active'`
)
.run(report.receivedAt, report.server);
const created = createMatch(handle, report, mapIdentity(handle, report), signature);
handle
.prepare('UPDATE leaderboard_matches SET reporter_match_key = ? WHERE id = ?')
.run(report.match, created.id);
match = { ...created, last_seq: 0 };
}
const result = { matchId: match.id, current: false };
if (report.seq !== undefined) {
const inserted = handle
.prepare(
`INSERT INTO leaderboard_match_reports (match_id, seq, applied)
VALUES (?, ?, 0) ON CONFLICT DO NOTHING`
)
.run(match.id, report.seq);
if (!inserted.changes) return result;
// Count received sequences even when their state is too old to apply.
// This represents gaps without allocating a row for each missing value.
handle
.prepare(
`UPDATE leaderboard_matches SET last_seq = MAX(last_seq, ?),
report_count = report_count + 1 WHERE id = ?`
)
.run(report.seq, match.id);
}
const stale =
report.seq !== undefined
? report.seq < match.last_seq
: report.event !== 'game_start' && report.round < match.highest_round;
const delayedStart =
report.event === 'game_start' &&
(stale || match.highest_round > 0 || match.status !== 'active');
let applied = false;
if (delayedStart) {
if (!match.start_observed) {
observeDelayedStart(handle, report, match);
applied = true;
}
} else if (stale) {
// Completion is forward progress, even if its sequence trails a report
// already seen. Do not rewind the newer roster or counters with it.
if (report.event === 'game_end' && match.status !== 'completed') {
completeDelayedMatch(handle, report, match);
applied = true;
}
} else if (match.status !== 'completed') {
applyReport(handle, report, match, signature);
applied = true;
const latest = handle
.prepare(
`SELECT id FROM leaderboard_matches
WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 1`
)
.get(report.server) as { id: number } | undefined;
result.current = latest?.id === match.id && match.server_deleted_at === null;
}
if (applied && report.seq !== undefined) {
handle
.prepare(
'UPDATE leaderboard_match_reports SET applied = 1 WHERE match_id = ? AND seq = ?'
)
.run(match.id, report.seq);
}
return result;
}
function completeDelayedMatch(
handle: Database.Database,
report: Extract<GameTelemetry, { round: number }>,
match: Match
): void {
handle
.prepare(
`UPDATE leaderboard_matches SET status = 'completed', ended_at = ?,
highest_round = MAX(highest_round, ?) WHERE id = ?`
)
.run(report.receivedAt, report.round, match.id);
const identity =
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO NOTHING`);
const endPlayer = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, highest_round, kills, headshots, downs, revives, best_score,
last_round, present, has_stats, completed, first_seen_at, last_seen_at, first_observed_round, present_at_start)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 1, 1, ?, ?, ?, 0)
ON CONFLICT(match_id, guid) DO UPDATE SET
highest_round = MAX(highest_round, excluded.highest_round),
kills = MAX(kills, excluded.kills), headshots = MAX(headshots, excluded.headshots),
downs = MAX(downs, excluded.downs), revives = MAX(revives, excluded.revives),
best_score = MAX(best_score, excluded.best_score), has_stats = 1, completed = 1`);
for (const player of report.players) {
identity.run(match.game, player.guid, player.name, report.receivedAt, report.receivedAt);
endPlayer.run(
match.id,
player.guid,
report.round,
player.kills,
player.headshots,
player.downs,
player.revives,
player.score,
Math.max(0, report.round - 1),
report.receivedAt,
report.receivedAt,
report.round
);
}
}
/** Recover start evidence without replacing the latest roster or its progress. */
function observeDelayedStart(handle: Database.Database, report: GameTelemetry, match: Match): void {
handle
.prepare(
`UPDATE leaderboard_matches SET start_observed = 1, start_players = ? WHERE id = ?`
)
.run(report.players.length, match.id);
const identity =
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO NOTHING`);
const playerStart = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, present, first_seen_at, last_seen_at, first_observed_round, present_at_start)
VALUES (?, ?, 0, ?, ?, 0, 1) ON CONFLICT(match_id, guid) DO UPDATE SET
present_at_start = 1, first_observed_round = 0`);
for (const player of report.players) {
identity.run(match.game, player.guid, player.name, match.started_at, match.started_at);
playerStart.run(match.id, player.guid, match.started_at, match.started_at);
}
}
/** Seed the single surviving scoreboard per server once; older games are gone. */
export function backfillLeaderboard(handle: Database.Database): void {
if (handle.prepare("SELECT 1 FROM settings WHERE key = 'leaderboard_v1'").get()) return;
@@ -239,7 +419,7 @@ export function backfillLeaderboard(handle: Database.Database): void {
} catch {
continue;
} // Legacy malformed snapshots cannot establish player records.
recordLeaderboardTelemetry(handle, report);
recordLegacyTelemetry(handle, report);
}
handle.prepare("INSERT INTO settings (key, value) VALUES ('leaderboard_v1', '1')").run();
})();
+7 -3
View File
@@ -11,12 +11,15 @@ import {
import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js';
const matchSummary = `m.id, m.game, m.highest_round AS observedRound, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
m.reporter_match_key AS reporterMatchKey,
CASE WHEN m.last_seq > 0 THEN m.last_seq - m.report_count END AS missingReports,
m.last_received_at AS lastReportedAt,
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
m.start_players AS startPlayers,
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
CASE WHEN m.status = 'completed' THEN
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id AND p.present = 1)
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id
AND (CASE WHEN m.reporter_match_key IS NULL THEN p.present ELSE p.completed END) = 1)
END AS endPlayers,
CASE WHEN m.status = 'completed' AND m.start_observed = 1 THEN
MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs,
@@ -112,7 +115,8 @@ export function getMatchDetail(id: number): MatchDetail | null {
ORDER BY identity.last_seen_at DESC, identity.game LIMIT 1),
(SELECT identity.name FROM leaderboard_players identity
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
p.has_stats AS hasStats, p.present AS presentAtEnd,
p.has_stats AS hasStats,
CASE WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd,
p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt,
CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound,
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
@@ -126,7 +130,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
FROM leaderboard_match_players p WHERE p.match_id = ?
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`
)
.all(match.game, id) as Array<
.all(match.game, match.reporterMatchKey, id) as Array<
Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
hasStats: number;
presentAtEnd: number;
+18 -6
View File
@@ -15,6 +15,14 @@ export function listTelemetry(): GameTelemetry[] {
}
export function saveTelemetry(report: GameTelemetry): GameTelemetry {
return saveTelemetryWithOutcome(report).telemetry;
}
/** Acknowledgements retain their match id even when the live snapshot must stay put. */
export function saveTelemetryWithOutcome(report: GameTelemetry): {
telemetry: GameTelemetry;
current: boolean;
} {
// Keep callers outside the HTTP route atomic too.
const handle = db();
return handle.transaction(() => {
@@ -24,12 +32,16 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
const previous: GameTelemetry | undefined = row?.payload
? JSON.parse(row.payload)
: undefined;
recordLeaderboardTelemetry(handle, report);
const recorded = recordLeaderboardTelemetry(handle, report);
if (!recorded.current) {
return {
telemetry: { ...report, timing: { matchId: recorded.matchId, players: {} } },
current: false
};
}
const match = handle
.prepare(
'SELECT id, started_at, start_observed FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1'
)
.get(report.server) as { id: number; started_at: string; start_observed: number };
.prepare('SELECT id, started_at, start_observed FROM leaderboard_matches WHERE id = ?')
.get(recorded.matchId) as { id: number; started_at: string; start_observed: number };
const sameMatch = previous?.timing?.matchId === match.id;
const serverTiming = handle
.prepare(
@@ -128,6 +140,6 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
)
.run(report.receivedAt, report.server, report.receivedAt);
}
return report;
return { telemetry: report, current: true };
})();
}
+1 -1
View File
@@ -147,7 +147,7 @@ function policies(): Policy[] {
id: 'id',
clock: 'last_received_at',
days: 'matchKeepDays',
eligible: "o.status IN ('completed','interrupted')",
eligible: "o.status IN ('completed','interrupted','end-report-missing')",
bytes: '0'
},
{
+21 -2
View File
@@ -13,7 +13,14 @@ export interface TelemetryPlayerStats extends TelemetryPlayer {
score: number;
}
export type GameEvent = { server: string; map: string } & (
export type GameEvent = {
server: string;
map: string;
/** Opaque identifier assigned by the reporter for one match on this server. */
match?: string;
/** Monotonically increasing report number within the reporter's match. */
seq?: number;
} & (
| { event: 'game_start'; players: TelemetryPlayer[] }
| {
event: 'round_change' | 'heartbeat' | 'game_end';
@@ -52,6 +59,13 @@ export function parseGameEvent(value: unknown): GameEvent {
if (typeof body[key] !== 'string' || !body[key].trim() || body[key].length > 255)
throw new Error(`${key} must be a nonempty string (up to 255 characters)`);
}
if (
body.match !== undefined &&
(typeof body.match !== 'string' || !body.match.trim() || body.match.length > 64)
)
throw new Error('match must be a nonempty string (up to 64 characters)');
if (body.seq !== undefined && (!Number.isSafeInteger(body.seq) || (body.seq as number) < 1))
throw new Error('seq must be a positive safe integer');
if (!Array.isArray(body.players)) throw new Error('players must be an array');
const guids = new Set<string>();
const players = body.players.map((value, index) => {
@@ -89,7 +103,12 @@ export function parseGameEvent(value: unknown): GameEvent {
}
return { ...identity, ...stats };
});
const common = { server: body.server as string, map: body.map as string };
const common = {
server: body.server as string,
map: body.map as string,
...(body.match !== undefined ? { match: body.match as string } : {}),
...(body.seq !== undefined ? { seq: body.seq as number } : {})
};
if (body.event === 'game_start') return { ...common, event: 'game_start', players };
if (!Number.isSafeInteger(body.round) || (body.round as number) < 0)
throw new Error('round must be a nonnegative integer');
+17 -6
View File
@@ -200,7 +200,7 @@
>Completed</option
><option value="active">In progress</option><option value="interrupted"
>Interrupted</option
></Select
><option value="end-report-missing">End report missing</option></Select
></Field
>
{#if suffix}
@@ -343,7 +343,9 @@
? 'In progress'
: match.status === 'completed'
? 'Completed'
: 'Interrupted'}
: match.status === 'end-report-missing'
? 'End report missing'
: 'Interrupted'}
{@const duration =
match.status === 'active'
? reporting
@@ -383,6 +385,10 @@
missing="Not recorded"
/></small
>{/if}
{#if match.missingReports !== null && match.missingReports > 0}<small
class="mobile-match-context"
>{`${match.missingReports} ${match.missingReports === 1 ? 'report missing' : 'reports missing'}`}</small
>{/if}
{#if server === undefined}<div class="instance">
<span title={match.server}>{matchInstanceLabel(match)}</span
>{#if match.server_deleted_at}<span>
@@ -395,12 +401,15 @@
class="status"
class:active={reporting}
class:interrupted={match.status === 'interrupted' ||
match.status === 'end-report-missing' ||
notReporting}>{status}</span
>{#if notReporting}<small
>Last report <RelativeTime
value={match.lastReportedAt}
missing="Not recorded"
/></small
>{/if}{#if match.missingReports !== null && match.missingReports > 0}<small
>{`${match.missingReports} ${match.missingReports === 1 ? 'report missing' : 'reports missing'}`}</small
>{/if}</td
>
<td class="numeric">{match.uniquePlayers}</td>
@@ -462,10 +471,12 @@
Players counts unique players across all reports. A dash means the value is unavailable,
not zero. In-progress matches show elapsed time since their recorded start and link to
the live server while reports are current. Otherwise, they show Not reporting with the
last report time. Completed durations require a recorded start and completed ending.
Round shows the final round for completed matches and the highest observed round for
unfinished matches. Open a completed match for exact timestamps and starting and ending
player counts.
last report time. Completed durations require a recorded start and completed ending. End
report missing means a newer game was reported before this game's end report arrived.
Missing report counts show gaps in the reporter's sequence and can decrease as delayed
reports arrive. Only completed games contribute to the leaderboard. Round shows the
final round for completed matches and the highest observed round for unfinished matches.
Open a completed match for exact timestamps and starting and ending player counts.
</p>
</details>
</section>
+7 -5
View File
@@ -2,7 +2,7 @@ import { jsonBody } from '$lib/server/request-body.js';
import { isHttpError, json } from '@sveltejs/kit';
import { parseGameEvent, type GameTelemetry } from '$lib/telemetry.js';
import { getServer } from '$lib/server/fleet/servers.js';
import { saveTelemetry } from '$lib/server/fleet/telemetry.js';
import { saveTelemetryWithOutcome } from '$lib/server/fleet/telemetry.js';
import { recordTelemetryRequest } from '$lib/server/fleet/telemetry-requests.js';
import { recordGameEvent } from '$lib/server/fleet/beta-tests.js';
import { db } from '$lib/server/db/index.js';
@@ -60,11 +60,13 @@ export async function POST({ request }: { request: Request }) {
const server = getServer(telemetry.server);
if (!server || server.destroyed_at)
return reject(404, 'Server is not registered or has been destroyed');
db().transaction(() => {
telemetry = saveTelemetry(telemetry);
recordGameEvent(telemetry);
const current = db().transaction(() => {
const saved = saveTelemetryWithOutcome(telemetry);
telemetry = saved.telemetry;
if (saved.current) recordGameEvent(telemetry);
return saved.current;
})();
emit({ type: 'server-telemetry', entries: [telemetry] });
if (current) emit({ type: 'server-telemetry', entries: [telemetry] });
} catch (error) {
console.error('Could not process telemetry', error);
log(
+7 -6
View File
@@ -605,12 +605,13 @@
appears here and on the player's profile, regardless of filters.
</p>
<p>
Only completed games contribute to records. Games in progress and interrupted games
stay out of totals and rankings. Players who left a completed game keep their
recorded stats. Game, map, and starting squad-size filters choose which matches
contribute; player search preserves ranks. Player totals count each GUID once. The
top stats cover all recorded players in the selected game and map; player search and
ranking qualification do not change those totals.
Only games with a received end report contribute to records. Games in progress,
interrupted games, and games missing their end report stay out of totals and
rankings. Players who left a completed game keep their recorded stats. Game, map,
and starting squad-size filters choose which matches contribute; player search
preserves ranks. Player totals count each GUID once. The top stats cover all
recorded players in the selected game and map; player search and ranking
qualification do not change those totals.
</p>
<p>
Highest round includes mid-game joins. Total rounds counts round progression during
@@ -159,7 +159,7 @@
{#if !profile.records}
<EmptyState
title="No qualifying match history yet"
description="Stats appear after a recorded game ends, provided this player was observed at round 2 or later. Games in progress and interrupted games do not contribute."
description="Stats appear after a game's end report arrives, provided this player was observed at round 2 or later. Games without an end report do not contribute."
/>
{/if}
<nav class="jump-links" aria-label="Player sections">
@@ -394,10 +394,10 @@
><span>Last seen <RelativeTime value={profile.lastSeen} /></span>
</div>
<p>
Lifetime stats combine completed games from Black Ops I and Black Ops II. Games in
progress and interrupted games do not contribute. Highest round includes mid-game joins;
total rounds counts observed progression. Kills, headshots, revives and downs use each
match's highest reported counters.
Lifetime stats combine completed games from Black Ops I and Black Ops II. Games without
an end report do not contribute. Highest round includes mid-game joins; total rounds
counts observed progression. Kills, headshots, revives and downs use each match's
highest reported counters.
</p>
<p>
Highest reported score is the largest observed points balance, not total points earned.
+417
View File
@@ -0,0 +1,417 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { GameEvent, TelemetryPlayerStats } from '../../src/lib/telemetry.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { gameEvent, postTelemetry } from '../helpers/telemetry.js';
let fixture: Fixture;
let received = 0;
const server = 't5_zombie_theater_1';
const otherServer = 't5_zombie_theater_2';
const player = (guid = 'ace', stats: Partial<TelemetryPlayerStats> = {}): TelemetryPlayerStats => ({
guid,
name: guid,
kills: 0,
headshots: 0,
downs: 0,
revives: 0,
score: 0,
...stats
});
beforeEach(async () => {
vi.resetModules();
received = 0;
fixture = await newFixture([server, otherServer]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
});
afterEach(async () => fixture.cleanup());
async function report(
match: string,
seq: number | undefined,
event: GameEvent['event'],
round = 0,
players = [player()],
source = server
) {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const common = {
match,
...(seq === undefined ? {} : { seq }),
server: source,
map: 'zombie_theater',
receivedAt: new Date(Date.UTC(2026, 9, 10, 12, 0, received++)).toISOString()
};
return saveTelemetry(
event === 'game_start'
? { ...common, event, players: players.map(({ name, guid }) => ({ name, guid })) }
: { ...common, event, round, players }
);
}
async function records() {
const { db } = await import('../../src/lib/server/db/index.js');
return db().prepare('SELECT * FROM leaderboard_matches ORDER BY id').all() as Array<{
id: number;
server: string;
reporter_match_key: string;
status: string;
highest_round: number;
start_observed: number;
start_players: number | null;
last_event: string;
last_seq: number | null;
report_count: number;
}>;
}
async function participation(id: number) {
const { db } = await import('../../src/lib/server/db/index.js');
return db()
.prepare('SELECT * FROM leaderboard_match_players WHERE match_id = ? ORDER BY guid')
.all(id);
}
async function live() {
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
const state = getServer(server)!;
return {
telemetry: listTelemetry().find((entry) => entry.server === server),
timers: {
active: state.active_since,
inactive: state.inactive_since,
started: state.game_started_at,
ended: state.game_ended_at
}
};
}
it('deduplicates a retried sequence after intervening reports and a database reopen', async () => {
await report('one', 1, 'game_start');
await report('one', 2, 'round_change', 2, [player('ace', { kills: 10 })]);
await report('one', 3, 'heartbeat', 5, [player('ace', { kills: 30 })]);
const before = await records();
const beforePlayers = await participation(before[0].id);
const beforeLive = await live();
const { closeDb, db } = await import('../../src/lib/server/db/index.js');
const identities = db().prepare('SELECT * FROM leaderboard_players ORDER BY guid').all();
closeDb();
await report('one', 2, 'round_change', 2, [player('ace', { kills: 10 })]);
expect(await records()).toEqual(before);
expect(await participation(before[0].id)).toEqual(beforePlayers);
expect(await live()).toEqual(beforeLive);
expect(db().prepare('SELECT * FROM leaderboard_players ORDER BY guid').all()).toEqual(
identities
);
expect(db().prepare('SELECT seq FROM leaderboard_match_reports ORDER BY seq').all()).toEqual([
{ seq: 1 },
{ seq: 2 },
{ seq: 3 }
]);
});
it('records a lower sequence gap fill without rewinding rounds, presence, or live timers', async () => {
await report('one', 1, 'game_start', 0, [player('ace'), player('departed')]);
await report('one', 4, 'heartbeat', 8, [player('ace', { kills: 80 })]);
const before = (await records())[0];
const beforePlayers = await participation(before.id);
const beforeLive = await live();
await report('one', 2, 'round_change', 2, [player('departed', { kills: 2 })]);
const matches = await records();
expect(matches).toHaveLength(1);
expect(matches[0]).toMatchObject({
id: before.id,
status: 'active',
highest_round: 8,
last_event: 'heartbeat',
last_seq: 4,
report_count: 3
});
expect(await participation(before.id)).toEqual(beforePlayers);
expect(await live()).toEqual(beforeLive);
const { db } = await import('../../src/lib/server/db/index.js');
expect(
db().prepare('SELECT applied FROM leaderboard_match_reports WHERE seq = 2').get()
).toEqual({ applied: 0 });
});
it('opens matches by reporter key when starts are missing and isolates late old-key heartbeats', async () => {
await report('old', 2, 'heartbeat', 8, [player('old-player', { kills: 80 })]);
const first = (await records())[0];
expect(first).toMatchObject({ reporter_match_key: 'old', start_observed: 0 });
await report('new', 2, 'heartbeat', 8, [player('new-player', { kills: 40 })]);
const before = await records();
expect(before).toHaveLength(2);
expect(before[0]).toMatchObject({ status: 'end-report-missing' });
expect(before[1]).toMatchObject({
reporter_match_key: 'new',
status: 'active',
start_observed: 0
});
const beforeLive = await live();
const beforePlayers = await participation(before[1].id);
const receipt = await report('old', 3, 'heartbeat', 9, []);
expect(receipt.timing?.matchId).toBe(first.id);
expect(await records()).toHaveLength(2);
expect((await records())[1]).toEqual(before[1]);
expect(await participation(before[1].id)).toEqual(beforePlayers);
expect(await live()).toEqual(beforeLive);
});
it('applies a delayed start only to start observation and the original roster', async () => {
await report('one', 4, 'heartbeat', 8, [player('ace', { kills: 80 })]);
const before = (await records())[0];
const beforeLive = await live();
await report('one', 1, 'game_start', 0, [player('ace'), player('departed')]);
expect(await records()).toHaveLength(1);
expect((await records())[0]).toMatchObject({
id: before.id,
start_observed: 1,
start_players: 2,
highest_round: 8,
last_event: 'heartbeat',
status: 'active',
last_seq: 4,
report_count: 2
});
expect(await participation(before.id)).toEqual([
expect.objectContaining({
guid: 'ace',
highest_round: 8,
last_round: 7,
present: 1,
has_stats: 1,
kills: 80,
present_at_start: 1
}),
expect.objectContaining({
guid: 'departed',
present: 0,
has_stats: 0,
kills: 0,
present_at_start: 1
})
]);
expect(await live()).toEqual(beforeLive);
});
it('marks a missing end when the next key starts and gives a late end credit only to its own match', async () => {
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { serverMatchHistory, getMatchDetail } =
await import('../../src/lib/server/fleet/match-history.js');
await report('old', 1, 'game_start');
await report('old', 2, 'round_change', 2, [player('ace', { kills: 10 })]);
await report('old', 3, 'heartbeat', 8, [player('ace', { kills: 70 })]);
await report('new', 1, 'game_start', 0, [player('other')]);
await report('new', 2, 'heartbeat', 3, [player('other', { kills: 15 })]);
const before = await records();
const beforeLive = await live();
expect(before[0].status).toBe('end-report-missing');
expect(serverMatchHistory().matches).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: before[0].id,
status: 'end-report-missing',
reporterMatchKey: 'old'
})
])
);
expect(getLeaderboard().entries).toEqual([]);
await report('old', 4, 'game_end', 9, [player('ace', { kills: 90 })]);
const after = await records();
expect(after).toHaveLength(2);
expect(after[0]).toMatchObject({ status: 'completed', highest_round: 9 });
expect(after[1]).toEqual(before[1]);
expect(await live()).toEqual(beforeLive);
expect(getMatchDetail(before[0].id)).toMatchObject({
status: 'completed',
reporterMatchKey: 'old',
missingReports: 0,
finalRound: 9
});
expect(getLeaderboard().entries).toEqual([
expect.objectContaining({ guid: 'ace', kills: 90, gamesCompleted: 1 })
]);
});
it('counts missing unique sequences and reduces gaps when out-of-order reports arrive', async () => {
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
await report('one', 1, 'game_start');
await report('one', 5, 'heartbeat', 8);
expect(serverMatchHistory().matches[0]).toMatchObject({
reporterMatchKey: 'one',
missingReports: 3
});
await report('one', 3, 'heartbeat', 5);
expect(serverMatchHistory().matches[0]).toMatchObject({ missingReports: 2 });
await report('one', 3, 'heartbeat', 5);
expect(serverMatchHistory().matches[0]).toMatchObject({ missingReports: 2 });
await report('one', 2, 'round_change', 2);
await report('one', 4, 'heartbeat', 7);
expect(serverMatchHistory().matches[0]).toMatchObject({ missingReports: 0 });
expect((await records())[0]).toMatchObject({ last_seq: 5, report_count: 5 });
});
it('does not claim gap knowledge for a keyed reporter that omits sequence numbers', async () => {
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
await report('one', undefined, 'game_start');
await report('one', undefined, 'heartbeat', 4);
expect(serverMatchHistory().matches[0]).toMatchObject({
reporterMatchKey: 'one',
missingReports: null
});
});
it('ignores a lower-round key-only report without inflating observed rounds survived', async () => {
await report('one', undefined, 'game_start');
await report('one', undefined, 'heartbeat', 8, [player('ace', { kills: 80 })]);
const match = (await records())[0];
const beforePlayers = await participation(match.id);
const beforeLive = await live();
await report('one', undefined, 'heartbeat', 4, [player('ace', { kills: 40 })]);
expect(await participation(match.id)).toEqual(beforePlayers);
expect(await live()).toEqual(beforeLive);
await report('one', undefined, 'game_end', 9, [player('ace', { kills: 90 })]);
expect(await records()).toHaveLength(1);
expect(await participation(match.id)).toEqual([
expect.objectContaining({ guid: 'ace', rounds_survived: 8, last_round: 8 })
]);
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
expect(getLeaderboard().entries).toEqual([
expect.objectContaining({ guid: 'ace', roundsSurvived: 8 })
]);
});
it('does not recount completed rounds when a newer sequence reports a lower round', async () => {
await report('one', 1, 'game_start');
await report('one', 2, 'heartbeat', 8, [player('ace', { kills: 80 })]);
await report('one', 3, 'heartbeat', 4, [player('ace', { kills: 40 })]);
const match = (await records())[0];
expect(await participation(match.id)).toEqual([
expect.objectContaining({ guid: 'ace', rounds_survived: 7, last_round: 7 })
]);
await report('one', 4, 'game_end', 9, [player('ace', { kills: 90 })]);
expect(await records()).toHaveLength(1);
expect(await participation(match.id)).toEqual([
expect.objectContaining({ guid: 'ace', rounds_survived: 8, last_round: 8 })
]);
});
it('marks both existing and new start-roster players after an initial round-zero heartbeat', async () => {
await report('one', 1, 'heartbeat', 0);
const match = (await records())[0];
expect(match.start_observed).toBe(0);
await report('one', 2, 'game_start', 0, [player('ace'), player('new-player')]);
expect(await records()).toHaveLength(1);
expect((await records())[0]).toMatchObject({
id: match.id,
start_observed: 1,
start_players: 2
});
expect(await participation(match.id)).toEqual([
expect.objectContaining({ guid: 'ace', present_at_start: 1, first_observed_round: 0 }),
expect.objectContaining({
guid: 'new-player',
present_at_start: 1,
first_observed_round: 0
})
]);
});
it('scopes opaque reporter match keys and sequences to the reporting server', async () => {
await report('same-key', 1, 'game_start');
await report('same-key', 1, 'game_start', 0, [player('other')], otherServer);
await report('same-key', 2, 'game_end', 4, [player('ace', { kills: 10 })]);
await report('same-key', 2, 'heartbeat', 6, [player('other')], otherServer);
const matches = await records();
expect(matches).toHaveLength(2);
expect(matches[0]).toMatchObject({ server, status: 'completed', report_count: 2 });
expect(matches[1]).toMatchObject({
server: otherServer,
status: 'active',
highest_round: 6,
report_count: 2
});
});
it('allows a delayed lower-sequence end to complete a match without rewinding its latest roster', async () => {
await report('one', 1, 'game_start', 0, [player('ace'), player('departed')]);
await report('one', 4, 'heartbeat', 8, [player('ace', { kills: 80 })]);
const before = (await records())[0];
const beforeLive = await live();
await report('one', 3, 'game_end', 7, [
player('departed', { kills: 5 }),
player('final-new', { kills: 20 })
]);
expect(await records()).toHaveLength(1);
expect((await records())[0]).toMatchObject({
id: before.id,
highest_round: 8,
status: 'completed',
last_seq: 4,
report_count: 3
});
expect(await participation(before.id)).toEqual([
expect.objectContaining({ guid: 'ace', present: 1, highest_round: 8, kills: 80 }),
expect.objectContaining({ guid: 'departed', present: 0, completed: 1 }),
expect.objectContaining({
guid: 'final-new',
present: 0,
completed: 1,
has_stats: 1,
highest_round: 7,
rounds_survived: 0,
kills: 20
})
]);
expect(await live()).toEqual(beforeLive);
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
expect(getMatchDetail(before.id)).toMatchObject({
endPlayers: 2,
players: expect.arrayContaining([
expect.objectContaining({ guid: 'ace', presentAtEnd: false }),
expect.objectContaining({ guid: 'departed', presentAtEnd: true }),
expect.objectContaining({ guid: 'final-new', presentAtEnd: true })
])
});
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
expect(getLeaderboard().entries).toEqual(
expect.arrayContaining([
expect.objectContaining({ guid: 'final-new', kills: 20, gamesCompleted: 1 })
])
);
});
it('acknowledges retries and old keys with their own match ID without broadcasting stale telemetry', async () => {
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
const listener = vi.fn();
const unsubscribe = subscribe(listener);
try {
const oldStart = { ...gameEvent('game_start'), match: 'old', seq: 1 };
const oldHeartbeat = { ...gameEvent('heartbeat', 5), match: 'old', seq: 2 };
const started = await (await postTelemetry(oldStart)).json();
await postTelemetry(oldHeartbeat);
await postTelemetry({ ...gameEvent('game_start'), match: 'new', seq: 1 });
await postTelemetry({ ...gameEvent('heartbeat', 3), match: 'new', seq: 2 });
const beforeLive = await live();
listener.mockClear();
for (const stale of [oldStart, oldHeartbeat, { ...oldHeartbeat, seq: 3 }]) {
const response = await postTelemetry(stale);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
ok: true,
telemetry: {
match: 'old',
seq: stale.seq,
timing: { matchId: started.telemetry.timing.matchId }
}
});
}
expect(listener).not.toHaveBeenCalled();
expect(await live()).toEqual(beforeLive);
} finally {
unsubscribe();
}
});
@@ -0,0 +1,130 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it } from 'vitest';
import { migrateLeaderboardReports } from '../../src/lib/server/db/leaderboard-reports.js';
import { runMigrations } from '../../src/lib/server/db/migration-runner.js';
import { migrations } from '../../src/lib/server/db/migrations.js';
let handle: Database.Database;
const migration = migrations.find(
(entry) => entry.name === 'durable leaderboard report identity and sequences'
)!;
beforeEach(() => {
handle = new Database(':memory:');
handle.pragma('foreign_keys = ON');
runMigrations(handle, migrations.slice(0, migration.version - 1));
handle.exec(`
INSERT INTO leaderboard_matches
(id,server,server_deleted_at,game,map,map_key,map_name,started_at,last_received_at,
status,last_event,last_fingerprint,highest_round)
VALUES (1,'old-server','2026-10-01T15:00:00.000Z','t5','zombie_moon','zombie_moon','Moon',
'2026-10-01T12:00:00.000Z','2026-10-01T14:00:00.000Z','completed','game_end','old',32);
INSERT INTO leaderboard_match_players
(match_id,guid,kills,first_seen_at,last_seen_at)
VALUES (1,'player',1000,'2026-10-01T12:00:00.000Z','2026-10-01T14:00:00.000Z');
INSERT INTO match_chat(match_id,message_key,occurrence,entry)
VALUES (1,'message',1,'{"message":"Saved chat"}');
`);
});
afterEach(() => handle.close());
function insertMatch(server: string, key: string | null): number {
return Number(
handle
.prepare(
`INSERT INTO leaderboard_matches
(server,game,map,map_key,map_name,started_at,last_received_at,last_event,
last_fingerprint,reporter_match_key)
VALUES (?,'t5','zombie_moon','zombie_moon','Moon',
'2026-10-12T12:00:00.000Z','2026-10-12T12:00:00.000Z','game_start','start',?)`
)
.run(server, key).lastInsertRowid
);
}
it('upgrades version 17 without inventing reporter identity or changing historical matches', () => {
const original = handle.prepare('SELECT * FROM leaderboard_matches').get();
const players = handle.prepare('SELECT * FROM leaderboard_match_players').all();
const chat = handle.prepare('SELECT * FROM match_chat').all();
runMigrations(handle, migrations);
expect(handle.pragma('user_version', { simple: true })).toBe(migrations.length);
expect(handle.prepare('SELECT * FROM leaderboard_matches').get()).toEqual({
...original!,
reporter_match_key: null,
last_seq: 0,
report_count: 0
});
expect(handle.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual(players);
expect(handle.prepare('SELECT * FROM match_chat').all()).toEqual(chat);
expect(handle.prepare('SELECT * FROM leaderboard_match_reports').all()).toEqual([]);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
it('enforces reporter keys per server while preserving unkeyed legacy history', () => {
runMigrations(handle, migrations);
const first = insertMatch('server-a', 'cycle-1');
expect(() => insertMatch('server-a', 'cycle-1')).toThrow('UNIQUE constraint failed');
expect(() => insertMatch('server-b', 'cycle-1')).not.toThrow();
expect(() => insertMatch('server-a', 'cycle-2')).not.toThrow();
expect(() => insertMatch('server-a', null)).not.toThrow();
expect(() => insertMatch('server-a', null)).not.toThrow();
handle
.prepare('UPDATE leaderboard_matches SET server_deleted_at=? WHERE id=?')
.run('2026-10-12T13:00:00.000Z', first);
expect(() => insertMatch('server-a', 'cycle-1')).toThrow('UNIQUE constraint failed');
});
it('stores received and applied sequence receipts once and removes them with retained matches', () => {
runMigrations(handle, migrations);
const id = insertMatch('server-a', 'cycle-1');
const receipt = handle.prepare(
'INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,?,?)'
);
receipt.run(id, 3, 1);
receipt.run(id, 1, 0);
expect(() => receipt.run(id, 3, 0)).toThrow('UNIQUE constraint failed');
for (const seq of [0, -1, 1.5])
expect(() => receipt.run(id, seq, 1)).toThrow('CHECK constraint failed');
expect(() => receipt.run(id, 2, 2)).toThrow('CHECK constraint failed');
expect(() => receipt.run(id + 100, 1, 1)).toThrow('FOREIGN KEY constraint failed');
expect(
handle.prepare('SELECT seq,applied FROM leaderboard_match_reports ORDER BY seq').all()
).toEqual([
{ seq: 1, applied: 0 },
{ seq: 3, applied: 1 }
]);
handle.prepare('DELETE FROM leaderboard_matches WHERE id=?').run(id);
expect(handle.prepare('SELECT * FROM leaderboard_match_reports').all()).toEqual([]);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
it('preserves reporter keys, counters and receipts when migration 18 is replayed', () => {
runMigrations(handle, migrations);
const id = insertMatch('server-a', 'cycle-1');
handle.prepare('UPDATE leaderboard_matches SET last_seq=3,report_count=2 WHERE id=?').run(id);
handle
.prepare('INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,3,1)')
.run(id);
handle
.prepare('INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,1,0)')
.run(id);
const matches = handle.prepare('SELECT * FROM leaderboard_matches ORDER BY id').all();
const reports = handle.prepare('SELECT * FROM leaderboard_match_reports ORDER BY seq').all();
const schemaVersion = handle.pragma('schema_version', { simple: true });
migrateLeaderboardReports(handle);
handle.pragma(`user_version = ${migration.version - 1}`);
runMigrations(handle, migrations);
expect(handle.prepare('SELECT * FROM leaderboard_matches ORDER BY id').all()).toEqual(matches);
expect(handle.prepare('SELECT * FROM leaderboard_match_reports ORDER BY seq').all()).toEqual(
reports
);
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
+38
View File
@@ -104,6 +104,44 @@ it('keeps unknown durations last in either direction and retains historical and
});
expect(data.matches.find((m) => m.id === 5)?.uniquePlayers).toBe(0);
});
it('filters missing end reports and exposes sequence gaps without completed results', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
db()
.prepare(
`UPDATE leaderboard_matches
SET status = 'end-report-missing', reporter_match_key = 'game-a', last_seq = 8, report_count = 6
WHERE id = 4`
)
.run();
const { serverMatchHistory, getMatchDetail } =
await import('../../src/lib/server/fleet/match-history.js');
const data = serverMatchHistory(undefined, 25, query('status=end-report-missing'));
expect(data).toMatchObject({
total: 1,
matches: [
{
id: 4,
status: 'end-report-missing',
reporterMatchKey: 'game-a',
missingReports: 2,
observedRound: 5,
finalRound: null,
durationMs: null,
endedAt: null,
endPlayers: null
}
]
});
expect(getMatchDetail(4)).toBeNull();
expect(serverMatchHistory().matches[0]).toMatchObject({
reporterMatchKey: null,
missingReports: null
});
db().prepare('UPDATE leaderboard_matches SET report_count = 8 WHERE id = 4').run();
expect(
serverMatchHistory(undefined, 25, query('status=end-report-missing')).matches[0]
).toMatchObject({ missingReports: 0 });
});
it('validates both API routes and loads the same filtered page on a direct page visit', async () => {
const { GET } = await import('../../src/routes/api/matches/+server.js');
const { GET: instanceGET } =
+11 -4
View File
@@ -255,15 +255,19 @@ it('prunes metrics and diagnostics while leaving unlimited histories intact', as
expect(a.db.prepare('SELECT count(*) AS n FROM metric_samples').get()).toEqual({ n: 0 });
});
it('prunes closed matches and audit events without deleting active matches or protected audit records', async () => {
it('prunes closed matches and their receipts without deleting active matches or protected audit records', async () => {
const a = await api();
for (const status of ['active', 'completed', 'interrupted'])
a.db
for (const status of ['active', 'completed', 'interrupted', 'end-report-missing']) {
const match = a.db
.prepare(
`INSERT INTO leaderboard_matches(server,game,map,map_key,map_name,started_at,last_received_at,status,last_event,last_fingerprint)
VALUES('s','t5','kino','kino','Kino',?,?,?,'game_end','test')`
)
.run(old, old, status);
a.db
.prepare('INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,1,1)')
.run(match.lastInsertRowid);
}
// Preview initializes the optional authentication schema.
a.retentionPreview();
a.db
@@ -274,11 +278,14 @@ it('prunes closed matches and audit events without deleting active matches or pr
.run(Date.parse(old));
a.setRetentionControl({ category: 'audit', id: '1', protected: true });
a.saveFleetSettings({ ...a.fleetSettings(), matchKeepDays: 1, auditKeepDays: 1 });
expect(a.pruneHistory('match')).toBe(2);
expect(a.pruneHistory('match')).toBe(3);
expect(a.pruneHistory('audit')).toBe(1);
expect(a.db.prepare('SELECT status FROM leaderboard_matches').all()).toEqual([
{ status: 'active' }
]);
expect(a.db.prepare('SELECT match_id,seq FROM leaderboard_match_reports').all()).toEqual([
{ match_id: 1, seq: 1 }
]);
expect(a.db.pragma('foreign_key_check')).toEqual([]);
});
it('keeps active beta evidence while expiring closed snapshots and then closed history', async () => {
+48
View File
@@ -18,6 +18,54 @@ afterEach(async () => fixture.cleanup());
const payload = gameEvent('round_change', 17);
it.each(['game_start', 'round_change', 'heartbeat', 'game_end'] as const)(
'preserves optional reporter identity and sequence for %s',
async (event) => {
const { parseGameEvent } = await import('../../src/lib/telemetry.js');
const report = gameEvent(event);
const match = ' opaque/Match:123 ';
expect(parseGameEvent({ ...report, match, seq: 7 })).toEqual({
...report,
match,
seq: 7
});
expect(parseGameEvent({ ...report, match })).toEqual({ ...report, match });
expect(parseGameEvent({ ...report, seq: 7 })).toEqual({ ...report, seq: 7 });
expect(parseGameEvent(report)).toEqual(report);
const response = await post({ ...report, match, seq: 7 });
expect(response.status).toBe(200);
expect((await response.json()).telemetry).toMatchObject({ match, seq: 7 });
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
expect(listTelemetry()[0]).toMatchObject({ match, seq: 7 });
}
);
it.each([
{ match: '' },
{ match: ' \t\n' },
{ match: 'x'.repeat(65) },
{ match: null },
{ match: 123 },
{ seq: 0 },
{ seq: -1 },
{ seq: 1.5 },
{ seq: Number.MAX_SAFE_INTEGER + 1 },
{ seq: null },
{ seq: '1' }
])('rejects malformed optional reporter metadata: %j', async (metadata) => {
const response = await post({ ...payload, ...metadata });
expect(response.status).toBe(400);
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
expect(listTelemetry()).toEqual([]);
});
it('accepts the maximum reporter key length and safe sequence number', async () => {
const { parseGameEvent } = await import('../../src/lib/telemetry.js');
expect(
parseGameEvent({ ...payload, match: 'x'.repeat(64), seq: Number.MAX_SAFE_INTEGER })
).toMatchObject({ match: 'x'.repeat(64), seq: Number.MAX_SAFE_INTEGER });
});
it.each(['game_end', 'round reset', 'map change'])(
'drops the previous match timing when game_start is missed after %s',
async (boundary) => {
+2
View File
@@ -17,6 +17,8 @@ const match: MatchDetail = {
observedRound: 4,
finalRound: 4,
status: 'completed',
reporterMatchKey: null,
missingReports: null,
startObserved: true,
startedAt: '2026-10-07T00:00:00Z',
lastReportedAt: '2026-10-07T00:01:00Z',
+35
View File
@@ -16,6 +16,8 @@ const match: ServerMatch = {
observedRound: 20,
finalRound: 20,
status: 'completed',
reporterMatchKey: null,
missingReports: null,
startedAt: '2026-10-06T17:26:00Z',
lastReportedAt: '2026-10-06T19:59:59Z',
endedAt: '2026-10-06T18:04:00Z',
@@ -154,6 +156,39 @@ it('labels unfinished rounds and missing durations without inventing final resul
expect(body).toContain('>—</span>');
expect(body).toContain('>0</td>');
});
it('labels and filters missing end reports and shows sequence gaps on desktop and mobile', () => {
scope('?matchStatus=end-report-missing');
const data = report();
data.matches = [
{
...match,
status: 'end-report-missing',
reporterMatchKey: 'game-a',
missingReports: 2,
finalRound: null,
durationMs: null
}
];
const body = render(MatchHistory, { props: { initial: data } }).body.replaceAll(
/<!--.*?-->/g,
''
);
for (const suffix of ['', '-mobile']) {
const select = body.match(
new RegExp(`<select\\b[^>]*id="match-status${suffix}"[^>]*>([\\s\\S]*?)</select>`)
)?.[1];
expect(select).toContain(
'<option value="end-report-missing" selected="">End report missing</option>'
);
}
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
expect(rows.match(/End report missing/g)).toHaveLength(2);
expect(rows.match(/2 reports missing/g)).toHaveLength(2);
expect(rows).toContain('Highest observed');
expect(rows).not.toContain('<a');
expect(rows).not.toContain('In progress');
expect(rows).not.toContain('38m');
});
it.each([
'2026-10-06T19:58:29Z',
'2026-10-05T18:00:00Z',
+1 -1
View File
@@ -143,7 +143,7 @@ it('renders a known player without qualifying history with identity and unavaila
const text = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
expect(text).toContain('New player');
expect(text).toContain('No qualifying match history yet');
expect(text).toContain('Stats appear after a recorded game ends');
expect(text).toContain("Stats appear after a game's end report arrives");
expect(text).toContain('observed at round 2 or later');
expect(text).toContain('Lifetime best round —');
expect(text).toContain('Best game kills —');