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
+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>