Bug hunt + 5e/PF2e parity + character-build UX overhaul

Audited every calculation (26-agent adversarial workflow + hand-verification).
Core math was sound; fixed real bugs, closed parity gaps, and overhauled the
character-build UX. 396 -> 424 tests; lint clean; build green.

Correctness fixes:
- Heavy armor no longer applies a negative DEX penalty to AC (3 paths + default)
- 5e Exhaustion 4 (HP-max halved) implemented via deriveEffectiveMaxHp + badge
- PF2e dazzled no longer makes a creature easier to hit
- PF2e immunity 'all' zeroes all damage (was misfiled as a condition immunity)
- Director schema bounds; PF2e spell-save basis from the `basic` flag; 5e
  disadvantage-only save guard; remove bogus Concentrating, add Broken/Quickened
- Fix 3 lint errors (useCloud hooks naming, useless escape, explicit any)

PF2e survival subsystem (parity with 5e death saves):
- mechanics/dying.ts (maxDying/isDead/knockOut/applyRecovery/pf2eRestDecay)
- DefensesSection knock-out + recovery-check + death UI; rest-decay in applyRest;
  drained HP reduction

Character-build UX:
- Persisted abilityBuild (base + per-source contributions); sheet & wizard show
  every ability source; higher-level PF2e gains L5/10/15/20 boosts
- Creation surfaces granted skills (incl. new PF2e background skill parsing)
- LevelUpModal enumerates features gained + prompts every owed choice (subclass,
  feats, fighting style, ...) via collectChoices/collectFeatures/subclassPrompt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:14:01 +02:00
