P13: dice — crit/fumble animation + roll visibility

- Crit/fumble emphasis: RollTray (and the Dice page result) highlight a nat-20 /
  critical-success as "✦ Critical ✦" (gold pop + glow) and a nat-1 / critical-
  failure as "✦ Fumble ✦" (red shake). naturalD20() exported from notation.
- DM sees players' rolls: RollFeed now also renders on the Combat page (players'
  rolls already broadcast to the GM).
- Players see the DM's public rolls: new gmRoll message — a hosting GM's rolls
  (Dice page + rollAndShow) broadcast to the table as "GM".
- DM secret-roll toggle (rollStore.secret) on the Dice page suppresses the broadcast.
- Realtime e2e: GM public roll appears in the player's table feed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 16:35:48 +02:00
parent d4ae60f199
commit d94df41a95
12 changed files with 129 additions and 25 deletions
+8
View File
@@ -46,6 +46,14 @@ export interface RollResult {
breakdown: string;
}
/** Natural face of the first kept d20 (for crit/fumble emphasis), if any. */
export function naturalD20(result: RollResult): number | undefined {
for (const t of result.terms) {
for (const d of t.dice) if (d.sides === 20 && d.kept) return d.value;
}
return undefined;
}
export class DiceParseError extends Error {}
export type RollMode = 'normal' | 'advantage' | 'disadvantage';
+1
View File
@@ -112,6 +112,7 @@ export const clientMessageSchema = z.discriminatedUnion('t', [
z.object({ t: z.literal('playerRoll'), characterId: z.string(), label: z.string().max(120), expression: z.string().max(200), total: int, breakdown: z.string().max(600) }),
// two-way play (GM → server)
z.object({ t: z.literal('seatGrant'), gmSecret: z.string(), targetPlayerId: z.string(), character: characterSchema }),
z.object({ t: z.literal('gmRoll'), gmSecret: z.string(), label: z.string().max(120), expression: z.string().max(200), total: int, breakdown: z.string().max(600) }),
]);
export type ClientMessage = z.infer<typeof clientMessageSchema>;
+5
View File
@@ -144,6 +144,11 @@ export function sendPlayerRoll(characterId: string, label: string, expression: s
send({ t: 'playerRoll', characterId, label, expression, total, breakdown });
}
/** GM: broadcast one of their own (public) rolls to the players. No-op unless hosting. */
export function broadcastGmRoll(label: string, expression: string, total: number, breakdown: string): void {
if (useSessionStore.getState().role === 'gm' && gmSecret) send({ t: 'gmRoll', gmSecret, label, expression, total, breakdown });
}
/** GM: approve a seat request, handing the player their authoritative sheet. */
export function grantSeat(targetPlayerId: string, character: Character): void {
if (useSessionStore.getState().role === 'gm') {
+8 -12
View File
@@ -1,9 +1,11 @@
import { rollDice, applyRollMode } from '@/lib/dice/notation';
import { rollDice, applyRollMode, naturalD20 } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { degreeOfSuccess, type Degree } from '@/lib/dice/check';
import { diceRepo } from '@/lib/db/repositories';
import { broadcastGmRoll } from '@/lib/sync/wsSync';
import { useRollStore } from '@/stores/rollStore';
import { useUiStore } from '@/stores/uiStore';
import { useSessionStore } from '@/stores/sessionStore';
import type { SystemId } from '@/lib/rules';
export interface RollOptions {
@@ -14,17 +16,6 @@ export interface RollOptions {
system?: SystemId;
}
/** Find the natural face of the (kept) d20, for crit/degree handling. */
function naturalD20(result: ReturnType<typeof rollDice>): number | undefined {
for (const t of result.terms) {
if (t.term.kind === 'dice' && t.term.sides === 20) {
const kept = t.dice.find((d) => d.kept);
if (kept) return kept.value;
}
}
return undefined;
}
/**
* Roll an expression, show it in the global tray, and persist it to history.
* Returns the result + degree. Used by the dice page, sheets, and statblocks.
@@ -57,6 +48,11 @@ export function rollAndShow(opts: RollOptions): { total: number; degree?: Degree
breakdown: result.breakdown,
});
// A hosting GM's public rolls go to the table, unless they've toggled "secret".
if (useSessionStore.getState().role === 'gm' && !useRollStore.getState().secret) {
broadcastGmRoll(label.trim() || 'Roll', opts.expression, result.total, result.breakdown);
}
return { total: result.total, ...(degree ? { degree } : {}) };
}