Files
kickkingsadmin/src/lib/replay-parse.ts
T
2026-07-29 12:47:29 +05:30

148 lines
4.3 KiB
TypeScript

import type {
ReplayEntity,
ReplayEvent,
ReplayFile,
ReplayFrame,
} from "@/types/replay";
function isRecord(v: unknown): v is Record<string, unknown> {
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);
}