148 lines
4.3 KiB
TypeScript
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);
|
|
}
|