Implements PF2e as an alternative game system alongside D&D 5e/5.5e. Settings modal "Game System" selector switches conditions, bestiary, stat block layout, and initiative calculation between systems. - Valued conditions with increment/decrement UX (Clumsy 2, Frightened 3) - 2,502 PF2e creatures from bundled search index (77 sources) - PF2e stat block: level, traits, Perception, Fort/Ref/Will, ability mods - Perception-based initiative rolling - System-scoped source cache (D&D and PF2e sources don't collide) - Backwards-compatible condition rehydration (ConditionId[] → ConditionEntry[]) - Difficulty indicator hidden in PF2e mode (excluded from MVP) Closes dostulata/initiative#19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
434 lines
11 KiB
TypeScript
434 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|