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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user