import type { ReplayEntity, ReplayEvent, ReplayFile, ReplayFrame, } from "@/types/replay"; function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } function asNumber(v: unknown, label: string): number { if (typeof v !== "number" || !Number.isFinite(v)) { throw new Error(`Invalid ${label}: expected a finite number`); } return v; } function asBoolean(v: unknown, label: string): boolean { if (typeof v !== "boolean") { throw new Error(`Invalid ${label}: expected a boolean`); } return v; } function asString(v: unknown, label: string): string { if (typeof v !== "string") { throw new Error(`Invalid ${label}: expected a string`); } return v; } function asNumberArray(v: unknown, label: string, len: number): number[] { if (!Array.isArray(v)) { throw new Error(`Invalid ${label}: expected an array`); } if (v.length !== len) { throw new Error( `Invalid ${label}: expected length ${len}, got ${v.length}`, ); } const out: number[] = []; for (let i = 0; i < v.length; i++) { const n = v[i]; if (typeof n !== "number" || !Number.isFinite(n)) { throw new Error(`Invalid ${label}[${i}]: expected a finite number`); } out.push(n); } return out; } function parseEntity(raw: unknown, index: number): ReplayEntity { if (!isRecord(raw)) { throw new Error(`entities[${index}] must be an object`); } return { id: asNumber(raw.id, `entities[${index}].id`), type: asString(raw.type, `entities[${index}].type`), team: asString(raw.team, `entities[${index}].team`), }; } function parseFrame( raw: unknown, index: number, entityCount: number, ): ReplayFrame { if (!isRecord(raw)) { throw new Error(`frames[${index}] must be an object`); } return { t: asNumber(raw.t, `frames[${index}].t`), x: asNumberArray(raw.x, `frames[${index}].x`, entityCount), y: asNumberArray(raw.y, `frames[${index}].y`, entityCount), vx: asNumberArray(raw.vx, `frames[${index}].vx`, entityCount), vy: asNumberArray(raw.vy, `frames[${index}].vy`, entityCount), }; } function parseEvent(raw: unknown, index: number): ReplayEvent { if (!isRecord(raw)) { throw new Error(`events[${index}] must be an object`); } return { t: asNumber(raw.t, `events[${index}].t`), type: asString(raw.type, `events[${index}].type`), kind: asString(raw.kind ?? "", `events[${index}].kind`), vol: asNumber(raw.vol ?? 0, `events[${index}].vol`), id: asNumber(raw.id ?? -1, `events[${index}].id`), team: asString(raw.team ?? "", `events[${index}].team`), redScore: asNumber(raw.redScore ?? 0, `events[${index}].redScore`), blueScore: asNumber(raw.blueScore ?? 0, `events[${index}].blueScore`), kickoff: asBoolean(raw.kickoff ?? false, `events[${index}].kickoff`), }; } /** Parse and lightly validate a replay JSON value from disk / upload. */ export function parseReplayFile(raw: unknown): ReplayFile { if (!isRecord(raw)) { throw new Error("Replay root must be a JSON object"); } const entitiesRaw = raw.entities; if (!Array.isArray(entitiesRaw) || entitiesRaw.length === 0) { throw new Error("Replay must include a non-empty entities array"); } const entities = entitiesRaw.map(parseEntity); const framesRaw = raw.frames; if (!Array.isArray(framesRaw) || framesRaw.length === 0) { throw new Error("Replay must include a non-empty frames array"); } const frames = framesRaw.map((f, i) => parseFrame(f, i, entities.length)); const eventsRaw = raw.events; if (!Array.isArray(eventsRaw)) { throw new Error("Replay must include an events array"); } const events = eventsRaw.map(parseEvent); const duration = asNumber(raw.duration, "duration"); if (duration < 0) { throw new Error("duration must be >= 0"); } return { version: asNumber(raw.version, "version"), matchId: asNumber(raw.matchId, "matchId"), isPractice: asBoolean(raw.isPractice ?? false, "isPractice"), fixedDeltaTime: asNumber(raw.fixedDeltaTime, "fixedDeltaTime"), duration, entities, frames, events, }; } export function parseReplayJsonText(text: string): ReplayFile { let raw: unknown; try { raw = JSON.parse(text) as unknown; } catch { throw new Error("File is not valid JSON"); } return parseReplayFile(raw); }