A D&D stat block opened for a combatant now offers a Min/Avg/Max toggle below the Hit Points line, deriving both ends from the Hit Dice formula (3d6 + 6 -> 9 / 16 / 24). The switch is applied as a delta, so manual HP edits and damage already taken survive it. Unlike the PF2e weak/elite adjustment this is a convenience tool, not a rules mechanic: nothing but HP changes and the combatant is not renamed. The toggle is hidden when the HP field carries prose instead of a dice pool. Also extracts a SegmentedControl primitive. Game system, theme and the PF2e weak/elite toggle were three hand-rolled copies of the same markup, which is why the D&D toggle first came out chunkier and in the wrong accent color. All four now share one definition, and segments expose aria-pressed instead of signalling the active state by color alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
96 lines
2.5 KiB
TypeScript
96 lines
2.5 KiB
TypeScript
import type { HpRange, HpVariant } from "@initiative/domain";
|
|
import {
|
|
combatantId,
|
|
creatureId,
|
|
EMPTY_UNDO_REDO_STATE,
|
|
} from "@initiative/domain";
|
|
import { describe, expect, it } from "vitest";
|
|
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
|
|
|
// An ogre with 3d6 + 6 hit points.
|
|
const RANGE: HpRange = { min: 9, average: 16, max: 24 };
|
|
|
|
function stateWithCreature(
|
|
maxHp: number,
|
|
currentHp: number,
|
|
hpVariant?: HpVariant,
|
|
): EncounterState {
|
|
return {
|
|
encounter: {
|
|
combatants: [
|
|
{
|
|
id: combatantId("c-1"),
|
|
name: "Ogre",
|
|
maxHp,
|
|
currentHp,
|
|
creatureId: creatureId("mm:ogre"),
|
|
...(hpVariant !== undefined && { hpVariant }),
|
|
},
|
|
],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
},
|
|
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
|
events: [],
|
|
nextId: 1,
|
|
lastCreatureId: null,
|
|
};
|
|
}
|
|
|
|
function setVariant(state: EncounterState, variant: HpVariant | undefined) {
|
|
return encounterReducer(state, {
|
|
type: "set-hp-variant",
|
|
id: combatantId("c-1"),
|
|
variant,
|
|
range: RANGE,
|
|
});
|
|
}
|
|
|
|
describe("set-hp-variant", () => {
|
|
it("raises max HP to the maximum roll and stores the variant", () => {
|
|
const next = setVariant(stateWithCreature(16, 16), "max");
|
|
const c = next.encounter.combatants[0];
|
|
expect(c.maxHp).toBe(24);
|
|
expect(c.currentHp).toBe(24);
|
|
expect(c.hpVariant).toBe("max");
|
|
});
|
|
|
|
it("lowers max HP to the minimum roll", () => {
|
|
const next = setVariant(stateWithCreature(16, 16), "min");
|
|
const c = next.encounter.combatants[0];
|
|
expect(c.maxHp).toBe(9);
|
|
expect(c.hpVariant).toBe("min");
|
|
});
|
|
|
|
it("returns to the average and clears the variant", () => {
|
|
const next = setVariant(stateWithCreature(24, 24, "max"), undefined);
|
|
const c = next.encounter.combatants[0];
|
|
expect(c.maxHp).toBe(16);
|
|
expect(c.hpVariant).toBeUndefined();
|
|
});
|
|
|
|
it("does not rename the combatant", () => {
|
|
const next = setVariant(stateWithCreature(16, 16), "max");
|
|
expect(next.encounter.combatants[0].name).toBe("Ogre");
|
|
});
|
|
|
|
it("is undoable", () => {
|
|
const start = stateWithCreature(16, 16);
|
|
const next = setVariant(start, "max");
|
|
const undone = encounterReducer(next, { type: "undo" });
|
|
expect(undone.encounter.combatants[0].maxHp).toBe(16);
|
|
expect(undone.encounter.combatants[0].hpVariant).toBeUndefined();
|
|
});
|
|
|
|
it("leaves state untouched for an unknown combatant", () => {
|
|
const start = stateWithCreature(16, 16);
|
|
const next = encounterReducer(start, {
|
|
type: "set-hp-variant",
|
|
id: combatantId("c-99"),
|
|
variant: "max",
|
|
range: RANGE,
|
|
});
|
|
expect(next).toBe(start);
|
|
});
|
|
});
|