Add JSON import/export for full encounter state

Export and import encounter, undo/redo history, and player characters
as a downloadable .json file. Export/import actions are in the action
bar overflow menu. Import validates using existing rehydration functions
and shows a confirmation dialog when replacing a non-empty encounter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-27 14:28:39 +01:00
parent f6766b729d
commit fba83bebd6
19 changed files with 1339 additions and 2 deletions

View File

@@ -0,0 +1,154 @@
import {
combatantId,
type Encounter,
type ExportBundle,
type PlayerCharacter,
playerCharacterId,
type UndoRedoState,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import {
assembleExportBundle,
validateImportBundle,
} from "../persistence/export-import.js";
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
const encounter: Encounter = {
combatants: [
{
id: combatantId("c-1"),
name: "Goblin",
initiative: 15,
maxHp: 7,
currentHp: 7,
ac: 15,
},
{
id: combatantId("c-2"),
name: "Aria",
initiative: 18,
maxHp: 45,
currentHp: 40,
ac: 16,
color: "blue",
icon: "sword",
playerCharacterId: playerCharacterId("pc-1"),
},
],
activeIndex: 0,
roundNumber: 2,
};
const undoRedoState: UndoRedoState = {
undoStack: [
{
combatants: [{ id: combatantId("c-1"), name: "Goblin", initiative: 15 }],
activeIndex: 0,
roundNumber: 1,
},
],
redoStack: [],
};
const playerCharacters: PlayerCharacter[] = [
{
id: playerCharacterId("pc-1"),
name: "Aria",
ac: 16,
maxHp: 45,
color: "blue",
icon: "sword",
},
];
describe("assembleExportBundle", () => {
it("returns a bundle with version 1", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
expect(bundle.version).toBe(1);
});
it("includes an ISO timestamp", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
expect(bundle.exportedAt).toMatch(ISO_TIMESTAMP_RE);
});
it("includes the encounter", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
expect(bundle.encounter).toEqual(encounter);
});
it("includes undo and redo stacks", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
});
it("includes player characters", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
expect(bundle.playerCharacters).toEqual(playerCharacters);
});
});
describe("round-trip: export then import", () => {
it("produces identical state after round-trip", () => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
);
const serialized = JSON.parse(JSON.stringify(bundle));
const result = validateImportBundle(serialized);
expect(typeof result).toBe("object");
const imported = result as ExportBundle;
expect(imported.version).toBe(bundle.version);
expect(imported.encounter).toEqual(bundle.encounter);
expect(imported.undoStack).toEqual(bundle.undoStack);
expect(imported.redoStack).toEqual(bundle.redoStack);
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
});
it("round-trips an empty encounter", () => {
const emptyEncounter: Encounter = {
combatants: [],
activeIndex: 0,
roundNumber: 1,
};
const emptyUndoRedo: UndoRedoState = {
undoStack: [],
redoStack: [],
};
const bundle = assembleExportBundle(emptyEncounter, emptyUndoRedo, []);
const serialized = JSON.parse(JSON.stringify(bundle));
const result = validateImportBundle(serialized);
expect(typeof result).toBe("object");
const imported = result as ExportBundle;
expect(imported.encounter.combatants).toHaveLength(0);
expect(imported.undoStack).toHaveLength(0);
expect(imported.redoStack).toHaveLength(0);
expect(imported.playerCharacters).toHaveLength(0);
});
});

View File

