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>
)}
+1
View File
@@ -19,5 +19,6 @@ export const SYSTEM_OPTIONS: { id: SystemId; label: string }[] = [
export * from './types';
export * from './progression';
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/abilities';
export { applyRest } from './rest';
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw } from './abilities';
describe('pf2eApplyBoosts', () => {
it('each boost adds +2 below 18', () => {
// Human Fighter: ancestry 2 free (str, con), background (str, free dex), class str, 4 free (str, con, dex, wis)
const scores = pf2eApplyBoosts(['str', 'con', 'str', 'dex', 'str', 'str', 'con', 'dex', 'wis']);
expect(scores.str).toBe(18); // 10 +2+2+2+2 (four boosts) = 18
expect(scores.con).toBe(14); // two boosts
expect(scores.dex).toBe(14); // two boosts
expect(scores.wis).toBe(12); // one boost
expect(scores.int).toBe(10);
expect(scores.cha).toBe(10);
});
it('applies a flaw before boosts', () => {
const scores = pf2eApplyBoosts(['con'], 'cha');
expect(scores.cha).toBe(8); // flaw -2
expect(scores.con).toBe(12);
});
it('a 5th boost on one ability only adds +1 (already at 18)', () => {
const scores = pf2eApplyBoosts(['str', 'str', 'str', 'str', 'str']);
expect(scores.str).toBe(19); // 18 then +1
});
});
describe('parseAncestryBoosts', () => {
it('reads fixed boosts plus a Free token (Dwarf)', () => {
expect(parseAncestryBoosts(['Constitution', 'Wisdom', 'Free'])).toEqual({ fixed: ['con', 'wis'], free: 1 });
});
it('reads "Two free ability boosts" (Human)', () => {
expect(parseAncestryBoosts(['Two free ability boosts'])).toEqual({ fixed: [], free: 2 });
});
it('tolerates junk', () => {
expect(parseAncestryBoosts(undefined)).toEqual({ fixed: [], free: 0 });
});
});
describe('parseBackgroundBoosts', () => {
it('lists the choose-one options and grants one free boost (Acolyte)', () => {
expect(parseBackgroundBoosts(['Intelligence', 'Wisdom'])).toEqual({ options: ['int', 'wis'], free: 1 });
});
});
describe('parseFlaw', () => {
it('reads a flaw string or array', () => {
expect(parseFlaw('Charisma')).toBe('cha');
expect(parseFlaw(['Strength'])).toBe('str');
expect(parseFlaw(undefined)).toBeUndefined();
});
});
+71
View File
@@ -0,0 +1,71 @@
import type { AbilityKey, AbilityScores } from '../types';
/**
* PF2e ability scores are built from boosts, not a 5e-style array. Every score
* starts at 10; an ancestry flaw subtracts 2; each boost adds +2 while the score
* is below 18, otherwise +1. Boosts are applied in canonical order (ancestry →
* background → class → free) so the rare >18 case resolves correctly.
*/
const ABIL_WORD: Record<string, AbilityKey> = {
strength: 'str', dexterity: 'dex', constitution: 'con',
intelligence: 'int', wisdom: 'wis', charisma: 'cha',
};
const NUM_WORD: Record<string, number> = { one: 1, two: 2, three: 3, four: 4 };
export function abilityWord(word: string): AbilityKey | undefined {
return ABIL_WORD[word.trim().toLowerCase()];
}
function base10(): AbilityScores {
return { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
}
/** Apply an ordered list of boosts (and an optional flaw) to a base-10 spread. */
export function pf2eApplyBoosts(boosts: readonly AbilityKey[], flaw?: AbilityKey | null): AbilityScores {
const s = base10();
if (flaw) s[flaw] -= 2;
for (const b of boosts) s[b] += s[b] < 18 ? 2 : 1;
return s;
}
/**
* Parse an ancestry `attribute` array into the fixed boosts and the number of free
* boosts it grants. Handles specific abilities ("Constitution"), the "Free" token,
* and prose like "Two free ability boosts".
*/
export function parseAncestryBoosts(attribute: unknown): { fixed: AbilityKey[]; free: number } {
const arr = Array.isArray(attribute) ? attribute.map(String) : [];
const fixed: AbilityKey[] = [];
let free = 0;
for (const raw of arr) {
const k = abilityWord(raw);
if (k) { fixed.push(k); continue; }
const m = /(\bone\b|\btwo\b|\bthree\b|\bfour\b|\d+)\s+free/i.exec(raw);
if (m) {
const word = m[1]!.toLowerCase();
const n = NUM_WORD[word] ?? Number(word);
free += Number.isFinite(n) ? n : 1;
} else if (/free/i.test(raw)) free += 1;
}
return { fixed, free };
}
/** Parse an ancestry flaw field (legacy ancestries) into a single ability, if any. */
export function parseFlaw(flaw: unknown): AbilityKey | undefined {
if (Array.isArray(flaw)) { for (const f of flaw) { const k = abilityWord(String(f)); if (k) return k; } return undefined; }
if (typeof flaw === 'string') return abilityWord(flaw);
return undefined;
}
/**
* Parse a background `attribute` array. A PF2e background grants one boost chosen
* from its listed abilities plus one free boost; the data lists the choose-from
* options. Returns the options and the free-boost count (1).
*/
export function parseBackgroundBoosts(attribute: unknown): { options: AbilityKey[]; free: number } {
const arr = Array.isArray(attribute) ? attribute.map(String) : [];
const options = arr.map(abilityWord).filter((k): k is AbilityKey => !!k);
return { options, free: options.length ? 1 : 0 };
}