parent cac5979820
commit b3914b1dda
34 changed files with 911 additions and 116 deletions
+39 -8
View File
@@ -2,9 +2,9 @@ import { useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react'; import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
import type { Character } from '@/lib/schemas'; import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules'; import type { AbilityKey, AbilityScores, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules'; import { getSystem, ABILITY_ABBR, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, computeAbilities } from '@/lib/rules';
import { allArmorMechanics5e, type ArmorMechanics } from '@/lib/mechanics'; import { allArmorMechanics5e, deriveEffectiveMaxHp, type ArmorMechanics } from '@/lib/mechanics';
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories'; import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
import { encodeClaim } from '@/lib/sync/playerLink'; import { encodeClaim } from '@/lib/sync/playerLink';
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize'; import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
@@ -104,6 +104,21 @@ export function CharacterSheet({ character }: { character: Character }) {
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}), ...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
}; };
// Ability-score source breakdown: use the persisted build, or synthesize a manual
// one from the finals for legacy characters so the sheet still shows totals.
const abilityBuild = c.abilityBuild ?? synthManualBuild(c.abilities);
const abilityBuildView = abilityBreakdown(abilityBuild);
/** Edit an ability total on the sheet → records a Manual adjustment, keeps build+finals in sync. */
const setAbilityTotal = (a: AbilityKey, total: number) => {
const nb = setManualTotal(abilityBuild, a, total);
update({ abilityBuild: nb, abilities: computeAbilities(nb) });
};
/** Re-generated base scores (AbilityGenModal) replace the base, keeping all sources. */
const applyGeneratedScores = (scores: AbilityScores) => {
const nb = setBuildBase(abilityBuild, scores);
update({ abilityBuild: nb, abilities: computeAbilities(nb) });
};
const ac = sys.baseArmorClass(rulesInput); const ac = sys.baseArmorClass(rulesInput);
const initiative = sys.initiativeModifier(rulesInput); const initiative = sys.initiativeModifier(rulesInput);
const skills = sys.skillModifiers(rulesInput); const skills = sys.skillModifiers(rulesInput);
@@ -221,6 +236,7 @@ export function CharacterSheet({ character }: { character: Character }) {
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6"> <div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
{ABILITIES.map((a) => { {ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]); const mod = sys.abilityModifier(c.abilities[a]);
const breakdown = abilityBuildView[a];
return ( return (
<div key={a} className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel px-2 py-3 text-center"> <div key={a} className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel px-2 py-3 text-center">
<div className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div> <div className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div>
@@ -231,12 +247,21 @@ export function CharacterSheet({ character }: { character: Character }) {
min={1} min={1}
max={30} max={30}
aria-label={`${ABILITY_ABBR[a]} score`} aria-label={`${ABILITY_ABBR[a]} score`}
onChange={(v) => update({ abilities: { ...c.abilities, [a]: v } })} onChange={(v) => setAbilityTotal(a, v)}
/> />
{breakdown.parts.length > 0 && (
<div className="mt-1 text-[9px] leading-tight text-muted" title={`${breakdown.base} base${breakdown.parts.map((p) => ` · ${p.delta >= 0 ? '+' : ''}${p.delta} ${p.label}`).join('')}`}>
{breakdown.base}
{breakdown.parts.map((p, i) => (
<span key={i}> {p.delta >= 0 ? '+' : ''}{p.delta}</span>
))}
</div>
)}
</div> </div>
); );
})} })}
</div> </div>
<p className="mt-1 text-[10px] text-muted">Hover a score to see its sources. Editing a score records a Manual adjustment.</p>
</section> </section>
{/* Perception (pf2e core proficiency — advances to legendary; drives initiative) */} {/* Perception (pf2e core proficiency — advances to legendary; drives initiative) */}
@@ -359,7 +384,7 @@ export function CharacterSheet({ character }: { character: Character }) {
</section> </section>
{genScores && ( {genScores && (
<AbilityGenModal onClose={() => setGenScores(false)} onApply={(abilities) => update({ abilities })} /> <AbilityGenModal onClose={() => setGenScores(false)} onApply={applyGeneratedScores} />
)} )}
{levelUp && ( {levelUp && (
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} /> <LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} />
@@ -398,17 +423,23 @@ function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) =
} }
setDelta(0); setDelta(0);
}; };
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0; const eff = deriveEffectiveMaxHp({ system: c.system, baseMaxHp: c.hp.max, level: c.level, exhaustion: c.defenses.exhaustion, conditions: c.conditions });
const effMax = eff.max;
const ratio = effMax > 0 ? c.hp.current / effMax : 0;
return ( return (
<div className="rounded-xl border border-line bg-panel p-4 text-center"> <div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="smallcaps">Hit Points</div> <div className="smallcaps">Hit Points</div>
<div className="my-1 font-mono font-display text-2xl font-semibold text-ink"> <div className="my-1 font-mono font-display text-2xl font-semibold text-ink">
{c.hp.current} {c.hp.current}
<span className="text-base text-muted"> / {c.hp.max}</span> <span className="text-base text-muted"> / {effMax}</span>
{eff.reduction > 0 && <span className="ml-1 text-xs text-muted line-through">{c.hp.max}</span>}
{c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>} {c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>}
</div> </div>
{eff.reduction > 0 && (
<div className="mb-1 text-[11px] text-warning" title={eff.reasons.join('; ')}>{eff.reasons.join(' · ')}</div>
)}
<div className="mb-2"> <div className="mb-2">
<Meter value={c.hp.current} max={c.hp.max} tone={ratio < 0.35 ? 'var(--app-danger)' : 'var(--app-verdigris)'} height={8} /> <Meter value={c.hp.current} max={effMax} tone={ratio < 0.35 ? 'var(--app-danger)' : 'var(--app-verdigris)'} height={8} />
</div> </div>
<div className="grid grid-cols-3 gap-1 text-[11px] text-muted"> <div className="grid grid-cols-3 gap-1 text-[11px] text-muted">
<label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label> <label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label>
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { Lightbulb, RotateCcw, Star } from 'lucide-react'; import { Lightbulb, RotateCcw, Star } from 'lucide-react';
import { type Campaign, type Character, type SpellEntry, newSpellEntry } from '@/lib/schemas'; import { type Campaign, type Character, type SpellEntry, type AbilityBuild, type AbilityAdjustment, newSpellEntry } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories'; import { charactersRepo } from '@/lib/db/repositories';
import { import {
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS, getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
@@ -150,11 +150,19 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
}; };
}))); })));
}); });
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => ({ const resolvePf2e = makeSkillResolver(sys.skills);
name: String(b.name), void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => {
desc: briefOverview(String((b.description ?? b.text) ?? '')), // PF2e backgrounds grant Trained in listed skills (Lore variants don't map to the
backgroundBoosts: parseBackgroundBoosts(b.attribute), // 16 core skills and are dropped here — they're tracked as custom Lore separately).
}))))); const rawSkills = Array.isArray((b as Record<string, unknown>).skill) ? ((b as Record<string, unknown>).skill as string[]) : [];
const skillGrants = rawSkills.map((s) => resolvePf2e(String(s))).filter((k): k is string => !!k);
return {
name: String(b.name),
desc: briefOverview(String((b.description ?? b.text) ?? '')),
backgroundBoosts: parseBackgroundBoosts(b.attribute),
...(skillGrants.length ? { skillGrants } : {}),
};
}))));
} }
return () => { on = false; }; return () => { on = false; };
// sys.skills is a stable singleton keyed by `system`, so `system` covers it. // sys.skills is a stable singleton keyed by `system`, so `system` covers it.
@@ -203,22 +211,24 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
for (let i = 0; i < bg.free; i++) slots.push({ id: `bg-free-${i}`, label: 'Background free boost', options: ABILITIES }); for (let i = 0; i < bg.free; i++) slots.push({ id: `bg-free-${i}`, label: 'Background free boost', options: ABILITIES });
if (keyOpts.length) slots.push({ id: 'class', label: 'Class key ability', options: keyOpts }); if (keyOpts.length) slots.push({ id: 'class', label: 'Class key ability', options: keyOpts });
for (let i = 0; i < 4; i++) slots.push({ id: `free-${i}`, label: 'Free boost', options: ABILITIES }); for (let i = 0; i < 4; i++) slots.push({ id: `free-${i}`, label: 'Free boost', options: ABILITIES });
// Higher-level characters also gained 4 ability boosts at levels 5/10/15/20.
for (const L of [5, 10, 15, 20]) if (L <= level) for (let i = 0; i < 4; i++) slots.push({ id: `lvl${L}-${i}`, label: `Level ${L} boost`, options: ABILITIES });
return { fixed: anc.fixed, flaw: selectedOrigin?.ancestryFlaw, slots }; return { fixed: anc.fixed, flaw: selectedOrigin?.ancestryFlaw, slots };
}, [system, selectedOrigin, selectedBackground, classDef, selectedClass]); }, [system, selectedOrigin, selectedBackground, classDef, selectedClass, level]);
const [boostPicks, setBoostPicks] = useState<Record<string, AbilityKey>>({}); const [boostPicks, setBoostPicks] = useState<Record<string, AbilityKey>>({});
// A boost slot's "source" — boosts within one source must target different abilities. // A boost slot's "source" — boosts within one source must target different abilities.
const boostSource = (id: string) => (id.startsWith('anc') ? 'ancestry' : id.startsWith('bg') ? 'background' : id === 'class' ? 'class' : 'free'); const boostSource = (id: string) => (id.startsWith('anc') ? 'ancestry' : id.startsWith('bg') ? 'background' : id === 'class' ? 'class' : id.startsWith('lvl') ? id.split('-')[0]! : 'free');
// Seed a legal default assignment (distinct within each source) when slots change. // Seed a legal default assignment (distinct within each source) when slots change.
useEffect(() => { useEffect(() => {
if (!boostSlots) return; if (!boostSlots) return;
const keyOpts = (classDef?.keyAbilities ?? []) as AbilityKey[]; const keyOpts = (classDef?.keyAbilities ?? []) as AbilityKey[];
const favored = keyOpts[0] ?? 'str'; const favored = keyOpts[0] ?? 'str';
const order: AbilityKey[] = [favored, ...ABILITIES.filter((a) => a !== favored)]; const order: AbilityKey[] = [favored, ...ABILITIES.filter((a) => a !== favored)];
const used: Record<string, Set<AbilityKey>> = { ancestry: new Set(boostSlots.fixed), background: new Set(), class: new Set(), free: new Set() }; const used: Record<string, Set<AbilityKey>> = { ancestry: new Set(boostSlots.fixed) };
const next: Record<string, AbilityKey> = {}; const next: Record<string, AbilityKey> = {};
for (const s of boostSlots.slots) { for (const s of boostSlots.slots) {
const u = used[boostSource(s.id)]!; const u = (used[boostSource(s.id)] ??= new Set<AbilityKey>());
const pick = (s.options.length < 6 ? s.options : order).find((o) => !u.has(o)) const pick = (s.options.length < 6 ? s.options : order).find((o) => !u.has(o))
?? order.find((o) => !u.has(o)) ?? s.options[0] ?? favored; ?? order.find((o) => !u.has(o)) ?? s.options[0] ?? favored;
next[s.id] = pick; next[s.id] = pick;
@@ -254,6 +264,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length; const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0; const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0;
// Capture the ability scores as a source breakdown (base + every boost/ASI/flaw) so
// the sheet can show where each point came from — final = computeAbilities(build).
const buildAbilityBuild = (): AbilityBuild => {
if (system === 'pf2e' && boostSlots) {
const adjustments: AbilityAdjustment[] = [];
if (boostSlots.flaw) adjustments.push({ label: 'Ancestry flaw', ability: boostSlots.flaw, kind: 'flat', amount: -2 });
for (const ab of boostSlots.fixed) adjustments.push({ label: 'Ancestry', ability: ab, kind: 'boost', amount: 0 });
for (const s of boostSlots.slots) {
const ab = boostPicks[s.id];
if (ab) adjustments.push({ label: s.label, ability: ab, kind: 'boost', amount: 0 });
}
return { base: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, adjustments };
}
const base = {} as AbilityScores;
ABILITIES.forEach((a, i) => { base[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
const adjustments: AbilityAdjustment[] = [];
if (selectedOrigin?.asiBonuses) for (const a of ABILITIES) { const v = selectedOrigin.asiBonuses[a] ?? 0; if (v) adjustments.push({ label: 'Race', ability: a, kind: 'flat', amount: v }); }
for (const a of ABILITIES) { const v = asiAlloc[a] ?? 0; if (v) adjustments.push({ label: 'ASI', ability: a, kind: 'flat', amount: v }); }
return { base, adjustments };
};
// ---- skills ---- // ---- skills ----
const [skills, setSkills] = useState<string[]>([]); const [skills, setSkills] = useState<string[]>([]);
const intMod = abilityModifier(abilities.int); const intMod = abilityModifier(abilities.int);
@@ -277,11 +308,22 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const toggleSkill = (key: string) => const toggleSkill = (key: string) =>
setSkills((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : prev.length < skillCount ? [...prev, key] : prev)); setSkills((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : prev.length < skillCount ? [...prev, key] : prev));
// 5e skill proficiencies granted by background + race (applied as trained, on top of class picks). // Skill proficiencies granted (trained) by ancestry/race + background — shown to the
const grantedSkills = useMemo( // user so they don't waste free picks, and merged on top of class picks at build time.
() => (system === '5e' ? [...(selectedOrigin?.skillGrants ?? []), ...(selectedBackground?.skillGrants ?? [])] : []), const grantedSkillSources = useMemo(() => {
[system, selectedOrigin, selectedBackground], const out: { key: string; source: string }[] = [];
); const add = (keys: string[] | undefined, source: string) => {
for (const k of keys ?? []) if (!out.some((o) => o.key === k)) out.push({ key: k, source });
};
add(selectedOrigin?.skillGrants, selectedOrigin?.name ?? (system === 'pf2e' ? 'Ancestry' : 'Race'));
add(selectedBackground?.skillGrants, selectedBackground?.name ?? 'Background');
return out;
}, [system, selectedOrigin, selectedBackground]);
const grantedSkills = useMemo(() => grantedSkillSources.map((g) => g.key), [grantedSkillSources]);
// Free picks exclude already-granted skills, so the user can't waste a choice on one.
const freeSkillOptions = useMemo(() => skillOptions.filter((k) => !grantedSkills.includes(k)), [skillOptions, grantedSkills]);
// Drop any free pick that becomes granted (e.g. after switching background).
useEffect(() => { setSkills((prev) => prev.filter((k) => !grantedSkills.includes(k))); }, [grantedSkills]);
// ---- spells ---- // ---- spells ----
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]); const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
@@ -348,7 +390,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
Class: !!selectedClass, Class: !!selectedClass,
Origin: true, Origin: true,
Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0, Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0,
Skills: skills.length === Math.min(skillCount, skillOptions.length), Skills: skills.length === Math.min(skillCount, freeSkillOptions.length),
Spells: true, Spells: true,
Details: true, Details: true,
Review: true, Review: true,
@@ -368,6 +410,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) })); const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }));
await charactersRepo.update(created.id, { await charactersRepo.update(created.id, {
...built, ...built,
abilityBuild: buildAbilityBuild(),
...(Object.keys(saveRanks).length ? { saveRanks } : {}), ...(Object.keys(saveRanks).length ? { saveRanks } : {}),
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }], classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
spellcasting: { ...built.spellcasting, spells }, spellcasting: { ...built.spellcasting, spells },
@@ -651,8 +694,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
{stepName === 'Skills' && ( {stepName === 'Skills' && (
<div> <div>
<p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p> <p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, freeSkillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
<p className="mb-2 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p> <p className="mb-2 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p>
{grantedSkillSources.length > 0 && (
<div className="mb-3 rounded-md border border-line bg-panel px-3 py-2">
<div className="mb-1 smallcaps text-[11px]">Already Trained (from your origin)</div>
<div className="flex flex-wrap gap-1.5">
{grantedSkillSources.map((g) => (
<span key={g.key} className="rounded border border-accent/40 bg-accent/5 px-2 py-0.5 text-xs text-ink" title={`Granted by ${g.source}`}>
{skillLabel(g.key)} <span className="text-muted">· {g.source}</span>
</span>
))}
</div>
</div>
)}
{level > 1 && (
<p className="mb-2 text-xs text-warning">
{system === 'pf2e'
? 'Higher-level characters also gain skill increases at levels 3, 5, 7, 9… — apply those from the character sheets level-up flow.'
: 'Some classes gain extra skill/expertise picks as they level — apply those from the character sheets level-up flow.'}
</p>
)}
{selectedClass && (() => { {selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug); const tip = getClassTip(system, selectedClass.slug);
return tip?.skillSuggestions ? ( return tip?.skillSuggestions ? (
@@ -663,7 +725,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
) : null; ) : null;
})()} })()}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{skillOptions.map((key) => { {freeSkillOptions.map((key) => {
const checked = skills.includes(key); const checked = skills.includes(key);
const disabled = !checked && skills.length >= skillCount; const disabled = !checked && skills.length >= skillCount;
return ( return (
+110 -46
View File
@@ -1,7 +1,11 @@
import { Minus, Plus, Star } from 'lucide-react'; import { useState } from 'react';
import { Minus, Plus, Skull, Star } from 'lucide-react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { NumberField } from '@/components/ui/NumberField'; import { NumberField } from '@/components/ui/NumberField';
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery } from '@/lib/mechanics';
import type { Degree } from '@/lib/dice/check';
import type { Condition } from '@/lib/schemas/common';
import { SheetSection, type SectionProps } from './common'; import { SheetSection, type SectionProps } from './common';
function Pips({ count, filled, onToggle, label }: { count: number; filled: number; onToggle: (n: number) => void; label: string }) { function Pips({ count, filled, onToggle, label }: { count: number; filled: number; onToggle: (n: number) => void; label: string }) {
@@ -23,57 +27,117 @@ function Pips({ count, filled, onToggle, label }: { count: number; filled: numbe
); );
} }
/** PF2e dying/wounded/doomed with knock-out + human-rolled recovery checks. */
function Pf2eDefenses({ c, update }: SectionProps) {
const d = c.defenses;
const [fromCrit, setFromCrit] = useState(false);
const dead = isDead(d.dying, d.doomed);
const withUnconscious = (conds: Condition[]): Condition[] =>
conds.some((x) => x.name.trim().toLowerCase() === 'unconscious') ? conds : [...conds, { name: 'Unconscious' }];
const withoutUnconscious = (conds: Condition[]): Condition[] =>
conds.filter((x) => x.name.trim().toLowerCase() !== 'unconscious');
const setDefenses = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
/** Set dying and keep the Unconscious condition in sync (dying>0 ⇒ unconscious). */
const setDying = (dying: number) =>
update({ defenses: { ...d, dying }, conditions: dying > 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions) });
const doKnockOut = () => {
const { dying } = knockOut(d, fromCrit);
update({ defenses: { ...d, dying }, conditions: withUnconscious(c.conditions) });
};
const doRecover = (degree: Degree) => {
const res = applyRecovery(d, degree);
update({ defenses: { ...d, ...res }, conditions: res.dying > 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions) });
};
return (
<div className="grid gap-3 sm:grid-cols-2">
<Field label={`Dying (0${maxDying(d.doomed)})`}>
<div className="flex items-center gap-2">
<NumberField className="w-16" value={d.dying} min={0} max={4} onChange={setDying} aria-label="Dying value" />
{dead && <span className="flex items-center gap-1 rounded bg-danger/15 px-1.5 py-0.5 text-xs font-semibold text-danger"><Skull size={12} aria-hidden /> Dead</span>}
</div>
</Field>
<Field label="Wounded">
<NumberField className="w-20" value={d.wounded} min={0} onChange={(v) => setDefenses({ wounded: v })} aria-label="Wounded value" />
</Field>
<Field label="Doomed">
<NumberField className="w-20" value={d.doomed} min={0} max={3} onChange={(v) => setDefenses({ doomed: v })} aria-label="Doomed value" />
</Field>
<Field label="Hero Points">
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" onClick={() => setDefenses({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point"><Minus size={14} aria-hidden /></Button>
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
<Button size="icon" variant="ghost" onClick={() => setDefenses({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
</div>
</Field>
<div className="sm:col-span-2 rounded-md border border-line bg-panel px-3 py-2">
{d.dying === 0 ? (
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted">Knocked to 0 HP? Enter dying (adds your Wounded value{d.wounded > 0 ? ` of ${d.wounded}` : ''}).</span>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[11px] text-muted">
<input type="checkbox" checked={fromCrit} onChange={(e) => setFromCrit(e.target.checked)} /> crit
</label>
<Button size="sm" variant="danger" onClick={doKnockOut}>Knock out</Button>
</div>
</div>
) : (
<div>
<div className="mb-1 flex items-center justify-between">
<span className="smallcaps">Recovery check</span>
<span className="font-mono text-sm text-ink">DC {recoveryDc(d.dying)}</span>
</div>
<p className="mb-2 text-[11px] text-muted">Roll a flat d20 vs the DC, then record the result (success lowers Dying; reaching 0 makes you Wounded):</p>
<div className="grid grid-cols-2 gap-1 sm:grid-cols-4">
<Button size="sm" variant="secondary" onClick={() => doRecover('critical-success')}>Crit success 2</Button>
<Button size="sm" variant="secondary" onClick={() => doRecover('success')}>Success 1</Button>
<Button size="sm" variant="secondary" onClick={() => doRecover('failure')}>Failure +1</Button>
<Button size="sm" variant="danger" onClick={() => doRecover('critical-failure')}>Crit fail +2</Button>
</div>
</div>
)}
</div>
</div>
);
}
export function DefensesSection({ c, update }: SectionProps) { export function DefensesSection({ c, update }: SectionProps) {
const d = c.defenses; const d = c.defenses;
const setD = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } }); const setD = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
return ( return (
<SheetSection title="Status & Defenses"> <SheetSection title="Status & Defenses">
<div className="grid gap-3 sm:grid-cols-2"> {c.system === '5e' ? (
{c.system === '5e' ? ( <div className="grid gap-3 sm:grid-cols-2">
<> <Field label="Death Saves">
<Field label="Death Saves"> <div className="flex items-center gap-4">
<div className="flex items-center gap-4"> <span className="flex items-center gap-1 text-xs text-success">
<span className="flex items-center gap-1 text-xs text-success"> Successes
Successes <Pips count={3} filled={d.deathSaves.successes} label="Death save success" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, successes: n } })} />
<Pips count={3} filled={d.deathSaves.successes} label="Death save success" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, successes: n } })} /> </span>
</span> <span className="flex items-center gap-1 text-xs text-danger">
<span className="flex items-center gap-1 text-xs text-danger"> Failures
Failures <Pips count={3} filled={d.deathSaves.failures} label="Death save failure" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, failures: n } })} />
<Pips count={3} filled={d.deathSaves.failures} label="Death save failure" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, failures: n } })} /> </span>
</span> </div>
</div> </Field>
</Field> <Field label="Inspiration">
<Field label="Inspiration"> <Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}> {d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'}
{d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'} </Button>
</Button> </Field>
</Field> <Field label="Exhaustion (06)">
<Field label="Exhaustion (06)"> <NumberField className="w-20" value={d.exhaustion} min={0} max={6} onChange={(v) => setD({ exhaustion: v })} aria-label="Exhaustion level" />
<NumberField className="w-20" value={d.exhaustion} min={0} max={6} onChange={(v) => setD({ exhaustion: v })} aria-label="Exhaustion level" /> {d.exhaustion >= 4 && <span className="ml-2 text-[11px] text-warning">Max HP halved</span>}
</Field> </Field>
</> </div>
) : ( ) : (
<> <Pf2eDefenses c={c} update={update} />
<Field label="Dying (04)"> )}
<NumberField className="w-20" value={d.dying} min={0} max={4} onChange={(v) => setD({ dying: v })} aria-label="Dying value" />
</Field>
<Field label="Wounded">
<NumberField className="w-20" value={d.wounded} min={0} onChange={(v) => setD({ wounded: v })} aria-label="Wounded value" />
</Field>
<Field label="Doomed">
<NumberField className="w-20" value={d.doomed} min={0} onChange={(v) => setD({ doomed: v })} aria-label="Doomed value" />
</Field>
<Field label="Hero Points">
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point"><Minus size={14} aria-hidden /></Button>
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
</div>
</Field>
</>
)}
</div>
</SheetSection> </SheetSection>
); );
} }
+150 -18
View File
@@ -1,16 +1,17 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { Campaign, Character, ClassEntry, Spellcasting } from '@/lib/schemas'; import type { Campaign, Character, ClassEntry, Spellcasting } from '@/lib/schemas';
import { normalizeClassMirror, totalLevel } from '@/lib/schemas'; import { normalizeClassMirror, totalLevel } from '@/lib/schemas';
import { import {
abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank, abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
planLevelUp, applyIncreases, bumpRank, getClassDef, planLevelUp, applyIncreases, bumpRank, getClassDef,
collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature,
} from '@/lib/rules'; } from '@/lib/rules';
import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression'; import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression';
import { rollDice } from '@/lib/dice/notation'; import { rollDice } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
import { LevelUpAdvisor } from './LevelUpAdvisor'; import { LevelUpAdvisor } from './LevelUpAdvisor';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
@@ -24,9 +25,12 @@ export function LevelUpModal({ character, onApply, onClose }: {
const is5e = character.system === '5e'; const is5e = character.system === '5e';
// 5e: level up a SPECIFIC class (or multiclass into a new one). pf2e stays single-class. // 5e: level up a SPECIFIC class (or multiclass into a new one). pf2e stays single-class.
const classList: ClassEntry[] = character.classes.length const classList: ClassEntry[] = useMemo(
? character.classes () => (character.classes.length
: character.className ? [{ className: character.className, level: character.level }] : []; ? character.classes
: character.className ? [{ className: character.className, level: character.level }] : []),
[character.classes, character.className, character.level],
);
const NEW = '__new__'; const NEW = '__new__';
const [levelClass, setLevelClass] = useState<string>(classList[0]?.className ?? character.className); const [levelClass, setLevelClass] = useState<string>(classList[0]?.className ?? character.className);
const [newClassName, setNewClassName] = useState<string>(getClassNames('5e').find((n) => !classList.some((e) => e.className === n)) ?? 'Fighter'); const [newClassName, setNewClassName] = useState<string>(getClassNames('5e').find((n) => !classList.some((e) => e.className === n)) ?? 'Fighter');
@@ -55,6 +59,72 @@ export function LevelUpModal({ character, onApply, onClose }: {
// Skill increase (pf2e): one skill to bump. // Skill increase (pf2e): one skill to bump.
const [skillKey, setSkillKey] = useState<string>(sys.skills[0]?.key ?? ''); const [skillKey, setSkillKey] = useState<string>(sys.skills[0]?.key ?? '');
// Classes after this level-up — drives feature/choice enumeration.
const nextClasses: ClassEntry[] = useMemo(() => {
if (is5e) {
return levelClass === NEW
? [...classList, { className: newClassName, level: 1 }]
: classList.map((e) => (e.className === levelingName ? { ...e, level: Math.min(20, e.level + 1) } : e));
}
return classList.length
? classList.map((e, i) => (i === 0 ? { ...e, level: plan.nextLevel } : e))
: [{ className: character.className, level: plan.nextLevel }];
}, [is5e, levelClass, newClassName, classList, levelingName, plan.nextLevel, character.className]);
// Features GAINED at this level (read-only "what you get").
const featuresGained = useMemo(() => {
const k = (f: UnlockedFeature) => `${f.source}|${f.name}|${f.level}`;
const before = new Set(collectFeatures(character.system, classList).map(k));
return collectFeatures(character.system, nextClasses).filter((f) => !before.has(k(f)));
}, [character.system, classList, nextClasses]);
// How many of a choice the character has already recorded (subclass also counts the
// class entry's own subclass field, set before the choices array existed).
const resolvedCount = (key: string) => {
const fromChoices = character.choices.find((c) => c.key === key)?.values.length ?? 0;
if (key.endsWith(':subclass')) {
const cn = key.slice(0, -':subclass'.length).toLowerCase();
return Math.max(fromChoices, nextClasses.some((e) => e.className.toLowerCase() === cn && e.subclass) ? 1 : 0);
}
return fromChoices;
};
// CHOICES still owed at the new level (Fighting Style, Pact Boon, Invocations, feats,
// pf2e subclass…) — the count not yet recorded.
const pendingChoices = useMemo(
() => collectChoices(character.system, nextClasses)
.map((choice) => ({ choice, pick: Math.max(0, choice.count - resolvedCount(choice.key)) }))
.filter((x) => x.pick > 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[character.system, nextClasses, character.choices],
);
// 5e subclass selection (a class field, not a generic choice) when it first unlocks.
const sub5e = useMemo(() => {
if (!is5e) return undefined;
const p = subclassPrompt('5e', levelingName);
const entry = nextClasses.find((e) => e.className === levelingName);
if (!p || !entry || entry.level < p.level || entry.subclass) return undefined;
return p;
}, [is5e, levelingName, nextClasses]);
const [choicePicks, setChoicePicks] = useState<Record<string, string[]>>({});
const [subclassPick, setSubclassPick] = useState<string>('');
const setChoicePick = (key: string, i: number, val: string) =>
setChoicePicks((p) => { const arr = [...(p[key] ?? [])]; arr[i] = val; return { ...p, [key]: arr }; });
// Seed defaults for option-based choices so an untouched picker still records its pick.
useEffect(() => {
setChoicePicks((prev) => {
const next = { ...prev };
for (const { choice, pick } of pendingChoices) {
if (!choice.options) continue;
const arr = [...(next[choice.key] ?? [])];
for (let i = 0; i < pick; i++) if (arr[i] == null) arr[i] = choice.options[0]!;
next[choice.key] = arr;
}
return next;
});
}, [pendingChoices]);
const atMax = total >= 20; const atMax = total >= 20;
const apply = () => { const apply = () => {
const gain = character.system === 'pf2e' const gain = character.system === 'pf2e'
@@ -65,19 +135,35 @@ export function LevelUpModal({ character, onApply, onClose }: {
if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e'); if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e');
if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e'); if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e');
// Resolve a subclass pick (5e prompt or a pf2e `:subclass` choice) onto the class entry.
const subFromChoice = Object.entries(choicePicks)
.find(([k, v]) => k.endsWith(':subclass') && v.some((x) => x.trim()))?.[1].find((x) => x.trim())?.trim();
const subToApply = (sub5e ? (subclassPick || sub5e.options[0]) : undefined) ?? subFromChoice;
const nextClassesFinal = subToApply
? nextClasses.map((e) => (e.className === levelingName && !e.subclass ? { ...e, subclass: subToApply } : e))
: nextClasses;
// Merge newly-picked choices into the character's resolved-choices array.
const merged = character.choices.map((c) => ({ key: c.key, values: [...c.values] }));
for (const [key, vals] of Object.entries(choicePicks)) {
const clean = vals.map((v) => v.trim()).filter(Boolean);
if (!clean.length) continue;
const existing = merged.find((c) => c.key === key);
if (existing) existing.values.push(...clean);
else merged.push({ key, values: clean });
}
const patch: Partial<Character> = { const patch: Partial<Character> = {
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain }, hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
abilities, abilities,
classes: nextClassesFinal,
...normalizeClassMirror({ classes: nextClassesFinal }),
choices: merged,
}; };
if (is5e) { if (is5e) {
// Bump the chosen class (or add a new one), re-derive the mirrors + combined slots. // Re-derive combined spell slots, preserving current values.
const nextClasses: ClassEntry[] = levelClass === NEW const derived = dnd5eClassesSlots(nextClassesFinal);
? [...classList, { className: newClassName, level: 1 }]
: classList.map((e) => (e.className === levelingName ? { ...e, level: Math.min(20, e.level + 1) } : e));
Object.assign(patch, normalizeClassMirror({ classes: nextClasses }));
patch.classes = nextClasses;
const derived = dnd5eClassesSlots(nextClasses);
const slots = derived.slots.map((r) => { const slots = derived.slots.map((r) => {
const ex = character.spellcasting.slots.find((s) => s.level === r.level); const ex = character.spellcasting.slots.find((s) => s.level === r.level);
return { level: r.level, max: r.max, current: ex ? Math.min(r.max, ex.current) : r.max }; return { level: r.level, max: r.max, current: ex ? Math.min(r.max, ex.current) : r.max };
@@ -86,9 +172,8 @@ export function LevelUpModal({ character, onApply, onClose }: {
if (derived.pact) spellcasting.pact = { ...derived.pact, current: Math.min(derived.pact.max, character.spellcasting.pact?.current ?? derived.pact.max) }; if (derived.pact) spellcasting.pact = { ...derived.pact, current: Math.min(derived.pact.max, character.spellcasting.pact?.current ?? derived.pact.max) };
else delete spellcasting.pact; else delete spellcasting.pact;
patch.spellcasting = spellcasting; patch.spellcasting = spellcasting;
} else { } else if (plan.slots) {
patch.level = plan.nextLevel; patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) };
if (plan.slots) patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) };
} }
if (skillInc && skillKey) { if (skillInc && skillKey) {
@@ -209,9 +294,56 @@ export function LevelUpModal({ character, onApply, onClose }: {
</section> </section>
)} )}
{plan.choices.filter((c) => c.kind === 'feat').map((c) => ( {/* What you gain at this level (read-only) */}
<p key={c.label} className="text-sm text-muted"> {c.label}</p> {featuresGained.length > 0 && (
))} <section>
<h3 className="mb-1 smallcaps">What you gain</h3>
<ul className="space-y-1">
{featuresGained.map((f, i) => (
<li key={i} className="text-sm leading-snug">
<span className="text-ink">{f.name}</span>
{f.text ? <span className="text-muted"> {f.text}</span> : null}
</li>
))}
</ul>
</section>
)}
{/* 5e subclass selection (at its archetype level) */}
{sub5e && (
<section>
<h3 className="mb-1 smallcaps">{sub5e.label}</h3>
<Select value={subclassPick || sub5e.options[0]} onChange={(e) => setSubclassPick(e.target.value)} aria-label="Subclass">
{sub5e.options.map((o) => <option key={o} value={o}>{o}</option>)}
</Select>
</section>
)}
{/* Choices still owed at this level (feats, fighting style, pact boon, …) */}
{pendingChoices.length > 0 && (
<section>
<h3 className="mb-1 smallcaps">Choices to make</h3>
<div className="space-y-2">
{pendingChoices.map(({ choice, pick }) => (
<div key={choice.key} className="rounded-md border border-line bg-panel px-3 py-2">
<div className="text-sm text-ink">{choice.label}{pick > 1 ? ` — choose ${pick}` : ''}</div>
{choice.hint && <div className="mb-1 text-xs text-muted">{choice.hint}</div>}
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{Array.from({ length: pick }, (_, i) => (
choice.options ? (
<Select key={i} aria-label={`${choice.label} ${i + 1}`} value={choicePicks[choice.key]?.[i] ?? choice.options[0]} onChange={(e) => setChoicePick(choice.key, i, e.target.value)}>
{choice.options.map((o) => <option key={o} value={o}>{o}</option>)}
</Select>
) : (
<Input key={i} aria-label={`${choice.label} ${i + 1}`} placeholder="Type your choice…" value={choicePicks[choice.key]?.[i] ?? ''} onChange={(e) => setChoicePick(choice.key, i, e.target.value)} />
)
))}
</div>
</div>
))}
</div>
</section>
)}
{/* Strategic build-route advice */} {/* Strategic build-route advice */}
<LevelUpAdvisor campaign={{ id: character.campaignId, name: '', system: character.system, description: '', createdAt: '', updatedAt: '' } as Campaign} character={character} /> <LevelUpAdvisor campaign={{ id: character.campaignId, name: '', system: character.system, description: '', createdAt: '', updatedAt: '' } as Campaign} character={character} />
+2 -2
View File
@@ -66,7 +66,7 @@ function ConflictResolver() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const setCloudSync = useConnectivityStore((s) => s.setCloudSync); const setCloudSync = useConnectivityStore((s) => s.setCloudSync);
const useCloud = async () => { const acceptCloud = async () => {
setBusy(true); setBusy(true);
try { if (await pullBackup()) { setCloudSync('saved'); setTimeout(() => location.reload(), 400); } } try { if (await pullBackup()) { setCloudSync('saved'); setTimeout(() => location.reload(), 400); } }
finally { setBusy(false); setOpen(false); } finally { setBusy(false); setOpen(false); }
@@ -86,7 +86,7 @@ function ConflictResolver() {
<div className="absolute right-0 top-9 z-50 w-64 rounded-xl border border-line bg-panel p-3 text-sm shadow-xl"> <div className="absolute right-0 top-9 z-50 w-64 rounded-xl border border-line bg-panel p-3 text-sm shadow-xl">
<p className="mb-2 text-muted">The cloud copy was changed on another device since this one last synced.</p> <p className="mb-2 text-muted">The cloud copy was changed on another device since this one last synced.</p>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Button size="sm" variant="secondary" disabled={busy} onClick={() => void useCloud()}>Use cloud version (reload)</Button> <Button size="sm" variant="secondary" disabled={busy} onClick={() => void acceptCloud()}>Use cloud version (reload)</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void keepMine()}>Keep this device (overwrite cloud)</Button> <Button size="sm" variant="ghost" disabled={busy} onClick={() => void keepMine()}>Keep this device (overwrite cloud)</Button>
</div> </div>
</div> </div>
+1 -1
View File
@@ -529,7 +529,7 @@ function CombatantRow({
)} )}
{/* Mechanical consequences the conditions impose (enforced read-model). */} {/* Mechanical consequences the conditions impose (enforced read-model). */}
{(() => { {(() => {
const st = deriveState(system, 30, c.conditions); const st = deriveState(system, 30, c.conditions, { hpMax: c.hp.max, ...(c.level !== undefined ? { level: c.level } : {}) });
return st.badges.length > 0 ? ( return st.badges.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1" aria-label="Condition effects"> <div className="mt-1 flex flex-wrap gap-1" aria-label="Condition effects">
{st.badges.map((b) => ( {st.badges.map((b) => (
+9 -5
View File
@@ -10,8 +10,12 @@ import { z } from 'zod';
*/ */
const str = z.coerce.string(); const str = z.coerce.string();
const coerceInt = z.coerce.number().int();
const coerceAmount = z.coerce.number().int().min(0).max(9999); const coerceAmount = z.coerce.number().int().min(0).max(9999);
// Bounded coercers reject the nonsensical values the model sometimes invents
// (negative DCs, 12th-rank spells, exhaustion: -3) at the schema boundary.
const coerceDc = z.coerce.number().int().min(0).max(35);
const coerceSpellLevel = z.coerce.number().int().min(0).max(9);
const coercePositive = z.coerce.number().int().min(1);
function asRecord(v: unknown): Record<string, unknown> | undefined { function asRecord(v: unknown): Record<string, unknown> | undefined {
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined; return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
@@ -119,7 +123,7 @@ export const rollRequestSchema = z.object({
label: str.default('Roll'), label: str.default('Roll'),
kind: z.enum(['check', 'save', 'attack', 'damage', 'custom']).catch('custom'), kind: z.enum(['check', 'save', 'attack', 'damage', 'custom']).catch('custom'),
expression: str.default('1d20'), expression: str.default('1d20'),
dc: coerceInt.optional(), dc: coerceDc.optional(),
against: str.optional(), against: str.optional(),
}); });
export type RollRequest = z.infer<typeof rollRequestSchema>; export type RollRequest = z.infer<typeof rollRequestSchema>;
@@ -129,9 +133,9 @@ export const directorActionSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('damage'), target: str, amount: coerceAmount, damageType: str.optional() }), z.object({ kind: z.literal('damage'), target: str, amount: coerceAmount, damageType: str.optional() }),
z.object({ kind: z.literal('heal'), target: str, amount: coerceAmount }), z.object({ kind: z.literal('heal'), target: str, amount: coerceAmount }),
z.object({ kind: z.literal('tempHp'), target: str, amount: coerceAmount }), z.object({ kind: z.literal('tempHp'), target: str, amount: coerceAmount }),
z.object({ kind: z.literal('condition'), target: str, op: z.enum(['add', 'remove']).catch('add'), name: str, value: coerceInt.optional() }), z.object({ kind: z.literal('condition'), target: str, op: z.enum(['add', 'remove']).catch('add'), name: str, value: coercePositive.optional() }),
z.object({ kind: z.literal('castSpell'), caster: str, spell: str, atLevel: coerceInt.optional() }), z.object({ kind: z.literal('castSpell'), caster: str, spell: str, atLevel: coerceSpellLevel.optional() }),
z.object({ kind: z.literal('spendResource'), actor: str, resource: str, amount: coerceInt.default(1) }), z.object({ kind: z.literal('spendResource'), actor: str, resource: str, amount: coerceAmount.default(1) }),
z.object({ kind: z.literal('advanceTurn') }), z.object({ kind: z.literal('advanceTurn') }),
z.object({ kind: z.literal('addCombatant'), name: str, monsterRef: str.optional() }), z.object({ kind: z.literal('addCombatant'), name: str, monsterRef: str.optional() }),
z.object({ kind: z.literal('log'), text: str }), z.object({ kind: z.literal('log'), text: str }),
+1 -1
View File
@@ -44,7 +44,7 @@ const HOOK_TEMPLATES = [
{ {
title: 'Old Debts', title: 'Old Debts',
opening: () => opening: () =>
`A face from the past — an ally, a rival, or someone who simply remembers — reappears with news that changes the situation. They\'re not hostile, but they want something only the party can provide.`, `A face from the past — an ally, a rival, or someone who simply remembers — reappears with news that changes the situation. They're not hostile, but they want something only the party can provide.`,
tension: 'What they need may directly conflict with the party\'s current goals.', tension: 'What they need may directly conflict with the party\'s current goals.',
}, },
{ {
+6
View File
@@ -183,6 +183,12 @@ describe('HP transitions', () => {
expect(applyDamage(rs, 8, 'cold').hp.current).toBe(27); // 30 - (8 - 5 'all') = 27 expect(applyDamage(rs, 8, 'cold').hp.current).toBe(27); // 30 - (8 - 5 'all') = 27
expect(applyDamage(rs, 4, 'cold').hp.current).toBe(30); // 4 - 5 → 0, no change expect(applyDamage(rs, 4, 'cold').hp.current).toBe(30); // 4 - 5 → 0, no change
}); });
it('pf2e immunity "all" zeroes every damage type', () => {
const im = { ...mk('a', 0, 30), damageDefenses: { resist: [], immune: ['all'], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [], weakness: [] } };
expect(applyDamage(im, 10, 'fire').hp.current).toBe(30); // immune to all → no change
expect(applyDamage(im, 10, 'cold').hp.current).toBe(30);
});
}); });
describe('endEncounter', () => { describe('endEncounter', () => {
+2 -1
View File
@@ -237,7 +237,8 @@ export function applyDamage(c: Combatant, amount: number, type?: string): Combat
let dmg = amount; let dmg = amount;
const def = c.damageDefenses; const def = c.damageDefenses;
if (type && def) { if (type && def) {
if (def.immune.includes(type)) return c; // immune — no change // 'all' (pf2e) immunity applies to every damage type.
if (def.immune.includes(type) || (def.immune as string[]).includes('all')) return c; // immune — no change
// 5e: halve / double // 5e: halve / double
if (def.vulnerable.includes(type)) dmg = amount * 2; if (def.vulnerable.includes(type)) dmg = amount * 2;
else if (def.resist.includes(type)) dmg = Math.floor(amount / 2); else if (def.resist.includes(type)) dmg = Math.floor(amount / 2);
+1
View File
@@ -42,6 +42,7 @@ export interface CompleteOptions<T = unknown> {
/** When provided, structured JSON output is requested and validated against it. /** When provided, structured JSON output is requested and validated against it.
* Input is left open (`any`) so schemas using coerce/preprocess/transform — whose * Input is left open (`any`) so schemas using coerce/preprocess/transform — whose
* parsed input type differs from T — are accepted; T is pinned to the output. */ * parsed input type differs from T — are accepted; T is pinned to the output. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema input type varies (coerce/preprocess); only output T matters here
schema?: ZodType<T, ZodTypeDef, any>; schema?: ZodType<T, ZodTypeDef, any>;
/** A short name describing the JSON shape (used by providers that require it). */ /** A short name describing the JSON shape (used by providers that require it). */
schemaName?: string; schemaName?: string;
+4 -1
View File
@@ -65,7 +65,10 @@ export const CONDITION_EFFECTS_PF2E: Record<string, ConditionEffect> = {
stupefied: { statusPenalty: 'mental' }, // spell attacks/DCs, mental checks stupefied: { statusPenalty: 'mental' }, // spell attacks/DCs, mental checks
frightened: { statusPenalty: 'all' }, // every check and DC; decays 1/turn frightened: { statusPenalty: 'all' }, // every check and DC; decays 1/turn
sickened: { statusPenalty: 'all' }, // every check and DC sickened: { statusPenalty: 'all' }, // every check and DC
dazzled: { attacksAgainst: 'advantage' }, // Dazzled imposes concealment on the dazzled creature's OWN targets (a flat check on
// its attacks) — it does NOT lower its AC. The app can't model the flat-check, so we
// record no advantage to attackers here rather than wrongly making it easier to hit.
dazzled: {},
// fatigued (flat -1 AC/saves) and slowed (action loss) are NOT value-scaled — see deriveState. // fatigued (flat -1 AC/saves) and slowed (action loss) are NOT value-scaled — see deriveState.
fatigued: {}, fatigued: {},
slowed: {}, slowed: {},
+45 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { deriveState, tickConditionsEndOfTurn } from './creatureState'; import { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn } from './creatureState';
import { concentrationDC } from './cast'; import { concentrationDC } from './cast';
import type { Condition } from '@/lib/schemas/common'; import type { Condition } from '@/lib/schemas/common';
@@ -58,6 +58,16 @@ describe('deriveState (5e)', () => {
expect(deriveState('5e', 30, [cond('Exhaustion', 5)]).speed).toBe(0); expect(deriveState('5e', 30, [cond('Exhaustion', 5)]).speed).toBe(0);
}); });
it('exhaustion 4 halves max HP when context is provided', () => {
const s = deriveState('5e', 30, [cond('Exhaustion', 4)], { hpMax: 45 });
expect(s.hpMaxReduction).toBe(22); // floor(45/2)
expect(s.badges).toContain('Max HP 22');
});
it('exhaustion below 4 leaves max HP untouched', () => {
expect(deriveState('5e', 30, [cond('Exhaustion', 3)], { hpMax: 45 }).hpMaxReduction).toBe(0);
});
it('prone badge is range-qualified (advantage melee, disadvantage ranged)', () => { it('prone badge is range-qualified (advantage melee, disadvantage ranged)', () => {
const s = deriveState('5e', 30, [cond('Prone')]); const s = deriveState('5e', 30, [cond('Prone')]);
expect(s.badges).toContain('Attacked: adv. melee / disadv. ranged'); expect(s.badges).toContain('Attacked: adv. melee / disadv. ranged');
@@ -92,6 +102,40 @@ describe('deriveState (pf2e)', () => {
expect(s.attacksAgainstModifier).toBe('advantage'); expect(s.attacksAgainstModifier).toBe('advantage');
}); });
it('dazzled does NOT make the creature easier to hit (concealment is on its own attacks)', () => {
const s = deriveState('pf2e', 25, [cond('Dazzled')]);
expect(s.attacksAgainstModifier).toBe('normal');
});
it('quickened badges the extra action', () => {
const s = deriveState('pf2e', 25, [cond('Quickened')]);
expect(s.badges).toContain('Quickened (+1 action)');
});
it('drained reduces max HP by level × value', () => {
const s = deriveState('pf2e', 25, [cond('Drained', 2)], { level: 5 });
expect(s.hpMaxReduction).toBe(10); // 5 × 2
expect(s.statusPenalties.con).toBe(2); // also a Fortitude penalty
});
});
describe('deriveEffectiveMaxHp', () => {
it('halves 5e max HP at exhaustion 4 (from defenses)', () => {
const r = deriveEffectiveMaxHp({ system: '5e', baseMaxHp: 40, level: 8, exhaustion: 4, conditions: [] });
expect(r.max).toBe(20);
expect(r.reduction).toBe(20);
});
it('subtracts level × drained for pf2e', () => {
const r = deriveEffectiveMaxHp({ system: 'pf2e', baseMaxHp: 50, level: 6, conditions: [cond('Drained', 3)] });
expect(r.max).toBe(32); // 50 - 18
});
it('never drops below 1', () => {
const r = deriveEffectiveMaxHp({ system: 'pf2e', baseMaxHp: 10, level: 20, conditions: [cond('Drained', 4)] });
expect(r.max).toBe(1);
});
it('frightened decays by 1 at end of turn, removed at 0', () => { it('frightened decays by 1 at end of turn, removed at 0', () => {
expect(tickConditionsEndOfTurn('pf2e', [cond('Frightened', 2)])).toEqual([{ name: 'Frightened', value: 1 }]); expect(tickConditionsEndOfTurn('pf2e', [cond('Frightened', 2)])).toEqual([{ name: 'Frightened', value: 1 }]);
expect(tickConditionsEndOfTurn('pf2e', [cond('Frightened', 1)])).toEqual([]); expect(tickConditionsEndOfTurn('pf2e', [cond('Frightened', 1)])).toEqual([]);
+59 -1
View File
@@ -22,10 +22,24 @@ export interface MechanicalState {
* apply to different rolls, so they are tracked separately — never summed. * apply to different rolls, so they are tracked separately — never summed.
*/ */
statusPenalties: Partial<Record<PenaltyTarget, number>>; statusPenalties: Partial<Record<PenaltyTarget, number>>;
/**
* Reduction to maximum HP from conditions: 5e exhaustion 4 halves the max; pf2e
* drained N subtracts level × N. 0 when no reduction applies (or when the caller
* passes no hpMax/level context).
*/
hpMaxReduction: number;
/** short human-readable badges for the UI, e.g. "Speed 0", "2 Str" */ /** short human-readable badges for the UI, e.g. "Speed 0", "2 Str" */
badges: string[]; badges: string[];
} }
/** Optional numeric context so deriveState can compute HP-max reductions. */
export interface DeriveContext {
/** the creature's *base* maximum HP (before reduction), for 5e exhaustion 4. */
hpMax?: number;
/** the creature's level, for pf2e drained (level × value). */
level?: number;
}
/** Combine two advantage states by the 5e rule: opposing sources cancel to normal. */ /** Combine two advantage states by the 5e rule: opposing sources cancel to normal. */
function combineAdv(a: AdvState, b: AdvState): AdvState { function combineAdv(a: AdvState, b: AdvState): AdvState {
if (a === b) return a; if (a === b) return a;
@@ -51,7 +65,7 @@ const PENALTY_LABEL: Record<PenaltyTarget, string> = {
* Unknown / homebrew condition names contribute nothing (they remain visible * Unknown / homebrew condition names contribute nothing (they remain visible
* labels), exactly as before. * labels), exactly as before.
*/ */
export function deriveState(system: SystemId, baseSpeed: number, conditions: Condition[]): MechanicalState { export function deriveState(system: SystemId, baseSpeed: number, conditions: Condition[], ctx?: DeriveContext): MechanicalState {
const table = conditionEffects(system); const table = conditionEffects(system);
let speed = baseSpeed; let speed = baseSpeed;
let speedZero = false; let speedZero = false;
@@ -60,6 +74,7 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con
let incapacitated = false; let incapacitated = false;
let abilityCheckDisadvantage = false; let abilityCheckDisadvantage = false;
let allSavesDisadvantage = false; let allSavesDisadvantage = false;
let hpMaxReduction = 0;
let prone5e = false; let prone5e = false;
const statusPenalties: Partial<Record<PenaltyTarget, number>> = {}; const statusPenalties: Partial<Record<PenaltyTarget, number>> = {};
const saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>> = {}; const saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>> = {};
@@ -74,6 +89,7 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con
if (lvl >= 1) abilityCheckDisadvantage = true; if (lvl >= 1) abilityCheckDisadvantage = true;
if (lvl >= 2) speed = Math.min(speed, Math.floor(baseSpeed / 2)); // speed halved if (lvl >= 2) speed = Math.min(speed, Math.floor(baseSpeed / 2)); // speed halved
if (lvl >= 3) { attackModifier = combineAdv(attackModifier, 'disadvantage'); allSavesDisadvantage = true; } if (lvl >= 3) { attackModifier = combineAdv(attackModifier, 'disadvantage'); allSavesDisadvantage = true; }
if (lvl >= 4 && ctx?.hpMax) hpMaxReduction = Math.floor(ctx.hpMax / 2); // HP maximum halved
if (lvl >= 5) speedZero = true; // speed 0 if (lvl >= 5) speedZero = true; // speed 0
extraBadges.push(lvl >= 6 ? 'Exhaustion 6 (dead)' : `Exhaustion ${lvl}`); extraBadges.push(lvl >= 6 ? 'Exhaustion 6 (dead)' : `Exhaustion ${lvl}`);
continue; continue;
@@ -88,6 +104,11 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con
extraBadges.push('Fatigued (1 AC & saves)'); extraBadges.push('Fatigued (1 AC & saves)');
continue; continue;
} }
// --- pf2e Quickened: grants one extra action; no roll penalty. ---
if (system === 'pf2e' && name === 'quickened') {
extraBadges.push('Quickened (+1 action)');
continue;
}
const eff = table[name]; const eff = table[name];
if (!eff) continue; if (!eff) continue;
@@ -108,6 +129,10 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con
const fam = eff.statusPenalty; const fam = eff.statusPenalty;
statusPenalties[fam] = Math.max(statusPenalties[fam] ?? 0, cond.value); statusPenalties[fam] = Math.max(statusPenalties[fam] ?? 0, cond.value);
} }
// pf2e Drained N also reduces maximum HP by level × N.
if (system === 'pf2e' && name === 'drained' && cond.value && ctx?.level) {
hpMaxReduction += ctx.level * cond.value;
}
} }
// 5e exhaustion 3+ imposes disadvantage on all saves (unless already auto-fail). // 5e exhaustion 3+ imposes disadvantage on all saves (unless already auto-fail).
@@ -128,6 +153,7 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con
for (const [fam, val] of Object.entries(statusPenalties) as [PenaltyTarget, number][]) { for (const [fam, val] of Object.entries(statusPenalties) as [PenaltyTarget, number][]) {
if (val > 0) badges.push(`${val} ${PENALTY_LABEL[fam]}`); if (val > 0) badges.push(`${val} ${PENALTY_LABEL[fam]}`);
} }
if (hpMaxReduction > 0) badges.push(`Max HP ${hpMaxReduction}`);
badges.push(...extraBadges); badges.push(...extraBadges);
return { return {
@@ -138,10 +164,42 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con
saveModifiers, saveModifiers,
abilityCheckDisadvantage, abilityCheckDisadvantage,
statusPenalties, statusPenalties,
hpMaxReduction,
badges, badges,
}; };
} }
/**
* Effective maximum HP after condition-based reductions: 5e exhaustion 4 halves the
* maximum; pf2e Drained N subtracts level × N. Pure — the sheet and tracker show this
* reduced max but never mutate the stored `hp.max`, matching the surface-don't-apply
* rule. Returns the reduced max plus human-readable reasons for the UI to explain it.
*/
export function deriveEffectiveMaxHp(input: {
system: SystemId;
baseMaxHp: number;
level: number;
/** 5e exhaustion level (from defenses), if tracked separately from conditions. */
exhaustion?: number;
conditions: Condition[];
}): { max: number; reduction: number; reasons: string[] } {
let reduction = 0;
const reasons: string[] = [];
if (input.system === '5e' && (input.exhaustion ?? 0) >= 4) {
reduction += Math.floor(input.baseMaxHp / 2);
reasons.push('Exhaustion 4 — maximum HP halved');
}
if (input.system === 'pf2e') {
const drained = input.conditions.find((c) => c.name.trim().toLowerCase() === 'drained')?.value ?? 0;
if (drained > 0) {
const drop = input.level * drained;
reduction += drop;
reasons.push(`Drained ${drained}${drop} maximum HP`);
}
}
return { max: Math.max(1, input.baseMaxHp - reduction), reduction, reasons };
}
/** /**
* End-of-turn auto-decay for valued conditions. In PF2e, Frightened decreases by 1 * End-of-turn auto-decay for valued conditions. In PF2e, Frightened decreases by 1
* at the end of each of the affected creature's turns (removed at 0). Returns a new * at the end of each of the affected creature's turns (removed at 0). Returns a new
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, pf2eRestDecay } from './dying';
import type { Condition } from '@/lib/schemas/common';
const cond = (name: string, value?: number): Condition => (value !== undefined ? { name, value } : { name });
describe('maxDying / isDead', () => {
it('doomed lowers the death threshold (floor 1)', () => {
expect(maxDying(0)).toBe(4);
expect(maxDying(1)).toBe(3);
expect(maxDying(3)).toBe(1);
expect(maxDying(9)).toBe(1);
});
it('dead once dying reaches the doomed-reduced maximum', () => {
expect(isDead(4, 0)).toBe(true);
expect(isDead(3, 0)).toBe(false);
expect(isDead(3, 1)).toBe(true); // max 3
expect(isDead(1, 3)).toBe(true); // max 1
});
});
describe('recoveryDc', () => {
it('is 10 + dying value', () => {
expect(recoveryDc(1)).toBe(11);
expect(recoveryDc(3)).toBe(13);
});
});
describe('knockOut', () => {
it('enters dying 1 + wounded (2 + wounded on a crit)', () => {
expect(knockOut({ wounded: 0 })).toEqual({ dying: 1 });
expect(knockOut({ wounded: 2 })).toEqual({ dying: 3 });
expect(knockOut({ wounded: 1 }, true)).toEqual({ dying: 3 });
});
});
describe('applyRecovery', () => {
it('adjusts dying by degree of success', () => {
expect(applyRecovery({ dying: 3, wounded: 0 }, 'critical-success')).toEqual({ dying: 1, wounded: 0 });
expect(applyRecovery({ dying: 2, wounded: 0 }, 'success')).toEqual({ dying: 1, wounded: 0 });
expect(applyRecovery({ dying: 2, wounded: 0 }, 'failure')).toEqual({ dying: 3, wounded: 0 });
expect(applyRecovery({ dying: 1, wounded: 0 }, 'critical-failure')).toEqual({ dying: 3, wounded: 0 });
});
it('recovering out of dying increases wounded', () => {
expect(applyRecovery({ dying: 1, wounded: 0 }, 'success')).toEqual({ dying: 0, wounded: 1 });
expect(applyRecovery({ dying: 2, wounded: 1 }, 'critical-success')).toEqual({ dying: 0, wounded: 2 });
});
it('does not increase wounded if not already dying', () => {
expect(applyRecovery({ dying: 0, wounded: 2 }, 'success')).toEqual({ dying: 0, wounded: 2 });
});
});
describe('pf2eRestDecay', () => {
it('decays doomed, clears wounded, decrements/removes drained, ends fatigued', () => {
const r = pf2eRestDecay({ doomed: 2, wounded: 3 }, [cond('Drained', 2), cond('Fatigued'), cond('Frightened', 1)]);
expect(r.defenses).toEqual({ doomed: 1, wounded: 0 });
expect(r.conditions).toEqual([cond('Drained', 1), cond('Frightened', 1)]);
});
it('removes drained when it would drop to 0', () => {
const r = pf2eRestDecay({ doomed: 0, wounded: 0 }, [cond('Drained', 1)]);
expect(r.conditions).toEqual([]);
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { Defenses } from '@/lib/schemas/character';
import type { Condition } from '@/lib/schemas/common';
import type { Degree } from '@/lib/dice/check';
/**
* Pathfinder 2e dying / wounded / doomed mechanics — the PF2e analog of 5e death
* saves (`deathSaves.ts`). Pure helpers; the sheet drives them with human-rolled
* recovery checks (the app never rolls). Dying/wounded/doomed live on
* `character.defenses`; Drained/Fatigued live in the conditions array.
*/
/** Maximum dying value before death — normally 4, reduced by doomed (floor 1). */
export function maxDying(doomed: number): number {
return Math.max(1, 4 - Math.max(0, doomed));
}
/** Dead once the dying value reaches the (doomed-reduced) maximum. */
export function isDead(dying: number, doomed: number): boolean {
return dying >= maxDying(doomed);
}
/** The flat recovery-check DC while dying: 10 + current dying value. */
export function recoveryDc(dying: number): number {
return 10 + Math.max(0, dying);
}
/**
* Knocked out (reduced to 0 HP): gain dying 1, plus your wounded value; 2 instead of
* 1 if the blow was a critical hit or you critically failed the triggering save.
* Wounded itself is unchanged here — it only grows when you RECOVER from dying.
*/
export function knockOut(d: Pick<Defenses, 'wounded'>, fromCrit = false): { dying: number } {
return { dying: (fromCrit ? 2 : 1) + Math.max(0, d.wounded) };
}
/**
* Apply a recovery-check degree to the dying value:
* critical success 2 · success 1 · failure +1 · critical failure +2.
* Reaching dying 0 ends the dying condition and leaves you wounded (or more wounded).
*/
export function applyRecovery(d: Pick<Defenses, 'dying' | 'wounded'>, degree: Degree): { dying: number; wounded: number } {
const delta = degree === 'critical-success' ? -2 : degree === 'success' ? -1 : degree === 'failure' ? 1 : 2;
const dying = Math.max(0, d.dying + delta);
const wounded = dying === 0 && d.dying > 0 ? d.wounded + 1 : d.wounded;
return { dying, wounded };
}
/**
* Condition decay from a full night's rest (Rest for the Night): doomed 1, wounded
* cleared (you wake at full HP), Drained 1, and Fatigued removed. Dying is assumed
* already 0. Returns both the defenses patch and the updated conditions array.
*/
export function pf2eRestDecay(
d: Pick<Defenses, 'doomed' | 'wounded'>,
conditions: Condition[],
): { defenses: { doomed: number; wounded: number }; conditions: Condition[] } {
const defenses = { doomed: Math.max(0, d.doomed - 1), wounded: 0 };
const next: Condition[] = [];
for (const c of conditions) {
const name = c.name.trim().toLowerCase();
if (name === 'fatigued') continue; // fatigued ends on a full rest
if (name === 'drained' && c.value !== undefined) {
const value = c.value - 1;
if (value > 0) next.push({ ...c, value }); // else drops
continue;
}
next.push(c);
}
return { defenses, conditions: next };
}
+2 -1
View File
@@ -18,8 +18,9 @@ export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefens
export { castSpell, availableSlotLevels, dropConcentration, concentrationDC } from './cast'; export { castSpell, availableSlotLevels, dropConcentration, concentrationDC } from './cast';
export { spendResource, regainResource } from './resources'; export { spendResource, regainResource } from './resources';
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves'; export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
export { maxDying, isDead, recoveryDc, knockOut, applyRecovery, pf2eRestDecay } from './dying';
export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects'; export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects';
export { deriveState, tickConditionsEndOfTurn, type MechanicalState } from './creatureState'; export { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn, type MechanicalState, type DeriveContext } from './creatureState';
let spellMechCache: Map<string, SpellMechanics> | null = null; let spellMechCache: Map<string, SpellMechanics> | null = null;
let armorMechCache: Map<string, ArmorMechanics> | null = null; let armorMechCache: Map<string, ArmorMechanics> | null = null;
+7 -1
View File
@@ -24,7 +24,13 @@ export function normalizeArmor5e(raw: Armor5e): ArmorMechanics | null {
: 'unarmored'; : 'unarmored';
// Shields don't cap Dex (they're additive); honor the source dexCap otherwise. // Shields don't cap Dex (they're additive); honor the source dexCap otherwise.
const dexCap = isShield ? null : (raw.dexCap ?? null); // When the source omits dexCap, fall back by category: heavy ignores Dex (0),
// medium caps at +2, light/unarmored is uncapped (null).
const dexCap = isShield
? null
: raw.dexCap !== undefined && raw.dexCap !== null
? raw.dexCap
: category === 'heavy' ? 0 : category === 'medium' ? 2 : null;
return { return {
name: raw.name, name: raw.name,
@@ -65,6 +65,9 @@ export function normalizeMonsterDefensesPf2e(raw: {
const immune: DamageType[] = []; const immune: DamageType[] = [];
const conditionImmune: string[] = []; const conditionImmune: string[] = [];
for (const i of Array.isArray(raw.immunity) ? (raw.immunity as string[]) : []) { for (const i of Array.isArray(raw.immunity) ? (raw.immunity as string[]) : []) {
// "all" is a damage immunity (applies to every type), like resistance/weakness —
// not a condition. Special-case it before the damage-type lookup.
if (String(i).trim().toLowerCase() === 'all') { immune.push('all' as DamageType); continue; }
const t = pf2eType(String(i)); const t = pf2eType(String(i));
if (t) immune.push(t); if (t) immune.push(t);
else conditionImmune.push(String(i).trim().toLowerCase()); else conditionImmune.push(String(i).trim().toLowerCase());
+14 -1
View File
@@ -69,7 +69,20 @@ describe('normalizeSpell5e', () => {
expect(m!.level).toBe(1); expect(m!.level).toBe(1);
expect(m!.concentration).toBe(true); expect(m!.concentration).toBe(true);
expect(m!.school).toBe('enchantment'); expect(m!.school).toBe('enchantment');
expect(m!.save).toEqual({ ability: 'wis', basis: 'half' }); // Fear is a non-basic Will save (degrees inflict Frightened) — it negates, not halves.
expect(m!.save).toEqual({ ability: 'wis', basis: 'negates' });
});
it('reads a basic save as half from the pf2e basic flag', () => {
const m = normalizeSpellPf2e({
name: 'Fireball', slug: 'fireball',
system: {
level: { value: 3 },
traits: { value: ['fire'] },
defense: { save: { statistic: 'reflex', basic: true } },
},
} as never);
expect(m!.save).toEqual({ ability: 'dex', basis: 'half' });
}); });
}); });
+8 -3
View File
@@ -35,7 +35,9 @@ function parseSave(desc: string): SpellSave | null {
if (/(?:succeed on|must (?:make|succeed)|makes?)\s*(?:a|an)?\s*$/.test(before)) score += 2; if (/(?:succeed on|must (?:make|succeed)|makes?)\s*(?:a|an)?\s*$/.test(before)) score += 2;
if (best === null || score > best.score) best = { ability, score, idx: m.index }; if (best === null || score > best.score) best = { ability, score, idx: m.index };
} }
if (!best) return null; // A negative best means the only matches were "(dis)advantage on X saves" mentions —
// those reference an external save, not a save THIS spell forces. Don't invent one.
if (!best || best.score < 0) return null;
const half = /half as much|half the damage|half damage|halve the damage/i.test(desc); const half = /half as much|half the damage|half damage|halve the damage/i.test(desc);
return { ability: best.ability, basis: half ? 'half' : 'negates' }; return { ability: best.ability, basis: half ? 'half' : 'negates' };
} }
@@ -93,8 +95,11 @@ export function normalizeSpellPf2e(raw: CompendiumEntry): SpellMechanics | null
const level = Number((sys.level as { value?: number } | undefined)?.value ?? 0) || 0; const level = Number((sys.level as { value?: number } | undefined)?.value ?? 0) || 0;
const traits = ((sys.traits as { value?: string[] } | undefined)?.value ?? []) as string[]; const traits = ((sys.traits as { value?: string[] } | undefined)?.value ?? []) as string[];
const concentration = traits.includes('concentrate') || /sustained/i.test(String((sys.duration as { value?: string } | undefined)?.value ?? '')); const concentration = traits.includes('concentrate') || /sustained/i.test(String((sys.duration as { value?: string } | undefined)?.value ?? ''));
const saveAbilityWord = String((sys.defense as { save?: { statistic?: string } } | undefined)?.save?.statistic ?? ''); const saveDef = (sys.defense as { save?: { statistic?: string; basic?: boolean } } | undefined)?.save;
const saveAbilityWord = String(saveDef?.statistic ?? '');
const saveAbil = ABILITY_WORD[saveAbilityWord.toLowerCase()] ?? pf2eSaveToAbility(saveAbilityWord); const saveAbil = ABILITY_WORD[saveAbilityWord.toLowerCase()] ?? pf2eSaveToAbility(saveAbilityWord);
// Only "basic" saves halve damage; non-basic saves negate/avert the effect. The
// Foundry data flags this explicitly (`defense.save.basic`); default to negates.
return { return {
slug, slug,
name, name,
@@ -102,7 +107,7 @@ export function normalizeSpellPf2e(raw: CompendiumEntry): SpellMechanics | null
school: traits.find((t) => SCHOOLS.has(t)) ?? '', school: traits.find((t) => SCHOOLS.has(t)) ?? '',
concentration, concentration,
ritual: traits.includes('ritual'), ritual: traits.includes('ritual'),
save: saveAbil ? { ability: saveAbil, basis: 'half' } : null, save: saveAbil ? { ability: saveAbil, basis: saveDef?.basic ? 'half' : 'negates' } : null,
damage: [], damage: [],
}; };
} }
+2 -1
View File
@@ -68,7 +68,8 @@ export function effectiveArmorClass(
shieldBonus = 0, shieldBonus = 0,
miscBonus = 0, miscBonus = 0,
): number { ): number {
const dex = armor.dexCap === null ? dexMod : Math.min(dexMod, armor.dexCap); // Heavy armor (dexCap 0) contributes exactly 0 from Dex — never a negative penalty.
const dex = armor.dexCap === null ? dexMod : armor.dexCap === 0 ? 0 : Math.min(dexMod, armor.dexCap);
return armor.baseAc + dex + shieldBonus + miscBonus; return armor.baseAc + dex + shieldBonus + miscBonus;
} }
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal } from './abilityBuild';
import type { AbilityBuild } from '@/lib/schemas/character';
const base10 = { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
describe('computeAbilities', () => {
it('applies pf2e boosts with the <18 (+2) / ≥18 (+1) rule and a flaw', () => {
const build: AbilityBuild = {
base: base10,
adjustments: [
{ label: 'Ancestry flaw', ability: 'cha', kind: 'flat', amount: -2 },
{ label: 'Ancestry', ability: 'str', kind: 'boost', amount: 0 },
{ label: 'Class', ability: 'str', kind: 'boost', amount: 0 },
{ label: 'Free', ability: 'str', kind: 'boost', amount: 0 },
{ label: 'Free', ability: 'str', kind: 'boost', amount: 0 }, // 10→12→14→16→18
{ label: 'Free', ability: 'str', kind: 'boost', amount: 0 }, // 18→19 (+1)
],
};
expect(computeAbilities(build).str).toBe(19);
expect(computeAbilities(build).cha).toBe(8);
});
it('adds flat 5e racial + ASI bonuses', () => {
const build: AbilityBuild = {
base: { ...base10, str: 15, con: 14 },
adjustments: [
{ label: 'Race', ability: 'str', kind: 'flat', amount: 2 },
{ label: 'ASI (L4)', ability: 'str', kind: 'flat', amount: 1 },
{ label: 'Race', ability: 'con', kind: 'flat', amount: 1 },
],
};
expect(computeAbilities(build).str).toBe(18);
expect(computeAbilities(build).con).toBe(15);
});
});
describe('abilityBreakdown', () => {
it('reports realized deltas per source', () => {
const build: AbilityBuild = {
base: base10,
adjustments: [
{ label: 'Ancestry', ability: 'str', kind: 'boost', amount: 0 },
{ label: 'Free', ability: 'str', kind: 'boost', amount: 0 },
],
};
const b = abilityBreakdown(build).str;
expect(b.base).toBe(10);
expect(b.parts).toEqual([{ label: 'Ancestry', delta: 2 }, { label: 'Free', delta: 2 }]);
expect(b.total).toBe(14);
});
});
describe('setManualTotal', () => {
it('records a Manual flat delta so the total matches the typed value', () => {
const build = setManualTotal(synthManualBuild(base10), 'str', 16);
expect(computeAbilities(build).str).toBe(16);
expect(build.adjustments).toContainEqual({ label: 'Manual', ability: 'str', kind: 'flat', amount: 6 });
});
it('removes the Manual delta when the target matches the computed total', () => {
const withBoost: AbilityBuild = { base: base10, adjustments: [{ label: 'Free', ability: 'str', kind: 'boost', amount: 0 }] };
const manual = setManualTotal(withBoost, 'str', 16); // 12 base+boost → manual +4
const reset = setManualTotal(manual, 'str', 12); // back to computed
expect(reset.adjustments.some((a) => a.label === 'Manual')).toBe(false);
expect(computeAbilities(reset).str).toBe(12);
});
});
+71
View File
@@ -0,0 +1,71 @@
import type { AbilityKey, AbilityScores } from './types';
import { ABILITY_KEYS } from './types';
import type { AbilityBuild } from '@/lib/schemas/character';
/**
* Replay an ability build into final scores. PF2e boosts add +2 while the score is
* below 18, otherwise +1 (so order matters); flat adjustments add their exact amount
* (5e racial/ASI bonuses, a PF2e flaw of 2, manual tweaks). This is the single place
* the final scores are derived from the persisted build.
*/
export function computeAbilities(build: AbilityBuild): AbilityScores {
const s: AbilityScores = { ...build.base };
for (const adj of build.adjustments) {
s[adj.ability] += adj.kind === 'boost' ? (s[adj.ability] < 18 ? 2 : 1) : adj.amount;
}
return s;
}
export interface AbilityBreakdown {
base: number;
parts: { label: string; delta: number }[];
total: number;
}
/**
* Per-ability breakdown for display: the base value plus each contribution's realized
* delta (a PF2e boost shows as +2 or +1 depending on the running total). Drives the
* "10 base · +2 Ancestry · +1 Free" line on the sheet and wizard.
*/
export function abilityBreakdown(build: AbilityBuild): Record<AbilityKey, AbilityBreakdown> {
const running: AbilityScores = { ...build.base };
const out = {} as Record<AbilityKey, AbilityBreakdown>;
for (const a of ABILITY_KEYS) out[a] = { base: build.base[a], parts: [], total: build.base[a] };
for (const adj of build.adjustments) {
const before = running[adj.ability];
const delta = adj.kind === 'boost' ? (before < 18 ? 2 : 1) : adj.amount;
running[adj.ability] = before + delta;
out[adj.ability].parts.push({ label: adj.label, delta });
out[adj.ability].total = running[adj.ability];
}
return out;
}
/** A build with no recorded sources — the migration fallback for legacy characters. */
export function synthManualBuild(abilities: AbilityScores): AbilityBuild {
return { base: { ...abilities }, adjustments: [] };
}
const MANUAL_LABEL = 'Manual';
/**
* Set/replace the single "Manual" flat adjustment for one ability so the final total
* equals `target`. Lets the sheet keep `abilities = computeAbilities(build)` while the
* user types an override, recording the override as a visible "Manual ±N" source.
*/
export function setManualTotal(build: AbilityBuild, ability: AbilityKey, target: number): AbilityBuild {
const others = build.adjustments.filter(
(adj) => !(adj.kind === 'flat' && adj.label === MANUAL_LABEL && adj.ability === ability),
);
const totalWithout = computeAbilities({ base: build.base, adjustments: others })[ability];
const delta = target - totalWithout;
const adjustments = delta !== 0
? [...others, { label: MANUAL_LABEL, ability, kind: 'flat' as const, amount: delta }]
: others;
return { base: { ...build.base }, adjustments };
}
/** Replace the base scores (e.g. re-rolling the 5e array) while keeping all sources. */
export function setBuildBase(build: AbilityBuild, base: AbilityScores): AbilityBuild {
return { base: { ...base }, adjustments: build.adjustments };
}
+1 -1
View File
@@ -10,7 +10,6 @@ export interface ConditionDef {
export const CONDITIONS_5E: readonly ConditionDef[] = [ export const CONDITIONS_5E: readonly ConditionDef[] = [
{ name: 'Blinded', valued: false }, { name: 'Blinded', valued: false },
{ name: 'Charmed', valued: false }, { name: 'Charmed', valued: false },
{ name: 'Concentrating', valued: false },
{ name: 'Deafened', valued: false }, { name: 'Deafened', valued: false },
{ name: 'Exhaustion', valued: true }, { name: 'Exhaustion', valued: true },
{ name: 'Frightened', valued: false }, { name: 'Frightened', valued: false },
@@ -29,6 +28,7 @@ export const CONDITIONS_5E: readonly ConditionDef[] = [
/** Pathfinder 2e conditions. Many carry a value. */ /** Pathfinder 2e conditions. Many carry a value. */
export const CONDITIONS_PF2E: readonly ConditionDef[] = [ export const CONDITIONS_PF2E: readonly ConditionDef[] = [
{ name: 'Blinded', valued: false }, { name: 'Blinded', valued: false },
{ name: 'Broken', valued: true },
{ name: 'Clumsy', valued: true }, { name: 'Clumsy', valued: true },
{ name: 'Concealed', valued: false }, { name: 'Concealed', valued: false },
{ name: 'Confused', valued: false }, { name: 'Confused', valued: false },
+2 -1
View File
@@ -83,7 +83,8 @@ export const dnd5e: RulesSystem = {
const misc = input.armorBonus ?? 0; const misc = input.armorBonus ?? 0;
if (input.equippedArmor) { if (input.equippedArmor) {
const { baseAc, dexCap } = input.equippedArmor; const { baseAc, dexCap } = input.equippedArmor;
return baseAc + (dexCap === null ? dex : Math.min(dex, dexCap)) + misc; // Heavy armor (dexCap 0) ignores Dex entirely — a negative Dex must not lower AC.
return baseAc + (dexCap === null ? dex : dexCap === 0 ? 0 : Math.min(dex, dexCap)) + misc;
} }
// Unarmored AC. Shield/misc bonuses are represented as armorBonus on top. // Unarmored AC. Shield/misc bonuses are represented as armorBonus on top.
return 10 + dex + misc; return 10 + dex + misc;
+21
View File
@@ -113,5 +113,26 @@ export function collectChoices(system: SystemId, classes: readonly ClassRef[]):
export function collectFeatures(system: SystemId, classes: readonly ClassRef[]): UnlockedFeature[] { export function collectFeatures(system: SystemId, classes: readonly ClassRef[]): UnlockedFeature[] {
return system === 'pf2e' ? collectFeaturesPf2e(classes) : collectFeatures5e(classes); return system === 'pf2e' ? collectFeaturesPf2e(classes) : collectFeatures5e(classes);
} }
/**
* The subclass selection a class makes (5e: at its archetype level; pf2e: at level 1),
* with the options to choose from. Used by the level-up flow to prompt the pick when the
* character reaches that level without a subclass set. Returns undefined if the class has
* no modeled subclasses.
*/
export function subclassPrompt(system: SystemId, className: string): { level: number; label: string; options: string[] } | undefined {
if (system === 'pf2e') {
const name = canonicalPf2e(className);
const subs = PF2E_SUBCLASSES[name];
if (!subs || subs.length === 0) return undefined;
return { level: 1, label: PF2E_SUBCLASS_LABEL[name] ?? 'Subclass', options: subs.map((s) => s.name) };
}
const prog = progFor5e(className);
if (!prog) return undefined;
const key = Object.keys(CLASS_PROGRESSION_5E).find((k) => k.toLowerCase() === className.trim().toLowerCase()) ?? className;
const options = (DND5E_SUBCLASSES[key] ?? []).map((s) => s.name);
if (options.length === 0) return undefined;
return { level: prog.subclass.level, label: prog.subclass.label, options };
}
// Back-compat (5e-only) exports. // Back-compat (5e-only) exports.
export { collectChoices5e, collectFeatures5e }; export { collectChoices5e, collectFeatures5e };
+18 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { collectChoices5e, collectFeatures5e, collectChoices, collectFeatures, countAtLevel } from './collect'; import { collectChoices5e, collectFeatures5e, collectChoices, collectFeatures, countAtLevel, subclassPrompt } from './collect';
const byKey = (cs: ReturnType<typeof collectChoices5e>, k: string) => cs.find((c) => c.key === k); const byKey = (cs: ReturnType<typeof collectChoices5e>, k: string) => cs.find((c) => c.key === k);
@@ -96,3 +96,20 @@ describe('PF2e feature/choice engine (parity)', () => {
expect(fs.some((f) => f.name === 'Dragon Instinct')).toBe(true); expect(fs.some((f) => f.name === 'Dragon Instinct')).toBe(true);
}); });
}); });
describe('subclassPrompt', () => {
it('5e Fighter chooses its archetype at level 3 with PHB options', () => {
const p = subclassPrompt('5e', 'Fighter');
expect(p?.level).toBe(3);
expect(p?.options).toContain('Battle Master');
});
it('pf2e classes choose their defining feature at level 1', () => {
const p = subclassPrompt('pf2e', 'Barbarian');
expect(p?.level).toBe(1);
expect(p?.label).toBe('Instinct');
expect(p?.options).toContain('Dragon Instinct');
});
it('returns undefined for a class with no modeled subclasses', () => {
expect(subclassPrompt('5e', 'Nonexistent')).toBeUndefined();
});
});
+2 -1
View File
@@ -20,7 +20,8 @@ export * from './types';
export * from './progression'; export * from './progression';
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities'; export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/abilities'; export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/abilities';
export { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, type AbilityBreakdown } from './abilityBuild';
export { applyRest } from './rest'; export { applyRest } from './rest';
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions'; export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
export { collectChoices, collectFeatures, collectChoices5e, collectFeatures5e } from './features/collect'; export { collectChoices, collectFeatures, collectChoices5e, collectFeatures5e, subclassPrompt } from './features/collect';
export type { UnlockedChoice, UnlockedFeature } from './features/types'; export type { UnlockedChoice, UnlockedFeature } from './features/types';
+2 -1
View File
@@ -95,7 +95,8 @@ export const pf2e: RulesSystem = {
if (input.equippedArmor) { if (input.equippedArmor) {
const { baseAc, dexCap } = input.equippedArmor; const { baseAc, dexCap } = input.equippedArmor;
// baseAc embeds the armor's flat AC (10 + armor item bonus); add Dex + prof + misc. // baseAc embeds the armor's flat AC (10 + armor item bonus); add Dex + prof + misc.
return baseAc + (dexCap === null ? dex : Math.min(dex, dexCap)) + prof + misc; // dexCap 0 ignores Dex entirely — a negative Dex must not lower AC.
return baseAc + (dexCap === null ? dex : dexCap === 0 ? 0 : Math.min(dex, dexCap)) + prof + misc;
} }
return 10 + dex + prof + misc; return 10 + dex + prof + misc;
}, },
+12
View File
@@ -1,5 +1,6 @@
import type { Character } from '@/lib/schemas/character'; import type { Character } from '@/lib/schemas/character';
import type { RestOption } from './types'; import type { RestOption } from './types';
import { pf2eRestDecay } from '@/lib/mechanics/dying';
/** /**
* Compute the character changes a rest produces. Pure — returns a partial patch * Compute the character changes a rest produces. Pure — returns a partial patch
@@ -43,6 +44,17 @@ export function applyRest(c: Character, opt: RestOption): Partial<Character> {
}; };
} }
// pf2e: a full night's rest decays doomed (1) and drained (1), clears wounded
// (you wake at full HP) and ends fatigued. Dying is assumed already 0.
if (c.system === 'pf2e') {
if (opt.restoresHp) {
const decay = pf2eRestDecay(c.defenses, c.conditions);
patch.defenses = { ...c.defenses, ...decay.defenses };
patch.conditions = decay.conditions;
}
return patch;
}
const exhaustion = const exhaustion =
opt.reduceExhaustion && c.defenses.exhaustion > 0 opt.reduceExhaustion && c.defenses.exhaustion > 0
? c.defenses.exhaustion - 1 ? c.defenses.exhaustion - 1
+10
View File
@@ -72,6 +72,16 @@ describe('5e derived stats', () => {
const ac = dnd5e.baseArmorClass(input); const ac = dnd5e.baseArmorClass(input);
expect(ac).toBe(10 + 2); expect(ac).toBe(10 + 2);
}); });
it('heavy armor (dexCap 0) ignores a negative DEX rather than penalising AC', () => {
// regression: Math.min(dexMod, 0) wrongly applied a negative DEX to heavy armor.
const lowDex: CharacterRulesInput = {
level: 1,
abilities: { str: 16, dex: 8, con: 14, int: 10, wis: 12, cha: 8 }, // DEX 8 = -1
equippedArmor: { baseAc: 18, dexCap: 0 }, // plate
};
expect(dnd5e.baseArmorClass(lowDex)).toBe(18); // not 17
});
}); });
describe('pf2e proficiency', () => { describe('pf2e proficiency', () => {
+25
View File
@@ -140,6 +140,27 @@ export const defensesSchema = z.object({
}); });
export type Defenses = z.infer<typeof defensesSchema>; export type Defenses = z.infer<typeof defensesSchema>;
/**
* One contribution to an ability score, kept so the sheet can show WHERE each point
* comes from (the old app showed only opaque finals). `kind: 'boost'` is a PF2e boost
* (+2 while below 18, else +1, applied in order); `kind: 'flat'` is an exact amount
* (5e racial/ASI bonus, a PF2e ancestry flaw of 2, or a manual tweak).
*/
export const abilityAdjustmentSchema = z.object({
label: z.string().min(1).max(60),
ability: abilityKeySchema,
kind: z.enum(['boost', 'flat']),
amount: int.default(0),
});
export type AbilityAdjustment = z.infer<typeof abilityAdjustmentSchema>;
/** A character's ability scores as base + ordered contributions (final = replay). */
export const abilityBuildSchema = z.object({
base: abilityScoresSchema,
adjustments: z.array(abilityAdjustmentSchema).default([]),
});
export type AbilityBuild = z.infer<typeof abilityBuildSchema>;
export const characterSchema = z.object({ export const characterSchema = z.object({
id: z.string(), id: z.string(),
/** Owning campaign; '' = unassigned (PCs can be created without a campaign and attached later). */ /** Owning campaign; '' = unassigned (PCs can be created without a campaign and attached later). */
@@ -170,6 +191,10 @@ export const characterSchema = z.object({
personality: z.string().max(4000).default(''), personality: z.string().max(4000).default(''),
abilities: abilityScoresSchema, abilities: abilityScoresSchema,
/** Optional source breakdown for `abilities` (base + per-source contributions), so the
* sheet can show where each point comes from. Absent on legacy characters (the sheet
* synthesizes a manual build from the finals). `abilities` stays the canonical total. */
abilityBuild: abilityBuildSchema.optional(),
hp: hpSchema, hp: hpSchema,
speed: int.nonnegative().default(30), speed: int.nonnegative().default(30),
/** shield / misc AC bonus on top of worn armor */ /** shield / misc AC bonus on top of worn armor */
File diff suppressed because one or more lines are too long