Bug hunt + 5e/PF2e parity + character-build UX overhaul
Audited every calculation (26-agent adversarial workflow + hand-verification). Core math was sound; fixed real bugs, closed parity gaps, and overhauled the character-build UX. 396 -> 424 tests; lint clean; build green. Correctness fixes: - Heavy armor no longer applies a negative DEX penalty to AC (3 paths + default) - 5e Exhaustion 4 (HP-max halved) implemented via deriveEffectiveMaxHp + badge - PF2e dazzled no longer makes a creature easier to hit - PF2e immunity 'all' zeroes all damage (was misfiled as a condition immunity) - Director schema bounds; PF2e spell-save basis from the `basic` flag; 5e disadvantage-only save guard; remove bogus Concentrating, add Broken/Quickened - Fix 3 lint errors (useCloud hooks naming, useless escape, explicit any) PF2e survival subsystem (parity with 5e death saves): - mechanics/dying.ts (maxDying/isDead/knockOut/applyRecovery/pf2eRestDecay) - DefensesSection knock-out + recovery-check + death UI; rest-decay in applyRest; drained HP reduction Character-build UX: - Persisted abilityBuild (base + per-source contributions); sheet & wizard show every ability source; higher-level PF2e gains L5/10/15/20 boosts - Creation surfaces granted skills (incl. new PF2e background skill parsing) - LevelUpModal enumerates features gained + prompts every owed choice (subclass, feats, fighting style, ...) via collectChoices/collectFeatures/subclassPrompt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,9 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
|
||||
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||
import { allArmorMechanics5e, type ArmorMechanics } from '@/lib/mechanics';
|
||||
import type { AbilityKey, AbilityScores, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
|
||||
import { getSystem, ABILITY_ABBR, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, computeAbilities } from '@/lib/rules';
|
||||
import { allArmorMechanics5e, deriveEffectiveMaxHp, type ArmorMechanics } from '@/lib/mechanics';
|
||||
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
|
||||
import { encodeClaim } from '@/lib/sync/playerLink';
|
||||
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
|
||||
@@ -104,6 +104,21 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
...(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 initiative = sys.initiativeModifier(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">
|
||||
{ABILITIES.map((a) => {
|
||||
const mod = sys.abilityModifier(c.abilities[a]);
|
||||
const breakdown = abilityBuildView[a];
|
||||
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 className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div>
|
||||
@@ -231,12 +247,21 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
min={1}
|
||||
max={30}
|
||||
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>
|
||||
<p className="mt-1 text-[10px] text-muted">Hover a score to see its sources. Editing a score records a “Manual” adjustment.</p>
|
||||
</section>
|
||||
|
||||
{/* Perception (pf2e core proficiency — advances to legendary; drives initiative) */}
|
||||
@@ -359,7 +384,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
</section>
|
||||
|
||||
{genScores && (
|
||||
<AbilityGenModal onClose={() => setGenScores(false)} onApply={(abilities) => update({ abilities })} />
|
||||
<AbilityGenModal onClose={() => setGenScores(false)} onApply={applyGeneratedScores} />
|
||||
)}
|
||||
{levelUp && (
|
||||
<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);
|
||||
};
|
||||
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 (
|
||||
<div className="rounded-xl border border-line bg-panel p-4 text-center">
|
||||
<div className="smallcaps">Hit Points</div>
|
||||
<div className="my-1 font-mono font-display text-2xl font-semibold text-ink">
|
||||
{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>}
|
||||
</div>
|
||||
{eff.reduction > 0 && (
|
||||
<div className="mb-1 text-[11px] text-warning" title={eff.reasons.join('; ')}>{eff.reasons.join(' · ')}</div>
|
||||
)}
|
||||
<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 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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
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 {
|
||||
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) => ({
|
||||
name: String(b.name),
|
||||
desc: briefOverview(String((b.description ?? b.text) ?? '')),
|
||||
backgroundBoosts: parseBackgroundBoosts(b.attribute),
|
||||
})))));
|
||||
const resolvePf2e = makeSkillResolver(sys.skills);
|
||||
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => {
|
||||
// PF2e backgrounds grant Trained in listed skills (Lore variants don't map to the
|
||||
// 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; };
|
||||
// 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 });
|
||||
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 });
|
||||
// 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 };
|
||||
}, [system, selectedOrigin, selectedBackground, classDef, selectedClass]);
|
||||
}, [system, selectedOrigin, selectedBackground, classDef, selectedClass, level]);
|
||||
|
||||
const [boostPicks, setBoostPicks] = useState<Record<string, AbilityKey>>({});
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
if (!boostSlots) return;
|
||||
const keyOpts = (classDef?.keyAbilities ?? []) as AbilityKey[];
|
||||
const favored = keyOpts[0] ?? 'str';
|
||||
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> = {};
|
||||
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))
|
||||
?? order.find((o) => !u.has(o)) ?? s.options[0] ?? favored;
|
||||
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 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 ----
|
||||
const [skills, setSkills] = useState<string[]>([]);
|
||||
const intMod = abilityModifier(abilities.int);
|
||||
@@ -277,11 +308,22 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
const toggleSkill = (key: string) =>
|
||||
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).
|
||||
const grantedSkills = useMemo(
|
||||
() => (system === '5e' ? [...(selectedOrigin?.skillGrants ?? []), ...(selectedBackground?.skillGrants ?? [])] : []),
|
||||
[system, selectedOrigin, selectedBackground],
|
||||
);
|
||||
// Skill proficiencies granted (trained) by ancestry/race + background — shown to the
|
||||
// user so they don't waste free picks, and merged on top of class picks at build time.
|
||||
const grantedSkillSources = useMemo(() => {
|
||||
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 ----
|
||||
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
|
||||
@@ -348,7 +390,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
Class: !!selectedClass,
|
||||
Origin: true,
|
||||
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,
|
||||
Details: 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)) }));
|
||||
await charactersRepo.update(created.id, {
|
||||
...built,
|
||||
abilityBuild: buildAbilityBuild(),
|
||||
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
|
||||
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
|
||||
spellcasting: { ...built.spellcasting, spells },
|
||||
@@ -651,8 +694,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
|
||||
{stepName === 'Skills' && (
|
||||
<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>
|
||||
{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 && (() => {
|
||||
const tip = getClassTip(system, selectedClass.slug);
|
||||
return tip?.skillSuggestions ? (
|
||||
@@ -663,7 +725,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
) : null;
|
||||
})()}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{skillOptions.map((key) => {
|
||||
{freeSkillOptions.map((key) => {
|
||||
const checked = skills.includes(key);
|
||||
const disabled = !checked && skills.length >= skillCount;
|
||||
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 { 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';
|
||||
|
||||
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) {
|
||||
const d = c.defenses;
|
||||
const setD = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
|
||||
|
||||
return (
|
||||
<SheetSection title="Status & Defenses">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{c.system === '5e' ? (
|
||||
<>
|
||||
<Field label="Death Saves">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1 text-xs text-success">
|
||||
Successes
|
||||
<Pips count={3} filled={d.deathSaves.successes} label="Death save success" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, successes: n } })} />
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-danger">
|
||||
Failures
|
||||
<Pips count={3} filled={d.deathSaves.failures} label="Death save failure" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, failures: n } })} />
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Inspiration">
|
||||
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
|
||||
{d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'}
|
||||
</Button>
|
||||
</Field>
|
||||
<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" />
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
{c.system === '5e' ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Death Saves">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1 text-xs text-success">
|
||||
Successes
|
||||
<Pips count={3} filled={d.deathSaves.successes} label="Death save success" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, successes: n } })} />
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-danger">
|
||||
Failures
|
||||
<Pips count={3} filled={d.deathSaves.failures} label="Death save failure" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, failures: n } })} />
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Inspiration">
|
||||
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
|
||||
{d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'}
|
||||
</Button>
|
||||
</Field>
|
||||
<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" />
|
||||
{d.exhaustion >= 4 && <span className="ml-2 text-[11px] text-warning">Max HP halved</span>}
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<Pf2eDefenses c={c} update={update} />
|
||||
)}
|
||||
</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 { normalizeClassMirror, totalLevel } from '@/lib/schemas';
|
||||
import {
|
||||
abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
|
||||
planLevelUp, applyIncreases, bumpRank, getClassDef,
|
||||
collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature,
|
||||
} from '@/lib/rules';
|
||||
import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression';
|
||||
import { rollDice } from '@/lib/dice/notation';
|
||||
import { createRng } from '@/lib/rng';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Input';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
import { LevelUpAdvisor } from './LevelUpAdvisor';
|
||||
|
||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||
@@ -24,9 +25,12 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
const is5e = character.system === '5e';
|
||||
|
||||
// 5e: level up a SPECIFIC class (or multiclass into a new one). pf2e stays single-class.
|
||||
const classList: ClassEntry[] = character.classes.length
|
||||
? character.classes
|
||||
: character.className ? [{ className: character.className, level: character.level }] : [];
|
||||
const classList: ClassEntry[] = useMemo(
|
||||
() => (character.classes.length
|
||||
? character.classes
|
||||
: character.className ? [{ className: character.className, level: character.level }] : []),
|
||||
[character.classes, character.className, character.level],
|
||||
);
|
||||
const NEW = '__new__';
|
||||
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');
|
||||
@@ -55,6 +59,72 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
// Skill increase (pf2e): one skill to bump.
|
||||
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 apply = () => {
|
||||
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 (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> = {
|
||||
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
|
||||
abilities,
|
||||
classes: nextClassesFinal,
|
||||
...normalizeClassMirror({ classes: nextClassesFinal }),
|
||||
choices: merged,
|
||||
};
|
||||
|
||||
if (is5e) {
|
||||
// Bump the chosen class (or add a new one), re-derive the mirrors + combined slots.
|
||||
const nextClasses: ClassEntry[] = levelClass === NEW
|
||||
? [...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);
|
||||
// Re-derive combined spell slots, preserving current values.
|
||||
const derived = dnd5eClassesSlots(nextClassesFinal);
|
||||
const slots = derived.slots.map((r) => {
|
||||
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 };
|
||||
@@ -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) };
|
||||
else delete spellcasting.pact;
|
||||
patch.spellcasting = spellcasting;
|
||||
} else {
|
||||
patch.level = plan.nextLevel;
|
||||
if (plan.slots) patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) };
|
||||
} else if (plan.slots) {
|
||||
patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) };
|
||||
}
|
||||
|
||||
if (skillInc && skillKey) {
|
||||
@@ -209,9 +294,56 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{plan.choices.filter((c) => c.kind === 'feat').map((c) => (
|
||||
<p key={c.label} className="text-sm text-muted">• {c.label}</p>
|
||||
))}
|
||||
{/* What you gain at this level (read-only) */}
|
||||
{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 */}
|
||||
<LevelUpAdvisor campaign={{ id: character.campaignId, name: '', system: character.system, description: '', createdAt: '', updatedAt: '' } as Campaign} character={character} />
|
||||
|
||||
Reference in New Issue
Block a user