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>
119 lines
3.0 KiB
TypeScript
119 lines
3.0 KiB
TypeScript
import type {
|
|
Encounter,
|
|
ExportBundle,
|
|
PlayerCharacter,
|
|
UndoRedoState,
|
|
} from "@initiative/domain";
|
|
import { rehydratePlayerCharacter } from "@initiative/domain";
|
|
import { rehydrateEncounter } from "./encounter-storage.js";
|
|
|
|
function rehydrateStack(raw: unknown[]): Encounter[] {
|
|
const result: Encounter[] = [];
|
|
for (const entry of raw) {
|
|
const rehydrated = rehydrateEncounter(entry);
|
|
if (rehydrated !== null) {
|
|
result.push(rehydrated);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
|
|
const result: PlayerCharacter[] = [];
|
|
for (const entry of raw) {
|
|
const rehydrated = rehydratePlayerCharacter(entry);
|
|
if (rehydrated !== null) {
|
|
result.push(rehydrated);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function validateImportBundle(data: unknown): ExportBundle | string {
|
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
return "Invalid file format";
|
|
}
|
|
|
|
const obj = data as Record<string, unknown>;
|
|
|
|
if (typeof obj.version !== "number" || obj.version !== 1) {
|
|
return "Invalid file format";
|
|
}
|
|
if (typeof obj.exportedAt !== "string") {
|
|
return "Invalid file format";
|
|
}
|
|
if (!Array.isArray(obj.undoStack) || !Array.isArray(obj.redoStack)) {
|
|
return "Invalid file format";
|
|
}
|
|
if (!Array.isArray(obj.playerCharacters)) {
|
|
return "Invalid file format";
|
|
}
|
|
|
|
const encounter = rehydrateEncounter(obj.encounter);
|
|
if (encounter === null) {
|
|
return "Invalid encounter data";
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
exportedAt: obj.exportedAt,
|
|
encounter,
|
|
undoStack: rehydrateStack(obj.undoStack),
|
|
redoStack: rehydrateStack(obj.redoStack),
|
|
playerCharacters: rehydrateCharacters(obj.playerCharacters),
|
|
};
|
|
}
|
|
|
|
export function assembleExportBundle(
|
|
encounter: Encounter,
|
|
undoRedoState: UndoRedoState,
|
|
playerCharacters: readonly PlayerCharacter[],
|
|
includeHistory = true,
|
|
): ExportBundle {
|
|
return {
|
|
version: 1,
|
|
exportedAt: new Date().toISOString(),
|
|
encounter,
|
|
undoStack: includeHistory ? undoRedoState.undoStack : [],
|
|
redoStack: includeHistory ? undoRedoState.redoStack : [],
|
|
playerCharacters: [...playerCharacters],
|
|
};
|
|
}
|
|
|
|
export function bundleToJson(bundle: ExportBundle): string {
|
|
return JSON.stringify(bundle, null, 2);
|
|
}
|
|
|
|
export function resolveFilename(name?: string): string {
|
|
const base =
|
|
name?.trim() ||
|
|
`initiative-export-${new Date().toISOString().slice(0, 10)}`;
|
|
return base.endsWith(".json") ? base : `${base}.json`;
|
|
}
|
|
|
|
export function triggerDownload(bundle: ExportBundle, name?: string): void {
|
|
const blob = new Blob([bundleToJson(bundle)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const filename = resolveFilename(name);
|
|
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = filename;
|
|
anchor.click();
|
|
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export async function readImportFile(
|
|
file: File,
|
|
): Promise<ExportBundle | string> {
|
|
try {
|
|
const text = await file.text();
|
|
const parsed: unknown = JSON.parse(text);
|
|
return validateImportBundle(parsed);
|
|
} catch {
|
|
return "Invalid file format";
|
|
}
|
|
}
|