Files
electric-cherry/src/lib/telemetry.ts
T
josh 2fa3792482 Fix active and match timer drift between status polls
Persist occupancy transitions with telemetry and carry timer anchors through live updates. Cover delayed polling, lobby rejoining, and lifecycle boundaries with regression tests.

Fixes #47
2026-09-27 23:29:04 -04:00

70 lines
3.4 KiB
TypeScript

export interface TelemetryPlayer {
name: string;
guid: string;
}
export interface TelemetryPlayerStats extends TelemetryPlayer {
kills: number;
headshots: number;
downs: number;
revives: number;
score: number;
}
export type GameEvent = { server: string; map: string } & (
| { event: 'game_start'; players: TelemetryPlayer[] }
| { event: 'round_change' | 'heartbeat' | 'game_end'; round: number; players: TelemetryPlayerStats[] }
);
export interface PlayerTiming {
serverJoinedAt: string;
matchJoinedAt: string;
roundStarted: number | null;
}
/** Server-owned anchors, included so successive live reports keep the same timers. */
export interface ServerTiming {
activeSince: string | null;
inactiveSince: string | null;
gameStartedAt: string | null;
gameEndedAt: string | null;
}
export type GameTelemetry = GameEvent & {
receivedAt: string;
timing?: { matchId: number; players: Record<string, PlayerTiming>; server?: ServerTiming };
};
/** Validate the four script payloads; beta state is never part of the protocol. */
export function parseGameEvent(value: unknown): GameEvent {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Payload must be a JSON object');
const body = value as Record<string, unknown>;
if (!['game_start', 'round_change', 'heartbeat', 'game_end'].includes(String(body.event))) throw new Error('event must be game_start, round_change, heartbeat, or game_end');
for (const key of ['server', 'map'] as const) {
if (typeof body[key] !== 'string' || !body[key].trim() || body[key].length > 255) throw new Error(`${key} must be a nonempty string (up to 255 characters)`);
}
if (!Array.isArray(body.players)) throw new Error('players must be an array');
const guids = new Set<string>();
const players = body.players.map((value, index) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`players[${index}] must be an object`);
const player = value as Record<string, unknown>;
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 };
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;
const stats = {} as Omit<TelemetryPlayerStats, keyof TelemetryPlayer>;
for (const key of ['kills', 'headshots', 'downs', 'revives', 'score'] as const) {
if (!Number.isSafeInteger(player[key]) || (player[key] as number) < 0) throw new Error(`players[${index}].${key} must be a nonnegative integer`);
stats[key] = player[key] as number;
}
return { ...identity, ...stats };
});
const common = { server: body.server as string, map: body.map as string };
if (body.event === 'game_start') return { ...common, event: 'game_start', players };
if (!Number.isSafeInteger(body.round) || (body.round as number) < 0) throw new Error('round must be a nonnegative integer');
return { ...common, event: body.event as 'round_change' | 'heartbeat' | 'game_end', round: body.round as number, players: players as TelemetryPlayerStats[] };
}