diff --git a/apps/sidekick/components/character-config-dialog.tsx b/apps/sidekick/components/character-config-dialog.tsx index 87abe154..f390d8b4 100644 --- a/apps/sidekick/components/character-config-dialog.tsx +++ b/apps/sidekick/components/character-config-dialog.tsx @@ -20,7 +20,7 @@ interface CharacterConfigDialogProps { } export function CharacterConfigDialog({ onClose }: CharacterConfigDialogProps) { - const { character, updateCharacter } = useCharacterService(); + const { character, updateCharacter, getMaxHp } = useCharacterService(); // Don't render if character is null if (!character) { @@ -46,16 +46,19 @@ export function CharacterConfigDialog({ onClose }: CharacterConfigDialogProps) { await updateCharacter(updatedCharacter); }; + const hpBonus = getMaxHp() - character.hitPoints.max; + const updateMaxHP = async (value: string) => { const numValue = parseInt(value) || 1; - const maxHP = Math.max(1, numValue); + const effectiveMax = Math.max(1, numValue); + const baseMax = effectiveMax - hpBonus; const updatedCharacter = { ...character, hitPoints: { ...character.hitPoints, - max: maxHP, - current: Math.min(character.hitPoints.current, maxHP), + max: baseMax, + current: Math.min(character.hitPoints.current, effectiveMax), }, }; await updateCharacter(updatedCharacter); @@ -118,6 +121,7 @@ export function CharacterConfigDialog({ onClose }: CharacterConfigDialogProps) { {/* Basic Settings */} Promise; updateMaxHP: (value: string) => Promise; updateInitiativeModifier: (value: string) => Promise; @@ -16,6 +17,7 @@ interface BasicSettingsSectionProps { export function BasicSettingsSection({ character, + effectiveMaxHp, updateMaxWounds, updateMaxHP, updateInitiativeModifier, @@ -30,13 +32,12 @@ export function BasicSettingsSection({ Maximum Wounds
- updateMaxWounds(e.target.value)} + onChange={(v) => updateMaxWounds(v)} className="w-full" />

