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:
@@ -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 8–15, 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>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { DefensesSection } from './sheet/DefensesSection';
|
||||
import { FeatsSection } from './sheet/FeatsSection';
|
||||
import { ClassFeaturesSection } from './sheet/ClassFeaturesSection';
|
||||
import { AbilityGenModal } from './sheet/AbilityGenModal';
|
||||
import { SheetAbilityTable } from './AbilityScoreTable';
|
||||
import { LevelUpModal } from './sheet/LevelUpModal';
|
||||
|
||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||
@@ -43,6 +44,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
const campaigns = useCampaigns();
|
||||
const [levelUp, setLevelUp] = useState(false);
|
||||
const [genScores, setGenScores] = useState(false);
|
||||
const [showAbilityTable, setShowAbilityTable] = useState(false);
|
||||
const [shared, setShared] = useState(false);
|
||||
// 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).
|
||||
@@ -237,40 +239,59 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
<section className="mb-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<SectionTitle>Ability Scores</SectionTitle>
|
||||
{/* The generator (array/point-buy/4d6) is a 5e concept; PF2e scores come from boosts. */}
|
||||
{c.system === '5e' && <Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>}
|
||||
<div className="flex gap-2">
|
||||
<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 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) => {
|
||||
const mod = sys.abilityModifier(c.abilities[a]);
|
||||
const breakdown = abilityBuildView[a];
|
||||
const bd = abilityBuildView[a];
|
||||
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">
|
||||
<div className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div>
|
||||
<div className="font-mono font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div>
|
||||
<NumberField
|
||||
className="mt-1"
|
||||
value={c.abilities[a]}
|
||||
min={1}
|
||||
max={30}
|
||||
aria-label={`${ABILITY_ABBR[a]} score`}
|
||||
onChange={(v) => setAbilityTotal(a, v)}
|
||||
/>
|
||||
{breakdown.parts.length > 0 && (
|
||||
<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>
|
||||
))}
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
onClick={() => setShowAbilityTable(true)}
|
||||
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"
|
||||
>
|
||||
<div className="smallcaps text-[10px] text-muted">{ABILITY_ABBR[a]}</div>
|
||||
<div className="font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div>
|
||||
<div className="font-mono text-sm text-ink">{c.abilities[a]}</div>
|
||||
{bd.parts.length > 0 && (
|
||||
<div className="mt-0.5 text-[9px] leading-tight text-muted/60">
|
||||
{bd.base}{bd.parts.map((p) => ` ${p.delta >= 0 ? '+' : ''}${p.delta}`).join('')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
|
||||
{/* 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) */}
|
||||
{c.system === 'pf2e' && (
|
||||
<section className="mb-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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 { charactersRepo } from '@/lib/db/repositories';
|
||||
import {
|
||||
@@ -15,13 +15,13 @@ import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } fro
|
||||
import type { RulesetClass } from '@/lib/ruleset/normalize';
|
||||
import { createRng } from '@/lib/rng';
|
||||
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 { DND5E_SUBCLASSES, dnd5eAsiLevels } from '@/lib/rules/dnd5e/progression';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Wizard5eAbilityTable, WizardPf2eAbilityTable } from '../AbilityScoreTable';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Codex';
|
||||
import { Field, Input, Select } from '@/components/ui/Input';
|
||||
@@ -640,151 +640,97 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
||||
)}
|
||||
|
||||
{stepName === 'Abilities' && (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{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>
|
||||
{(boostSlots.fixed.length > 0 || boostSlots.flaw) && (
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs">
|
||||
<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 (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>
|
||||
)}
|
||||
{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 className="text-sm text-muted">
|
||||
PF2e scores start at 10. Each <span className="text-ink font-medium">boost</span> adds +2 (or +1 if the score is already 18+).
|
||||
Click a cell in a free-boost column to assign it — highlighted cells are active.
|
||||
</p>
|
||||
) : null;
|
||||
})()}
|
||||
{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 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 + asi;
|
||||
const isKey = selectedClass?.keyAbilities.includes(a);
|
||||
const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1;
|
||||
// Point-buy caps each field at what the remaining budget can afford,
|
||||
// so the user can't overspend and wonder why Next is disabled.
|
||||
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;
|
||||
<WizardPf2eAbilityTable
|
||||
fixed={boostSlots.fixed}
|
||||
flaw={boostSlots.flaw}
|
||||
slots={boostSlots.slots}
|
||||
picks={boostPicks}
|
||||
abilities={abilities}
|
||||
keyAbilities={selectedClass?.keyAbilities}
|
||||
onPick={(slotId, ability) => {
|
||||
setBoostPicks((prev) => {
|
||||
if (prev[slotId] === ability) {
|
||||
const next = { ...prev };
|
||||
delete next[slotId];
|
||||
return next;
|
||||
}
|
||||
return best;
|
||||
})()
|
||||
: 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) => {
|
||||
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={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">
|
||||
<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 || asi) ? <div className="text-[10px] leading-tight text-muted">{base} base{racial ? ` · +${racial} race` : ''}{asi ? ` · +${asi} ASI` : ''}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>)}
|
||||
return { ...prev, [slotId]: ability };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{system !== 'pf2e' && (
|
||||
<>
|
||||
{/* Method selector */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{([['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>
|
||||
))}
|
||||
{method === 'roll' && (
|
||||
<Button size="sm" variant="secondary" onClick={() => chooseMethod('roll')}>
|
||||
<RotateCcw size={13} aria-hidden /> Reroll
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Class tip */}
|
||||
{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="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>
|
||||
) : 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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user