Implement the 009-combatant-hp feature that adds optional max HP and current HP tracking per combatant with +/- controls, direct entry, and persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-05 17:18:03 +01:00
parent a9c280a6d6
commit 8185fde0e8
21 changed files with 1367 additions and 2 deletions

View File

@@ -0,0 +1,166 @@
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);
});
});
});

View File

@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import { setHp } from "../set-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 !== undefined
? { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }
: {}),
};
}
function enc(combatants: Combatant[]): Encounter {
return { combatants, activeIndex: 0, roundNumber: 1 };
}
function successResult(
encounter: Encounter,
id: string,
maxHp: number | undefined,
) {
const result = setHp(encounter, combatantId(id), maxHp);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("setHp", () => {
describe("acceptance scenarios", () => {
it("sets maxHp on a combatant with no HP — currentHp defaults to maxHp", () => {
const e = enc([makeCombatant("A")]);
const { encounter } = successResult(e, "A", 20);
expect(encounter.combatants[0].maxHp).toBe(20);
expect(encounter.combatants[0].currentHp).toBe(20);
});
it("increases maxHp while at full health — currentHp stays synced", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 20 })]);
const { encounter } = successResult(e, "A", 30);
expect(encounter.combatants[0].maxHp).toBe(30);
expect(encounter.combatants[0].currentHp).toBe(30);
});
it("increases maxHp while not at full health — currentHp unchanged", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 12 })]);
const { encounter } = successResult(e, "A", 30);
expect(encounter.combatants[0].maxHp).toBe(30);
expect(encounter.combatants[0].currentHp).toBe(12);
});
it("reduces maxHp below currentHp — clamps currentHp", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]);
const { encounter } = successResult(e, "A", 10);
expect(encounter.combatants[0].maxHp).toBe(10);
expect(encounter.combatants[0].currentHp).toBe(10);
});
it("clears maxHp — both maxHp and currentHp become undefined", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", undefined);
expect(encounter.combatants[0].maxHp).toBeUndefined();
expect(encounter.combatants[0].currentHp).toBeUndefined();
});
});
describe("invariants", () => {
it("is pure — same input produces same output", () => {
const e = enc([makeCombatant("A")]);
const r1 = setHp(e, combatantId("A"), 10);
const r2 = setHp(e, combatantId("A"), 10);
expect(r1).toEqual(r2);
});
it("does not mutate input encounter", () => {
const e = enc([makeCombatant("A")]);
const original = JSON.parse(JSON.stringify(e));
setHp(e, combatantId("A"), 10);
expect(e).toEqual(original);
});
it("emits MaxHpSet event with correct shape", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]);
const { events } = successResult(e, "A", 10);
expect(events).toEqual([
{
type: "MaxHpSet",
combatantId: combatantId("A"),
previousMaxHp: 20,
newMaxHp: 10,
previousCurrentHp: 18,
newCurrentHp: 10,
},
]);
});
it("preserves activeIndex and roundNumber", () => {
const e = {
combatants: [makeCombatant("A"), makeCombatant("B")],
activeIndex: 1,
roundNumber: 3,
};
const { encounter } = successResult(e, "A", 10);
expect(encounter.activeIndex).toBe(1);
expect(encounter.roundNumber).toBe(3);
});
});
describe("error cases", () => {
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("Z"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
it("rejects maxHp of 0", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects negative maxHp", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), -5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects non-integer maxHp", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
});
describe("edge cases", () => {
it("maxHp=1 is valid", () => {
const e = enc([makeCombatant("A")]);
const { encounter } = successResult(e, "A", 1);
expect(encounter.combatants[0].maxHp).toBe(1);
expect(encounter.combatants[0].currentHp).toBe(1);
});
it("setting same maxHp does not change currentHp", () => {
const e = enc([makeCombatant("A", { maxHp: 10, currentHp: 7 })]);
const { encounter } = successResult(e, "A", 10);
expect(encounter.combatants[0].currentHp).toBe(7);
});
it("clear then re-set loses currentHp — UI must commit on blur", () => {
// Simulates: user clears max HP field then retypes a new value
// If the domain sees clear→set as two calls, currentHp resets.
// This is why the UI commits max HP only on blur, not per-keystroke.
const e = enc([makeCombatant("A", { maxHp: 22, currentHp: 12 })]);
const cleared = successResult(e, "A", undefined);
expect(cleared.encounter.combatants[0].currentHp).toBeUndefined();
const retyped = successResult(cleared.encounter, "A", 122);
// currentHp resets to 122 (first-set path) — original 12 is lost
expect(retyped.encounter.combatants[0].currentHp).toBe(122);
});
it("single committed change preserves currentHp", () => {
// The blur-commit approach: domain only sees 22→122, not 22→undefined→122
const e = enc([makeCombatant("A", { maxHp: 22, currentHp: 12 })]);
const { encounter } = successResult(e, "A", 122);
expect(encounter.combatants[0].maxHp).toBe(122);
expect(encounter.combatants[0].currentHp).toBe(12);
});
it("does not affect other combatants", () => {
const e = enc([
makeCombatant("A"),
makeCombatant("B", { maxHp: 30, currentHp: 25 }),
]);
const { encounter } = successResult(e, "A", 10);
expect(encounter.combatants[1].maxHp).toBe(30);
expect(encounter.combatants[1].currentHp).toBe(25);
});
});
});