import { describe, expect, it } from "vitest"; import { adjustHp } from "../adjust-hp.js"; import type { Combatant, Encounter } from "../types.js"; import { combatantId, isDomainError } from "../types.js"; function makeCombatant( name: string, opts?: { maxHp: number; currentHp: number }, ): Combatant { return { id: combatantId(name), name, ...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp } : {}), }; } function enc(combatants: Combatant[]): Encounter { return { combatants, activeIndex: 0, roundNumber: 1 }; } function successResult(encounter: Encounter, id: string, delta: number) { const result = adjustHp(encounter, combatantId(id), delta); if (isDomainError(result)) { throw new Error(`Expected success, got error: ${result.message}`); } return result; } describe("adjustHp", () => { describe("acceptance scenarios", () => { it("+1 increases currentHp by 1", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); const { encounter } = successResult(e, "A", 1); expect(encounter.combatants[0].currentHp).toBe(16); }); it("-1 decreases currentHp by 1", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); const { encounter } = successResult(e, "A", -1); expect(encounter.combatants[0].currentHp).toBe(14); }); it("clamps at 0 — cannot go below zero", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 3 })]); const { encounter } = successResult(e, "A", -10); expect(encounter.combatants[0].currentHp).toBe(0); }); it("clamps at maxHp — cannot exceed max", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]); const { encounter } = successResult(e, "A", 10); expect(encounter.combatants[0].currentHp).toBe(20); }); }); describe("invariants", () => { it("is pure — same input produces same output", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const r1 = adjustHp(e, combatantId("A"), -5); const r2 = adjustHp(e, combatantId("A"), -5); expect(r1).toEqual(r2); }); it("does not mutate input encounter", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const original = JSON.parse(JSON.stringify(e)); adjustHp(e, combatantId("A"), -3); expect(e).toEqual(original); }); it("emits CurrentHpAdjusted event with delta", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); const { events } = successResult(e, "A", -5); expect(events).toEqual([ { type: "CurrentHpAdjusted", combatantId: combatantId("A"), previousHp: 15, newHp: 10, delta: -5, }, ]); }); it("preserves activeIndex and roundNumber", () => { const e = { combatants: [ makeCombatant("A", { maxHp: 20, currentHp: 10 }), makeCombatant("B"), ], activeIndex: 1, roundNumber: 5, }; const { encounter } = successResult(e, "A", -3); expect(encounter.activeIndex).toBe(1); expect(encounter.roundNumber).toBe(5); }); }); describe("error cases", () => { it("returns error for nonexistent combatant", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const result = adjustHp(e, combatantId("Z"), -1); expect(isDomainError(result)).toBe(true); if (isDomainError(result)) { expect(result.code).toBe("combatant-not-found"); } }); it("returns error when combatant has no HP tracking", () => { const e = enc([makeCombatant("A")]); const result = adjustHp(e, combatantId("A"), -1); expect(isDomainError(result)).toBe(true); if (isDomainError(result)) { expect(result.code).toBe("no-hp-tracking"); } }); it("returns error for zero delta", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const result = adjustHp(e, combatantId("A"), 0); expect(isDomainError(result)).toBe(true); if (isDomainError(result)) { expect(result.code).toBe("zero-delta"); } }); it("returns error for non-integer delta", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); const result = adjustHp(e, combatantId("A"), 1.5); expect(isDomainError(result)).toBe(true); if (isDomainError(result)) { expect(result.code).toBe("invalid-delta"); } }); }); describe("edge cases", () => { it("large negative delta beyond currentHp clamps to 0", () => { const e = enc([makeCombatant("A", { maxHp: 100, currentHp: 50 })]); const { encounter } = successResult(e, "A", -9999); expect(encounter.combatants[0].currentHp).toBe(0); }); it("large positive delta beyond maxHp clamps to maxHp", () => { const e = enc([makeCombatant("A", { maxHp: 100, currentHp: 50 })]); const { encounter } = successResult(e, "A", 9999); expect(encounter.combatants[0].currentHp).toBe(100); }); it("does not affect other combatants", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 15 }), makeCombatant("B", { maxHp: 30, currentHp: 25 }), ]); const { encounter } = successResult(e, "A", -5); expect(encounter.combatants[1].currentHp).toBe(25); }); it("adjusting from 0 upward works", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 0 })]); const { encounter } = successResult(e, "A", 5); expect(encounter.combatants[0].currentHp).toBe(5); }); }); });