@@ -51,18 +52,15 @@ export function BasicSettingsSection({ Maximum Hit Points

- updateMaxHP(e.target.value)} + min={1} + max={1000} + value={effectiveMaxHp} + onChange={(v) => updateMaxHP(v)} className="w-full" /> -

- Base maximum hit points for the character. -

+

Maximum hit points for the character.

@@ -72,13 +70,12 @@ export function BasicSettingsSection({ Initiative Modifier
- updateInitiativeModifier(e.target.value)} + onChange={(v) => updateInitiativeModifier(v)} className="w-full" />

@@ -95,13 +92,12 @@ export function BasicSettingsSection({ Base Inventory Size

- updateMaxInventorySize(e.target.value)} + onChange={(v) => updateMaxInventorySize(v)} className="w-full" />

@@ -122,13 +118,12 @@ export function BasicSettingsSection({ Starting Skill Points

- updateSkillPointsConfig("startingPoints", e.target.value)} + onChange={(v) => updateSkillPointsConfig("startingPoints", v)} className="w-full" />

Skill points available at level 1.

@@ -141,13 +136,12 @@ export function BasicSettingsSection({ Points Per Level
- updateSkillPointsConfig("pointsPerLevel", e.target.value)} + onChange={(v) => updateSkillPointsConfig("pointsPerLevel", v)} className="w-full" />

diff --git a/apps/sidekick/components/feature-traits-display.tsx b/apps/sidekick/components/feature-traits-display.tsx index 1ae8e053..0eb12611 100644 --- a/apps/sidekick/components/feature-traits-display.tsx +++ b/apps/sidekick/components/feature-traits-display.tsx @@ -221,6 +221,17 @@ const formatEffectDescription = (effect: FeatureTrait): string => { const formatted = formatValue(effect.statBonus.hitDiceBonus); if (formatted) bonuses.push(`${formatted} Hit Dice`); } + if (effect.statBonus.hitDieSizeOverride) { + bonuses.push(`Hit Die → d${effect.statBonus.hitDieSizeOverride}`); + } + if (effect.statBonus.hitDieSizeStep) { + const steps = effect.statBonus.hitDieSizeStep; + bonuses.push(steps > 0 ? `Hit Die +${steps} step` : `Hit Die ${steps} step`); + } + if (effect.statBonus.maxHpBonus) { + const formatted = formatValue(effect.statBonus.maxHpBonus); + if (formatted) bonuses.push(`${formatted} Max HP`); + } // Resources if (effect.statBonus.resourceMaxBonuses) { diff --git a/apps/sidekick/components/level-up-guide.tsx b/apps/sidekick/components/level-up-guide.tsx index fb52136a..5b41aab4 100644 --- a/apps/sidekick/components/level-up-guide.tsx +++ b/apps/sidekick/components/level-up-guide.tsx @@ -51,7 +51,7 @@ const STEPS = [ ]; export function LevelUpGuide({ open, onOpenChange }: LevelUpGuideProps) { - const { character, updateCharacter } = useCharacterService(); + const { character, updateCharacter, getHitDice, getMaxHp } = useCharacterService(); const { showError } = useToastService(); const [currentStep, setCurrentStep] = useState(0); const [levelUpData, setLevelUpData] = useState({ @@ -71,7 +71,7 @@ export function LevelUpGuide({ open, onOpenChange }: LevelUpGuideProps) { // Early return if no character loaded (after all hooks) if (!character) return null; - const hitDieSize = character._hitDice.size; + const hitDieSize = getHitDice().size; const handleNext = () => { if (currentStep === 0) { @@ -289,6 +289,7 @@ export function LevelUpGuide({ open, onOpenChange }: LevelUpGuideProps) { character={character} levelUpData={levelUpData} hitDieSize={`d${hitDieSize}`} + currentMaxHp={getMaxHp()} onHpChange={(newHp) => setLevelUpData((prev) => ({ ...prev, newMaxHp: newHp }))} onReroll={rollHitPoints} /> diff --git a/apps/sidekick/components/level-up-guide/hit-points-step.tsx b/apps/sidekick/components/level-up-guide/hit-points-step.tsx index ae2c71ca..ae6fdba6 100644 --- a/apps/sidekick/components/level-up-guide/hit-points-step.tsx +++ b/apps/sidekick/components/level-up-guide/hit-points-step.tsx @@ -29,6 +29,7 @@ interface HitPointsStepProps { character: Character; levelUpData: LevelUpData; hitDieSize: string; + currentMaxHp: number; onHpChange: (hp: number) => void; onReroll: () => void; } @@ -37,6 +38,7 @@ export function HitPointsStep({ character, levelUpData, hitDieSize, + currentMaxHp, onHpChange, onReroll, }: HitPointsStepProps) { @@ -54,7 +56,7 @@ export function HitPointsStep({

-
{character.hitPoints.max}
+
{currentMaxHp}
diff --git a/apps/sidekick/components/sections/combat-summary.tsx b/apps/sidekick/components/sections/combat-summary.tsx index 7edee33e..2b50323f 100644 --- a/apps/sidekick/components/sections/combat-summary.tsx +++ b/apps/sidekick/components/sections/combat-summary.tsx @@ -35,12 +35,13 @@ import { GeneralActionsRow } from "./general-actions-row"; // Health Bar Subcomponent function HealthBar() { - const { character } = useCharacterService(); + const { character, getMaxHp } = useCharacterService(); // All hooks called first, then safety check if (!character) return null; - const { current, max, temporary } = character.hitPoints; + const max = getMaxHp(); + const { current, temporary } = character.hitPoints; const healthPercentage = (current / max) * 100; const getHealthBarColor = () => { @@ -352,13 +353,15 @@ function HPActionDialog({ // Quick Actions Bar Subcomponent function QuickActionsBar() { - const { character, applyDamage, applyHealing, applyTemporaryHP } = useCharacterService(); + const { character, applyDamage, applyHealing, applyTemporaryHP, getMaxHp } = + useCharacterService(); const [openDialog, setOpenDialog] = useState(null); // All hooks called first, then safety check if (!character) return null; - const { current: currentHp, max: maxHp } = character.hitPoints; + const maxHp = getMaxHp(); + const currentHp = character.hitPoints.current; const handleApplyAction = (actionType: ActionType, amount: number) => { switch (actionType) { diff --git a/apps/sidekick/components/sections/hit-dice-section.tsx b/apps/sidekick/components/sections/hit-dice-section.tsx index 27b707d1..21aceeb3 100644 --- a/apps/sidekick/components/sections/hit-dice-section.tsx +++ b/apps/sidekick/components/sections/hit-dice-section.tsx @@ -17,14 +17,22 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". export function HitDiceSection() { // Get everything we need from service hooks - const { character, performSafeRest, performCatchBreath, performMakeCamp, updateCharacterFields } = - useCharacterService(); + const { + character, + performSafeRest, + performCatchBreath, + performMakeCamp, + updateCharacterFields, + getHitDice, + } = useCharacterService(); const { uiState, updateCollapsibleState } = useUIStateService(); + const computedHitDice = getHitDice(); + const [isEditing, setIsEditing] = useState(false); const [editValues, setEditValues] = useState({ level: character?.level || 1, - hitDieSize: character?._hitDice.size || 6, + hitDieSize: computedHitDice?.size || 6, currentHitDice: character?._hitDice.current || character?.level || 1, }); @@ -35,10 +43,18 @@ export function HitDiceSection() { const onToggle = (isOpen: boolean) => updateCollapsibleState("hitDice", isOpen); const handleSave = () => { + // If the user didn't change the die size, preserve the current override state. + // If they changed it, set it as a manual override. + const sizeOverride = + editValues.hitDieSize === computedHitDice.size + ? character._hitDice.sizeOverride + : (editValues.hitDieSize as typeof character._hitDice.size); + const updatedCharacter = { level: editValues.level, _hitDice: { - size: editValues.hitDieSize, + ...character._hitDice, + sizeOverride, current: editValues.currentHitDice, max: editValues.level, // Max hit dice always equals level }, @@ -50,7 +66,7 @@ export function HitDiceSection() { const handleCancel = () => { setEditValues({ level: character.level, - hitDieSize: character._hitDice.size, + hitDieSize: computedHitDice.size, currentHitDice: character._hitDice.current, }); setIsEditing(false); @@ -86,7 +102,7 @@ export function HitDiceSection() { {character._hitDice.current}/{character._hitDice.max} - d{character._hitDice.size} + d{computedHitDice.size}
{isOpen ? ( @@ -176,7 +192,7 @@ export function HitDiceSection() {
-
d{character._hitDice.size}
+
d{computedHitDice.size}
Hit Die
@@ -193,7 +209,7 @@ export function HitDiceSection() {
- Field Rest Options (d{character._hitDice.size} + STR{" "} + Field Rest Options (d{computedHitDice.size} + STR{" "} {character._attributes.strength >= 0 ? "+" : ""} {character._attributes.strength})
@@ -218,7 +234,7 @@ export function HitDiceSection() { > Make Camp -
({character._hitDice.size} + STR)
+
({computedHitDice.size} + STR)
@@ -249,7 +265,7 @@ export function HitDiceSection() { onClick={() => { setEditValues({ level: character.level, - hitDieSize: character._hitDice.size, + hitDieSize: computedHitDice.size, currentHitDice: character._hitDice.current, }); setIsEditing(true); diff --git a/apps/sidekick/components/ui/numeric-input.tsx b/apps/sidekick/components/ui/numeric-input.tsx new file mode 100644 index 00000000..26a978f0 --- /dev/null +++ b/apps/sidekick/components/ui/numeric-input.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { Input } from "../ui/input"; + +interface NumericInputProps { + id: string; + value: number; + min?: number; + max?: number; + onChange: (value: string) => void; + className?: string; +} + +/** + * A numeric input that lets the user type freely (including clearing the field) + * and only commits the value on blur or Enter. + */ +export function NumericInput({ id, value, min = 0, max, onChange, className }: NumericInputProps) { + const [localValue, setLocalValue] = useState(String(value)); + + useEffect(() => { + setLocalValue(String(value)); + }, [value]); + + const commit = () => { + const parsed = parseInt(localValue); + if (isNaN(parsed)) { + setLocalValue(String(value)); + return; + } + const clamped = Math.max(min, max != null ? Math.min(max, parsed) : parsed); + setLocalValue(String(clamped)); + onChange(String(clamped)); + }; + + return ( + setLocalValue(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + }} + className={className} + /> + ); +} diff --git a/apps/sidekick/data/ancestries/oozeling.ts b/apps/sidekick/data/ancestries/oozeling.ts index c39201c6..96f6654b 100644 --- a/apps/sidekick/data/ancestries/oozeling.ts +++ b/apps/sidekick/data/ancestries/oozeling.ts @@ -13,7 +13,15 @@ export const oozeling: AncestryDefinition = { name: "Odd Constitution", description: "Increment your Hit Dice one step (d6 » d8 » d10 » d12 » d20); they always heal you for the maximum amount. Magical healing always heals you for the minimum amount.", - traits: [], // Passive feature - no mechanical traits to process + traits: [ + { + id: "oozeling-odd-constitution-0", + type: "stat_bonus", + statBonus: { + hitDieSizeStep: 1, + }, + }, + ], }, ], nameConfig: { diff --git a/apps/sidekick/data/classes/shadowmancer.ts b/apps/sidekick/data/classes/shadowmancer.ts index 5ecc5e41..8cb8a4d9 100644 --- a/apps/sidekick/data/classes/shadowmancer.ts +++ b/apps/sidekick/data/classes/shadowmancer.ts @@ -155,6 +155,13 @@ const greaterInvocations: ClassFeature[] = [ allowedAttributes: ["dexterity"], amount: 1, }, + { + id: "fiendish-boon-1", + type: "stat_bonus", + statBonus: { + hitDiceBonus: { type: "fixed", value: -1 }, + }, + }, ], }, { diff --git a/apps/sidekick/data/subclasses/hunter-wildheart.ts b/apps/sidekick/data/subclasses/hunter-wildheart.ts index 7cfc7fe2..87e749b9 100644 --- a/apps/sidekick/data/subclasses/hunter-wildheart.ts +++ b/apps/sidekick/data/subclasses/hunter-wildheart.ts @@ -11,7 +11,16 @@ export const hunterWildheart: SubclassDefinition = { level: 3, name: "Impressive Form", description: "+5 max HP. Upgrade your Hit Dice to d10s.", - traits: [], // Passive feature - no mechanical traits to process + traits: [ + { + id: "impressive-form-0", + type: "stat_bonus", + statBonus: { + maxHpBonus: { type: "fixed", value: 5 }, + hitDieSizeOverride: 10, + }, + }, + ], }, { id: "i-have-the-high-ground", diff --git a/apps/sidekick/lib/hooks/use-character-service.ts b/apps/sidekick/lib/hooks/use-character-service.ts index e4759fb1..d6c2b6c4 100644 --- a/apps/sidekick/lib/hooks/use-character-service.ts +++ b/apps/sidekick/lib/hooks/use-character-service.ts @@ -126,6 +126,7 @@ export function useCharacterService() { getSkillValue: characterService.getSkillValue.bind(characterService), getInitiative: characterService.getInitiative.bind(characterService), getHitDice: characterService.getHitDice.bind(characterService), + getMaxHp: characterService.getMaxHp.bind(characterService), getMaxWounds: characterService.getMaxWounds.bind(characterService), getArmorValue: characterService.getArmorValue.bind(characterService), getResourceMaxValue: characterService.getResourceMaxValue.bind(characterService), diff --git a/apps/sidekick/lib/schemas/character.ts b/apps/sidekick/lib/schemas/character.ts index e75e63e8..e4229fac 100644 --- a/apps/sidekick/lib/schemas/character.ts +++ b/apps/sidekick/lib/schemas/character.ts @@ -53,7 +53,7 @@ export const skillSchema = z.object({ export const hitPointsSchema = z.object({ current: z.int().min(0), - max: z.int().min(1), + max: z.int(), temporary: z.int().min(0), }); @@ -72,6 +72,9 @@ export const hitDiceSchema = z.object({ z.literal(12), z.literal(20), ]), + sizeOverride: z + .union([z.literal(4), z.literal(6), z.literal(8), z.literal(10), z.literal(12), z.literal(20)]) + .optional(), current: z.int().min(0), max: z.int().min(1), }); diff --git a/apps/sidekick/lib/schemas/stat-bonus.ts b/apps/sidekick/lib/schemas/stat-bonus.ts index bdf8e705..a19ab462 100644 --- a/apps/sidekick/lib/schemas/stat-bonus.ts +++ b/apps/sidekick/lib/schemas/stat-bonus.ts @@ -16,6 +16,18 @@ export const skillBonusSchema = z.object({ advantage: flexibleValueSchema.optional(), }); +// Bonus schema that allows negative values (e.g., Fiendish Boon's -1 hit dice) +const signedFlexibleValueSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("fixed"), + value: z.number().int(), + }), + z.object({ + type: z.literal("formula"), + expression: z.string().min(1).max(100), + }), +]); + export const statBonusSchema = z.object({ // Core attributes attributes: attributeBonusesSchema, @@ -24,7 +36,12 @@ export const statBonusSchema = z.object({ skillBonuses: z.record(z.string(), skillBonusSchema).optional(), // Combat and health stats - hitDiceBonus: flexibleValueSchema.optional(), + maxHpBonus: flexibleValueSchema.optional(), + hitDieSizeOverride: z + .union([z.literal(4), z.literal(6), z.literal(8), z.literal(10), z.literal(12), z.literal(20)]) + .optional(), + hitDieSizeStep: z.number().int().optional(), + hitDiceBonus: signedFlexibleValueSchema.optional(), maxWoundsBonus: flexibleValueSchema.optional(), armorBonus: flexibleValueSchema.optional(), initiativeBonus: skillBonusSchema.optional(), diff --git a/apps/sidekick/lib/services/__tests__/hit-dice-override.test.ts b/apps/sidekick/lib/services/__tests__/hit-dice-override.test.ts new file mode 100644 index 00000000..63ec60b6 --- /dev/null +++ b/apps/sidekick/lib/services/__tests__/hit-dice-override.test.ts @@ -0,0 +1,328 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { CharacterService } from "../character-service"; +import { ContentRepositoryService } from "../content-repository-service"; +import { ServiceFactory, getCharacterService } from "../service-factory"; +import { createTestCharacter, loadCharacterForTesting } from "./test-utils"; + +describe("Hit Dice Override", () => { + let characterService: CharacterService; + + beforeEach(() => { + ServiceFactory.reset(); + ServiceFactory.setStorageImplementation("inMemory"); + characterService = getCharacterService(); + }); + + afterEach(() => { + ServiceFactory.reset(); + ServiceFactory.setStorageImplementation("localStorage"); + }); + + it("Oozeling Keeper of the Wild Heart should have d12 hit dice", async () => { + // Hunter base hit die is d8 + // Oozeling "Odd Constitution" increments one step: d8 → d10 + // Keeper of the Wild Heart "Impressive Form" overrides to d10 + // Step applies after override: d10 → d12 + const character = await createTestCharacter({ + classId: "hunter", + ancestryId: "oozeling", + backgroundId: "fearless", + }); + + character.level = 3; + character.traitSelections = [ + { + type: "subclass", + grantedByTraitId: "subclass-0", + subclassId: "hunter-wildheart", + }, + ]; + + await characterService.updateCharacter(character); + await loadCharacterForTesting(character); + + const hitDice = characterService.getHitDice(); + expect(hitDice.size).toBe(12); + }); + + it("Oozeling Hunter without subclass should have d10 hit dice", async () => { + // Hunter base d8, Oozeling steps up one: d8 → d10 + const character = await createTestCharacter({ + classId: "hunter", + ancestryId: "oozeling", + backgroundId: "fearless", + }); + + await loadCharacterForTesting(character); + + const hitDice = characterService.getHitDice(); + expect(hitDice.size).toBe(10); + }); + + it("Keeper of the Wild Heart without Oozeling should have d10 hit dice", async () => { + // Hunter base d8, Wild Heart overrides to d10 + const character = await createTestCharacter({ + classId: "hunter", + ancestryId: "human", + backgroundId: "fearless", + }); + + character.level = 3; + character.traitSelections = [ + { + type: "subclass", + grantedByTraitId: "subclass-0", + subclassId: "hunter-wildheart", + }, + ]; + + await characterService.updateCharacter(character); + await loadCharacterForTesting(character); + + const hitDice = characterService.getHitDice(); + expect(hitDice.size).toBe(10); + }); + + it("Keeper of the Wild Heart should grant +5 max HP", async () => { + const character = await createTestCharacter({ + classId: "hunter", + ancestryId: "human", + backgroundId: "fearless", + }); + + // hitPoints.max is set at creation and only changes via the Level Up wizard. + // Setting level here just unlocks the subclass features; it doesn't change hitPoints.max. + character.level = 3; + character.traitSelections = [ + { + type: "subclass", + grantedByTraitId: "subclass-0", + subclassId: "hunter-wildheart", + }, + ]; + + await characterService.updateCharacter(character); + await loadCharacterForTesting(character); + + // getMaxHp() should be base hitPoints.max (still the level-1 starting value) + 5 from Impressive Form + expect(characterService.getMaxHp()).toBe(character.hitPoints.max + 5); + }); + + it("Survivalist Dwarf Shadowmancer with Fiendish Boon should have correct hit dice quantity", async () => { + // Dwarf: +2 max hit dice + // Survivalist: +1 max hit die + // Shadowmancer Fiendish Boon (Greater Invocation, available at level 4): -1 max hit dice + const character = await createTestCharacter({ + classId: "shadowmancer", + ancestryId: "dwarf", + backgroundId: "survivalist", + }); + + // Level 4 to unlock "A Gift from the Master" (Greater Invocation pick) + character.level = 4; + + // Look up Fiendish Boon from the content repository instead of hardcoding + const pool = ContentRepositoryService.getInstance().getFeaturePool("greater-invocations"); + const fiendishBoon = pool!.features.find((f) => f.id === "fiendish-boon")!; + + character.traitSelections = [ + ...character.traitSelections, + { + type: "pool_feature" as const, + grantedByTraitId: "gift-from-the-master-1-0", + poolId: "greater-invocations", + feature: fiendishBoon, + }, + ]; + + await characterService.updateCharacter(character); + await loadCharacterForTesting(character); + + const hitDice = characterService.getHitDice(); + // Created at level 1, so base _hitDice.max = 1 + // Dwarf +2, Survivalist +1, Fiendish Boon -1 = net +2 + // Total: 1 + 2 = 3 + expect(hitDice.max).toBe(3); + + // Verify without Fiendish Boon it would be 4 + // by checking that the -1 actually matters + character.traitSelections = character.traitSelections.filter( + (s) => !(s.type === "pool_feature" && s.poolId === "greater-invocations"), + ); + await characterService.updateCharacter(character); + await loadCharacterForTesting(character); + + expect(characterService.getHitDice().max).toBe(4); // 1 + 2 + 1 = 4 + }); + + it("effective max HP can be overridden to 1 even with HP bonuses", async () => { + const character = await createTestCharacter({ + classId: "hunter", + ancestryId: "human", + backgroundId: "fearless", + }); + + character.level = 3; + character.traitSelections = [ + { + type: "subclass", + grantedByTraitId: "subclass-0", + subclassId: "hunter-wildheart", + }, + ]; + + // Simulate a witch curse: set base HP so that effective = 1 + // Impressive Form grants +5, so base must be -4 for effective = max(1, -4+5) = 1 + character.hitPoints = { ...character.hitPoints, max: -4, current: 1 }; + + await characterService.updateCharacter(character); + await loadCharacterForTesting(character); + + expect(characterService.getMaxHp()).toBe(1); + }); + + it("manual sizeOverride takes priority over feature bonuses", async () => { + // Oozeling Wild Heart Hunter would normally compute d12 (d8 base, override d10, +1 step) + const character = await createTestCharacter({ + classId: "hunter", + ancestryId: "oozeling", + backgroundId: "fearless", + }); + + character.level = 3; + character.traitSelections = [ + { + type: "subclass", + grantedByTraitId: "subclass-0", + subclassId: "hunter-wildheart", + }, + ]; + + // User manually overrides to d4 + character._hitDice = { ...character._hitDice, sizeOverride: 4 }; + + await characterService.updateCharacter(character); + await loadCharacterForTesting(character); + + expect(characterService.getHitDice().size).toBe(4); + + // Clearing the override restores the feature-computed value (d12) + // This is a regression guard — there's no UI to clear the override today + character._hitDice = { ...character._hitDice, sizeOverride: undefined }; + await characterService.updateCharacter(character); + await loadCharacterForTesting(character); + + expect(characterService.getHitDice().size).toBe(12); + }); + + it("applyHealing should cap at computed max HP including bonuses", async () => { + const character = await createTestCharacter({ + classId: "hunter", + ancestryId: "human", + backgroundId: "fearless", + }); + + character.level = 3; + character.traitSelections = [ + { + type: "subclass", + grantedByTraitId: "subclass-0", + subclassId: "hunter-wildheart", + }, + ]; + + // Set current HP to 1 so healing has room + character.hitPoints = { ...character.hitPoints, current: 1 }; + + await characterService.updateCharacter(character); + await loadCharacterForTesting(character); + + const maxHp = characterService.getMaxHp(); + expect(maxHp).toBe(character.hitPoints.max + 5); + + // Heal for a huge amount — should cap at computed max + await characterService.applyHealing(9999); + const healed = characterService.getCurrentCharacter()!; + expect(healed.hitPoints.current).toBe(maxHp); + }); + + it("performSafeRest should restore HP to computed max including bonuses", async () => { + const character = await createTestCharacter({ + classId: "hunter", + ancestryId: "human", + backgroundId: "fearless", + }); + + character.level = 3; + character.traitSelections = [ + { + type: "subclass", + grantedByTraitId: "subclass-0", + subclassId: "hunter-wildheart", + }, + ]; + + character.hitPoints = { ...character.hitPoints, current: 1 }; + + await characterService.updateCharacter(character); + await loadCharacterForTesting(character); + + const maxHp = characterService.getMaxHp(); + + await characterService.performSafeRest(); + const rested = characterService.getCurrentCharacter()!; + expect(rested.hitPoints.current).toBe(maxHp); + }); + + it("performCatchBreath should use computed hit die size for healing", async () => { + const character = await createTestCharacter({ + classId: "hunter", + ancestryId: "oozeling", + backgroundId: "fearless", + attributes: { strength: 0 }, + }); + + // Oozeling Hunter has d10 hit dice (d8 + 1 step) + // Set HP low so healing doesn't cap + character.hitPoints = { ...character.hitPoints, current: 1 }; + character._hitDice = { ...character._hitDice, current: 1 }; + + await characterService.updateCharacter(character); + await loadCharacterForTesting(character); + + expect(characterService.getHitDice().size).toBe(10); + + await characterService.performCatchBreath(); + const afterRest = characterService.getCurrentCharacter()!; + + // With d10 + 0 STR, minimum heal is 1, max is 10 + // Just verify healing happened and used the right die (healed > 0, healed <= 10) + const healed = afterRest.hitPoints.current - 1; + expect(healed).toBeGreaterThanOrEqual(1); + expect(healed).toBeLessThanOrEqual(10); + }); + + it("performMakeCamp should use computed hit die size for healing", async () => { + const character = await createTestCharacter({ + classId: "hunter", + ancestryId: "oozeling", + backgroundId: "fearless", + attributes: { strength: 0 }, + }); + + // Oozeling Hunter has d10 hit dice + // Make Camp heals hitDieSize + STR = 10 + 0 = 10 + character.hitPoints = { ...character.hitPoints, current: 1 }; + character._hitDice = { ...character._hitDice, current: 1 }; + + await characterService.updateCharacter(character); + await loadCharacterForTesting(character); + + await characterService.performMakeCamp(); + const afterCamp = characterService.getCurrentCharacter()!; + + // Make Camp heals max die + STR = 10 + 0 = 10, so current should be 11 + expect(afterCamp.hitPoints.current).toBe(11); + }); +}); diff --git a/apps/sidekick/lib/services/character-service.ts b/apps/sidekick/lib/services/character-service.ts index d2cb78de..b9201471 100644 --- a/apps/sidekick/lib/services/character-service.ts +++ b/apps/sidekick/lib/services/character-service.ts @@ -1153,7 +1153,33 @@ export class CharacterService { const result = { ...baseHitDice }; - // Apply hit dice bonuses + // Manual override takes highest priority — user explicitly set this value + if (result.sizeOverride) { + result.size = result.sizeOverride; + } else { + // Apply feature bonuses to die size + const dieSizes = [4, 6, 8, 10, 12, 20] as const; + let totalSteps = 0; + + for (const bonus of bonuses) { + if (bonus.hitDieSizeOverride && bonus.hitDieSizeOverride > result.size) { + result.size = bonus.hitDieSizeOverride; + } + if (bonus.hitDieSizeStep) { + totalSteps += bonus.hitDieSizeStep; + } + } + + if (totalSteps !== 0) { + const currentIndex = dieSizes.indexOf(result.size as (typeof dieSizes)[number]); + if (currentIndex !== -1) { + const newIndex = Math.max(0, Math.min(dieSizes.length - 1, currentIndex + totalSteps)); + result.size = dieSizes[newIndex]; + } + } + } + + // Apply hit dice count bonuses (always, regardless of size override) for (const bonus of bonuses) { if (bonus.hitDiceBonus) { result.max += calculateFlexibleValue(bonus.hitDiceBonus); @@ -1163,6 +1189,24 @@ export class CharacterService { return result; } + /** + * Get computed max HP with bonuses applied + */ + getMaxHp(): number { + if (!this._character) throw new Error("No character loaded"); + + let result = this._character.hitPoints.max; + const bonuses = this.getAllStatBonuses(); + + for (const bonus of bonuses) { + if (bonus.maxHpBonus) { + result += calculateFlexibleValue(bonus.maxHpBonus); + } + } + + return Math.max(1, result); + } + /** * Get computed max wounds with bonuses applied */ @@ -1460,10 +1504,7 @@ export class CharacterService { async applyHealing(amount: number): Promise { if (!this._character) return; - const newCurrent = Math.min( - this._character.hitPoints.max, - this._character.hitPoints.current + amount, - ); + const newCurrent = Math.min(this.getMaxHp(), this._character.hitPoints.current + amount); this.setCharacter({ ...this._character, @@ -1598,7 +1639,7 @@ export class CharacterService { ); // Calculate what was restored for logging - const healingAmount = this._character.hitPoints.max - this._character.hitPoints.current; + const healingAmount = this.getMaxHp() - this._character.hitPoints.current; const hitDiceRestored = this._character._hitDice.max - this._character._hitDice.current; const woundsRemoved = this._character.wounds.current > 0 ? 1 : 0; @@ -1607,7 +1648,7 @@ export class CharacterService { ...this._character, hitPoints: { ...this._character.hitPoints, - current: this._character.hitPoints.max, // Full HP restoration + current: this.getMaxHp(), // Full HP restoration temporary: 0, // Clear temporary HP }, _hitDice: { @@ -1655,7 +1696,7 @@ export class CharacterService { } // Roll the hit die using dice formula service - const hitDieSize = this._character._hitDice.size; + const hitDieSize = this.getHitDice().size; const strengthMod = this.getAttributes().strength; // Build formula for catch breath @@ -1674,7 +1715,7 @@ export class CharacterService { // Calculate actual healing applied const currentHP = this._character.hitPoints.current; - const maxHP = this._character.hitPoints.max; + const maxHP = this.getMaxHp(); const actualHealing = Math.min(totalHealing, maxHP - currentHP); // Update character @@ -1720,13 +1761,13 @@ export class CharacterService { } // Calculate healing: max hit die + strength - const hitDieSize = this._character._hitDice.size; + const hitDieSize = this.getHitDice().size; const strengthMod = this.getAttributes().strength; const totalHealing = Math.max(1, hitDieSize + strengthMod); // Minimum 1 HP // Calculate actual healing applied const currentHP = this._character.hitPoints.current; - const maxHP = this._character.hitPoints.max; + const maxHP = this.getMaxHp(); const actualHealing = Math.min(totalHealing, maxHP - currentHP); // Update character with make camp restoration diff --git a/apps/sidekick/lib/services/pdf-export-service.ts b/apps/sidekick/lib/services/pdf-export-service.ts index 2ec062ee..c8983ca9 100644 --- a/apps/sidekick/lib/services/pdf-export-service.ts +++ b/apps/sidekick/lib/services/pdf-export-service.ts @@ -116,7 +116,7 @@ export class PDFExportService { this.setSaveAdvantages(form, character.saveAdvantages); // Hit Points - using exact field names with centered alignment - this.setTextField(form, "HP - Max", character.hitPoints.max.toString(), true); + this.setTextField(form, "HP - Max", characterService.getMaxHp().toString(), true); // Armor Class - using exact field name with centered alignment this.setTextField(form, "Armor", armorValue.toString(), true);