Combatants can now be assigned to party or enemy side via a toggle in the difficulty breakdown panel. Party-side NPCs subtract their XP from the encounter total, letting allied NPCs reduce difficulty. PCs default to party, non-PCs to enemy — users who don't use sides see no change. Side persists across reload and export/import. Closes #22 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
310 lines
7.8 KiB
TypeScript
310 lines
7.8 KiB
TypeScript
import {
|
|
combatantId,
|
|
type Encounter,
|
|
type ExportBundle,
|
|
type PlayerCharacter,
|
|
playerCharacterId,
|
|
type UndoRedoState,
|
|
} from "@initiative/domain";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
assembleExportBundle,
|
|
bundleToJson,
|
|
resolveFilename,
|
|
validateImportBundle,
|
|
} from "../persistence/export-import.js";
|
|
|
|
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
|
const DEFAULT_FILENAME_RE = /^initiative-export-\d{4}-\d{2}-\d{2}\.json$/;
|
|
|
|
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("assembleExportBundle with includeHistory", () => {
|
|
it("excludes undo/redo stacks when includeHistory is false", () => {
|
|
const bundle = assembleExportBundle(
|
|
encounter,
|
|
undoRedoState,
|
|
playerCharacters,
|
|
false,
|
|
);
|
|
expect(bundle.undoStack).toHaveLength(0);
|
|
expect(bundle.redoStack).toHaveLength(0);
|
|
});
|
|
|
|
it("includes undo/redo stacks when includeHistory is true", () => {
|
|
const bundle = assembleExportBundle(
|
|
encounter,
|
|
undoRedoState,
|
|
playerCharacters,
|
|
true,
|
|
);
|
|
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
|
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
|
|
});
|
|
|
|
it("includes undo/redo stacks by default", () => {
|
|
const bundle = assembleExportBundle(
|
|
encounter,
|
|
undoRedoState,
|
|
playerCharacters,
|
|
);
|
|
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
|
});
|
|
});
|
|
|
|
describe("bundleToJson", () => {
|
|
it("produces valid JSON that round-trips through validateImportBundle", () => {
|
|
const bundle = assembleExportBundle(
|
|
encounter,
|
|
undoRedoState,
|
|
playerCharacters,
|
|
);
|
|
const json = bundleToJson(bundle);
|
|
const parsed: unknown = JSON.parse(json);
|
|
const result = validateImportBundle(parsed);
|
|
expect(typeof result).toBe("object");
|
|
});
|
|
});
|
|
|
|
describe("resolveFilename", () => {
|
|
it("uses date-based default when no name provided", () => {
|
|
const result = resolveFilename();
|
|
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
|
});
|
|
|
|
it("uses date-based default for empty string", () => {
|
|
const result = resolveFilename("");
|
|
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
|
});
|
|
|
|
it("uses date-based default for whitespace-only string", () => {
|
|
const result = resolveFilename(" ");
|
|
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
|
});
|
|
|
|
it("appends .json to a custom name", () => {
|
|
expect(resolveFilename("my-encounter")).toBe("my-encounter.json");
|
|
});
|
|
|
|
it("does not double-append .json", () => {
|
|
expect(resolveFilename("my-encounter.json")).toBe("my-encounter.json");
|
|
});
|
|
|
|
it("trims whitespace from custom name", () => {
|
|
expect(resolveFilename(" my-encounter ")).toBe("my-encounter.json");
|
|
});
|
|
});
|
|
|
|
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 a combatant with cr field", () => {
|
|
const encounterWithCr: Encounter = {
|
|
combatants: [
|
|
{
|
|
id: combatantId("c-1"),
|
|
name: "Custom Thug",
|
|
cr: "2",
|
|
},
|
|
],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
};
|
|
const emptyUndoRedo: UndoRedoState = {
|
|
undoStack: [],
|
|
redoStack: [],
|
|
};
|
|
const bundle = assembleExportBundle(encounterWithCr, 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[0].cr).toBe("2");
|
|
});
|
|
|
|
it("round-trips a combatant with side field", () => {
|
|
const encounterWithSide: Encounter = {
|
|
combatants: [
|
|
{
|
|
id: combatantId("c-1"),
|
|
name: "Allied Guard",
|
|
cr: "2",
|
|
side: "party",
|
|
},
|
|
{
|
|
id: combatantId("c-2"),
|
|
name: "Goblin",
|
|
side: "enemy",
|
|
},
|
|
],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
};
|
|
const emptyUndoRedo: UndoRedoState = {
|
|
undoStack: [],
|
|
redoStack: [],
|
|
};
|
|
const bundle = assembleExportBundle(encounterWithSide, 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[0].side).toBe("party");
|
|
expect(imported.encounter.combatants[1].side).toBe("enemy");
|
|
});
|
|
|
|
it("round-trips a combatant without side field as undefined", () => {
|
|
const encounterNoSide: Encounter = {
|
|
combatants: [{ id: combatantId("c-1"), name: "Custom" }],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
};
|
|
const emptyUndoRedo: UndoRedoState = {
|
|
undoStack: [],
|
|
redoStack: [],
|
|
};
|
|
const bundle = assembleExportBundle(encounterNoSide, 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[0].side).toBeUndefined();
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|