Add player timing and profile links to server scoreboard
This commit is contained in:
@@ -42,7 +42,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, ...event } = report;
|
||||
const { receivedAt: _receivedAt, timing: _timing, ...event } = report;
|
||||
const players = [...event.players].sort((a, b) => a.guid.localeCompare(b.guid));
|
||||
return createHash('sha256').update(JSON.stringify({ ...event, players })).digest('hex');
|
||||
}
|
||||
|
||||
@@ -8,11 +8,25 @@ export function listTelemetry(): 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.
|
||||
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);
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?) ON CONFLICT(server) DO UPDATE SET
|
||||
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')`)
|
||||
.run(report.receivedAt, report.server, report.receivedAt);
|
||||
}
|
||||
return report;
|
||||
})();
|
||||
}
|
||||
|
||||
+10
-1
@@ -16,7 +16,16 @@ export type GameEvent = { server: string; map: string } & (
|
||||
| { 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. */
|
||||
export function parseGameEvent(value: unknown): GameEvent {
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
import { elapsedSince } from '$ui/duration.js';
|
||||
import { mapTitle } from '$ui/server-labels.js';
|
||||
import { playerProfileHref } from '$lib/leaderboard.js';
|
||||
import CopyValue from './CopyValue.svelte';
|
||||
import Badge from './primitives/Badge.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 = [
|
||||
{ key: 'score', label: 'Score' },
|
||||
{ key: 'kills', label: 'Kills' },
|
||||
@@ -64,6 +67,9 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<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)}
|
||||
<th scope="col" class="stat" aria-sort={sortBy === column.key ? descending ? 'descending' : 'ascending' : 'none'}>
|
||||
<button type="button" onclick={() => sort(column.key)}
|
||||
@@ -76,11 +82,15 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted as player}
|
||||
{@const timing = report.timing?.players[player.guid]}
|
||||
<tr>
|
||||
<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>
|
||||
</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)}
|
||||
<td class="stat" class:score={column.key === 'score'}>
|
||||
{#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>
|
||||
<a href="/leaderboard">View lifetime leaderboard →</a>
|
||||
</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}
|
||||
|
||||
<style>
|
||||
@@ -109,13 +120,17 @@
|
||||
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: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); }
|
||||
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; }
|
||||
thead .player { min-width: 230px; }
|
||||
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 { 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); }
|
||||
.stat { text-align: right; white-space: nowrap; }
|
||||
td.stat { font: var(--text-sm) var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
@@ -38,7 +38,7 @@ 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(() => {
|
||||
saveTelemetry(telemetry);
|
||||
telemetry = saveTelemetry(telemetry);
|
||||
recordGameEvent(telemetry);
|
||||
})();
|
||||
emit({ type: 'server-telemetry', entries: [telemetry] });
|
||||
|
||||
@@ -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 activeTab === 'scoreboard'}
|
||||
<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>
|
||||
{:else if activeTab === 'chat'}
|
||||
<Card padding="lg">
|
||||
|
||||
@@ -13,6 +13,33 @@ afterEach(async () => fixture.cleanup());
|
||||
|
||||
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 () => {
|
||||
const { GET } = await import('../../src/routes/api/events/servers/+server.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 listener = vi.fn();
|
||||
const unsubscribe = subscribe(listener);
|
||||
expect(await (await post(payload)).json()).toEqual({ ok: true, telemetry: { ...payload, receivedAt: expect.any(String) } });
|
||||
expect(listener).toHaveBeenCalledWith({ type: 'server-telemetry', entries: [{ ...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), timing: expect.any(Object) }] });
|
||||
unsubscribe();
|
||||
await post({ ...payload, round: 1 });
|
||||
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
closeDb();
|
||||
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');
|
||||
expect(getServer(payload.server)?.last_players).toBeNull();
|
||||
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 start = gameEvent('game_start');
|
||||
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_ended_at).toBeNull();
|
||||
expect((await post(payload)).status).toBe(200);
|
||||
|
||||
Reference in New Issue
Block a user