diff --git a/e2e-realtime/realtime.spec.ts b/e2e-realtime/realtime.spec.ts index 31924e3..fd8c40f 100644 --- a/e2e-realtime/realtime.spec.ts +++ b/e2e-realtime/realtime.spec.ts @@ -46,6 +46,12 @@ test('GM hosts a session; a player on another device sees live combat', async ({ await player.getByTestId('player-live').getByRole('link').click(); await expect(player.getByText('Lia the Brave').first()).toBeVisible(); + // GM rolls publicly on the Dice page → the player sees it in the table feed. + await gm.getByLabel('Primary').getByRole('link', { name: 'Dice' }).click(); + await gm.getByRole('button', { name: 'Roll', exact: true }).click(); + await expect(player.getByText('Table rolls')).toBeVisible(); + await expect(player.getByText('GM').first()).toBeVisible(); + await gmCtx.close(); await playerCtx.close(); }); diff --git a/server/src/index.ts b/server/src/index.ts index 730ec23..78d983d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -100,6 +100,7 @@ export function buildServer() { case 'seatGrant': hub.seatGrant(sender, m.gmSecret, m.targetPlayerId, m.character); break; case 'playerPatch': hub.playerPatch(sender, m.characterId, m.diff); break; case 'playerRoll': hub.playerRoll(sender, m.characterId, m.label, m.expression, m.total, m.breakdown); break; + case 'gmRoll': hub.gmRoll(sender, m.gmSecret, m.label, m.expression, m.total, m.breakdown); break; } }); socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); }); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 8a024ca..1ffc143 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -184,6 +184,15 @@ export class RoomHub { for (const p of room.players) if (p !== socket) p.send(msg); } + /** The GM broadcasts one of their own (public) rolls to the players. */ + gmRoll(socket: Sender, gmSecret: string, label: string, expression: string, total: number, breakdown: string): void { + const room = this.gmRoom(socket, gmSecret); + if (!room) return; + room.lastActivity = this.now(); + const msg = { t: 'rollBroadcast', playerName: 'GM', label, expression, total, breakdown } as const; + for (const p of room.players) p.send(msg); // players only; the GM already sees it locally + } + disconnect(socket: Sender): void { const conn = this.conns.get(socket); if (!conn) return; diff --git a/src/components/ui/RollTray.tsx b/src/components/ui/RollTray.tsx index 097c2bd..661b84b 100644 --- a/src/components/ui/RollTray.tsx +++ b/src/components/ui/RollTray.tsx @@ -1,22 +1,42 @@ -import { useRollStore } from '@/stores/rollStore'; +import { useRollStore, type TrayRoll } from '@/stores/rollStore'; import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check'; +import { naturalD20 } from '@/lib/dice/notation'; import { cn } from '@/lib/cn'; +/** A nat-20 / critical-success is a crit; a nat-1 / critical-failure is a fumble. */ +function critKind(roll: TrayRoll): 'crit' | 'fumble' | null { + if (roll.degree === 'critical-success') return 'crit'; + if (roll.degree === 'critical-failure') return 'fumble'; + const n = naturalD20(roll.result); + if (n === 20) return 'crit'; + if (n === 1) return 'fumble'; + return null; +} + /** Floating result of the most recent roll, shared across the whole app. */ export function RollTray() { const last = useRollStore((s) => s.last); const dismiss = useRollStore((s) => s.dismiss); if (!last) return null; + const crit = critKind(last); + return (
{last.label &&
{last.label}
} + {crit === 'crit' &&
✦ Critical ✦
} + {crit === 'fumble' &&
✦ Fumble ✦
} {last.degree && (
{DEGREE_LABEL[last.degree]} @@ -26,7 +46,7 @@ export function RollTray() {
-
{last.result.total}
+
{last.result.total}
{last.result.breakdown}
diff --git a/src/features/combat/CombatPage.tsx b/src/features/combat/CombatPage.tsx index e42b4f0..47956ed 100644 --- a/src/features/combat/CombatPage.tsx +++ b/src/features/combat/CombatPage.tsx @@ -8,6 +8,7 @@ import { Input } from '@/components/ui/Input'; import { Modal } from '@/components/ui/Modal'; import { useEncounters, useEncounter } from './hooks'; import { EncounterTracker } from './EncounterTracker'; +import { RollFeed } from '@/features/player/RollFeed'; export function CombatPage() { return {(campaign) => }; @@ -100,6 +101,9 @@ function Combat({ campaign }: { campaign: Campaign }) {
)} + {/* Players' live rolls reach the GM here while hosting a session. */} + + setCreating(false)} diff --git a/src/features/dice/DicePage.tsx b/src/features/dice/DicePage.tsx index f2bc3e1..9714a4c 100644 --- a/src/features/dice/DicePage.tsx +++ b/src/features/dice/DicePage.tsx @@ -1,11 +1,13 @@ import { useEffect, useState } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; -import { rollDice, applyRollMode, DiceParseError, type RollResult } from '@/lib/dice/notation'; +import { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation'; import { createRng } from '@/lib/rng'; import { diceRepo } from '@/lib/db/repositories'; +import { broadcastGmRoll } from '@/lib/sync/wsSync'; import { useUiStore } from '@/stores/uiStore'; import { useMacroStore } from '@/stores/macroStore'; import { useRollStore } from '@/stores/rollStore'; +import { useSessionStore } from '@/stores/sessionStore'; import { Page, PageHeader } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; @@ -29,6 +31,9 @@ export function DicePage() { const mode = useRollStore((s) => s.mode); const toggleMode = useRollStore((s) => s.toggleMode); + const secret = useRollStore((s) => s.secret); + const toggleSecret = useRollStore((s) => s.toggleSecret); + const isGm = useSessionStore((s) => s.role) === 'gm'; const roll = async (expression: string, label = '') => { try { @@ -45,6 +50,7 @@ export function DicePage() { total: result.total, breakdown: result.breakdown, }); + if (!useRollStore.getState().secret) broadcastGmRoll((label + modeTag).trim() || 'Roll', finalExpr, result.total, result.breakdown); } catch (e) { setError(e instanceof DiceParseError ? e.message : 'Could not roll that expression'); } @@ -105,12 +111,26 @@ export function DicePage() { > Disadvantage + {isGm && ( + + )} {mode !== 'normal' && (

{mode === 'advantage' ? 'Advantage' : 'Disadvantage'} is on — the next single die rolls twice, keeping the {mode === 'advantage' ? 'highest' : 'lowest'}.

)} + {isGm && secret && ( +

🤫 Secret is on — your rolls are not shared with the players.

+ )} {/* Saved rolls (macros) */}
@@ -150,15 +170,21 @@ export function DicePage() { {error &&

{error}

} - {last && ( -
- -
{last.breakdown}
- - Rolled {last.expression}: {last.total} - -
- )} + {last && (() => { + const nat = naturalD20(last); + const crit = nat === 20 ? 'crit' : nat === 1 ? 'fumble' : null; + return ( +
+ {crit === 'crit' &&
✦ Critical! ✦
} + {crit === 'fumble' &&
✦ Fumble! ✦
} + +
{last.breakdown}
+ + Rolled {last.expression}: {last.total} + +
+ ); + })()}