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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user