From ce6e2f430fe8eff0bbae079214d6d16399b7484b Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 10 Jun 2026 01:36:39 +0200 Subject: [PATCH] Phase 4 (part 2): higher-level ASIs in the builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../characters/builder/CreationWizard.tsx | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx index bd7a566..4add558 100644 --- a/src/features/characters/builder/CreationWizard.tsx +++ b/src/features/characters/builder/CreationWizard.tsx @@ -18,7 +18,7 @@ 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 { DND5E_SUBCLASSES } from '@/lib/rules/dnd5e/progression'; +import { DND5E_SUBCLASSES, dnd5eAsiLevels } from '@/lib/rules/dnd5e/progression'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; 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); }, [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>>({}); + 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(() => { 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. + // 5e: fold the chosen race's ability score increases + any level-up ASIs into the final scores. if (selectedOrigin?.asiBonuses) { for (const a of ABILITIES) out[a] += selectedOrigin.asiBonuses[a] ?? 0; } + for (const a of ABILITIES) out[a] += asiAlloc[a] ?? 0; 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 pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0; @@ -339,7 +347,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un const stepValid: Record = { Class: !!selectedClass, Origin: true, - Abilities: system === 'pf2e' ? true : poolValid && pbValid, + Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0, Skills: skills.length === Math.min(skillCount, skillOptions.length), Spells: 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.

)} + {asiCount > 0 && ( +
+
+ Ability Score Improvements at level {level} ({asiCount} ASI{asiCount > 1 ? 's' : ''}) + {asiRemaining} / {asiCount * 2} pts left +
+
+ {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 ( +
+ {ABILITY_ABBR[a]} {v > 0 ? +{v} : null} + + + + +
+ ); + })} +
+

Assign your improvements (+1 each, max 20 per ability). You can take feats instead later on the sheet.

+
+ )} {selectedClass && (() => { const tip = getClassTip(system, selectedClass.slug); const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0 @@ -578,8 +610,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
{ABILITIES.map((a, i) => { 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 value = base + racial; + const value = base + racial + asi; const isKey = selectedClass?.keyAbilities.includes(a); const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1; const fMax = method === 'pointbuy' ? POINT_BUY_MAX : 30; @@ -606,7 +639,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
{value} {formatModifier(abilityModifier(value))} - {racial ?
{base} base · +{racial} race
: null} + {(racial || asi) ?
{base} base{racial ? ` · +${racial} race` : ''}{asi ? ` · +${asi} ASI` : ''}
: null}
);