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:
@@ -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<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>(() => {
|
||||
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<string, boolean> = {
|
||||
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.
|
||||
</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 && (() => {
|
||||
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
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{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
|
||||
<div className="mt-1">
|
||||
<span className="font-display text-sm font-semibold text-ink">{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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user