Builder now applies race/background mechanics; fix skill data + pf2e template softlock

The wizard collected race/background/skill choices but applied almost none of them.
Now (5e):
- Racial ASI is parsed and folded into the final ability scores (Human +1 each, etc.);
  every downstream stat (HP, AC, attacks, saves, DCs) reflects it. Shown on the
  Abilities step and Review.
- Background fixed skills and racial 'proficiency in the X skill' traits are granted
  as trained, on top of class picks.
- Skill step prefers the curated class table: canonical 5e skill lists (fixes the
  'and Survival'/'Animal Handling' tokenization that dropped skills) and the correct
  pf2e free-choice skill count (was inflated for ~13 classes).

UX:
- 'Ready-made hero' templates no longer softlock the Abilities step: scores above the
  point-buy cap switch to a new Manual entry mode (1-30) instead of '-Infinity / 27'.
- Standard-array/roll assignment swaps values on collision instead of disabling them.

Parsers extracted to builder/origin.ts with unit tests; buildCharacter gains
grantedSkills. Deferred: pf2e ancestry/background/class attribute boosts (needs the
boost-based generator), subclass/class-feature/feat mechanical effects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:24:41 +02:00
parent 35728fab3d
commit 4d354571a5
6 changed files with 195 additions and 22 deletions
@@ -15,6 +15,7 @@ import { newId } from '@/lib/ids';
import { formatModifier } from '@/lib/format';
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
import { briefOverview, dedupeByName } from './overview';
import { parseAsiBonuses, makeSkillResolver, parseBackgroundSkills, parseTraitSkills } from './origin';
import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
@@ -24,9 +25,15 @@ import { NumberField } from '@/components/ui/NumberField';
import { cn } from '@/lib/cn';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
type AbilityMethod = 'standard' | 'pointbuy' | 'roll';
type AbilityMethod = 'standard' | 'pointbuy' | 'roll' | 'manual';
interface Origin { name: string; desc: string; meta?: string; hp?: number; speed?: number }
interface Origin {
name: string; desc: string; meta?: string; hp?: number; speed?: number;
/** 5e racial ability score increases, applied to the final scores. */
asiBonuses?: Partial<AbilityScores>;
/** skill keys this origin grants as trained (background skills, racial proficiencies). */
skillGrants?: string[];
}
interface SpellOpt { name: string; level: number; meta: string }
/** A class's "what it plays like" tag, to help newcomers pick. */
@@ -88,19 +95,24 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
useEffect(() => {
let on = true;
if (system === '5e') {
const resolve = makeSkillResolver(sys.skills);
void loadRaces5e().then((rs) => {
if (!on) return;
const strip = (s: string) => s.replace(/\*{2,3}[^*]+\*{2,3}\s*/g, '').trim();
setOrigins(rs.map((r) => {
const desc = [r.asi, r.vision, r.traits].filter(Boolean).map(strip).join('\n\n');
const speedFt = /(\d+) feet/.exec(r.speed)?.[1];
const asiMatches = [...r.asi.matchAll(/(\w+) score (?:each )?increases? by (\d+)/g)];
const asiSummary = asiMatches.map(([, ability, n]) => ability && n ? `+${n} ${ability.slice(0, 3).toUpperCase()}` : '').filter(Boolean).join(', ');
const asiBonuses = parseAsiBonuses(r.asi ?? '');
const asiSummary = ABILITIES.filter((a) => asiBonuses[a]).map((a) => `+${asiBonuses[a]} ${ABILITY_ABBR[a]}`).join(', ');
const meta = [asiSummary, speedFt ? `${speedFt} ft` : ''].filter(Boolean).join(' · ');
return { name: r.name, desc, meta };
const skillGrants = parseTraitSkills(r.traits ?? '', resolve);
return { name: r.name, desc, meta, asiBonuses, ...(skillGrants.length ? { skillGrants } : {}) };
}));
});
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills }))));
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => {
const skillGrants = parseBackgroundSkills(b.skills ?? '', resolve);
return { name: b.name, desc: b.desc, meta: b.skills, ...(skillGrants.length ? { skillGrants } : {}) };
})));
} else {
void loadPf2e('ancestries').then((rs) => {
if (!on) return;
@@ -136,6 +148,8 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const selectedClass = classes.find((c) => c.slug === classSlug) ?? null;
const selectedOrigin = origins.find((o) => o.name === ancestry) ?? null;
const selectedBackground = backgrounds.find((b) => b.name === background) ?? null;
const classDef = selectedClass ? getClassDef(system, selectedClass.name) : undefined;
const isCaster = !!selectedClass && selectedClass.caster !== 'none';
// ---- abilities ----
@@ -152,28 +166,44 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const abilities = useMemo<AbilityScores>(() => {
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) {
for (const a of ABILITIES) out[a] += selectedOrigin.asiBonuses[a] ?? 0;
}
return out;
}, [usesPool, pool, assignment, pb]);
}, [usesPool, pool, assignment, pb, system, selectedOrigin]);
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0;
// ---- skills ----
const [skills, setSkills] = useState<string[]>([]);
const intMod = abilityModifier(abilities.int);
// Prefer the curated class table (canonical free-choice count) over the parsed
// data file, which over-counts pf2e classes and mangles some 5e skill names.
const baseSkillCount = classDef?.skillCount ?? selectedClass?.skillCount ?? 2;
const skillCount = selectedClass
? selectedClass.skillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0)
? baseSkillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0)
: 2;
const skillOptions = useMemo(() => {
if (!selectedClass || selectedClass.skillChoices.length === 0) return allSkillKeys;
// map class skill labels → our skill keys
const wanted = new Set(selectedClass.skillChoices.map((s) => s.toLowerCase()));
if (!selectedClass) return allSkillKeys;
// Curated class skill list (canonical for 5e); empty curated list = choose from all.
if (classDef && classDef.skillKeys.length > 0) return classDef.skillKeys;
if (selectedClass.skillChoices.length === 0) return allSkillKeys;
// Fallback to the parsed data, tolerating Oxford "and X" remnants.
const wanted = new Set(selectedClass.skillChoices.map((s) => s.toLowerCase().replace(/^and\s+/, '')));
const labelOf = (k: string) => sys.skills.find((s) => s.key === k)?.label.toLowerCase() ?? k.toLowerCase();
const matched = allSkillKeys.filter((k) => wanted.has(labelOf(k)) || wanted.has(k.toLowerCase()));
return matched.length ? matched : allSkillKeys;
}, [selectedClass, allSkillKeys, sys]);
}, [selectedClass, classDef, allSkillKeys, sys]);
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],
);
// ---- spells ----
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
const [spellQuery, setSpellQuery] = useState('');
@@ -195,9 +225,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
// ---- derived build ----
const built = useMemo(() => buildCharacter(system, {
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
...(grantedSkills.length ? { grantedSkills } : {}),
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}),
}), [system, selectedClass, level, abilities, skills, selectedOrigin]);
}), [system, selectedClass, level, abilities, skills, grantedSkills, selectedOrigin]);
// Rough "how many should I pick" suggestion for the Spells step. Not enforced —
// just guidance: a few cantrips plus a handful of starting leveled spells.
@@ -226,7 +257,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const applyTemplate = (t: { label: string; className: string; ability: AbilityScores }) => {
const c = classes.find((x) => x.name === t.className);
if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); }
setMethod('pointbuy');
// Templates can use scores above the point-buy cap (PF2e starts a key stat at 18),
// which would softlock the point-buy step ("-Infinity / 27"). Use manual entry then.
const fitsPointBuy = ABILITIES.every((a) => t.ability[a] >= POINT_BUY_MIN && t.ability[a] <= POINT_BUY_MAX);
setMethod(fitsPointBuy ? 'pointbuy' : 'manual');
setPb(ABILITIES.map((a) => t.ability[a]));
if (!name) setName(t.label.replace(/^[^A-Za-z]+/, ''));
};
@@ -395,13 +429,19 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{stepName === 'Abilities' && (
<div>
<div className="mb-3 flex gap-1">
{([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest']] as const).map(([m, label]) => (
<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>
))}
</div>
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}><RotateCcw size={13} aria-hidden /> Reroll</Button>}
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
{method === 'manual' && <p className="mb-3 text-sm text-muted">Enter each score directly (130).</p>}
{system === '5e' && selectedOrigin && Object.values(selectedOrigin.asiBonuses ?? {}).some(Boolean) && (
<p className="mb-2 text-xs text-accent">
{selectedOrigin.name} racial bonus ({ABILITIES.filter((a) => selectedOrigin.asiBonuses?.[a]).map((a) => `+${selectedOrigin.asiBonuses![a]} ${ABILITY_ABBR[a]}`).join(', ')}) is added to your final scores.
</p>
)}
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0
@@ -417,19 +457,35 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{usesPool && !poolValid && <p className="mb-2 text-sm text-warning">Assign each value to a different ability.</p>}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{ABILITIES.map((a, i) => {
const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
const racial = (system === '5e' && selectedOrigin?.asiBonuses?.[a]) || 0;
const base = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
const value = base + racial;
const isKey = selectedClass?.keyAbilities.includes(a);
const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1;
const fMax = method === 'pointbuy' ? POINT_BUY_MAX : 30;
return (
<div key={a} className={cn('rounded-lg border bg-surface p-3 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>
{usesPool ? (
<Select className="mt-1" aria-label={`${ABILITY_ABBR[a]} value`} value={assignment[i]} onChange={(e) => setAssignment((prev) => prev.map((v, j) => (j === i ? Number(e.target.value) : v)))}>
{pool.map((p, idx) => <option key={idx} value={idx} disabled={assignment.includes(idx) && assignment[i] !== idx}>{p}</option>)}
<Select className="mt-1" aria-label={`${ABILITY_ABBR[a]} value`} value={assignment[i]} onChange={(e) => {
const v = Number(e.target.value);
// Swap on collision so a value can be moved between abilities directly.
setAssignment((prev) => {
const next = [...prev];
const j = next.findIndex((x, k) => x === v && k !== i);
if (j !== -1) next[j] = next[i]!;
next[i] = v;
return next;
});
}}>
{pool.map((p, idx) => <option key={idx} value={idx}>{p}</option>)}
</Select>
) : (
<NumberField className="mt-1" value={pb[i]!} min={POINT_BUY_MIN} max={POINT_BUY_MAX} aria-label={`${ABILITY_ABBR[a]} score`} onChange={(v) => setPb((prev) => prev.map((x, j) => (j === i ? Math.max(POINT_BUY_MIN, Math.min(POINT_BUY_MAX, v)) : x)))} />
<NumberField className="mt-1" value={pb[i]!} min={fMin} max={fMax} aria-label={`${ABILITY_ABBR[a]} score`} onChange={(v) => setPb((prev) => prev.map((x, j) => (j === i ? Math.max(fMin, Math.min(fMax, v)) : x)))} />
)}
<div className="mt-1 text-xs text-accent">{formatModifier(abilityModifier(value))}</div>
<div className="mt-1 text-xs text-accent">
{racial ? <span className="text-muted">{base}+{racial} · </span> : null}{formatModifier(abilityModifier(value))}
</div>
</div>
);
})}
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { parseAsiBonuses, makeSkillResolver, parseBackgroundSkills, parseTraitSkills } from './origin';
import { getSystem } from '@/lib/rules';
const resolve = makeSkillResolver(getSystem('5e').skills);
describe('parseAsiBonuses', () => {
it('parses a single ability increase', () => {
expect(parseAsiBonuses('Your Dexterity score increases by 2.')).toEqual({ dex: 2 });
});
it('parses two abilities (Half-Orc)', () => {
expect(parseAsiBonuses('Your Strength score increases by 2, and your Constitution score increases by 1.'))
.toEqual({ str: 2, con: 1 });
});
it('parses Human "+1 to all"', () => {
expect(parseAsiBonuses('Your ability scores each increase by 1.'))
.toEqual({ str: 1, dex: 1, con: 1, int: 1, wis: 1, cha: 1 });
});
it('is empty for prose with no ASI', () => {
expect(parseAsiBonuses('You have darkvision.')).toEqual({});
});
});
describe('parseBackgroundSkills', () => {
it('takes both fixed skills (Acolyte)', () => {
expect(parseBackgroundSkills('Insight, Religion', resolve)).toEqual(['insight', 'religion']);
});
it('stops at a choose-one clause (Charlatan)', () => {
expect(parseBackgroundSkills('Deception, and either Culture, Insight, or Sleight of Hand.', resolve)).toEqual(['deception']);
});
it('handles "plus your choice" (Crime Syndicate Member)', () => {
expect(parseBackgroundSkills('Deception, plus your choice of one between Sleight of Hand or Stealth.', resolve)).toEqual(['deception']);
});
});
describe('parseTraitSkills', () => {
it('grants Perception from Elf Keen Senses', () => {
expect(parseTraitSkills('Keen Senses. You have proficiency in the Perception skill.', resolve)).toEqual(['perception']);
});
it('grants Intimidation from Half-Orc Menacing', () => {
expect(parseTraitSkills('Menacing. You gain proficiency in the Intimidation skill.', resolve)).toEqual(['intimidation']);
});
});
+58
View File
@@ -0,0 +1,58 @@
import type { AbilityKey, AbilityScores } from '@/lib/rules';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const ABILITY_BY_WORD: Record<string, AbilityKey> = {
strength: 'str', dexterity: 'dex', constitution: 'con',
intelligence: 'int', wisdom: 'wis', charisma: 'cha',
};
/**
* Parse a 5e race's ASI prose into structured ability bonuses. Handles the Human
* "Your ability scores each increase by 1" case and the usual "Your X score
* increases by N[, and your Y score increases by M]" form.
*/
export function parseAsiBonuses(asi: string): Partial<AbilityScores> {
const out: Partial<AbilityScores> = {};
const each = /ability scores?\s+each\s+increase(?:s)?\s+by\s+(\d+)/i.exec(asi);
if (each) {
const n = Number(each[1]);
for (const a of ABILITIES) out[a] = (out[a] ?? 0) + n;
return out;
}
for (const m of asi.matchAll(/(strength|dexterity|constitution|intelligence|wisdom|charisma)\s+score\s+(?:each\s+)?increase(?:s)?\s+by\s+(\d+)/gi)) {
const key = ABILITY_BY_WORD[m[1]!.toLowerCase()];
if (key) out[key] = (out[key] ?? 0) + Number(m[2]);
}
return out;
}
/** A "skill label/key → key" resolver for the active system. */
export function makeSkillResolver(skills: readonly { key: string; label: string }[]): (name: string) => string | undefined {
const byLabel = new Map<string, string>();
for (const s of skills) { byLabel.set(s.label.toLowerCase(), s.key); byLabel.set(s.key.toLowerCase(), s.key); }
return (name) => byLabel.get(name.trim().toLowerCase());
}
/** Fixed skill proficiencies a 5e background grants (ignores "either/or/choice" clauses). */
export function parseBackgroundSkills(skills: string, resolve: (n: string) => string | undefined): string[] {
const out: string[] = [];
for (let token of skills.split(',')) {
token = token.replace(/\b(and|plus)\b/gi, '').replace(/\.$/, '').trim();
if (!token) continue;
if (/either|\bor\b|choice|between/i.test(token)) break; // a choose-one clause — stop here
const key = resolve(token);
if (key && !out.includes(key)) out.push(key);
}
return out;
}
/** Skill proficiencies granted by racial traits ("proficiency in the X skill"). */
export function parseTraitSkills(traits: string, resolve: (n: string) => string | undefined): string[] {
const out: string[] = [];
for (const m of traits.matchAll(/proficiency in the ([A-Za-z][A-Za-z ]*?) skill/gi)) {
const key = resolve(m[1]!);
if (key && !out.includes(key)) out.push(key);
}
return out;
}