Rehydration functions (reconstructing typed domain objects from untyped JSON) lived in persistence adapters, duplicating domain validation. Adding a field required updating both the domain type and a separate adapter function — the adapter was missed for `level`, silently dropping it on reload. Now adding a field only requires updating the domain type and its co-located rehydration function. - Add `rehydratePlayerCharacter` and `rehydrateCombatant` to domain - Persistence adapters delegate to domain instead of reimplementing - Add `tempHp` validation (was silently dropped during rehydration) - Tighten initiative validation to integer-only - Exhaustive domain tests (53 cases); adapter tests slimmed to round-trip - Remove stale `jsinspect-plus` Knip ignoreDependencies entry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
68 lines
1.6 KiB
TypeScript
68 lines
1.6 KiB
TypeScript
import {
|
|
type Combatant,
|
|
createEncounter,
|
|
type Encounter,
|
|
isDomainError,
|
|
rehydrateCombatant,
|
|
} from "@initiative/domain";
|
|
|
|
const STORAGE_KEY = "initiative:encounter";
|
|
|
|
export function saveEncounter(encounter: Encounter): void {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(encounter));
|
|
} catch {
|
|
// Silently swallow errors (quota exceeded, storage unavailable)
|
|
}
|
|
}
|
|
|
|
export function rehydrateEncounter(parsed: unknown): Encounter | null {
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
return null;
|
|
|
|
const obj = parsed as Record<string, unknown>;
|
|
|
|
if (!Array.isArray(obj.combatants)) return null;
|
|
if (typeof obj.activeIndex !== "number") return null;
|
|
if (typeof obj.roundNumber !== "number") return null;
|
|
|
|
const combatants = obj.combatants as unknown[];
|
|
|
|
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
|
|
if (combatants.length === 0) {
|
|
return {
|
|
combatants: [],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
};
|
|
}
|
|
|
|
const rehydrated: Combatant[] = [];
|
|
for (const c of combatants) {
|
|
const result = rehydrateCombatant(c);
|
|
if (result === null) return null;
|
|
rehydrated.push(result);
|
|
}
|
|
|
|
const encounter = createEncounter(
|
|
rehydrated,
|
|
obj.activeIndex,
|
|
obj.roundNumber,
|
|
);
|
|
if (isDomainError(encounter)) return null;
|
|
|
|
return encounter;
|
|
}
|
|
|
|
export function loadEncounter(): Encounter | null {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (raw === null) return null;
|
|
|
|
const parsed: unknown = JSON.parse(raw);
|
|
return rehydrateEncounter(parsed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|