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:
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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
@@ -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 } : {}) };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user