Support player ping in game telemetry

This commit is contained in:
2026-10-06 21:10:25 -04:00
parent 2520735850
commit 675d9b1576
6 changed files with 61 additions and 4 deletions
+8
View File
@@ -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. preserve their precision. Round and stat values are nonnegative JSON integers.
Each GUID must appear only once within a report's player array. 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 ### Game start
Send when the game starts. Round and stats are not required. Send when the game starts. Round and stats are not required.
+1 -1
View File
@@ -70,7 +70,7 @@ export function selectServerObservation<T extends ServerObservation>(server: T,
return { ...result, health: 'online', status_source: 'telemetry', last_players: players, return { ...result, health: 'online', status_source: 'telemetry', last_players: players,
last_map: report.map, last_seen_at: report.receivedAt, last_error: null, 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, 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, active_since: players > 0 ? timing.activeSince : null,
inactive_since: players === 0 ? timing.inactiveSince : null, inactive_since: players === 0 ? timing.inactiveSince : null,
game_started_at: players === 0 ? null : timing.gameStartedAt, game_started_at: players === 0 ? null : timing.gameStartedAt,
+9 -1
View File
@@ -1,6 +1,8 @@
export interface TelemetryPlayer { export interface TelemetryPlayer {
name: string; name: string;
guid: string; guid: string;
/** Server-measured latency in milliseconds; absent/null when unavailable. */
ping?: number | null;
} }
export interface TelemetryPlayerStats extends TelemetryPlayer { export interface TelemetryPlayerStats extends TelemetryPlayer {
@@ -51,7 +53,13 @@ export function parseGameEvent(value: unknown): GameEvent {
for (const key of ['name', 'guid'] as const) { 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`); 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`); if (guids.has(identity.guid)) throw new Error(`players[${index}].guid must be unique within the report`);
guids.add(identity.guid); guids.add(identity.guid);
if (body.event === 'game_start') return identity; if (body.event === 'game_start') return identity;
+11
View File
@@ -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) }); 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 => { 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); const selected = selectServerObservation(server({ health: state === 'starting' || state === 'restarting' ? 'starting' : 'offline', container_state: state }), report(), now);
expect(selected.health).not.toBe('online'); expect(selected.health).not.toBe('online');
+5 -2
View File
@@ -494,7 +494,9 @@ describe('database fleet and container readiness', () => {
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); 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()); db.prepare("UPDATE servers SET last_players = 4, last_seen_at = ? WHERE name = 'managed'").run(new Date().toISOString());
inspect.mockResolvedValue({ ok: true, state: 'running' }); 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 events: unknown[] = [];
const unsubscribe = subscribe(event => events.push(event)); const unsubscribe = subscribe(event => events.push(event));
try { await fanoutStatus(); } finally { unsubscribe(); } 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, expect(server).toMatchObject({ health: 'online', last_players: 3, status_source: 'telemetry', last_error: null,
rcon_status: { last_players: 4, last_error: 'timeout:old process' } }); rcon_status: { last_players: 4, last_error: 'timeout:old process' } });
expect(server.roster).toHaveLength(3); 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({ expect(events).toContainEqual(expect.objectContaining({ type: 'server-status', entries: [expect.objectContaining({
health: 'online', players: 3, roster: server.roster, lastSeenAt: server.last_seen_at health: 'online', players: 3, roster: server.roster, lastSeenAt: server.last_seen_at
})] })); })] }));
+27
View File
@@ -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); 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 })), it.each([null, [], {}, ...['boot', 'session_start', 'match_end', 'feature', 'other'].map(event => ({ ...payload, event })),
{ ...payload, map: '' }, { ...payload, map: undefined }, { ...payload, round: -1 }, { ...payload, map: '' }, { ...payload, map: undefined }, { ...payload, round: -1 },
{ ...payload, round: 1.5 }, { ...payload, round: '17' }, { ...payload, players: 4 }, { ...payload, players: null }, { ...payload, round: 1.5 }, { ...payload, round: '17' }, { ...payload, players: 4 }, { ...payload, players: null },