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