From 675d9b1576850401b93f05e8b2a6a8900238c28c Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Tue, 6 Oct 2026 21:10:25 -0400 Subject: [PATCH] Support player ping in game telemetry --- docs/beta-test-plans.md | 8 ++++++++ src/lib/server-observation.ts | 2 +- src/lib/telemetry.ts | 10 +++++++++- tests/unit/server-observation.test.ts | 11 +++++++++++ tests/unit/server-status.test.ts | 7 +++++-- tests/unit/telemetry.test.ts | 27 +++++++++++++++++++++++++++ 6 files changed, 61 insertions(+), 4 deletions(-) diff --git a/docs/beta-test-plans.md b/docs/beta-test-plans.md index f55aa97..dabdcee 100644 --- a/docs/beta-test-plans.md +++ b/docs/beta-test-plans.md @@ -33,6 +33,14 @@ there are no players. Names and GUIDs are strings; keep GUIDs as strings to preserve their precision. Round and stat values are nonnegative JSON integers. Each GUID must appear only once within a report's player array. +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 +zero for a missing measurement. Older reporters without this field remain valid. +Fleet displays ping from the same telemetry snapshot as the player roster and +scores, with `—` for unknown values. Ping is retained and streamed with the latest +report; it does not contribute to leaderboard statistics. + ### Game start Send when the game starts. Round and stats are not required. diff --git a/src/lib/server-observation.ts b/src/lib/server-observation.ts index ce8ed39..b680f1e 100644 --- a/src/lib/server-observation.ts +++ b/src/lib/server-observation.ts @@ -70,7 +70,7 @@ export function selectServerObservation(server: T, return { ...result, health: 'online', status_source: 'telemetry', last_players: players, last_map: report.map, last_seen_at: report.receivedAt, last_error: null, roster: report.players.map(player => ({ num: null, score: 'score' in player ? player.score : null, - ping: null, guid: player.guid, name: player.name })), + ping: player.ping ?? null, guid: player.guid, name: player.name })), active_since: players > 0 ? timing.activeSince : null, inactive_since: players === 0 ? timing.inactiveSince : null, game_started_at: players === 0 ? null : timing.gameStartedAt, diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 6b2edeb..8288ec2 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -1,6 +1,8 @@ export interface TelemetryPlayer { name: string; guid: string; + /** Server-measured latency in milliseconds; absent/null when unavailable. */ + ping?: number | null; } export interface TelemetryPlayerStats extends TelemetryPlayer { @@ -51,7 +53,13 @@ export function parseGameEvent(value: unknown): GameEvent { for (const key of ['name', 'guid'] as const) { if (typeof player[key] !== 'string' || !player[key].trim()) throw new Error(`players[${index}].${key} must be a nonempty string`); } - const identity = { name: player.name as string, guid: player.guid as string }; + const identity: TelemetryPlayer = { name: player.name as string, guid: player.guid as string }; + if (player.ping !== undefined) { + if (player.ping !== null && (!Number.isSafeInteger(player.ping) || (player.ping as number) < 0)) { + throw new Error(`players[${index}].ping must be a nonnegative integer or null`); + } + identity.ping = player.ping as number | null; + } if (guids.has(identity.guid)) throw new Error(`players[${index}].guid must be unique within the report`); guids.add(identity.guid); if (body.event === 'game_start') return identity; diff --git a/tests/unit/server-observation.test.ts b/tests/unit/server-observation.test.ts index cc70f5b..3f05c7f 100644 --- a/tests/unit/server-observation.test.ts +++ b/tests/unit/server-observation.test.ts @@ -56,6 +56,17 @@ describe('shared server observation', () => { expect(fallback).toMatchObject({ status_source: 'rcon', health: 'online', last_players: 4, last_seen_at: at(40_000) }); }); + it('displays telemetry pings and clears missing readings on subsequent reports', () => { + const telemetry = report(0, 4); + telemetry.players = telemetry.players.map((player, i) => ({ ...player, ping: [0, 42, 999, null][i] })); + const selected = selectServerObservation(server(), telemetry, now); + expect(selected.roster?.map(player => player.ping)).toEqual([0, 42, 999, null]); + expect(selectServerObservation(selected, report(1, 4), now + 1).roster?.map(player => player.ping)).toEqual([null, null, null, null]); + const start = { ...gameEvent('game_start', 0, 1, 'managed'), event: 'game_start' as const, receivedAt: at(), + players: [{ name: 'Player 1', guid: '123', ping: 25 }] }; + expect(selectServerObservation(server(), start, now).roster?.[0]).toMatchObject({ score: null, ping: 25 }); + }); + it.each(['starting', 'restarting', 'unhealthy', 'exited'])('telemetry cannot override container state %s', state => { const selected = selectServerObservation(server({ health: state === 'starting' || state === 'restarting' ? 'starting' : 'offline', container_state: state }), report(), now); expect(selected.health).not.toBe('online'); diff --git a/tests/unit/server-status.test.ts b/tests/unit/server-status.test.ts index de8b522..39ea2ea 100644 --- a/tests/unit/server-status.test.ts +++ b/tests/unit/server-status.test.ts @@ -494,7 +494,9 @@ describe('database fleet and container readiness', () => { const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); db.prepare("UPDATE servers SET last_players = 4, last_seen_at = ? WHERE name = 'managed'").run(new Date().toISOString()); inspect.mockResolvedValue({ ok: true, state: 'running' }); - await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed')); + const telemetry = gameEvent('heartbeat', 15, 3, 'managed'); + telemetry.players[0].ping = 42; + await postTelemetry(telemetry); const events: unknown[] = []; const unsubscribe = subscribe(event => events.push(event)); try { await fanoutStatus(); } finally { unsubscribe(); } @@ -503,7 +505,8 @@ describe('database fleet and container readiness', () => { expect(server).toMatchObject({ health: 'online', last_players: 3, status_source: 'telemetry', last_error: null, rcon_status: { last_players: 4, last_error: 'timeout:old process' } }); expect(server.roster).toHaveLength(3); - expect(server.roster[0]).toMatchObject({ ping: null, score: 5000 }); + expect(server.roster[0]).toMatchObject({ ping: 42, score: 5000 }); + expect(server.roster[1].ping).toBeNull(); expect(events).toContainEqual(expect.objectContaining({ type: 'server-status', entries: [expect.objectContaining({ health: 'online', players: 3, roster: server.roster, lastSeenAt: server.last_seen_at })] })); diff --git a/tests/unit/telemetry.test.ts b/tests/unit/telemetry.test.ts index 6739557..e05793b 100644 --- a/tests/unit/telemetry.test.ts +++ b/tests/unit/telemetry.test.ts @@ -180,6 +180,33 @@ it('accepts all four events without an active beta and updates match lifecycle', expect((await post(gameEvent('game_end', 0, 0))).status).toBe(200); }); +it.each(['game_start', 'round_change', 'heartbeat', 'game_end'] as const)('preserves %s player pings through ingestion, streaming and reload', async event => { + const payload = gameEvent(event, 17); + payload.players = payload.players.map((player, i) => ({ ...player, ping: [0, 42, 999, null][i] })); + const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + try { + const response = await post(payload); + expect(response.status).toBe(200); + expect((await response.json()).telemetry.players).toEqual(payload.players); + expect(listener).toHaveBeenCalledWith({ type: 'server-telemetry', entries: [expect.objectContaining({ players: payload.players })] }); + const { closeDb } = await import('../../src/lib/server/db/index.js'); + closeDb(); + const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); + const { telemetryReport } = await import('../../src/lib/server/fleet/status-evidence.js'); + expect(listTelemetry()[0].players).toEqual(payload.players); + expect(telemetryReport(payload.server)?.players).toEqual(payload.players); + } finally { unsubscribe(); } +}); + +it.each([-1, 1.5, '42', false, {}, [], Number.MAX_SAFE_INTEGER + 1])('rejects invalid player ping: %j', async ping => { + for (const event of ['game_start', 'heartbeat'] as const) { + const body = gameEvent(event, 17, 1); + expect((await post({ ...body, players: [{ ...body.players[0], ping }] })).status).toBe(400); + } +}); + it.each([null, [], {}, ...['boot', 'session_start', 'match_end', 'feature', 'other'].map(event => ({ ...payload, event })), { ...payload, map: '' }, { ...payload, map: undefined }, { ...payload, round: -1 }, { ...payload, round: 1.5 }, { ...payload, round: '17' }, { ...payload, players: 4 }, { ...payload, players: null },