Files
healer-man/src/game/dungeonRunCheckpoint.ts
T
2026-09-13 11:07:56 -04:00

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 } : {}),
});
}