import { describe, expect, it } from "vitest"; import { addCombatant, type CombatantInit } from "../add-combatant.js"; import { creatureId } from "../creature-types.js"; import { playerCharacterId } from "../player-character-types.js"; import type { Combatant, Encounter } from "../types.js"; import { combatantId, isDomainError } from "../types.js"; import { expectDomainError } from "./test-helpers.js"; // --- Helpers --- function makeCombatant( name: string, overrides?: Partial, ): Combatant { return { id: combatantId(name), name, ...overrides }; } const A = makeCombatant("A"); const B = makeCombatant("B"); const C = makeCombatant("C"); function enc( combatants: Combatant[], activeIndex = 0, roundNumber = 1, ): Encounter { return { combatants, activeIndex, roundNumber }; } function successResult( encounter: Encounter, id: string, name: string, init?: CombatantInit, ) { const result = addCombatant(encounter, combatantId(id), name, init); if (isDomainError(result)) { throw new Error(`Expected success, got error: ${result.message}`); } return result; } // --- Acceptance Scenarios --- describe("addCombatant", () => { describe("acceptance scenarios", () => { it("scenario 1: add to empty encounter", () => { const e = enc([], 0, 1); const { encounter, events } = successResult(e, "gandalf", "Gandalf"); expect(encounter.combatants).toEqual([ { id: combatantId("gandalf"), name: "Gandalf" }, ]); expect(encounter.activeIndex).toBe(0); expect(encounter.roundNumber).toBe(1); expect(events).toEqual([ { type: "CombatantAdded", combatantId: combatantId("gandalf"), name: "Gandalf", position: 0, }, ]); }); it("scenario 2: add to encounter with [A, B]", () => { const e = enc([A, B], 0, 1); const { encounter, events } = successResult(e, "C", "C"); expect(encounter.combatants).toEqual([ A, B, { id: combatantId("C"), name: "C" }, ]); expect(encounter.activeIndex).toBe(0); expect(encounter.roundNumber).toBe(1); expect(events).toEqual([ { type: "CombatantAdded", combatantId: combatantId("C"), name: "C", position: 2, }, ]); }); it("scenario 3: add during mid-round does not change active combatant", () => { const e = enc([A, B, C], 2, 3); const { encounter, events } = successResult(e, "D", "D"); expect(encounter.combatants).toHaveLength(4); expect(encounter.combatants[3]).toEqual({ id: combatantId("D"), name: "D", }); expect(encounter.activeIndex).toBe(2); expect(encounter.roundNumber).toBe(3); expect(events).toEqual([ { type: "CombatantAdded", combatantId: combatantId("D"), name: "D", position: 3, }, ]); }); it("scenario 4: two sequential adds preserve order", () => { const e = enc([A]); const first = successResult(e, "B", "B"); const second = successResult(first.encounter, "C", "C"); expect(second.encounter.combatants).toEqual([ A, { id: combatantId("B"), name: "B" }, { id: combatantId("C"), name: "C" }, ]); expect(first.events).toHaveLength(1); expect(second.events).toHaveLength(1); }); it("scenario 5: empty name returns error", () => { const e = enc([A, B]); const result = addCombatant(e, combatantId("x"), ""); expectDomainError(result, "invalid-name"); }); it("scenario 6: whitespace-only name returns error", () => { const e = enc([A, B]); const result = addCombatant(e, combatantId("x"), " "); expectDomainError(result, "invalid-name"); }); }); describe("invariants", () => { it("INV-1: encounter may have zero combatants (adding to empty is valid)", () => { const e = enc([]); const result = addCombatant(e, combatantId("a"), "A"); expect(isDomainError(result)).toBe(false); }); it("INV-2: activeIndex remains valid after adding", () => { const scenarios: Encounter[] = [ enc([], 0, 1), enc([A], 0, 1), enc([A, B, C], 2, 3), ]; for (const e of scenarios) { const result = successResult(e, "new", "New"); const { combatants, activeIndex } = result.encounter; // After adding a combatant, list is always non-empty expect(combatants.length).toBeGreaterThan(0); expect(activeIndex).toBeGreaterThanOrEqual(0); expect(activeIndex).toBeLessThan(combatants.length); } }); it("INV-3: roundNumber is preserved (never decreases)", () => { const e = enc([A, B], 1, 5); const { encounter } = successResult(e, "C", "C"); expect(encounter.roundNumber).toBe(5); }); it("INV-4: determinism — same input produces same output", () => { const e = enc([A, B], 1, 3); const result1 = addCombatant(e, combatantId("x"), "X"); const result2 = addCombatant(e, combatantId("x"), "X"); expect(result1).toEqual(result2); }); it("INV-5: every success emits exactly one CombatantAdded event", () => { const scenarios: Encounter[] = [enc([]), enc([A]), enc([A, B, C], 2, 5)]; for (const e of scenarios) { const result = successResult(e, "z", "Z"); expect(result.events).toHaveLength(1); expect(result.events[0].type).toBe("CombatantAdded"); } }); it("INV-6: addCombatant does not change activeIndex or roundNumber", () => { const e = enc([A, B, C], 2, 7); const { encounter } = successResult(e, "D", "D"); expect(encounter.activeIndex).toBe(2); expect(encounter.roundNumber).toBe(7); }); it("INV-7: new combatant is always appended at the end", () => { const e = enc([A, B]); const { encounter } = successResult(e, "C", "C"); expect(encounter.combatants.at(-1)).toEqual({ id: combatantId("C"), name: "C", }); // Existing combatants preserve order expect(encounter.combatants[0]).toEqual(A); expect(encounter.combatants[1]).toEqual(B); }); }); describe("with CombatantInit", () => { it("creates combatant with maxHp and currentHp set to maxHp", () => { const e = enc([]); const { encounter } = successResult(e, "orc", "Orc", { maxHp: 15, }); const c = encounter.combatants[0]; expect(c.maxHp).toBe(15); expect(c.currentHp).toBe(15); }); it("creates combatant with ac", () => { const e = enc([]); const { encounter } = successResult(e, "orc", "Orc", { ac: 13, }); expect(encounter.combatants[0].ac).toBe(13); }); it("creates combatant with initiative and sorts into position", () => { const hi = makeCombatant("Hi", { initiative: 20 }); const lo = makeCombatant("Lo", { initiative: 10 }); const e = enc([hi, lo]); const { encounter } = successResult(e, "mid", "Mid", { initiative: 15, }); expect(encounter.combatants.map((c) => c.name)).toEqual([ "Hi", "Mid", "Lo", ]); }); it("rejects invalid maxHp (non-integer)", () => { const e = enc([]); const result = addCombatant(e, combatantId("x"), "X", { maxHp: 1.5, }); expectDomainError(result, "invalid-max-hp"); }); it("rejects invalid maxHp (zero)", () => { const e = enc([]); const result = addCombatant(e, combatantId("x"), "X", { maxHp: 0, }); expectDomainError(result, "invalid-max-hp"); }); it("rejects invalid ac (negative)", () => { const e = enc([]); const result = addCombatant(e, combatantId("x"), "X", { ac: -1, }); expectDomainError(result, "invalid-ac"); }); it("rejects invalid initiative (non-integer)", () => { const e = enc([]); const result = addCombatant(e, combatantId("x"), "X", { initiative: 3.5, }); expectDomainError(result, "invalid-initiative"); }); it("creates combatant with creatureId", () => { const e = enc([]); const cId = creatureId("srd:goblin"); const { encounter } = successResult(e, "gob", "Goblin", { creatureId: cId, }); expect(encounter.combatants[0].creatureId).toBe(cId); }); it("creates combatant with color and icon", () => { const e = enc([]); const { encounter } = successResult(e, "pc", "Aria", { color: "blue", icon: "sword", }); const c = encounter.combatants[0]; expect(c.color).toBe("blue"); expect(c.icon).toBe("sword"); }); it("creates combatant with playerCharacterId", () => { const e = enc([]); const pcId = playerCharacterId("pc-1"); const { encounter } = successResult(e, "pc", "Aria", { playerCharacterId: pcId, }); expect(encounter.combatants[0].playerCharacterId).toBe(pcId); }); it("creates combatant with all init fields", () => { const e = enc([]); const cId = creatureId("srd:orc"); const pcId = playerCharacterId("pc-1"); const { encounter } = successResult(e, "orc", "Orc", { maxHp: 15, ac: 13, initiative: 12, creatureId: cId, color: "red", icon: "axe", playerCharacterId: pcId, }); const c = encounter.combatants[0]; expect(c.maxHp).toBe(15); expect(c.currentHp).toBe(15); expect(c.ac).toBe(13); expect(c.initiative).toBe(12); expect(c.creatureId).toBe(cId); expect(c.color).toBe("red"); expect(c.icon).toBe("axe"); expect(c.playerCharacterId).toBe(pcId); }); it("CombatantAdded event includes init", () => { const e = enc([]); const { events } = successResult(e, "orc", "Orc", { maxHp: 15, ac: 13, }); expect(events[0]).toMatchObject({ type: "CombatantAdded", init: { maxHp: 15, ac: 13 }, }); }); it("preserves activeIndex through initiative sort", () => { const hi = makeCombatant("Hi", { initiative: 20 }); const lo = makeCombatant("Lo", { initiative: 10 }); // Lo is active (index 1) const e = enc([hi, lo], 1); const { encounter } = successResult(e, "mid", "Mid", { initiative: 15, }); // Lo should still be active after sort const loIdx = encounter.combatants.findIndex((c) => c.name === "Lo"); expect(encounter.activeIndex).toBe(loIdx); }); }); });