133 lines
5.9 KiB
TypeScript
133 lines
5.9 KiB
TypeScript
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
|
import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes";
|
|
import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes";
|
|
import { normalizeDungeonRunProgress, type DungeonRunProgress } from "./dungeonRunTypes";
|
|
|
|
// Version 1 snapshots sorted consumed action ids and cannot be migrated
|
|
// reliably when multiple durable actions write the same state slot. Failing
|
|
// those active-run checkpoints closed is safer than resuming corrupted doors.
|
|
export const DUNGEON_RUN_CHECKPOINT_VERSION = 2;
|
|
|
|
export interface DungeonRunCheckpoint {
|
|
readonly version: typeof DUNGEON_RUN_CHECKPOINT_VERSION;
|
|
readonly dungeonId: string;
|
|
readonly difficultyId: string;
|
|
readonly checkpointId: string | null;
|
|
readonly spawn: WorldSpawn;
|
|
readonly encounters: readonly DungeonEncounterSnapshot[];
|
|
readonly savedAt: number;
|
|
readonly run?: DungeonRunProgress;
|
|
}
|
|
|
|
function finiteVector(value: unknown): Vector3Tuple | null {
|
|
if (!Array.isArray(value) || value.length !== 3 || value.some((entry) => (
|
|
typeof entry !== "number" || !Number.isFinite(entry)
|
|
))) return null;
|
|
return [value[0], value[1], value[2]];
|
|
}
|
|
|
|
function booleanRecord(value: unknown): Readonly<Record<string, boolean>> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const entries = Object.entries(value as Record<string, unknown>);
|
|
if (entries.some(([key, entry]) => !key || typeof entry !== "boolean")) return null;
|
|
return Object.freeze(Object.fromEntries(entries) as Record<string, boolean>);
|
|
}
|
|
|
|
function numberRecord(value: unknown): Readonly<Record<string, number>> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const entries = Object.entries(value as Record<string, unknown>);
|
|
if (entries.some(([key, entry]) => !key || typeof entry !== "number" || !Number.isFinite(entry))) return null;
|
|
return Object.freeze(Object.fromEntries(entries) as Record<string, number>);
|
|
}
|
|
|
|
function stringRecord(value: unknown): Readonly<Record<string, string>> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const entries = Object.entries(value as Record<string, unknown>);
|
|
if (entries.some(([key, entry]) => !key || typeof entry !== "string")) return null;
|
|
return Object.freeze(Object.fromEntries(entries) as Record<string, string>);
|
|
}
|
|
|
|
function stringList(value: unknown): readonly string[] | null {
|
|
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || !entry)) return null;
|
|
return Object.freeze([...new Set(value)]);
|
|
}
|
|
|
|
function normalizeSnapshot(value: unknown): DungeonEncounterSnapshot | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const input = value as Partial<DungeonEncounterSnapshot>;
|
|
if (
|
|
input.schemaVersion !== DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION
|
|
|| typeof input.encounterId !== "string"
|
|
|| !input.encounterId
|
|
|| typeof input.phase !== "string"
|
|
|| !input.phase
|
|
|| typeof input.phaseStartedAt !== "number"
|
|
|| !Number.isFinite(input.phaseStartedAt)
|
|
|| typeof input.revision !== "number"
|
|
|| !Number.isFinite(input.revision)
|
|
|| (input.checkpointId !== null && typeof input.checkpointId !== "string")
|
|
) return null;
|
|
const flags = booleanRecord(input.flags);
|
|
const counters = numberRecord(input.counters);
|
|
const timers = numberRecord(input.timers);
|
|
const worldObjectStates = stringRecord(input.worldObjectStates);
|
|
const transitionStates = booleanRecord(input.transitionStates);
|
|
const activeSpawnGroupIds = stringList(input.activeSpawnGroupIds);
|
|
const completedObjectiveIds = stringList(input.completedObjectiveIds);
|
|
const consumedActionIds = stringList(input.consumedActionIds);
|
|
if (!flags || !counters || !timers || !worldObjectStates || !transitionStates
|
|
|| !activeSpawnGroupIds || !completedObjectiveIds || !consumedActionIds) return null;
|
|
return Object.freeze({
|
|
schemaVersion: DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION,
|
|
encounterId: input.encounterId,
|
|
phase: input.phase,
|
|
phaseStartedAt: input.phaseStartedAt,
|
|
flags,
|
|
counters,
|
|
timers,
|
|
worldObjectStates,
|
|
transitionStates,
|
|
activeSpawnGroupIds,
|
|
completedObjectiveIds,
|
|
consumedActionIds,
|
|
checkpointId: input.checkpointId ?? null,
|
|
revision: Math.max(0, Math.trunc(input.revision)),
|
|
});
|
|
}
|
|
|
|
export function normalizeDungeonRunCheckpoint(value: unknown): DungeonRunCheckpoint | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const input = value as Partial<DungeonRunCheckpoint>;
|
|
const position = finiteVector(input.spawn?.footPosition);
|
|
const yaw = input.spawn?.yaw;
|
|
if (
|
|
input.version !== DUNGEON_RUN_CHECKPOINT_VERSION
|
|
|| typeof input.dungeonId !== "string"
|
|
|| !input.dungeonId
|
|
|| typeof input.difficultyId !== "string"
|
|
|| !input.difficultyId
|
|
|| !position
|
|
|| typeof yaw !== "number"
|
|
|| !Number.isFinite(yaw)
|
|
|| !Array.isArray(input.encounters)
|
|
|| input.encounters.length > 128
|
|
|| typeof input.savedAt !== "number"
|
|
|| !Number.isFinite(input.savedAt)
|
|
|| (input.checkpointId !== null && typeof input.checkpointId !== "string")
|
|
) return null;
|
|
const encounters = input.encounters.map(normalizeSnapshot);
|
|
if (encounters.some((entry) => entry === null)) return null;
|
|
const run = input.run === undefined ? undefined : normalizeDungeonRunProgress(input.run);
|
|
if (run === null || (run && (run.dungeonId !== input.dungeonId || run.difficultyId !== input.difficultyId))) return null;
|
|
return Object.freeze({
|
|
version: DUNGEON_RUN_CHECKPOINT_VERSION,
|
|
dungeonId: input.dungeonId,
|
|
difficultyId: input.difficultyId,
|
|
checkpointId: input.checkpointId ?? null,
|
|
spawn: { footPosition: position, yaw },
|
|
encounters: Object.freeze(encounters as DungeonEncounterSnapshot[]),
|
|
savedAt: input.savedAt,
|
|
...(run ? { run } : {}),
|
|
});
|
|
}
|