PF2e builder: boost-based ability generation (replaces incorrect 5e arrays)

PF2e characters were generated with the 5e method (standard array / 27-pt point-buy /
4d6) and never applied ancestry/background/class attribute boosts, so every derived stat
was wrong. The PF2e ability step is now boost-based:

- Every score starts at 10; ancestry fixed boosts + flaw, ancestry free boost(s),
  background (choose-one + free), class key ability, and 4 free boosts are applied with
  the +2-below-18 / +1 rule (pf2eApplyBoosts).
- Boost data parsed from the bundled ancestry/background fields (parseAncestryBoosts,
  parseBackgroundBoosts, parseFlaw). Defaults are a legal, class-favoring assignment;
  same-source duplicate boosts are disabled in the pickers (PF2e doesn't allow them).
- Live ability cards show the computed scores. 5e keeps array/point-buy/manual.

Verified in-app (Dwarf Alchemist Acolyte → STR 14/DEX 12/CON 14/INT 18/WIS 12/CHA 8,
flaw + fixed boosts correct). Pure core has 8 unit tests. 5e path unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 19:08:02 +02:00
parent f7e1c43612
commit 9c53f74124
5 changed files with 237 additions and 8 deletions
@@ -5,6 +5,7 @@ import { type Campaign, type Character, type SpellEntry, newSpellEntry } from '@
import { charactersRepo } from '@/lib/db/repositories';
import {
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw,
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
} from '@/lib/rules';
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
@@ -33,6 +34,12 @@ interface Origin {
asiBonuses?: Partial<AbilityScores>;
/** skill keys this origin grants as trained (background skills, racial proficiencies). */
skillGrants?: string[];
/** pf2e ancestry boosts: specific abilities + count of free boosts. */
ancestryBoosts?: { fixed: AbilityKey[]; free: number };
/** pf2e legacy ancestry flaw (-2), if any. */
ancestryFlaw?: AbilityKey;
/** pf2e background boosts: choose-one options + free-boost count. */
backgroundBoosts?: { options: AbilityKey[]; free: number };
}
interface SpellOpt { name: string; level: number; meta: string }
@@ -119,19 +126,27 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
setOrigins(dedupeByName(rs.map((r) => {
const hp = typeof r.hp === 'number' ? r.hp : undefined;
const speed = (r.speed as { land?: number } | undefined)?.land;
const attrArr = Array.isArray(r.attribute) ? (r.attribute as string[]).filter((a) => a !== 'Free') : [];
const attrSummary = attrArr.map((a) => `+${String(a).slice(0, 3)}`).join('/');
const meta = [attrSummary, hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · ');
const ancestryBoosts = parseAncestryBoosts(r.attribute);
const ancestryFlaw = parseFlaw((r as Record<string, unknown>).attribute_flaw ?? (r as Record<string, unknown>).flaw);
const fixedSummary = ancestryBoosts.fixed.map((a) => `+${ABILITY_ABBR[a]}`).join('/');
const freeSummary = ancestryBoosts.free ? `+${ancestryBoosts.free} free` : '';
const meta = [fixedSummary, freeSummary, ancestryFlaw ? `${ABILITY_ABBR[ancestryFlaw]}` : '', hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · ');
return {
name: String(r.name),
desc: briefOverview(String((r.summary ?? r.text) ?? '')),
...(hp !== undefined ? { hp } : {}),
...(speed ? { speed } : {}),
ancestryBoosts,
...(ancestryFlaw ? { ancestryFlaw } : {}),
meta,
};
})));
});
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => ({ name: String(b.name), desc: briefOverview(String((b.description ?? b.text) ?? '')) })))));
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),
})))));
}
return () => { on = false; };
// sys.skills is a stable singleton keyed by `system`, so `system` covers it.
@@ -164,15 +179,59 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
if (m === 'standard') { setPool([...STANDARD_ARRAY]); setAssignment(ABILITIES.map((_, i) => i)); }
if (m === 'roll') { setPool(rollAbilityScores(createRng())); setAssignment(ABILITIES.map((_, i) => i)); }
};
// ---- pf2e ability boosts (start at 10; ancestry/background/class/free boosts) ----
const boostSlots = useMemo(() => {
if (system !== 'pf2e') return null;
const anc = selectedOrigin?.ancestryBoosts ?? { fixed: [], free: 0 };
const bg = selectedBackground?.backgroundBoosts ?? { options: [], free: 0 };
const keyOpts = (classDef?.keyAbilities ?? selectedClass?.keyAbilities ?? []) as AbilityKey[];
const slots: { id: string; label: string; options: AbilityKey[] }[] = [];
for (let i = 0; i < anc.free; i++) slots.push({ id: `anc-free-${i}`, label: 'Ancestry free boost', options: ABILITIES });
if (bg.options.length) slots.push({ id: 'bg-choice', label: 'Background boost', options: bg.options });
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 });
return { fixed: anc.fixed, flaw: selectedOrigin?.ancestryFlaw, slots };
}, [system, selectedOrigin, selectedBackground, classDef, selectedClass]);
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');
// 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 next: Record<string, AbilityKey> = {};
for (const s of boostSlots.slots) {
const u = used[boostSource(s.id)]!;
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;
u.add(pick);
}
setBoostPicks(next);
}, [boostSlots, classDef]);
const pf2eAbilities = useMemo(() => {
if (!boostSlots) return null;
const chosen = boostSlots.slots.map((s) => boostPicks[s.id]).filter((b): b is AbilityKey => !!b);
return pf2eApplyBoosts([...boostSlots.fixed, ...chosen], boostSlots.flaw);
}, [boostSlots, boostPicks]);
const abilities = useMemo<AbilityScores>(() => {
if (system === 'pf2e') return pf2eAbilities ?? { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
const out = {} as AbilityScores;
ABILITIES.forEach((a, i) => { out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
// 5e: fold the chosen race's ability score increases into the final scores.
if (system === '5e' && selectedOrigin?.asiBonuses) {
if (selectedOrigin?.asiBonuses) {
for (const a of ABILITIES) out[a] += selectedOrigin.asiBonuses[a] ?? 0;
}
return out;
}, [usesPool, pool, assignment, pb, system, selectedOrigin]);
}, [system, pf2eAbilities, usesPool, pool, assignment, pb, selectedOrigin]);
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0;
@@ -269,7 +328,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const stepValid: Record<string, boolean> = {
Class: !!selectedClass,
Origin: true,
Abilities: poolValid && pbValid,
Abilities: system === 'pf2e' ? true : poolValid && pbValid,
Skills: skills.length === Math.min(skillCount, skillOptions.length),
Spells: true,
Review: true,
@@ -430,6 +489,51 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{stepName === 'Abilities' && (
<div>
{system === 'pf2e' && boostSlots && (
<div className="space-y-3">
<p className="text-sm text-muted">PF2e scores are built from <span className="text-ink">boosts</span>: each starts at 10, and a boost adds +2 (or +1 once a score reaches 18). Assign your free boosts below.</p>
{(boostSlots.fixed.length > 0 || boostSlots.flaw) && (
<div className="flex flex-wrap items-center gap-1 text-xs">
<span className="text-muted">{selectedOrigin?.name ?? 'Ancestry'}:</span>
{boostSlots.fixed.map((a, i) => <span key={i} className="rounded bg-accent/10 px-1.5 py-0.5 font-medium text-accent">+{ABILITY_ABBR[a]}</span>)}
{boostSlots.flaw && <span className="rounded bg-danger/10 px-1.5 py-0.5 font-medium text-danger">{ABILITY_ABBR[boostSlots.flaw]}</span>}
<span className="text-faint">(fixed)</span>
</div>
)}
{boostSlots.slots.length > 0 && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{boostSlots.slots.map((s) => {
// Abilities already taken by OTHER slots in the same source (can't double-boost).
const taken = new Set<AbilityKey>(boostSource(s.id) === 'ancestry' ? boostSlots.fixed : []);
for (const o of boostSlots.slots) {
if (o.id !== s.id && boostSource(o.id) === boostSource(s.id) && boostPicks[o.id]) taken.add(boostPicks[o.id]!);
}
return (
<label key={s.id} className="text-xs text-muted">
{s.label}
<Select className="mt-0.5" aria-label={s.label} value={boostPicks[s.id] ?? s.options[0]} onChange={(e) => setBoostPicks((p) => ({ ...p, [s.id]: e.target.value as AbilityKey }))}>
{s.options.map((o) => <option key={o} value={o} disabled={taken.has(o) && boostPicks[s.id] !== o}>{ABILITY_ABBR[o]}{taken.has(o) ? ' ·' : ''}</option>)}
</Select>
</label>
);
})}
</div>
)}
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{ABILITIES.map((a) => {
const isKey = selectedClass?.keyAbilities.includes(a);
return (
<div key={a} className={cn('rounded-lg border bg-surface p-2 text-center', isKey ? 'border-accent/60' : 'border-line')}>
<div className="flex items-center justify-center gap-0.5 smallcaps">{ABILITY_ABBR[a]}{isKey && <Star size={10} className="text-accent" aria-hidden />}</div>
<div className="font-display text-lg font-semibold text-ink">{abilities[a]}</div>
<div className="text-xs text-accent">{formatModifier(abilityModifier(abilities[a]))}</div>
</div>
);
})}
</div>
</div>
)}
{system !== 'pf2e' && (<>
<div className="mb-3 flex flex-wrap gap-1">
{([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest'], ['manual', 'Manual']] as const).map(([m, label]) => (
<button key={m} onClick={() => chooseMethod(m)} className={cn('rounded-md px-3 py-1.5 text-sm', method === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>{label}</button>
@@ -491,6 +595,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
);
})}
</div>
</>)}
</div>
)}