import type { ConditionId, PlayerCharacter } from "@initiative/domain"; import { combatantId, createEncounter, EMPTY_UNDO_REDO_STATE, isDomainError, playerCharacterId, } from "@initiative/domain"; import { describe, expect, it } from "vitest"; import type { SearchResult } from "../use-bestiary.js"; import { type EncounterState, encounterReducer } from "../use-encounter.js"; function emptyState(): EncounterState { return { encounter: { combatants: [], activeIndex: 0, roundNumber: 1, }, undoRedoState: EMPTY_UNDO_REDO_STATE, events: [], nextId: 0, lastCreatureId: null, }; } function stateWith(...names: string[]): EncounterState { let state = emptyState(); for (const name of names) { state = encounterReducer(state, { type: "add-combatant", name }); } return state; } function stateWithHp(name: string, maxHp: number): EncounterState { const state = stateWith(name); const id = state.encounter.combatants[0].id; return encounterReducer(state, { type: "set-hp", id, maxHp, }); } const BESTIARY_ENTRY: SearchResult = { system: "dnd", name: "Goblin", source: "MM", sourceDisplayName: "Monster Manual", ac: 15, hp: 7, dex: 14, cr: "1/4", initiativeProficiency: 0, size: "Small", type: "humanoid", }; const PF2E_BESTIARY_ENTRY: SearchResult = { system: "pf2e", name: "Goblin Warrior", source: "B1", sourceDisplayName: "Bestiary", level: -1, ac: 16, hp: 6, perception: 5, size: "small", type: "humanoid", }; describe("encounterReducer", () => { describe("add-combatant", () => { it("adds a combatant and pushes undo", () => { const next = encounterReducer(emptyState(), { type: "add-combatant", name: "Goblin", }); expect(next.encounter.combatants).toHaveLength(1); expect(next.encounter.combatants[0].name).toBe("Goblin"); expect(next.undoRedoState.undoStack).toHaveLength(1); expect(next.nextId).toBe(1); }); it("applies optional init values", () => { const next = encounterReducer(emptyState(), { type: "add-combatant", name: "Goblin", init: { initiative: 15, ac: 13, maxHp: 7 }, }); const c = next.encounter.combatants[0]; expect(c.initiative).toBe(15); expect(c.ac).toBe(13); expect(c.maxHp).toBe(7); expect(c.currentHp).toBe(7); }); it("increments IDs", () => { const s1 = encounterReducer(emptyState(), { type: "add-combatant", name: "A", }); const s2 = encounterReducer(s1, { type: "add-combatant", name: "B", }); expect(s2.encounter.combatants[0].id).toBe("c-1"); expect(s2.encounter.combatants[1].id).toBe("c-2"); }); it("returns unchanged state for invalid name", () => { const state = emptyState(); const next = encounterReducer(state, { type: "add-combatant", name: "", }); expect(next).toBe(state); }); }); describe("remove-combatant", () => { it("removes combatant and pushes undo", () => { const state = stateWith("Goblin"); const id = state.encounter.combatants[0].id; const next = encounterReducer(state, { type: "remove-combatant", id, }); expect(next.encounter.combatants).toHaveLength(0); expect(next.undoRedoState.undoStack).toHaveLength(2); }); }); describe("edit-combatant", () => { it("renames combatant", () => { const state = stateWith("Goblin"); const id = state.encounter.combatants[0].id; const next = encounterReducer(state, { type: "edit-combatant", id, newName: "Hobgoblin", }); expect(next.encounter.combatants[0].name).toBe("Hobgoblin"); }); }); describe("advance-turn / retreat-turn", () => { it("advances and retreats turn", () => { const state = stateWith("A", "B"); const advanced = encounterReducer(state, { type: "advance-turn", }); expect(advanced.encounter.activeIndex).toBe(1); const retreated = encounterReducer(advanced, { type: "retreat-turn", }); expect(retreated.encounter.activeIndex).toBe(0); }); it("returns unchanged state on empty encounter", () => { const state = emptyState(); const next = encounterReducer(state, { type: "advance-turn" }); expect(next).toBe(state); }); }); describe("set-hp / adjust-hp / set-temp-hp", () => { it("sets max HP", () => { const state = stateWith("Goblin"); const id = state.encounter.combatants[0].id; const next = encounterReducer(state, { type: "set-hp", id, maxHp: 20, }); expect(next.encounter.combatants[0].maxHp).toBe(20); expect(next.encounter.combatants[0].currentHp).toBe(20); }); it("adjusts HP", () => { const state = stateWithHp("Goblin", 20); const id = state.encounter.combatants[0].id; const next = encounterReducer(state, { type: "adjust-hp", id, delta: -5, }); expect(next.encounter.combatants[0].currentHp).toBe(15); }); it("sets temp HP", () => { const state = stateWithHp("Goblin", 20); const id = state.encounter.combatants[0].id; const next = encounterReducer(state, { type: "set-temp-hp", id, tempHp: 5, }); expect(next.encounter.combatants[0].tempHp).toBe(5); }); }); describe("set-ac", () => { it("sets AC", () => { const state = stateWith("Goblin"); const id = state.encounter.combatants[0].id; const next = encounterReducer(state, { type: "set-ac", id, value: 15, }); expect(next.encounter.combatants[0].ac).toBe(15); }); }); describe("set-initiative", () => { it("sets initiative", () => { const state = stateWith("Goblin"); const id = state.encounter.combatants[0].id; const next = encounterReducer(state, { type: "set-initiative", id, value: 18, }); expect(next.encounter.combatants[0].initiative).toBe(18); }); }); describe("toggle-condition / toggle-concentration", () => { it("toggles condition", () => { const state = stateWith("Goblin"); const id = state.encounter.combatants[0].id; const next = encounterReducer(state, { type: "toggle-condition", id, conditionId: "blinded" as ConditionId, }); expect(next.encounter.combatants[0].conditions).toContainEqual({ id: "blinded", }); }); it("toggles concentration", () => { const state = stateWith("Wizard"); const id = state.encounter.combatants[0].id; const next = encounterReducer(state, { type: "toggle-concentration", id, }); expect(next.encounter.combatants[0].isConcentrating).toBe(true); }); }); describe("clear-encounter", () => { it("clears combatants, resets history and nextId", () => { const state = stateWith("A", "B"); const next = encounterReducer(state, { type: "clear-encounter", }); expect(next.encounter.combatants).toHaveLength(0); expect(next.undoRedoState.undoStack).toHaveLength(0); expect(next.undoRedoState.redoStack).toHaveLength(0); expect(next.nextId).toBe(0); }); }); describe("undo / redo", () => { it("undo restores previous state", () => { const state = stateWith("Goblin"); const next = encounterReducer(state, { type: "undo" }); expect(next.encounter.combatants).toHaveLength(0); expect(next.undoRedoState.undoStack).toHaveLength(0); expect(next.undoRedoState.redoStack).toHaveLength(1); }); it("redo restores undone state", () => { const state = stateWith("Goblin"); const undone = encounterReducer(state, { type: "undo" }); const redone = encounterReducer(undone, { type: "redo" }); expect(redone.encounter.combatants).toHaveLength(1); expect(redone.encounter.combatants[0].name).toBe("Goblin"); }); it("undo returns unchanged state when stack is empty", () => { const state = emptyState(); const next = encounterReducer(state, { type: "undo" }); expect(next).toBe(state); }); it("redo returns unchanged state when stack is empty", () => { const state = emptyState(); const next = encounterReducer(state, { type: "redo" }); expect(next).toBe(state); }); }); describe("add-from-bestiary", () => { it("adds creature with HP, AC, and creatureId", () => { const next = encounterReducer(emptyState(), { type: "add-from-bestiary", entry: BESTIARY_ENTRY, }); const c = next.encounter.combatants[0]; expect(c.name).toBe("Goblin"); expect(c.maxHp).toBe(7); expect(c.ac).toBe(15); expect(c.creatureId).toBe("mm:goblin"); expect(next.lastCreatureId).toBe("mm:goblin"); expect(next.undoRedoState.undoStack).toHaveLength(1); }); it("auto-numbers duplicate names", () => { const s1 = encounterReducer(emptyState(), { type: "add-from-bestiary", entry: BESTIARY_ENTRY, }); const s2 = encounterReducer(s1, { type: "add-from-bestiary", entry: BESTIARY_ENTRY, }); const names = s2.encounter.combatants.map((c) => c.name); expect(names).toContain("Goblin 1"); expect(names).toContain("Goblin 2"); }); it("adds PF2e creature with HP, AC, and creatureId", () => { const next = encounterReducer(emptyState(), { type: "add-from-bestiary", entry: PF2E_BESTIARY_ENTRY, }); const c = next.encounter.combatants[0]; expect(c.name).toBe("Goblin Warrior"); expect(c.maxHp).toBe(6); expect(c.ac).toBe(16); expect(c.creatureId).toBe("b1:goblin-warrior"); }); }); describe("add-multiple-from-bestiary", () => { it("adds multiple creatures in one action", () => { const next = encounterReducer(emptyState(), { type: "add-multiple-from-bestiary", entry: BESTIARY_ENTRY, count: 3, }); expect(next.encounter.combatants).toHaveLength(3); expect(next.undoRedoState.undoStack).toHaveLength(1); expect(next.lastCreatureId).toBe("mm:goblin"); }); }); describe("add-from-player-character", () => { it("adds combatant with PC attributes", () => { const pc: PlayerCharacter = { id: playerCharacterId("pc-1"), name: "Aria", ac: 16, maxHp: 30, color: "blue", icon: "sword", }; const next = encounterReducer(emptyState(), { type: "add-from-player-character", pc, }); const c = next.encounter.combatants[0]; expect(c.name).toBe("Aria"); expect(c.maxHp).toBe(30); expect(c.ac).toBe(16); expect(c.color).toBe("blue"); expect(c.icon).toBe("sword"); expect(c.playerCharacterId).toBe("pc-1"); expect(next.lastCreatureId).toBeNull(); }); }); describe("import", () => { it("replaces encounter and undo/redo state", () => { const state = stateWith("A", "B"); const enc = createEncounter([ { id: combatantId("c-5"), name: "Imported" }, ]); if (isDomainError(enc)) throw new Error("Setup failed"); const next = encounterReducer(state, { type: "import", encounter: enc, undoRedoState: EMPTY_UNDO_REDO_STATE, }); expect(next.encounter.combatants).toHaveLength(1); expect(next.encounter.combatants[0].name).toBe("Imported"); expect(next.nextId).toBe(5); }); }); describe("events accumulation", () => { it("accumulates events across actions", () => { const s1 = encounterReducer(emptyState(), { type: "add-combatant", name: "A", }); const s2 = encounterReducer(s1, { type: "add-combatant", name: "B", }); expect(s2.events.length).toBeGreaterThanOrEqual(2); }); }); });