Temp HP absorbs damage before current HP, cannot be healed, and does not stack (higher value wins). Displayed as cyan +N after current HP with a Shield button in the HP adjustment popover. Column space is reserved across all rows only when any combatant has temp HP. Concentration pulse fires on any damage, including damage fully absorbed by temp HP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
106 lines
2.3 KiB
TypeScript
106 lines
2.3 KiB
TypeScript
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 previousTempHp = target.tempHp ?? 0;
|
|
let newTempHp = previousTempHp;
|
|
let effectiveDelta = delta;
|
|
|
|
if (delta < 0 && previousTempHp > 0) {
|
|
const absorbed = Math.min(previousTempHp, Math.abs(delta));
|
|
newTempHp = previousTempHp - absorbed;
|
|
effectiveDelta = delta + absorbed;
|
|
}
|
|
|
|
const newHp = Math.max(
|
|
0,
|
|
Math.min(target.maxHp, previousHp + effectiveDelta),
|
|
);
|
|
|
|
const events: DomainEvent[] = [];
|
|
|
|
if (newTempHp !== previousTempHp) {
|
|
events.push({
|
|
type: "TempHpSet",
|
|
combatantId,
|
|
previousTempHp: previousTempHp || undefined,
|
|
newTempHp: newTempHp || undefined,
|
|
});
|
|
}
|
|
|
|
if (newHp !== previousHp) {
|
|
events.push({
|
|
type: "CurrentHpAdjusted",
|
|
combatantId,
|
|
previousHp,
|
|
newHp,
|
|
delta,
|
|
});
|
|
}
|
|
|
|
return {
|
|
encounter: {
|
|
combatants: encounter.combatants.map((c) =>
|
|
c.id === combatantId
|
|
? { ...c, currentHp: newHp, tempHp: newTempHp || undefined }
|
|
: c,
|
|
),
|
|
activeIndex: encounter.activeIndex,
|
|
roundNumber: encounter.roundNumber,
|
|
},
|
|
events,
|
|
};
|
|
}
|