Add player timing and profile links to server scoreboard

This commit is contained in:
2026-09-20 22:03:58 -04:00
parent 3a63e6d3ce
commit 1e6ff3abbf
7 changed files with 79 additions and 13 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ function mapIdentity(handle: Database.Database, report: GameTelemetry) {
function fingerprint(report: GameTelemetry): string { function fingerprint(report: GameTelemetry): string {
// Receipt timestamps and roster ordering do not identify new gameplay. // Receipt timestamps and roster ordering do not identify new gameplay.
const { receivedAt: _receivedAt, ...event } = report; const { receivedAt: _receivedAt, timing: _timing, ...event } = report;
const players = [...event.players].sort((a, b) => a.guid.localeCompare(b.guid)); const players = [...event.players].sort((a, b) => a.guid.localeCompare(b.guid));
return createHash('sha256').update(JSON.stringify({ ...event, players })).digest('hex'); return createHash('sha256').update(JSON.stringify({ ...event, players })).digest('hex');
} }
+17 -2
View File
@@ -8,11 +8,25 @@ export function listTelemetry(): GameTelemetry[] {
return rows.map(row => JSON.parse(row.payload) as GameTelemetry); return rows.map(row => JSON.parse(row.payload) as GameTelemetry);
} }
export function saveTelemetry(report: GameTelemetry): void { export function saveTelemetry(report: GameTelemetry): GameTelemetry {
// Keep callers outside the HTTP route atomic too. // Keep callers outside the HTTP route atomic too.
const handle = db(); const handle = db();
handle.transaction(() => { return handle.transaction(() => {
const row = handle.prepare('SELECT payload FROM server_telemetry WHERE server = ?').get(report.server) as { payload: string | null } | undefined;
const previous: GameTelemetry | undefined = row?.payload ? JSON.parse(row.payload) : undefined;
recordLeaderboardTelemetry(handle, report); recordLeaderboardTelemetry(handle, report);
const match = handle.prepare('SELECT id FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1').get(report.server) as { id: number };
const sameMatch = previous?.timing?.matchId === match.id;
// A gap beyond the normal heartbeat window cannot establish continuity.
const continuous = previous && Date.parse(report.receivedAt) - Date.parse(previous.receivedAt) <= 120_000;
report = { ...report, timing: { matchId: match.id, players: Object.fromEntries(report.players.map(player => {
const prior = continuous ? previous?.timing?.players[player.guid] : undefined;
return [player.guid, {
serverJoinedAt: prior?.serverJoinedAt ?? report.receivedAt,
matchJoinedAt: sameMatch && prior ? prior.matchJoinedAt : report.receivedAt,
roundStarted: sameMatch && prior ? prior.roundStarted : report.event === 'game_start' ? 1 : report.round
}];
})) } };
handle.prepare(`INSERT INTO server_telemetry (server, round, players, received_at, payload) handle.prepare(`INSERT INTO server_telemetry (server, round, players, received_at, payload)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(server) DO UPDATE SET VALUES (?, ?, ?, ?, ?) ON CONFLICT(server) DO UPDATE SET
round = excluded.round, players = excluded.players, received_at = excluded.received_at, payload = excluded.payload`) round = excluded.round, players = excluded.players, received_at = excluded.received_at, payload = excluded.payload`)
@@ -26,5 +40,6 @@ export function saveTelemetry(report: GameTelemetry): void {
AND healthy_at <= ? AND beta_id IN (SELECT id FROM betas WHERE state = 'running')`) AND healthy_at <= ? AND beta_id IN (SELECT id FROM betas WHERE state = 'running')`)
.run(report.receivedAt, report.server, report.receivedAt); .run(report.receivedAt, report.server, report.receivedAt);
} }
return report;
})(); })();
} }
+10 -1
View File
@@ -16,7 +16,16 @@ export type GameEvent = { server: string; map: string } & (
| { event: 'round_change' | 'heartbeat' | 'game_end'; round: number; players: TelemetryPlayerStats[] } | { event: 'round_change' | 'heartbeat' | 'game_end'; round: number; players: TelemetryPlayerStats[] }
); );
export type GameTelemetry = GameEvent & { receivedAt: string }; export interface PlayerTiming {
serverJoinedAt: string;
matchJoinedAt: string;
roundStarted: number | null;
}
export type GameTelemetry = GameEvent & {
receivedAt: string;
timing?: { matchId: number; players: Record<string, PlayerTiming> };
};
/** Validate the four script payloads; beta state is never part of the protocol. */ /** Validate the four script payloads; beta state is never part of the protocol. */
export function parseGameEvent(value: unknown): GameEvent { export function parseGameEvent(value: unknown): GameEvent {
+18 -3
View File
@@ -3,11 +3,14 @@
import { uiNow } from '$ui/stores/servers.js'; import { uiNow } from '$ui/stores/servers.js';
import { elapsedSince } from '$ui/duration.js'; import { elapsedSince } from '$ui/duration.js';
import { mapTitle } from '$ui/server-labels.js'; import { mapTitle } from '$ui/server-labels.js';
import { playerProfileHref } from '$lib/leaderboard.js';
import CopyValue from './CopyValue.svelte'; import CopyValue from './CopyValue.svelte';
import Badge from './primitives/Badge.svelte'; import Badge from './primitives/Badge.svelte';
import EmptyState from './primitives/EmptyState.svelte'; import EmptyState from './primitives/EmptyState.svelte';
let { report, mapLabel }: { report: GameTelemetry | undefined; mapLabel?: string } = $props(); let { report, mapLabel, game }: { report: GameTelemetry | undefined; mapLabel?: string; game: string } = $props();
const timingNow = $derived(report && (report.event === 'game_end' || $uiNow - Date.parse(report.receivedAt) > 120_000)
? Date.parse(report.receivedAt) : $uiNow);
const columns = [ const columns = [
{ key: 'score', label: 'Score' }, { key: 'score', label: 'Score' },
{ key: 'kills', label: 'Kills' }, { key: 'kills', label: 'Kills' },
@@ -64,6 +67,9 @@
<thead> <thead>
<tr> <tr>
<th scope="col" class="player">Player</th> <th scope="col" class="player">Player</th>
<th scope="col" class="stat">Time on server</th>
<th scope="col" class="stat">Time in match</th>
<th scope="col" class="stat">Round started</th>
{#each columns as column (column.key)} {#each columns as column (column.key)}
<th scope="col" class="stat" aria-sort={sortBy === column.key ? descending ? 'descending' : 'ascending' : 'none'}> <th scope="col" class="stat" aria-sort={sortBy === column.key ? descending ? 'descending' : 'ascending' : 'none'}>
<button type="button" onclick={() => sort(column.key)} <button type="button" onclick={() => sort(column.key)}
@@ -76,11 +82,15 @@
</thead> </thead>
<tbody> <tbody>
{#each sorted as player} {#each sorted as player}
{@const timing = report.timing?.players[player.guid]}
<tr> <tr>
<th scope="row" class="player"> <th scope="row" class="player">
<span class="player-name">{player.name}</span> <a class="player-name" href={playerProfileHref(game.toLowerCase(), player.guid)}>{player.name}</a>
<div class="guid"><span>GUID</span><CopyValue value={player.guid} label={`GUID for ${player.name}`} /></div> <div class="guid"><span>GUID</span><CopyValue value={player.guid} label={`GUID for ${player.name}`} /></div>
</th> </th>
<td class="stat">{timing ? elapsedSince(timing.serverJoinedAt, timingNow) : '—'}</td>
<td class="stat">{timing ? elapsedSince(timing.matchJoinedAt, timingNow) : '—'}</td>
<td class="stat">{timing?.roundStarted ?? '—'}</td>
{#each columns as column (column.key)} {#each columns as column (column.key)}
<td class="stat" class:score={column.key === 'score'}> <td class="stat" class:score={column.key === 'score'}>
{#if player[column.key] === null}<span class="unreported" aria-label="Not yet reported">—</span> {#if player[column.key] === null}<span class="unreported" aria-label="Not yet reported">—</span>
@@ -97,6 +107,7 @@
<p class="note">{report.event === 'game_start' ? 'Player stats arrive with the next round report or heartbeat.' : report.event === 'game_end' ? 'Final stats from this game. The scoreboard resets when the next game starts.' : 'Stats update at each round change, heartbeat, and game end.'}</p> <p class="note">{report.event === 'game_start' ? 'Player stats arrive with the next round report or heartbeat.' : report.event === 'game_end' ? 'Final stats from this game. The scoreboard resets when the next game starts.' : 'Stats update at each round change, heartbeat, and game end.'}</p>
<a href="/leaderboard">View lifetime leaderboard →</a> <a href="/leaderboard">View lifetime leaderboard →</a>
</div> </div>
<p class="timing-note">Times and joining rounds are estimated from player reports. Server time carries across consecutive matches; ended or stale reports show time at the last update.</p>
{/if} {/if}
<style> <style>
@@ -109,13 +120,17 @@
time { font-size: var(--text-xs); color: var(--text-tertiary); } time { font-size: var(--text-xs); color: var(--text-tertiary); }
.table-scroll { max-width: 100%; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); } .table-scroll { max-width: 100%; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); } .table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
table { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0; } table { width: 100%; min-width: 1000px; border-collapse: separate; border-spacing: 0; }
th, td { padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border-subtle); } th, td { padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border-subtle); }
thead th { font-size: var(--text-xs); color: var(--text-secondary); font-weight: var(--weight-semibold); background: var(--surface-sunken); } thead th { font-size: var(--text-xs); color: var(--text-secondary); font-weight: var(--weight-semibold); background: var(--surface-sunken); }
.player { text-align: left; position: sticky; left: 0; z-index: 1; } .player { text-align: left; position: sticky; left: 0; z-index: 1; }
thead .player { min-width: 230px; } thead .player { min-width: 230px; }
tbody .player { background: var(--surface); font-weight: var(--weight-medium); } tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
.player-name { display: block; max-width: 320px; overflow-wrap: anywhere; font-size: var(--text-sm); color: var(--text-primary); } .player-name { display: block; max-width: 320px; overflow-wrap: anywhere; font-size: var(--text-sm); color: var(--text-primary); }
.player-name { text-decoration: none; }
.player-name:hover { color: var(--accent-primary-hover); text-decoration: underline; }
.player-name:focus-visible { outline: none; border-radius: var(--radius-sm); box-shadow: var(--focus-ring); }
.timing-note { margin: var(--space-2) 0 0; font-size: var(--text-xs); color: var(--text-tertiary); }
.guid { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); } .guid { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); }
.stat { text-align: right; white-space: nowrap; } .stat { text-align: right; white-space: nowrap; }
td.stat { font: var(--text-sm) var(--font-mono); font-variant-numeric: tabular-nums; } td.stat { font: var(--text-sm) var(--font-mono); font-variant-numeric: tabular-nums; }
+1 -1
View File
@@ -38,7 +38,7 @@ export async function POST({ request }: { request: Request }) {
const server = getServer(telemetry.server); const server = getServer(telemetry.server);
if (!server || server.destroyed_at) return reject(404, 'Server is not registered or has been destroyed'); if (!server || server.destroyed_at) return reject(404, 'Server is not registered or has been destroyed');
db().transaction(() => { db().transaction(() => {
saveTelemetry(telemetry); telemetry = saveTelemetry(telemetry);
recordGameEvent(telemetry); recordGameEvent(telemetry);
})(); })();
emit({ type: 'server-telemetry', entries: [telemetry] }); emit({ type: 'server-telemetry', entries: [telemetry] });
+1 -1
View File
@@ -310,7 +310,7 @@
{#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeTab)}>Retry</Button></div>{/if} {#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeTab)}>Retry</Button></div>{/if}
{#if activeTab === 'scoreboard'} {#if activeTab === 'scoreboard'}
<Card padding="lg"> <Card padding="lg">
{#key name}<PlayerScoreboard report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key} {#key name}<PlayerScoreboard game={fleetIdentity(server).game} report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key}
</Card> </Card>
{:else if activeTab === 'chat'} {:else if activeTab === 'chat'}
<Card padding="lg"> <Card padding="lg">
+31 -4
View File
@@ -13,6 +13,33 @@ afterEach(async () => fixture.cleanup());
const payload = gameEvent('round_change', 17); const payload = gameEvent('round_change', 17);
it('persists player timing across matches and resets after absence or a telemetry gap', async () => {
const { saveTelemetry, listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { closeDb } = await import('../../src/lib/server/db/index.js');
const guid = roster(1)[0].guid;
const at = (seconds: number) => new Date(Date.parse('2026-09-20T12:00:00Z') + seconds * 1000).toISOString();
const save = (event: Parameters<typeof gameEvent>[0], seconds: number, round = 1, players = 1) =>
saveTelemetry({ ...gameEvent(event, round, players), receivedAt: at(seconds) });
save('game_start', 0);
save('heartbeat', 60, 5);
const end = save('game_end', 90, 6);
expect(end.timing?.players[guid]).toEqual({ serverJoinedAt: at(0), matchJoinedAt: at(0), roundStarted: 1 });
const next = save('game_start', 100);
expect(next.timing?.players[guid]).toEqual({ serverJoinedAt: at(0), matchJoinedAt: at(100), roundStarted: 1 });
closeDb();
expect(listTelemetry()[0].timing).toEqual(next.timing);
save('heartbeat', 120, 2, 0);
expect(save('heartbeat', 140, 3).timing?.players[guid]).toEqual({
serverJoinedAt: at(140), matchJoinedAt: at(140), roundStarted: 3
});
expect(save('heartbeat', 400, 8).timing?.players[guid]).toEqual({
serverJoinedAt: at(400), matchJoinedAt: at(400), roundStarted: 8
});
expect(save('heartbeat', 420, 1).timing?.players[guid]).toEqual({
serverJoinedAt: at(400), matchJoinedAt: at(420), roundStarted: 1
});
});
it('timestamps initial telemetry and subsequent status and telemetry events with the server clock', async () => { it('timestamps initial telemetry and subsequent status and telemetry events with the server clock', async () => {
const { GET } = await import('../../src/routes/api/events/servers/+server.js'); const { GET } = await import('../../src/routes/api/events/servers/+server.js');
const { emit } = await import('../../src/lib/server/pipeline/bus.js'); const { emit } = await import('../../src/lib/server/pipeline/bus.js');
@@ -43,14 +70,14 @@ it.each(['round_change', 'heartbeat'] as const)('persists %s stats, broadcasts u
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
const listener = vi.fn(); const listener = vi.fn();
const unsubscribe = subscribe(listener); const unsubscribe = subscribe(listener);
expect(await (await post(payload)).json()).toEqual({ ok: true, telemetry: { ...payload, receivedAt: expect.any(String) } }); expect(await (await post(payload)).json()).toEqual({ ok: true, telemetry: { ...payload, receivedAt: expect.any(String), timing: expect.any(Object) } });
expect(listener).toHaveBeenCalledWith({ type: 'server-telemetry', entries: [{ ...payload, receivedAt: expect.any(String) }] }); expect(listener).toHaveBeenCalledWith({ type: 'server-telemetry', entries: [{ ...payload, receivedAt: expect.any(String), timing: expect.any(Object) }] });
unsubscribe(); unsubscribe();
await post({ ...payload, round: 1 }); await post({ ...payload, round: 1 });
const { closeDb } = await import('../../src/lib/server/db/index.js'); const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb(); closeDb();
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
expect(listTelemetry()).toEqual([{ ...payload, round: 1, receivedAt: expect.any(String) }]); expect(listTelemetry()).toEqual([{ ...payload, round: 1, receivedAt: expect.any(String), timing: expect.any(Object) }]);
const { getServer } = await import('../../src/lib/server/fleet/servers.js'); const { getServer } = await import('../../src/lib/server/fleet/servers.js');
expect(getServer(payload.server)?.last_players).toBeNull(); expect(getServer(payload.server)?.last_players).toBeNull();
const { GET } = await import('../../src/routes/api/events/servers/+server.js'); const { GET } = await import('../../src/routes/api/events/servers/+server.js');
@@ -68,7 +95,7 @@ it('accepts all four events without an active beta and updates match lifecycle',
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const start = gameEvent('game_start'); const start = gameEvent('game_start');
expect((await post(start)).status).toBe(200); expect((await post(start)).status).toBe(200);
expect(listTelemetry()).toEqual([{ ...start, receivedAt: expect.any(String) }]); expect(listTelemetry()).toEqual([{ ...start, receivedAt: expect.any(String), timing: expect.any(Object) }]);
expect(getServer(start.server)?.game_started_at).toBeTruthy(); expect(getServer(start.server)?.game_started_at).toBeTruthy();
expect(getServer(start.server)?.game_ended_at).toBeNull(); expect(getServer(start.server)?.game_ended_at).toBeNull();
expect((await post(payload)).status).toBe(200); expect((await post(payload)).status).toBe(200);