replay wip
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user