MPMB-style ability score table: one row per ability, one column per source

Replaces the ability score card grid in both the character sheet and
creation wizard with a proper breakdown table matching MPMB's layout:

- Sheet: "Breakdown" button opens a modal with every source labeled as a
  column (Ancestry, Race, ASI, Level boost, etc.), a Manual override ±
  cell per ability, and Total/Mod columns. Clicking any ability card also
  opens it. Legacy characters without abilityBuild show a single Base column.

- 5e wizard: method buttons (standard/point-buy/roll/manual) remain, but
  the ability grid is replaced by a table — Base column adapts to method
  (select for array/roll, number field for buy/manual), Race and ASI columns
  appear only when relevant, point-buy remaining shown in footer.

- PF2e wizard: replaces dropdown grid with a click-to-assign boost table —
  fixed ancestry boosts shown as +2 chips, free-boost slot columns let you
  click any cell to assign/unassign that slot to that ability (with source
  uniqueness enforced inline).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:31:08 +02:00
parent ae676aa86c
commit 1ca8bb4482
4 changed files with 700 additions and 168 deletions
@@ -0,0 +1,565 @@
/**
* MPMB-style ability score tables: one row per ability, one column per source.
* Three variants:
* SheetAbilityTable sheet breakdown modal (build sources + manual override)
* Wizard5eAbilityTable 5e wizard Abilities step
* WizardPf2eAbilityTable PF2e wizard boost-picker
*/
import { useState } from 'react';
import { ABILITY_ABBR, ABILITY_LABELS, abilityModifier } from '@/lib/rules';
import { abilityBreakdown } from '@/lib/rules/abilityBuild';
import type { AbilityBuild } from '@/lib/schemas/character';
import type { AbilityKey, AbilityScores } from '@/lib/rules/types';
import { POINT_BUY_MIN, POINT_BUY_MAX, pointBuyRemaining } from '@/lib/rules/abilityGen';
import { NumberField } from '@/components/ui/NumberField';
import { Select } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
import { formatModifier } from '@/lib/format';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
// ─── shared cell/header styles ───────────────────────────────────────────────
const TH = 'px-2 py-1.5 text-[10px] font-medium smallcaps text-muted text-center whitespace-nowrap select-none';
const TH_L = 'px-2 py-1.5 text-[10px] font-medium smallcaps text-muted text-left whitespace-nowrap select-none';
const TD = 'px-2 py-1 text-center tabular-nums';
const TD_L = 'px-2 py-1 text-left';
function DeltaBadge({ delta, kind }: { delta: number; kind?: 'boost' | 'flat' | undefined }) {
if (delta === 0) return <span className="text-muted/30"></span>;
const pos = delta > 0;
return (
<span className={cn(
'inline-flex items-center justify-center rounded px-1 min-w-[28px] h-5 text-xs font-medium',
pos
? kind === 'boost'
? 'bg-violet-500/15 text-violet-400'
: 'bg-accent/15 text-accent'
: 'bg-danger/15 text-danger',
)}>
{pos ? '+' : ''}{delta}
</span>
);
}
// ─── Sheet breakdown table ────────────────────────────────────────────────────
export interface SheetAbilityTableProps {
build: AbilityBuild;
/** When provided, shows the Manual override column and calls this on change. */
onSetManual?: (ability: AbilityKey, delta: number) => void;
keyAbilities?: AbilityKey[] | undefined;
}
export function SheetAbilityTable({ build, onSetManual, keyAbilities }: SheetAbilityTableProps) {
const bd = abilityBreakdown(build);
// Collect unique source labels; show Manual last (it's the override column)
const nonManualLabels = [...new Set(
build.adjustments.filter((a) => a.label !== 'Manual').map((a) => a.label),
)];
function ManualCell({ ability }: { ability: AbilityKey }) {
const adj = build.adjustments.find((a) => a.label === 'Manual' && a.ability === ability);
const [editing, setEditing] = useState(false);
const delta = adj?.amount ?? 0;
if (!onSetManual) {
return <span className={cn('text-sm tabular-nums', delta ? (delta > 0 ? 'text-accent' : 'text-danger') : 'text-muted/30')}>{delta ? (delta > 0 ? `+${delta}` : delta) : '—'}</span>;
}
if (editing) {
return (
<NumberField
value={delta}
min={-30}
max={30}
className="w-16"
aria-label={`Manual override for ${ABILITY_LABELS[ability]}`}
onChange={(v) => onSetManual(ability, v)}
/>
);
}
return (
<button
type="button"
onClick={() => setEditing(true)}
onBlur={() => setEditing(false)}
className={cn(
'inline-flex items-center justify-center rounded border border-dashed border-line hover:border-accent w-12 h-6 text-xs tabular-nums transition-colors',
delta ? (delta > 0 ? 'text-accent border-accent/30' : 'text-danger border-danger/30') : 'text-muted/40',
)}
title="Click to add a manual adjustment"
>
{delta ? (delta > 0 ? `+${delta}` : delta) : '±'}
</button>
);
}
return (
<div className="overflow-x-auto rounded-lg border border-line">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-elevated border-b border-line">
<th className={TH_L}>Ability</th>
<th className={TH}>Base</th>
{nonManualLabels.map((label) => (
<th key={label} className={TH}>{label}</th>
))}
{onSetManual && <th className={TH}>Manual</th>}
<th className={cn(TH, 'font-semibold text-ink')}>Total</th>
<th className={TH}>Mod</th>
</tr>
</thead>
<tbody>
{ABILITIES.map((ability, idx) => {
const b = bd[ability];
const total = b.total;
const mod = abilityModifier(total);
const isKey = keyAbilities?.includes(ability);
return (
<tr key={ability} className={cn(
'border-b border-line/50 last:border-b-0',
idx % 2 === 0 ? 'bg-surface' : 'bg-panel',
isKey && 'ring-1 ring-inset ring-accent/20',
)}>
<td className={TD_L}>
<span className="font-medium text-ink">{ABILITY_LABELS[ability]}</span>
<span className="ml-1.5 text-[10px] text-muted smallcaps">{ABILITY_ABBR[ability]}</span>
{isKey && <span className="ml-1 text-[9px] text-accent"></span>}
</td>
<td className={cn(TD, 'font-mono text-muted')}>{b.base}</td>
{nonManualLabels.map((label) => {
const parts = b.parts.filter((p) => p.label === label);
const totalDelta = parts.reduce((s, p) => s + p.delta, 0);
const adj = build.adjustments.find((a) => a.label === label && a.ability === ability);
return (
<td key={label} className={TD}>
{totalDelta !== 0
? <DeltaBadge delta={totalDelta} kind={adj?.kind} />
: <span className="text-muted/20"></span>}
</td>
);
})}
{onSetManual && (
<td className={TD}>
<ManualCell ability={ability} />
</td>
)}
<td className={TD}>
<span className={cn(
'font-display font-semibold text-base',
total >= 18 ? 'text-accent-deep' : 'text-ink',
)}>{total}</span>
</td>
<td className={TD}>
<span className={cn(
'font-mono text-sm font-medium',
mod >= 0 ? 'text-accent' : 'text-danger',
)}>{formatModifier(mod)}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ─── 5e Wizard table ──────────────────────────────────────────────────────────
export interface Wizard5eAbilityTableProps {
method: 'standard' | 'pointbuy' | 'roll' | 'manual';
pool: number[]; // sorted pool for standard/roll
assignment: number[]; // pool[assignment[i]] = base score for ABILITIES[i]
pb: number[]; // point-buy raw bases per ability (same order as ABILITIES)
racial: Partial<AbilityScores>;
asiAlloc: Partial<AbilityScores>;
asiCount: number; // total ASI points available (asiCount*2 pts from feat/ASI)
keyAbilities?: string[] | undefined;
onChangeAssignment: (abilityIdx: number, poolIdx: number) => void;
onChangePb: (abilityIdx: number, value: number) => void;
onChangeAsi: (ability: AbilityKey, delta: number) => void;
}
export function Wizard5eAbilityTable({
method, pool, assignment, pb, racial, asiAlloc, asiCount, keyAbilities,
onChangeAssignment, onChangePb, onChangeAsi,
}: Wizard5eAbilityTableProps) {
const usesPool = method === 'standard' || method === 'roll';
const hasRacial = ABILITIES.some((a) => (racial[a] ?? 0) !== 0);
const hasAsi = asiCount > 0;
const asiUsed = ABILITIES.reduce((s, a) => s + (asiAlloc[a] ?? 0), 0);
const asiRemaining = asiCount * 2 - asiUsed;
const pbRemaining = pointBuyRemaining(pb);
return (
<div className="overflow-x-auto rounded-lg border border-line">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-elevated border-b border-line">
<th className={TH_L}>Ability</th>
<th className={TH}>
{method === 'standard' ? 'Array' : method === 'roll' ? 'Roll' : 'Base'}
</th>
{hasRacial && <th className={TH}>Race</th>}
{hasAsi && (
<th className={TH}>
ASI
<span className={cn('ml-1 text-[9px]', asiRemaining < 0 ? 'text-danger' : 'text-muted')}>
({asiRemaining} left)
</span>
</th>
)}
<th className={cn(TH, 'font-semibold text-ink')}>Total</th>
<th className={TH}>Mod</th>
</tr>
</thead>
<tbody>
{ABILITIES.map((ability, i) => {
const base = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!;
const race = racial[ability] ?? 0;
const asi = asiAlloc[ability] ?? 0;
const total = base + race + asi;
const mod = abilityModifier(total);
const isKey = keyAbilities?.includes(ability);
const pbMax = method === 'pointbuy'
? (() => {
let best = pb[i]!;
for (let c = pb[i]! + 1; c <= POINT_BUY_MAX; c++) {
if (pointBuyRemaining(pb.map((x, j) => (j === i ? c : x))) >= 0) best = c; else break;
}
return best;
})()
: 30;
return (
<tr key={ability} className={cn(
'border-b border-line/50 last:border-b-0',
i % 2 === 0 ? 'bg-surface' : 'bg-panel',
isKey && 'ring-1 ring-inset ring-accent/20',
)}>
{/* Ability name */}
<td className={TD_L}>
<span className="font-medium text-ink">{ABILITY_LABELS[ability]}</span>
<span className="ml-1.5 text-[10px] text-muted smallcaps">{ABILITY_ABBR[ability]}</span>
{isKey && <span className="ml-1 text-[9px] text-accent"></span>}
</td>
{/* Base score input */}
<td className={TD}>
{usesPool ? (
<Select
className="w-16 text-sm"
aria-label={`${ABILITY_ABBR[ability]} array value`}
value={assignment[i]}
onChange={(e) => {
const v = Number(e.target.value);
onChangeAssignment(i, v);
}}
>
{pool.map((p, idx) => (
<option key={idx} value={idx}>{p}</option>
))}
</Select>
) : (
<NumberField
className="w-16"
value={pb[i]!}
min={method === 'pointbuy' ? POINT_BUY_MIN : 1}
max={pbMax}
aria-label={`${ABILITY_ABBR[ability]} base score`}
onChange={(v) => onChangePb(i, v)}
/>
)}
</td>
{/* Racial bonus */}
{hasRacial && (
<td className={TD}>
{race !== 0
? <DeltaBadge delta={race} kind="flat" />
: <span className="text-muted/20"></span>}
</td>
)}
{/* ASI stepper */}
{hasAsi && (
<td className={TD}>
<div className="inline-flex items-center gap-0.5">
<button
type="button"
disabled={asi <= 0}
onClick={() => onChangeAsi(ability, -1)}
className="w-5 h-5 rounded text-muted hover:text-ink disabled:opacity-25 text-xs leading-none"
></button>
<span className={cn(
'w-6 text-center text-xs tabular-nums',
asi > 0 ? 'text-accent font-medium' : 'text-muted/40',
)}>
{asi > 0 ? `+${asi}` : '0'}
</span>
<button
type="button"
disabled={asiRemaining <= 0 || base + race + asi >= 20}
onClick={() => onChangeAsi(ability, +1)}
className="w-5 h-5 rounded text-muted hover:text-ink disabled:opacity-25 text-xs leading-none"
>+</button>
</div>
</td>
)}
{/* Total */}
<td className={TD}>
<span className={cn(
'font-display font-semibold text-base',
total >= 18 ? 'text-accent-deep' : 'text-ink',
)}>{total}</span>
</td>
{/* Modifier */}
<td className={TD}>
<span className={cn(
'font-mono text-sm font-medium',
mod >= 0 ? 'text-accent' : 'text-danger',
)}>{formatModifier(mod)}</span>
</td>
</tr>
);
})}
</tbody>
{method === 'pointbuy' && (
<tfoot>
<tr className="bg-elevated border-t border-line">
<td colSpan={2 + (hasRacial ? 1 : 0) + (hasAsi ? 1 : 0)} className="px-2 py-1.5 text-left">
<span className="text-xs text-muted">
Point buy remaining:{' '}
<span className={cn('font-semibold', pbRemaining < 0 ? 'text-danger' : 'text-ink')}>
{pbRemaining} / 27
</span>
<span className="ml-2 text-muted/60">(scores 815, standard array: 15 14 13 12 10 8)</span>
</span>
</td>
<td colSpan={2} />
</tr>
</tfoot>
)}
{method === 'standard' && (
<tfoot>
<tr className="bg-elevated border-t border-line">
<td colSpan={2 + (hasRacial ? 1 : 0) + (hasAsi ? 1 : 0) + 2} className="px-2 py-1.5 text-left">
<span className="text-xs text-muted/60">Standard array: 15 · 14 · 13 · 12 · 10 · 8 assign each value once</span>
</td>
</tr>
</tfoot>
)}
</table>
</div>
);
}
// ─── PF2e Wizard boost-picker table ──────────────────────────────────────────
export interface PF2eBoostSlot {
id: string;
label: string;
options: AbilityKey[];
}
export interface WizardPf2eAbilityTableProps {
fixed: AbilityKey[]; // ancestry fixed boosts
flaw?: AbilityKey | undefined;
classBoost?: AbilityKey | undefined;
slots: PF2eBoostSlot[]; // interactive free-boost slots
picks: Record<string, AbilityKey>;
abilities: AbilityScores; // final computed scores (for Total column)
keyAbilities?: string[] | undefined;
onPick: (slotId: string, ability: AbilityKey) => void;
}
function slotGroup(id: string): string {
if (id.startsWith('anc')) return 'ancestry';
if (id.startsWith('bg')) return 'background';
if (id === 'class') return 'class';
if (id.startsWith('free')) return 'free';
// lvl5-0, lvl10-1, etc.
const m = id.match(/^(lvl\d+)/);
return m ? m[1]! : id;
}
function slotHeader(s: PF2eBoostSlot): string {
const { id, label } = s;
if (id.startsWith('anc-free')) {
const n = parseInt(id.split('-')[2] ?? '0') + 1;
return `Anc. ${n}`;
}
if (id === 'bg-choice') return 'Bg.';
if (id.startsWith('bg-free')) {
const n = parseInt(id.split('-')[2] ?? '0') + 1;
return `Bg. ${n}`;
}
if (id === 'class') return 'Class';
if (id.startsWith('free-')) return `Free ${parseInt(id.split('-')[1] ?? '0') + 1}`;
const m = id.match(/^lvl(\d+)-(\d+)/);
if (m) return `L${m[1]} ${parseInt(m[2]!) + 1}`;
return label.replace('boost', '').trim();
}
export function WizardPf2eAbilityTable({
fixed, flaw, classBoost, slots, picks, abilities, keyAbilities, onPick,
}: WizardPf2eAbilityTableProps) {
const fixedSet = new Set(fixed);
const hasFlaw = !!flaw;
const hasClassBoost = !!classBoost;
return (
<div className="overflow-x-auto rounded-lg border border-line">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-elevated border-b border-line">
<th className={TH_L}>Ability</th>
<th className={TH}>Base</th>
{fixed.length > 0 && <th className={TH}>Ancestry</th>}
{hasFlaw && <th className={TH}>Flaw</th>}
{hasClassBoost && <th className={TH}>Class</th>}
{slots.map((s) => (
<th key={s.id} className={TH} title={s.label}>
{slotHeader(s)}
</th>
))}
<th className={cn(TH, 'font-semibold text-ink')}>Total</th>
<th className={TH}>Mod</th>
</tr>
</thead>
<tbody>
{ABILITIES.map((ability, idx) => {
const total = abilities[ability];
const mod = abilityModifier(total);
const isKey = keyAbilities?.includes(ability);
return (
<tr key={ability} className={cn(
'border-b border-line/50 last:border-b-0',
idx % 2 === 0 ? 'bg-surface' : 'bg-panel',
isKey && 'ring-1 ring-inset ring-accent/20',
)}>
{/* Name */}
<td className={TD_L}>
<span className="font-medium text-ink">{ABILITY_LABELS[ability]}</span>
<span className="ml-1.5 text-[10px] text-muted smallcaps">{ABILITY_ABBR[ability]}</span>
{isKey && <span className="ml-1 text-[9px] text-accent"></span>}
</td>
{/* Base = 10 */}
<td className={cn(TD, 'text-muted/60')}>10</td>
{/* Fixed ancestry boosts */}
{fixed.length > 0 && (
<td className={TD}>
{fixedSet.has(ability)
? <DeltaBadge delta={2} kind="boost" />
: <span className="text-muted/20"></span>}
</td>
)}
{/* Flaw */}
{hasFlaw && (
<td className={TD}>
{flaw === ability
? <DeltaBadge delta={-2} kind="flat" />
: <span className="text-muted/20"></span>}
</td>
)}
{/* Class key ability boost */}
{hasClassBoost && (
<td className={TD}>
{classBoost === ability
? <DeltaBadge delta={2} kind="boost" />
: <span className="text-muted/20"></span>}
</td>
)}
{/* Interactive free boost slots */}
{slots.map((s) => {
const isAllowed = s.options.includes(ability);
const pickedHere = picks[s.id] === ability;
const group = slotGroup(s.id);
// Can't pick this ability in this slot if:
// (a) it's in fixed ancestry boosts and the slot is an ancestry slot
const blockedByFixed = group === 'ancestry' && fixedSet.has(ability);
// (b) another slot in the same group already picked this ability
const blockedByGroup = !pickedHere && slots.some(
(o) => o.id !== s.id && slotGroup(o.id) === group && picks[o.id] === ability,
);
const canPick = isAllowed && !blockedByFixed && !blockedByGroup;
return (
<td key={s.id} className={TD}>
{isAllowed ? (
<button
type="button"
onClick={() => {
if (pickedHere) {
// clicking again clears the pick — pass a sentinel
// We do this by re-calling with the same ability so the
// handler can toggle; caller must check pickedHere.
onPick(s.id, ability);
} else if (canPick) {
onPick(s.id, ability);
}
}}
disabled={!canPick && !pickedHere}
aria-pressed={pickedHere}
title={pickedHere ? 'Click to unassign' : canPick ? `Assign boost to ${ABILITY_LABELS[ability]}` : 'Already boosted by this source'}
className={cn(
'inline-flex items-center justify-center rounded w-8 h-6 text-xs font-medium transition-colors',
pickedHere
? 'bg-violet-500/20 text-violet-300 border border-violet-500/40'
: canPick
? 'bg-surface border border-line text-muted/50 hover:border-accent/60 hover:text-accent'
: 'text-muted/20 cursor-not-allowed',
)}
>
{pickedHere ? '+2' : '·'}
</button>
) : (
<span className="text-muted/20 text-xs"></span>
)}
</td>
);
})}
{/* Total */}
<td className={TD}>
<span className={cn(
'font-display font-semibold text-base',
total >= 18 ? 'text-accent-deep' : 'text-ink',
)}>{total}</span>
</td>
{/* Modifier */}
<td className={TD}>
<span className={cn(
'font-mono text-sm font-medium',
mod >= 0 ? 'text-accent' : 'text-danger',
)}>{formatModifier(mod)}</span>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="bg-elevated border-t border-line">
<td colSpan={2 + (fixed.length > 0 ? 1 : 0) + (hasFlaw ? 1 : 0) + (hasClassBoost ? 1 : 0) + slots.length + 2} className="px-2 py-1.5 text-left">
<span className="text-xs text-muted/60">
Each boost adds +2 (or +1 if the score is already 18 or above). A source cannot boost the same ability twice.
</span>
</td>
</tr>
</tfoot>
</table>
</div>
);
}
+44 -23
View File
@@ -30,6 +30,7 @@ import { DefensesSection } from './sheet/DefensesSection';
import { FeatsSection } from './sheet/FeatsSection'; import { FeatsSection } from './sheet/FeatsSection';
import { ClassFeaturesSection } from './sheet/ClassFeaturesSection'; import { ClassFeaturesSection } from './sheet/ClassFeaturesSection';
import { AbilityGenModal } from './sheet/AbilityGenModal'; import { AbilityGenModal } from './sheet/AbilityGenModal';
import { SheetAbilityTable } from './AbilityScoreTable';
import { LevelUpModal } from './sheet/LevelUpModal'; import { LevelUpModal } from './sheet/LevelUpModal';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
@@ -43,6 +44,7 @@ export function CharacterSheet({ character }: { character: Character }) {
const campaigns = useCampaigns(); const campaigns = useCampaigns();
const [levelUp, setLevelUp] = useState(false); const [levelUp, setLevelUp] = useState(false);
const [genScores, setGenScores] = useState(false); const [genScores, setGenScores] = useState(false);
const [showAbilityTable, setShowAbilityTable] = useState(false);
const [shared, setShared] = useState(false); const [shared, setShared] = useState(false);
// Manual fallback: holds the link to show in a selectable dialog when neither // Manual fallback: holds the link to show in a selectable dialog when neither
// Web Share nor Clipboard is available (e.g. iPad without a secure context). // Web Share nor Clipboard is available (e.g. iPad without a secure context).
@@ -237,40 +239,59 @@ export function CharacterSheet({ character }: { character: Character }) {
<section className="mb-6"> <section className="mb-6">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<SectionTitle>Ability Scores</SectionTitle> <SectionTitle>Ability Scores</SectionTitle>
{/* The generator (array/point-buy/4d6) is a 5e concept; PF2e scores come from boosts. */} <div className="flex gap-2">
{c.system === '5e' && <Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>} <Button size="sm" variant="ghost" onClick={() => setShowAbilityTable(true)}>Breakdown</Button>
{c.system === '5e' && <Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>}
</div>
</div> </div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6"> {/* Compact at-a-glance row */}
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{ABILITIES.map((a) => { {ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]); const mod = sys.abilityModifier(c.abilities[a]);
const breakdown = abilityBuildView[a]; const bd = abilityBuildView[a];
return ( return (
<div key={a} className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel px-2 py-3 text-center"> <button
<div className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div> key={a}
<div className="font-mono font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div> type="button"
<NumberField onClick={() => setShowAbilityTable(true)}
className="mt-1" className="flex flex-col items-center gap-0.5 rounded-xl border border-line bg-panel px-2 py-3 text-center hover:border-accent/50 transition-colors group"
value={c.abilities[a]} >
min={1} <div className="smallcaps text-[10px] text-muted">{ABILITY_ABBR[a]}</div>
max={30} <div className="font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div>
aria-label={`${ABILITY_ABBR[a]} score`} <div className="font-mono text-sm text-ink">{c.abilities[a]}</div>
onChange={(v) => setAbilityTotal(a, v)} {bd.parts.length > 0 && (
/> <div className="mt-0.5 text-[9px] leading-tight text-muted/60">
{breakdown.parts.length > 0 && ( {bd.base}{bd.parts.map((p) => ` ${p.delta >= 0 ? '+' : ''}${p.delta}`).join('')}
<div className="mt-1 text-[9px] leading-tight text-muted" title={`${breakdown.base} base${breakdown.parts.map((p) => ` · ${p.delta >= 0 ? '+' : ''}${p.delta} ${p.label}`).join('')}`}>
{breakdown.base}
{breakdown.parts.map((p, i) => (
<span key={i}> {p.delta >= 0 ? '+' : ''}{p.delta}</span>
))}
</div> </div>
)} )}
</div> </button>
); );
})} })}
</div> </div>
<p className="mt-1 text-[10px] text-muted">Hover a score to see its sources. Editing a score records a Manual adjustment.</p>
</section> </section>
{/* Ability score breakdown modal (MPMB-style) */}
<Modal
open={showAbilityTable}
onClose={() => setShowAbilityTable(false)}
title="Ability Scores"
className="max-w-4xl"
>
<SheetAbilityTable
build={abilityBuild}
onSetManual={(ability, delta) => {
const otherAdjs = abilityBuild.adjustments.filter(
(adj) => !(adj.label === 'Manual' && adj.ability === ability),
);
const baseTotal = computeAbilities({ base: abilityBuild.base, adjustments: otherAdjs })[ability];
setAbilityTotal(ability, baseTotal + delta);
}}
/>
<p className="mt-3 text-xs text-muted">
Click <strong>Manual</strong> to add an override adjustment. Violet = PF2e boost (+2/+1 rule). Click any score on the sheet to open this table.
</p>
</Modal>
{/* Perception (pf2e core proficiency — advances to legendary; drives initiative) */} {/* Perception (pf2e core proficiency — advances to legendary; drives initiative) */}
{c.system === 'pf2e' && ( {c.system === 'pf2e' && (
<section className="mb-6"> <section className="mb-6">
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { Lightbulb, RotateCcw, Star } from 'lucide-react'; import { Lightbulb, RotateCcw } from 'lucide-react';
import { type Campaign, type Character, type SpellEntry, type AbilityBuild, type AbilityAdjustment, newSpellEntry } from '@/lib/schemas'; import { type Campaign, type Character, type SpellEntry, type AbilityBuild, type AbilityAdjustment, newSpellEntry } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories'; import { charactersRepo } from '@/lib/db/repositories';
import { import {
@@ -15,13 +15,13 @@ import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } fro
import type { RulesetClass } from '@/lib/ruleset/normalize'; import type { RulesetClass } from '@/lib/ruleset/normalize';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { newId } from '@/lib/ids'; import { newId } from '@/lib/ids';
import { formatModifier } from '@/lib/format';
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder'; 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, dnd5eAsiLevels } 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 { Wizard5eAbilityTable, WizardPf2eAbilityTable } from '../AbilityScoreTable';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex'; import { Badge } from '@/components/ui/Codex';
import { Field, Input, Select } from '@/components/ui/Input'; import { Field, Input, Select } from '@/components/ui/Input';
@@ -640,151 +640,97 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
)} )}
{stepName === 'Abilities' && ( {stepName === 'Abilities' && (
<div> <div className="space-y-4">
{system === 'pf2e' && boostSlots && ( {system === 'pf2e' && boostSlots && (
<div className="space-y-3"> <>
<p className="text-sm text-muted">PF2e scores are built from <span className="text-ink">boosts</span>: each starts at 10, and a boost adds +2 (or +1 once a score reaches 18). Assign your free boosts below.</p> <p className="text-sm text-muted">
{(boostSlots.fixed.length > 0 || boostSlots.flaw) && ( PF2e scores start at 10. Each <span className="text-ink font-medium">boost</span> adds +2 (or +1 if the score is already 18+).
<div className="flex flex-wrap items-center gap-1 text-xs"> Click a cell in a free-boost column to assign it highlighted cells are active.
<span className="text-muted">{selectedOrigin?.name ?? 'Ancestry'}:</span>
{boostSlots.fixed.map((a, i) => <span key={i} className="rounded bg-accent/10 px-1.5 py-0.5 font-medium text-accent">+{ABILITY_ABBR[a]}</span>)}
{boostSlots.flaw && <span className="rounded bg-danger/10 px-1.5 py-0.5 font-medium text-danger">{ABILITY_ABBR[boostSlots.flaw]}</span>}
<span className="text-faint">(fixed)</span>
</div>
)}
{boostSlots.slots.length > 0 && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{boostSlots.slots.map((s) => {
// Abilities already taken by OTHER slots in the same source (can't double-boost).
const taken = new Set<AbilityKey>(boostSource(s.id) === 'ancestry' ? boostSlots.fixed : []);
for (const o of boostSlots.slots) {
if (o.id !== s.id && boostSource(o.id) === boostSource(s.id) && boostPicks[o.id]) taken.add(boostPicks[o.id]!);
}
return (
<label key={s.id} className="text-xs text-muted">
{s.label}
<Select className="mt-0.5" aria-label={s.label} value={boostPicks[s.id] ?? s.options[0]} onChange={(e) => setBoostPicks((p) => ({ ...p, [s.id]: e.target.value as AbilityKey }))}>
{s.options.map((o) => <option key={o} value={o} disabled={taken.has(o) && boostPicks[s.id] !== o}>{ABILITY_ABBR[o]}{taken.has(o) ? ' ·' : ''}</option>)}
</Select>
</label>
);
})}
</div>
)}
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{ABILITIES.map((a) => {
const isKey = selectedClass?.keyAbilities.includes(a);
return (
<div key={a} className={cn('rounded-lg border bg-surface p-2 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>
<div className="font-display text-lg font-semibold text-ink">{abilities[a]}</div>
<div className="text-xs text-accent">{formatModifier(abilityModifier(abilities[a]))}</div>
</div>
);
})}
</div>
</div>
)}
{system !== 'pf2e' && (<>
<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>
)}
{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
? `Prioritise ${selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')} for a ${selectedClass.name}.`
: null);
return text ? (
<p className="mb-3 flex items-start gap-1.5 text-xs text-muted">
<Lightbulb size={12} className="mt-0.5 shrink-0 text-accent" aria-hidden />
{text}
</p> </p>
) : null; <WizardPf2eAbilityTable
})()} fixed={boostSlots.fixed}
{usesPool && !poolValid && <p className="mb-2 text-sm text-warning">Assign each value to a different ability.</p>} flaw={boostSlots.flaw}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> slots={boostSlots.slots}
{ABILITIES.map((a, i) => { picks={boostPicks}
const racial = (system === '5e' && selectedOrigin?.asiBonuses?.[a]) || 0; abilities={abilities}
const asi = (system === '5e' && asiAlloc[a]) || 0; keyAbilities={selectedClass?.keyAbilities}
const base = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!; onPick={(slotId, ability) => {
const value = base + racial + asi; setBoostPicks((prev) => {
const isKey = selectedClass?.keyAbilities.includes(a); if (prev[slotId] === ability) {
const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1; const next = { ...prev };
// Point-buy caps each field at what the remaining budget can afford, delete next[slotId];
// so the user can't overspend and wonder why Next is disabled. return next;
const fMax = method === 'pointbuy'
? (() => {
let best = pb[i]!;
for (let cand = pb[i]! + 1; cand <= POINT_BUY_MAX; cand++) {
if (pointBuyRemaining(pb.map((x, j) => (j === i ? cand : x))) >= 0) best = cand; else break;
} }
return best; return { ...prev, [slotId]: ability };
})() });
: 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) => { {system !== 'pf2e' && (
const v = Number(e.target.value); <>
// Swap on collision so a value can be moved between abilities directly. {/* Method selector */}
setAssignment((prev) => { <div className="flex flex-wrap items-center gap-2">
const next = [...prev]; {([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest'], ['manual', 'Manual']] as const).map(([m, label]) => (
const j = next.findIndex((x, k) => x === v && k !== i); <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')}>
if (j !== -1) next[j] = next[i]!; {label}
next[i] = v; </button>
return next; ))}
}); {method === 'roll' && (
}}> <Button size="sm" variant="secondary" onClick={() => chooseMethod('roll')}>
{pool.map((p, idx) => <option key={idx} value={idx}>{p}</option>)} <RotateCcw size={13} aria-hidden /> Reroll
</Select> </Button>
) : ( )}
<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>
)}
<div className="mt-1"> {/* Class tip */}
<span className="font-display text-sm font-semibold text-ink">{value}</span> {selectedClass && (() => {
<span className="ml-1 text-xs text-accent">{formatModifier(abilityModifier(value))}</span> const tip = getClassTip(system, selectedClass.slug);
{(racial || asi) ? <div className="text-[10px] leading-tight text-muted">{base} base{racial ? ` · +${racial} race` : ''}{asi ? ` · +${asi} ASI` : ''}</div> : null} const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0
</div> ? `Prioritise ${selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')} for a ${selectedClass.name}.`
</div> : null);
); return text ? (
})} <p className="flex items-start gap-1.5 text-xs text-muted">
</div> <Lightbulb size={12} className="mt-0.5 shrink-0 text-accent" aria-hidden />
</>)} {text}
</p>
) : null;
})()}
{usesPool && !poolValid && (
<p className="text-sm text-warning">Assign each value to a different ability.</p>
)}
{/* MPMB-style ability table */}
<Wizard5eAbilityTable
method={method}
pool={pool}
assignment={assignment}
pb={pb}
racial={selectedOrigin?.asiBonuses ?? {}}
asiAlloc={asiAlloc}
asiCount={asiCount}
keyAbilities={selectedClass?.keyAbilities}
onChangeAssignment={(abilityIdx, poolIdx) => {
setAssignment((prev) => {
const next = [...prev];
// Swap on collision so a value can be moved between abilities directly.
const j = next.findIndex((x, k) => x === poolIdx && k !== abilityIdx);
if (j !== -1) next[j] = next[abilityIdx]!;
next[abilityIdx] = poolIdx;
return next;
});
}}
onChangePb={(abilityIdx, value) => {
setPb((prev) => prev.map((x, j) => (j === abilityIdx ? value : x)));
}}
onChangeAsi={(ability, delta) => {
setAsiAlloc((p) => ({ ...p, [ability]: Math.max(0, (p[ability] ?? 0) + delta) }));
}}
/>
</>
)}
</div> </div>
)} )}
File diff suppressed because one or more lines are too long