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>
92 lines
2.2 KiB
TypeScript
92 lines
2.2 KiB
TypeScript
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 && (!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,
|
|
tempHp: newMaxHp === undefined ? undefined : c.tempHp,
|
|
}
|
|
: c,
|
|
),
|
|
activeIndex: encounter.activeIndex,
|
|
roundNumber: encounter.roundNumber,
|
|
},
|
|
events: [
|
|
{
|
|
type: "MaxHpSet",
|
|
combatantId,
|
|
previousMaxHp,
|
|
newMaxHp,
|
|
previousCurrentHp,
|
|
newCurrentHp,
|
|
},
|
|
],
|
|
};
|
|
}
|