Files
ttrpg_manager/src/features/characters/CharacterSheet.tsx
T
NilsBriggen 9647e6b3d6 Phase 4: interactive dice & sheet rolling
- Dice notation: exploding (Nd6!) and reroll-below (Nd6r2), capped + tested
- Degrees of success / roll-vs-DC (PF2e ±10 + nat 20/1 steps; 5e crit on nat 20/1)
- Global roll tray (shared store + RollTray in layout) with animated result + degree
- Roll from the character sheet: skills, saves, and attack to-hit/damage are
  clickable and post to the tray + history (rollAndShow/rollCheck helpers)
- Saved roll macros per campaign (persisted) on the Dice page

10 new unit tests (notation explode/reroll, degrees), interactive-dice e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:32:15 +02:00

316 lines
12 KiB
TypeScript

import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import { charactersRepo } from '@/lib/db/repositories';
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { rollCheck } from '@/lib/useRoll';
import { Page } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { formatModifier } from '@/lib/format';
import { InventorySection } from './sheet/InventorySection';
import { AttacksSection } from './sheet/AttacksSection';
import { ResourcesSection } from './sheet/ResourcesSection';
import { SpellcastingSection } from './sheet/SpellcastingSection';
import { DefensesSection } from './sheet/DefensesSection';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
const RANK_LABEL: Record<ProficiencyRank, string> = {
untrained: 'Untrained',
trained: 'Trained',
expert: 'Expert',
master: 'Master',
legendary: 'Legendary',
};
export function CharacterSheet({ character }: { character: Character }) {
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
const [c, setC] = useState<Character>(character);
const save = useDebouncedCallback((next: Character) => {
// Persist everything except identity/timestamps (update() stamps updatedAt).
const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next;
void charactersRepo.update(id, rest);
}, 350);
const update = (patch: Partial<Character>) => {
setC((prev) => {
const next = { ...prev, ...patch };
save(next);
return next;
});
};
const sys = getSystem(c.system);
const rulesInput: CharacterRulesInput = {
level: c.level,
abilities: c.abilities,
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
armorBonus: c.armorBonus,
perceptionRank: c.perceptionRank,
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
};
const ac = sys.baseArmorClass(rulesInput);
const initiative = sys.initiativeModifier(rulesInput);
const skills = sys.skillModifiers(rulesInput);
const saves = sys.saveModifiers(rulesInput);
const profLabel = c.system === '5e' ? `+${sys.proficiencyValue(c.level, 'trained')} prof` : `level ${c.level}`;
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
return (
<Page>
<div className="mb-4">
<Link to="/characters" className="text-sm text-muted hover:text-ink">
Back to characters
</Link>
</div>
{/* Header */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<Input
className="max-w-xs font-display text-xl font-bold"
value={c.name}
aria-label="Character name"
onChange={(e) => update({ name: e.target.value })}
/>
<span className="rounded-full bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted">
{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}
</span>
<div className="ml-auto text-sm text-muted">{profLabel}</div>
</div>
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
</Labeled>
<Labeled label="Class">
<Input value={c.className} onChange={(e) => update({ className: e.target.value })} />
</Labeled>
<Labeled label="Level">
<NumberField value={c.level} min={1} max={20} onChange={(level) => update({ level })} />
</Labeled>
<Labeled label="Speed">
<NumberField value={c.speed} min={0} onChange={(speed) => update({ speed })} />
</Labeled>
</div>
{/* Vital stats */}
<div className="mb-6 grid gap-3 sm:grid-cols-3">
<StatCard label="Armor Class" value={ac} hint={`10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-muted">
<span>Armor/shield bonus</span>
<NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
</div>
</StatCard>
<StatCard label="Initiative" value={formatModifier(initiative)} hint={c.system === 'pf2e' ? 'Perception' : 'DEX mod'} />
<HpCard c={c} update={update} />
</div>
{/* Abilities */}
<section className="mb-6">
<SectionTitle>Ability Scores</SectionTitle>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
{ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]);
return (
<div key={a} className="rounded-lg border border-line bg-panel p-3 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}</div>
<div className="my-1 font-display text-2xl font-bold text-accent">{formatModifier(mod)}</div>
<NumberField
value={c.abilities[a]}
min={1}
max={30}
aria-label={`${ABILITY_ABBR[a]} score`}
onChange={(v) => update({ abilities: { ...c.abilities, [a]: v } })}
/>
</div>
);
})}
</div>
</section>
{/* Saves */}
<section className="mb-6">
<SectionTitle>Saving Throws</SectionTitle>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{c.system === 'pf2e'
? PF2E_SAVES.map((s) => (
<RankRow
key={s.key}
label={s.label}
modifier={saves[s.ability]}
rank={(c.saveRanks[s.ability] as ProficiencyRank) ?? 'untrained'}
ranks={ranks}
onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [s.ability]: rank } })}
system={c.system}
/>
))
: ABILITIES.map((a) => (
<RankRow
key={a}
label={`${ABILITY_ABBR[a]} save`}
modifier={saves[a]}
rank={(c.saveRanks[a] as ProficiencyRank) ?? 'untrained'}
ranks={ranks}
onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [a]: rank } })}
system={c.system}
/>
))}
</div>
</section>
{/* Skills */}
<section className="mb-6">
<SectionTitle>Skills</SectionTitle>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{skills.map((s) => (
<RankRow
key={s.key}
label={`${s.label} (${ABILITY_ABBR[s.ability]})`}
modifier={s.modifier}
rank={(c.skillRanks[s.key] as ProficiencyRank) ?? 'untrained'}
ranks={ranks}
onRank={(rank) => update({ skillRanks: { ...c.skillRanks, [s.key]: rank } })}
system={c.system}
/>
))}
</div>
</section>
{/* Status & defenses (system-specific) */}
<DefensesSection c={c} update={update} />
{/* Attacks + passive perception */}
<AttacksSection c={c} update={update} />
{/* Spellcasting */}
<SpellcastingSection c={c} update={update} />
{/* Class resources + rest */}
<ResourcesSection c={c} update={update} />
{/* Inventory, currency, encumbrance */}
<InventorySection c={c} update={update} />
{/* Notes */}
<section className="mb-6">
<SectionTitle>Notes</SectionTitle>
<textarea
value={c.notes}
onChange={(e) => update({ notes: e.target.value })}
placeholder="Backstory, bonds, reminders…"
className="min-h-32 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
</section>
</Page>
);
}
function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
const [delta, setDelta] = useState(0);
const apply = (mode: 'damage' | 'heal') => {
if (!Number.isFinite(delta) || delta <= 0) return;
if (mode === 'damage') {
const absorbed = Math.min(c.hp.temp, delta);
update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } });
} else {
update({ hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + delta) } });
}
setDelta(0);
};
return (
<div className="rounded-lg border border-line bg-panel p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Hit Points</div>
<div className="my-1 font-display text-2xl font-bold text-ink">
{c.hp.current}
<span className="text-base text-muted"> / {c.hp.max}</span>
{c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>}
</div>
<div className="grid grid-cols-3 gap-1 text-[11px] text-muted">
<label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label>
<label>Max<NumberField value={c.hp.max} min={0} onChange={(max) => update({ hp: { ...c.hp, max } })} aria-label="Max HP" /></label>
<label>Temp<NumberField value={c.hp.temp} min={0} onChange={(temp) => update({ hp: { ...c.hp, temp } })} aria-label="Temp HP" /></label>
</div>
<div className="mt-2 flex items-center gap-1">
<NumberField className="w-16" value={delta} min={0} onChange={setDelta} aria-label="HP change amount" />
<Button size="sm" variant="danger" className="flex-1" onClick={() => apply('damage')}>
Damage
</Button>
<Button size="sm" variant="secondary" className="flex-1" onClick={() => apply('heal')}>
Heal
</Button>
</div>
</div>
);
}
function RankRow({
label,
modifier,
rank,
ranks,
onRank,
system,
}: {
label: string;
modifier: number;
rank: ProficiencyRank;
ranks: ProficiencyRank[];
onRank: (r: ProficiencyRank) => void;
system: SystemId;
}) {
return (
<div className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5">
<button
onClick={() => rollCheck(modifier, label, { system })}
className="w-9 rounded font-display text-lg font-semibold text-accent hover:bg-accent/10"
title={`Roll ${label}`}
>
{formatModifier(modifier)}
</button>
<span className="flex-1 truncate text-sm text-ink">{label}</span>
<Select className="w-auto py-1 text-xs" value={rank} onChange={(e) => onRank(e.target.value as ProficiencyRank)}>
{ranks.map((r) => (
<option key={r} value={r}>
{RANK_LABEL[r]}
</option>
))}
</Select>
</div>
);
}
function Labeled({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted">{label}</span>
{children}
</label>
);
}
function StatCard({ label, value, hint, children }: { label: string; value: string | number; hint?: string; children?: React.ReactNode }) {
return (
<div className="rounded-lg border border-line bg-panel p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{label}</div>
<div className="my-1 font-display text-3xl font-bold text-accent">{value}</div>
{hint && <div className="text-xs text-muted">{hint}</div>}
{children}
</div>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="mb-2 font-display text-lg font-semibold text-ink">{children}</h2>;
}