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:
24
packages/application/src/adjust-hp-use-case.ts
Normal file
24
packages/application/src/adjust-hp-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
adjustHp,
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
|
||||
export function adjustHpUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
delta: number,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = adjustHp(encounter, combatantId, delta);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
||||
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
|
||||
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
||||
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
||||
export type { EncounterStore } from "./ports.js";
|
||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||
|
||||
24
packages/application/src/set-hp-use-case.ts
Normal file
24
packages/application/src/set-hp-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
setHp,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
|
||||
export function setHpUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
maxHp: number | undefined,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = setHp(encounter, combatantId, maxHp);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
}
|
||||
166
packages/domain/src/__tests__/adjust-hp.test.ts
Normal file
166
packages/domain/src/__tests__/adjust-hp.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
197
packages/domain/src/__tests__/set-hp.test.ts
Normal file
197
packages/domain/src/__tests__/set-hp.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
77
packages/domain/src/adjust-hp.ts
Normal file
77
packages/domain/src/adjust-hp.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||
|
||||
export interface AdjustHpSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that adjusts a combatant's current HP by a delta.
|
||||
*
|
||||
* The result is clamped to [0, maxHp]. Requires the combatant to have
|
||||
* HP tracking enabled (maxHp must be set).
|
||||
*/
|
||||
export function adjustHp(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
delta: number,
|
||||
): AdjustHpSuccess | DomainError {
|
||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||
|
||||
if (targetIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${combatantId}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
|
||||
if (target.maxHp === undefined || target.currentHp === undefined) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "no-hp-tracking",
|
||||
message: `Combatant "${combatantId}" does not have HP tracking enabled`,
|
||||
};
|
||||
}
|
||||
|
||||
if (delta === 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "zero-delta",
|
||||
message: "Delta must not be zero",
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isInteger(delta)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-delta",
|
||||
message: `Delta must be an integer, got ${delta}`,
|
||||
};
|
||||
}
|
||||
|
||||
const previousHp = target.currentHp;
|
||||
const newHp = Math.max(0, Math.min(target.maxHp, previousHp + delta));
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId ? { ...c, currentHp: newHp } : c,
|
||||
),
|
||||
activeIndex: encounter.activeIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "CurrentHpAdjusted",
|
||||
combatantId,
|
||||
previousHp,
|
||||
newHp,
|
||||
delta,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -39,10 +39,29 @@ export interface InitiativeSet {
|
||||
readonly newValue: number | undefined;
|
||||
}
|
||||
|
||||
export interface MaxHpSet {
|
||||
readonly type: "MaxHpSet";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly previousMaxHp: number | undefined;
|
||||
readonly newMaxHp: number | undefined;
|
||||
readonly previousCurrentHp: number | undefined;
|
||||
readonly newCurrentHp: number | undefined;
|
||||
}
|
||||
|
||||
export interface CurrentHpAdjusted {
|
||||
readonly type: "CurrentHpAdjusted";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly previousHp: number;
|
||||
readonly newHp: number;
|
||||
readonly delta: number;
|
||||
}
|
||||
|
||||
export type DomainEvent =
|
||||
| TurnAdvanced
|
||||
| RoundAdvanced
|
||||
| CombatantAdded
|
||||
| CombatantRemoved
|
||||
| CombatantUpdated
|
||||
| InitiativeSet;
|
||||
| InitiativeSet
|
||||
| MaxHpSet
|
||||
| CurrentHpAdjusted;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
|
||||
export { type AdjustHpSuccess, adjustHp } from "./adjust-hp.js";
|
||||
export { advanceTurn } from "./advance-turn.js";
|
||||
export {
|
||||
type EditCombatantSuccess,
|
||||
@@ -8,8 +9,10 @@ export type {
|
||||
CombatantAdded,
|
||||
CombatantRemoved,
|
||||
CombatantUpdated,
|
||||
CurrentHpAdjusted,
|
||||
DomainEvent,
|
||||
InitiativeSet,
|
||||
MaxHpSet,
|
||||
RoundAdvanced,
|
||||
TurnAdvanced,
|
||||
} from "./events.js";
|
||||
@@ -17,6 +20,7 @@ export {
|
||||
type RemoveCombatantSuccess,
|
||||
removeCombatant,
|
||||
} from "./remove-combatant.js";
|
||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||
export {
|
||||
type SetInitiativeSuccess,
|
||||
setInitiative,
|
||||
|
||||
88
packages/domain/src/set-hp.ts
Normal file
88
packages/domain/src/set-hp.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||
|
||||
export interface SetHpSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that sets, updates, or clears a combatant's max HP.
|
||||
*
|
||||
* - Setting maxHp initializes currentHp to maxHp (full health).
|
||||
* - Updating maxHp clamps currentHp to the new maxHp if needed.
|
||||
* - Clearing maxHp (undefined) also clears currentHp.
|
||||
*/
|
||||
export function setHp(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
maxHp: number | undefined,
|
||||
): SetHpSuccess | DomainError {
|
||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||
|
||||
if (targetIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${combatantId}"`,
|
||||
};
|
||||
}
|
||||
|
||||
if (maxHp !== undefined) {
|
||||
if (!Number.isInteger(maxHp) || maxHp < 1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-max-hp",
|
||||
message: `Max HP must be a positive integer, got ${maxHp}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
const previousMaxHp = target.maxHp;
|
||||
const previousCurrentHp = target.currentHp;
|
||||
|
||||
let newMaxHp: number | undefined;
|
||||
let newCurrentHp: number | undefined;
|
||||
|
||||
if (maxHp === undefined) {
|
||||
newMaxHp = undefined;
|
||||
newCurrentHp = undefined;
|
||||
} else if (previousMaxHp === undefined) {
|
||||
// First time setting HP — full health
|
||||
newMaxHp = maxHp;
|
||||
newCurrentHp = maxHp;
|
||||
} else {
|
||||
// Updating existing maxHp
|
||||
newMaxHp = maxHp;
|
||||
if (previousCurrentHp === previousMaxHp) {
|
||||
// At full health — stay at full health
|
||||
newCurrentHp = maxHp;
|
||||
} else {
|
||||
// Clamp currentHp to new max
|
||||
newCurrentHp = Math.min(previousCurrentHp ?? maxHp, maxHp);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId
|
||||
? { ...c, maxHp: newMaxHp, currentHp: newCurrentHp }
|
||||
: c,
|
||||
),
|
||||
activeIndex: encounter.activeIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "MaxHpSet",
|
||||
combatantId,
|
||||
previousMaxHp,
|
||||
newMaxHp,
|
||||
previousCurrentHp,
|
||||
newCurrentHp,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export interface Combatant {
|
||||
readonly id: CombatantId;
|
||||
readonly name: string;
|
||||
readonly initiative?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly currentHp?: number;
|
||||
}
|
||||
|
||||
export interface Encounter {
|
||||
|
||||
Reference in New Issue
Block a user