Support player ping in game telemetry
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
})] }));
|
})] }));
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user