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

@@ -16,6 +16,10 @@ function formatEvent(e: ReturnType<typeof useEncounter>["events"][number]) {
return `Renamed combatant: ${e.oldName}${e.newName}`;
case "InitiativeSet":
return `Initiative: ${e.combatantId} ${e.previousValue ?? "unset"}${e.newValue ?? "unset"}`;
case "MaxHpSet":
return `Max HP: ${e.combatantId} ${e.previousMaxHp ?? "unset"}${e.newMaxHp ?? "unset"}`;
case "CurrentHpAdjusted":
return `HP: ${e.combatantId} ${e.previousHp}${e.newHp} (${e.delta > 0 ? "+" : ""}${e.delta})`;
}
}
@@ -71,6 +75,97 @@ function EditableName({
);
}
function MaxHpInput({
maxHp,
onCommit,
}: {
maxHp: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
const prev = useRef(maxHp);
// Sync draft when domain value changes externally (e.g. from another source)
if (maxHp !== prev.current) {
prev.current = maxHp;
setDraft(maxHp?.toString() ?? "");
}
const commit = useCallback(() => {
if (draft === "") {
onCommit(undefined);
return;
}
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n) && n >= 1) {
onCommit(n);
} else {
// Revert invalid input
setDraft(maxHp?.toString() ?? "");
}
}, [draft, maxHp, onCommit]);
return (
<input
type="number"
min={1}
value={draft}
placeholder="Max HP"
style={{ width: "5em" }}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
/>
);
}
function CurrentHpInput({
currentHp,
maxHp,
onCommit,
}: {
currentHp: number | undefined;
maxHp: number | undefined;
onCommit: (value: number) => void;
}) {
const [draft, setDraft] = useState(currentHp?.toString() ?? "");
const prev = useRef(currentHp);
if (currentHp !== prev.current) {
prev.current = currentHp;
setDraft(currentHp?.toString() ?? "");
}
const commit = useCallback(() => {
if (draft === "" || currentHp === undefined) return;
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n)) {
onCommit(n);
} else {
setDraft(currentHp.toString());
}
}, [draft, currentHp, onCommit]);
return (
<input
type="number"
min={0}
max={maxHp}
value={draft}
placeholder="HP"
disabled={maxHp === undefined}
style={{ width: "4em" }}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
/>
);
}
export function App() {
const {
encounter,
@@ -80,6 +175,8 @@ export function App() {
removeCombatant,
editCombatant,
setInitiative,
setHp,
adjustHp,
} = useEncounter();
const activeCombatant = encounter.combatants[encounter.activeIndex];
const [nameInput, setNameInput] = useState("");
@@ -127,6 +224,27 @@ export function App() {
}
}}
/>{" "}
{c.maxHp !== undefined && c.currentHp !== undefined && (
<button type="button" onClick={() => adjustHp(c.id, -1)}>
-
</button>
)}
<CurrentHpInput
currentHp={c.currentHp}
maxHp={c.maxHp}
onCommit={(value) => {
if (c.currentHp === undefined) return;
const delta = value - c.currentHp;
if (delta !== 0) adjustHp(c.id, delta);
}}
/>
{c.maxHp !== undefined && <span>/</span>}
{c.maxHp !== undefined && c.currentHp !== undefined && (
<button type="button" onClick={() => adjustHp(c.id, 1)}>
+
</button>
)}{" "}
<MaxHpInput maxHp={c.maxHp} onCommit={(v) => setHp(c.id, v)} />{" "}
<button type="button" onClick={() => removeCombatant(c.id)}>
Remove
</button>

View File

@@ -1,9 +1,11 @@
import type { EncounterStore } from "@initiative/application";
import {
addCombatantUseCase,
adjustHpUseCase,
advanceTurnUseCase,
editCombatantUseCase,
removeCombatantUseCase,
setHpUseCase,
setInitiativeUseCase,
} from "@initiative/application";
import type { CombatantId, DomainEvent, Encounter } from "@initiative/domain";
@@ -132,6 +134,32 @@ export function useEncounter() {
[makeStore],
);
const setHp = useCallback(
(id: CombatantId, maxHp: number | undefined) => {
const result = setHpUseCase(makeStore(), id, maxHp);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const adjustHp = useCallback(
(id: CombatantId, delta: number) => {
const result = adjustHpUseCase(makeStore(), id, delta);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
return {
encounter,
events,
@@ -140,5 +168,7 @@ export function useEncounter() {
removeCombatant,
editCombatant,
setInitiative,
setHp,
adjustHp,
} as const;
}

View File

@@ -41,12 +41,30 @@ export function loadEncounter(): Encounter | null {
const rehydrated = combatants.map((c) => {
const entry = c as Record<string, unknown>;
return {
const base = {
id: combatantId(entry.id as string),
name: entry.name as string,
initiative:
typeof entry.initiative === "number" ? entry.initiative : undefined,
};
// Validate and attach HP fields if valid
const maxHp = entry.maxHp;
const currentHp = entry.currentHp;
if (typeof maxHp === "number" && Number.isInteger(maxHp) && maxHp >= 1) {
const validCurrentHp =
typeof currentHp === "number" &&
Number.isInteger(currentHp) &&
currentHp >= 0 &&
currentHp <= maxHp;
return {
...base,
maxHp,
currentHp: validCurrentHp ? currentHp : maxHp,
};
}
return base;
});
const result = createEncounter(