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>
This commit is contained in:
2026-06-08 01:32:15 +02:00
parent 3e5bdc06e2
commit 9647e6b3d6
14 changed files with 431 additions and 16 deletions
+14 -2
View File
@@ -1,11 +1,12 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
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';
@@ -151,6 +152,7 @@ export function CharacterSheet({ character }: { character: Character }) {
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) => (
@@ -161,6 +163,7 @@ export function CharacterSheet({ character }: { character: Character }) {
rank={(c.saveRanks[a] as ProficiencyRank) ?? 'untrained'}
ranks={ranks}
onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [a]: rank } })}
system={c.system}
/>
))}
</div>
@@ -178,6 +181,7 @@ export function CharacterSheet({ character }: { character: Character }) {
rank={(c.skillRanks[s.key] as ProficiencyRank) ?? 'untrained'}
ranks={ranks}
onRank={(rank) => update({ skillRanks: { ...c.skillRanks, [s.key]: rank } })}
system={c.system}
/>
))}
</div>
@@ -256,16 +260,24 @@ function RankRow({
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">
<span className="w-8 font-display text-lg font-semibold text-accent">{formatModifier(modifier)}</span>
<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) => (
@@ -4,6 +4,7 @@ import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
import type { Attack } from '@/lib/schemas';
import { formatModifier } from '@/lib/format';
import { rollAndShow } from '@/lib/useRoll';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
@@ -66,9 +67,22 @@ export function AttacksSection({ c, update }: SectionProps) {
</Select>
<label className="text-xs text-muted">dice<Input className="ml-1 inline-block h-8 w-20" value={a.damageDice} onChange={(e) => patch(a.id, { damageDice: e.target.value })} aria-label="Damage dice" /></label>
<label className="text-xs text-muted">+item<NumberField className="ml-1 w-14" value={a.itemBonus} onChange={(v) => patch(a.id, { itemBonus: v })} aria-label="Item bonus" /></label>
<span className="ml-auto rounded bg-elevated px-2 py-1 text-sm">
<span className="text-muted">to hit </span><span className="font-display font-semibold text-accent">{formatModifier(result.toHit)}</span>
<span className="text-muted"> · dmg </span><span className="font-mono text-ink">{result.damage}{a.damageType ? ` ${a.damageType}` : ''}</span>
<span className="ml-auto flex items-center gap-1 rounded bg-elevated px-2 py-1 text-sm">
<button
onClick={() => rollAndShow({ expression: `1d20${formatModifier(result.toHit)}`, label: `${a.name} — attack` })}
className="rounded px-1 font-display font-semibold text-accent hover:bg-accent/10"
title="Roll attack"
>
{formatModifier(result.toHit)}
</button>
<span className="text-muted">to hit ·</span>
<button
onClick={() => rollAndShow({ expression: result.damage, label: `${a.name} — damage` })}
className="rounded px-1 font-mono text-ink hover:bg-accent/10"
title="Roll damage"
>
{result.damage}{a.damageType ? ` ${a.damageType}` : ''}
</button>
</span>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}></Button>
</li>