Merge: bug hunt + 5e/PF2e parity + character-build UX overhaul
This commit is contained in:
@@ -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 sheet’s level-up flow.'
|
||||||
|
: 'Some classes gain extra skill/expertise picks as they level — apply those from the character sheet’s 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 (
|
||||||
|
|||||||
@@ -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 (0–6)">
|
||||||
<Field label="Exhaustion (0–6)">
|
<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 (0–4)">
|
)}
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user