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"; import { expectDomainError } from "./test-helpers.js"; function makeCombatant( name: string, opts?: { maxHp: number; currentHp: number; tempHp?: number }, ): Combatant { return { id: combatantId(name), name, ...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp, tempHp: opts.tempHp, } : {}), }; } 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); expectDomainError(result, "combatant-not-found"); }); it("returns error when combatant has no HP tracking", () => { const e = enc([makeCombatant("A")]); const result = adjustHp(e, combatantId("A"), -1); expectDomainError(result, "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); expectDomainError(result, "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); expectDomainError(result, "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); }); }); describe("temporary HP absorption", () => { it("damage fully absorbed by temp HP — currentHp unchanged", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }), ]); const { encounter } = successResult(e, "A", -5); expect(encounter.combatants[0].currentHp).toBe(15); expect(encounter.combatants[0].tempHp).toBe(3); }); it("damage partially absorbed by temp HP — overflow reduces currentHp", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }), ]); const { encounter } = successResult(e, "A", -10); expect(encounter.combatants[0].tempHp).toBeUndefined(); expect(encounter.combatants[0].currentHp).toBe(8); }); it("damage exceeding both temp HP and currentHp — both reach minimum", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 5, tempHp: 3 }), ]); const { encounter } = successResult(e, "A", -50); expect(encounter.combatants[0].tempHp).toBeUndefined(); expect(encounter.combatants[0].currentHp).toBe(0); }); it("healing does not restore temp HP", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }), ]); const { encounter } = successResult(e, "A", 5); expect(encounter.combatants[0].currentHp).toBe(15); expect(encounter.combatants[0].tempHp).toBe(3); }); it("temp HP cleared to undefined when fully depleted", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 5 }), ]); const { encounter } = successResult(e, "A", -5); expect(encounter.combatants[0].tempHp).toBeUndefined(); expect(encounter.combatants[0].currentHp).toBe(15); }); it("emits only TempHpSet when damage fully absorbed", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }), ]); const { events } = successResult(e, "A", -3); expect(events).toEqual([ { type: "TempHpSet", combatantId: combatantId("A"), previousTempHp: 8, newTempHp: 5, }, ]); }); it("emits both TempHpSet and CurrentHpAdjusted when damage overflows", () => { const e = enc([ makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }), ]); const { events } = successResult(e, "A", -10); expect(events).toHaveLength(2); expect(events[0]).toEqual({ type: "TempHpSet", combatantId: combatantId("A"), previousTempHp: 3, newTempHp: undefined, }); expect(events[1]).toEqual({ type: "CurrentHpAdjusted", combatantId: combatantId("A"), previousHp: 15, newHp: 8, delta: -10, }); }); it("damage with no temp HP works as before", () => { const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); const { encounter, events } = successResult(e, "A", -5); expect(encounter.combatants[0].currentHp).toBe(10); expect(encounter.combatants[0].tempHp).toBeUndefined(); expect(events).toHaveLength(1); expect(events[0].type).toBe("CurrentHpAdjusted"); }); }); });