diff --git a/src/lib/server/fleet/leaderboard-ingest.ts b/src/lib/server/fleet/leaderboard-ingest.ts index f0593c6..a757299 100644 --- a/src/lib/server/fleet/leaderboard-ingest.ts +++ b/src/lib/server/fleet/leaderboard-ingest.ts @@ -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'); } diff --git a/src/lib/server/fleet/telemetry.ts b/src/lib/server/fleet/telemetry.ts index 3315b51..e0b5372 100644 --- a/src/lib/server/fleet/telemetry.ts +++ b/src/lib/server/fleet/telemetry.ts @@ -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; })(); } diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index c987b9c..b6e1108 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -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 }; +}; /** Validate the four script payloads; beta state is never part of the protocol. */ export function parseGameEvent(value: unknown): GameEvent { diff --git a/src/lib/ui/components/PlayerScoreboard.svelte b/src/lib/ui/components/PlayerScoreboard.svelte index 88a05eb..d239a45 100644 --- a/src/lib/ui/components/PlayerScoreboard.svelte +++ b/src/lib/ui/components/PlayerScoreboard.svelte @@ -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 @@ Player + Time on server + Time in match + Round started {#each columns as column (column.key)}