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> | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const entries = Object.entries(value as Record); if (entries.some(([key, entry]) => !key || typeof entry !== "boolean")) return null; return Object.freeze(Object.fromEntries(entries) as Record); } function numberRecord(value: unknown): Readonly> | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const entries = Object.entries(value as Record); if (entries.some(([key, entry]) => !key || typeof entry !== "number" || !Number.isFinite(entry))) return null; return Object.freeze(Object.fromEntries(entries) as Record); } function stringRecord(value: unknown): Readonly> | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const entries = Object.entries(value as Record); if (entries.some(([key, entry]) => !key || typeof entry !== "string")) return null; return Object.freeze(Object.fromEntries(entries) as Record); } 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; 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; 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 } : {}), }); }