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:
@@ -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 (1–30).</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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user