@@ -0,0 +1,249 @@
import type { ExportBundle } from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { validateImportBundle } from "../persistence/export-import.js";
function validBundle(): Record<string, unknown> {
return {
version: 1,
exportedAt: "2026-03-27T12:00:00.000Z",
encounter: {
combatants: [{ id: "c-1", name: "Goblin", initiative: 15 }],
activeIndex: 0,
roundNumber: 1,
},
undoStack: [],
redoStack: [],
playerCharacters: [],
};
}
describe("validateImportBundle", () => {
it("accepts a valid bundle", () => {
const result = validateImportBundle(validBundle());
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.version).toBe(1);
expect(bundle.encounter.combatants).toHaveLength(1);
expect(bundle.encounter.combatants[0].name).toBe("Goblin");
});
it("accepts a valid bundle with empty encounter", () => {
const input = {
...validBundle(),
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.encounter.combatants).toHaveLength(0);
});
it("accepts a bundle with undo/redo stacks", () => {
const enc = {
combatants: [{ id: "c-1", name: "Orc" }],
activeIndex: 0,
roundNumber: 1,
};
const input = {
...validBundle(),
undoStack: [enc],
redoStack: [enc],
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.undoStack).toHaveLength(1);
expect(bundle.redoStack).toHaveLength(1);
});
it("accepts a bundle with player characters", () => {
const input = {
...validBundle(),
playerCharacters: [
{
id: "pc-1",
name: "Aria",
ac: 16,
maxHp: 45,
color: "blue",
icon: "sword",
},
],
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.playerCharacters).toHaveLength(1);
expect(bundle.playerCharacters[0].name).toBe("Aria");
});
it("rejects non-object input", () => {
expect(validateImportBundle(null)).toBe("Invalid file format");
expect(validateImportBundle(42)).toBe("Invalid file format");
expect(validateImportBundle("string")).toBe("Invalid file format");
expect(validateImportBundle([])).toBe("Invalid file format");
expect(validateImportBundle(undefined)).toBe("Invalid file format");
});
it("rejects missing version field", () => {
const input = validBundle();
delete input.version;
expect(validateImportBundle(input)).toBe("Invalid file format");
});
it("rejects version 0 or negative", () => {
expect(validateImportBundle({ ...validBundle(), version: 0 })).toBe(
"Invalid file format",
);
expect(validateImportBundle({ ...validBundle(), version: -1 })).toBe(
"Invalid file format",
);
});
it("rejects unknown version", () => {
expect(validateImportBundle({ ...validBundle(), version: 99 })).toBe(
"Invalid file format",
);
});
it("rejects missing encounter field", () => {
const input = validBundle();
delete input.encounter;
expect(validateImportBundle(input)).toBe("Invalid encounter data");
});
it("rejects invalid encounter data", () => {
expect(
validateImportBundle({ ...validBundle(), encounter: "not an object" }),
).toBe("Invalid encounter data");
});
it("rejects missing undoStack", () => {
const input = validBundle();
delete input.undoStack;
expect(validateImportBundle(input)).toBe("Invalid file format");
});
it("rejects missing redoStack", () => {
const input = validBundle();
delete input.redoStack;
expect(validateImportBundle(input)).toBe("Invalid file format");
});
it("rejects missing playerCharacters", () => {
const input = validBundle();
delete input.playerCharacters;
expect(validateImportBundle(input)).toBe("Invalid file format");
});
it("rejects non-string exportedAt", () => {
expect(validateImportBundle({ ...validBundle(), exportedAt: 12345 })).toBe(
"Invalid file format",
);
});
it("drops invalid entries from undo stack", () => {
const valid = {
combatants: [{ id: "c-1", name: "Orc" }],
activeIndex: 0,
roundNumber: 1,
};
const input = {
...validBundle(),
undoStack: [valid, "invalid", { bad: true }, valid],
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.undoStack).toHaveLength(2);
});
it("drops invalid player characters", () => {
const input = {
...validBundle(),
playerCharacters: [
{ id: "pc-1", name: "Valid", ac: 10, maxHp: 20 },
{ id: "", name: "Bad ID" },
"not an object",
{ id: "pc-3", name: "Also Valid", ac: 15, maxHp: 30 },
],
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.playerCharacters).toHaveLength(2);
});
it("rejects JSON array instead of object", () => {
expect(validateImportBundle([1, 2, 3])).toBe("Invalid file format");
});
it("rejects encounter that fails rehydration (missing combatant fields)", () => {
const input = {
...validBundle(),
encounter: {
combatants: [{ noId: true }],
activeIndex: 0,
roundNumber: 1,
},
};
expect(validateImportBundle(input)).toBe("Invalid encounter data");
});
it("strips invalid color/icon from player characters but keeps the character", () => {
const input = {
...validBundle(),
playerCharacters: [
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 20,
color: "neon-pink",
icon: "bazooka",
},
],
};
const result = validateImportBundle(input);
// rehydrateCharacter rejects characters with invalid color/icon members
// that are not in the valid sets, so this character is dropped
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.playerCharacters).toHaveLength(0);
});
it("keeps player characters with valid optional color and icon", () => {
const input = {
...validBundle(),
playerCharacters: [
{
id: "pc-1",
name: "Aria",
ac: 16,
maxHp: 45,
color: "blue",
icon: "sword",
},
],
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.playerCharacters).toHaveLength(1);
expect(bundle.playerCharacters[0].color).toBe("blue");
expect(bundle.playerCharacters[0].icon).toBe("sword");
});
it("ignores unknown extra fields on the bundle", () => {
const input = {
...validBundle(),
unknownField: "should be ignored",
anotherExtra: 42,
};
const result = validateImportBundle(input);
expect(typeof result).toBe("object");
const bundle = result as ExportBundle;
expect(bundle.version).toBe(1);
expect("unknownField" in bundle).toBe(false);
});
});