Phase 4 (part 2): higher-level ASIs in the builder

Building a character above level 1 now grants its Ability Score Improvements. The wizard
computes the ASI count for the input level + class (4/8/12/16/19, plus Fighter's 6/14 and
Rogue's 10) and shows an allocation panel (+1 each, capped at 20, can't over-spend, gated
on Next). Folds into the final scores alongside race bonuses; the ability card shows the
'base · +race · +ASI' breakdown.

Verified: a level-8 Fighter correctly offers 3 ASIs (levels 4, 6, 8 = 6 points). Build OK,
339 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 01:36:39 +02:00
parent f8ad8cb681
commit ce6e2f430f
@@ -18,7 +18,7 @@ import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
import { briefOverview, dedupeByName } from './overview'; import { briefOverview, dedupeByName } from './overview';
import { parseAsiBonuses, makeSkillResolver, parseBackgroundSkills, parseTraitSkills } from './origin'; import { parseAsiBonuses, makeSkillResolver, parseBackgroundSkills, parseTraitSkills } from './origin';
import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression'; import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression';
import { DND5E_SUBCLASSES } from '@/lib/rules/dnd5e/progression'; import { DND5E_SUBCLASSES, dnd5eAsiLevels } from '@/lib/rules/dnd5e/progression';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex'; import { Badge } from '@/components/ui/Codex';
@@ -233,16 +233,24 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
return pf2eApplyBoosts([...boostSlots.fixed, ...chosen], boostSlots.flaw); return pf2eApplyBoosts([...boostSlots.fixed, ...chosen], boostSlots.flaw);
}, [boostSlots, boostPicks]); }, [boostSlots, boostPicks]);
// 5e: ability-score improvements gained by the input level for this class (4/8/12/16/19, +class extras).
const asiCount = system === '5e' && selectedClass ? dnd5eAsiLevels(selectedClass.name).filter((l) => l <= level).length : 0;
const [asiAlloc, setAsiAlloc] = useState<Partial<Record<AbilityKey, number>>>({});
useEffect(() => { setAsiAlloc({}); }, [classSlug, level, system]); // reset when class/level changes
const asiUsed = ABILITIES.reduce((n, a) => n + (asiAlloc[a] ?? 0), 0);
const asiRemaining = asiCount * 2 - asiUsed;
const abilities = useMemo<AbilityScores>(() => { const abilities = useMemo<AbilityScores>(() => {
if (system === 'pf2e') return pf2eAbilities ?? { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }; if (system === 'pf2e') return pf2eAbilities ?? { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
const out = {} as AbilityScores; const out = {} as AbilityScores;
ABILITIES.forEach((a, i) => { out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; }); 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. // 5e: fold the chosen race's ability score increases + any level-up ASIs into the final scores.
if (selectedOrigin?.asiBonuses) { if (selectedOrigin?.asiBonuses) {
for (const a of ABILITIES) out[a] += selectedOrigin.asiBonuses[a] ?? 0; for (const a of ABILITIES) out[a] += selectedOrigin.asiBonuses[a] ?? 0;
} }
for (const a of ABILITIES) out[a] += asiAlloc[a] ?? 0;
return out; return out;
}, [system, pf2eAbilities, usesPool, pool, assignment, pb, selectedOrigin]); }, [system, pf2eAbilities, usesPool, pool, assignment, pb, selectedOrigin, asiAlloc]);
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length; const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0; const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0;
@@ -339,7 +347,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const stepValid: Record<string, boolean> = { const stepValid: Record<string, boolean> = {
Class: !!selectedClass, Class: !!selectedClass,
Origin: true, Origin: true,
Abilities: system === 'pf2e' ? true : poolValid && pbValid, Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0,
Skills: skills.length === Math.min(skillCount, skillOptions.length), Skills: skills.length === Math.min(skillCount, skillOptions.length),
Spells: true, Spells: true,
Details: true, Details: true,
@@ -562,6 +570,30 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
{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. {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> </p>
)} )}
{asiCount > 0 && (
<div className="mb-3 rounded-lg border border-accent/30 bg-accent/5 p-2">
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-ink">Ability Score Improvements at level {level} <span className="text-muted">({asiCount} ASI{asiCount > 1 ? 's' : ''})</span></span>
<span className={cn('font-medium', asiRemaining < 0 ? 'text-danger' : 'text-accent')}>{asiRemaining} / {asiCount * 2} pts left</span>
</div>
<div className="grid grid-cols-3 gap-1 sm:grid-cols-6">
{ABILITIES.map((a) => {
const v = asiAlloc[a] ?? 0;
const base = (usesPool ? (pool[assignment[ABILITIES.indexOf(a)]!] ?? 10) : pb[ABILITIES.indexOf(a)]!) + (selectedOrigin?.asiBonuses?.[a] ?? 0);
return (
<div key={a} className="flex items-center justify-between rounded border border-line bg-surface px-1.5 py-1 text-xs">
<span className="smallcaps">{ABILITY_ABBR[a]} {v > 0 ? <span className="text-accent">+{v}</span> : null}</span>
<span className="flex gap-0.5">
<button type="button" disabled={v <= 0} onClick={() => setAsiAlloc((p) => ({ ...p, [a]: Math.max(0, (p[a] ?? 0) - 1) }))} className="rounded px-1 text-muted hover:text-ink disabled:opacity-30"></button>
<button type="button" disabled={asiRemaining <= 0 || base + v >= 20} onClick={() => setAsiAlloc((p) => ({ ...p, [a]: (p[a] ?? 0) + 1 }))} className="rounded px-1 text-muted hover:text-ink disabled:opacity-30">+</button>
</span>
</div>
);
})}
</div>
<p className="mt-1 text-[10px] text-muted">Assign your improvements (+1 each, max 20 per ability). You can take feats instead later on the sheet.</p>
</div>
)}
{selectedClass && (() => { {selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug); const tip = getClassTip(system, selectedClass.slug);
const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0 const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0
@@ -578,8 +610,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{ABILITIES.map((a, i) => { {ABILITIES.map((a, i) => {
const racial = (system === '5e' && selectedOrigin?.asiBonuses?.[a]) || 0; const racial = (system === '5e' && selectedOrigin?.asiBonuses?.[a]) || 0;
const asi = (system === '5e' && asiAlloc[a]) || 0;
const base = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!; const base = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
const value = base + racial; const value = base + racial + asi;
const isKey = selectedClass?.keyAbilities.includes(a); const isKey = selectedClass?.keyAbilities.includes(a);
const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1; const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1;
const fMax = method === 'pointbuy' ? POINT_BUY_MAX : 30; const fMax = method === 'pointbuy' ? POINT_BUY_MAX : 30;
@@ -606,7 +639,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
<div className="mt-1"> <div className="mt-1">
<span className="font-display text-sm font-semibold text-ink">{value}</span> <span className="font-display text-sm font-semibold text-ink">{value}</span>
<span className="ml-1 text-xs text-accent">{formatModifier(abilityModifier(value))}</span> <span className="ml-1 text-xs text-accent">{formatModifier(abilityModifier(value))}</span>
{racial ? <div className="text-[10px] leading-tight text-muted">{base} base · +{racial} race</div> : null} {(racial || asi) ? <div className="text-[10px] leading-tight text-muted">{base} base{racial ? ` · +${racial} race` : ''}{asi ? ` · +${asi} ASI` : ''}</div> : null}
</div> </div>
</div> </div>
); );