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; 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; 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(); 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; 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; 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[] }; }