Files
electric-cherry/src/lib/telemetry.ts
T

78 lines
3.9 KiB
TypeScript

export interface TelemetryPlayer {
name: string;
guid: string;
/** Server-measured latency in milliseconds; absent/null when unavailable. */
ping?: number | null;
}
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: 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;
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[] };
}