diff --git a/CHANGELOG.md b/CHANGELOG.md index 49709a27..1b08f046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -602,6 +602,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed +- **A rest that rolls no dice no longer pops the dice tray.** When the GM called a rest, every + character's line opened the dice tray for that player, and flashed it for everyone else, even when + the rest rolled nothing and the tray had only an empty board to show. Now only a rest that rolls + dice opens the tray and throws them. One that rolls none still puts its line in the dice log, so + everyone can see what it did, and the stream overlay leaves it out. - **The same switch at the table.** The dice tray's ANIMATION, the choices a roll asks about before it is rolled (ADVANTAGE, PUSH and the like), and TRY ON THE MAP on a house rule in the builder's graph now use the one switch the builder has: a pill whose knob slides across and lights up when diff --git a/README.md b/README.md index 138d74fd..d558fab0 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ CITY_NET/ │ │ ├── cyberware.js # Chrome as rows, and the three ways it arrives. Reads a Companion export from `type` rather than `name` — real exports leave the name blank, which is why the import brought back nothing at all — and takes the humanity cost with it. Also parses the free-text field this replaced, and gathers the printed form's numbered boxes. Nothing here knows where a piece is installed, because no source does. Each row keeps the mods fitted to it (cyberMods), as the sheet's own reading does │ │ ├── cyberwareEffects.js # What the chrome does to the numbers. One engine, a profile per system - the naming and the field lists differ, the matching and the resolution order do not, so a system contributes two lookup tables rather than a copy of the file. Cities Without Number adds a recompute step over the overlaid copy, because it rolls skills off attribute modifiers rather than attributes: an implant that raised DEX and stopped there moved no Dex roll. An overlay, never a write: the stored stat stays what the player typed and the total is computed on the way out, so nothing compounds and taking a piece out gives the stat back. Joins two vocabularies — modifiers name a stat, the sheet uses field ids, and the Companion says Intelligence where the sheet says INT. Only the (x2) cost marker is normalised away, never a whole bracket: Language (Streetslang) and Language (Other) are two skills. A name the sheet has no field for is reported rather than forced onto a near match │ │ ├── mutate.js # One writer at a time, per sheet. A sheet is a single JSON blob, so changing one field rewrites all of them — and with nothing held across the gap between the read and the write, two writers to the same sheet each built from the same stale copy and the second silently discarded the first. Queued per sheet id rather than globally, since writes to different sheets are genuinely independent and during a fight they are constant -│ │ ├── rests.js # Calling a custom system's rest at the table: who rests (every player character in the running system or the ones named, case aside, each with its first token; chosen NPC tokens with the sheet linked to them in this game, or none); a preview rolls and writes nothing; calling one works each sheet out through its write queue with formulas worked out, then writes the token's health and conditions (a player's onto every token of theirs), one dice-log line each, and tells every screen +│ │ ├── rests.js # Calling a custom system's rest at the table: who rests (every player character in the running system or the ones named, case aside, each with its first token; chosen NPC tokens with the sheet linked to them in this game, or none); a preview rolls and writes nothing; calling one works each sheet out through its write queue with formulas worked out, then writes the token's health and conditions (a player's onto every token of theirs), one dice-log line each, and tells every screen. A character's line for a rest that rolled no dice is sent quiet: into the log, with no tray opened, flashed or thrown for it │ │ ├── giving.js # GIVE ITEM at the table: an item the running game can give (shops/give.js), so many of it, to player characters online or not and NPC tokens with a sheet, all or none to each (no room: named and skipped, the others still get theirs). Placed through each sheet's write queue at write time; a preview says where it would land and writes nothing, and may ask about every player character at once; only the player given it is told, privately │ │ ├── givenList.js # GIVE ITEM's GIVEN list, in its own table (given_items): a line per GIVE naming who gave what to whom and when, with the note; a GIVE nobody got leaves none; each game keeps its last 50, so another game's gifts never push its own out │ │ ├── vehicleState.js # CWN vehicles, resolved against the DB: a rider points at another player's sheet by name, so it takes a query. Shared by the attack path and the token mirror so the badge cannot claim what the damage does not do. Mirrors only the derived combat numbers plus who is aboard onto tokens, never the sheet — whole-table, since boarding changes the driver's badge too. Also lends a gunner the mounts of the car they are in, seats and unseats people, and builds the roster the VEHICLES window reads — occupants live on their own sheets, so one pass turns that inside out @@ -1230,7 +1230,7 @@ CITY_NET/ │ │ ├── downloadText.ts # A text file handed to the browser to save (CATALOGUES.EXE, the CHOICES page's IMPORT) │ │ ├── trayThrow.ts # A throw in the tray (6c4b2), by real physics (cannon-es): the server's throw as dice, the floor as the camera sees it with walls along the picture's edges at a die's foot and top, each die tossed in from its own lane by the left edge, spinning, toward a spot spread over the floor, the felt gripping harder late on, until all lie still; the face that ends on top printed with the server's result and its words turned to the camera; a die left on its rim or end, or thrown as a stand-in, laid flat; worked out once from the seed so every screen sees the same, played back by the clock; revealAfter, when the total shows │ │ ├── trayBoard.ts # What DICE_TRAY.EXE's board shows (6c4e; mockup tray-scoreboard v3): a lone plain total in big digits across both lines, otherwise centered lines of 16 (each roll's name and value, symbols left or faces rolled wrapped greedily, a single roll's outcome on its own line, a group's beside each roll where it fits), pages of two lines, the 5 by 7 letters, rolling noise, the left-to-right decode and READY_ -│ │ ├── rollNotify.ts # When a roll flashes DICE_TRAY.EXE's buttons (6c4g): someone else's, by login account, while the tray is shut, with its bell on; and what the rail's DICE_ROLLER does when clicked while it flashes (the menu, never shut, and the tray) +│ │ ├── rollNotify.ts # When a roll flashes DICE_TRAY.EXE's buttons (6c4g): someone else's, by login account, while the tray is shut, with its bell on; and what the rail's DICE_ROLLER does when clicked while it flashes (the menu, never shut, and the tray); a quiet line (a rest that rolled no dice) neither flashes nor opens the tray (rollOpensTray) │ │ ├── trayWaiting.ts # What DICE_TRAY.EXE's board shows while it waits (6c4f; mockup tray-scoreboard v3): one picture for the whole wait, the next one after each roll, READY_ first: READY_, PONG played (paddles chasing the ball with a paddle's speed and their own aim, scoring, a dashed net, back to 0-0 after a 9), a SNAKE steering itself to its food, RAIN, PIPES laid dot by dot and SCANNER. SNAKE and PIPES start over when full without setting their clock back, and every picture catches up at most MOST_BEHIND_MS in a frame, so none can hold the board up; and (6c4f2) BREAKOUT played (three rows of bricks, a bat following the ball that misses now and then, a broken wall built again), BLOCKS as one game across the whole board (our own shapes, not Tetris's, coming in at random; each character cell clears on its own, five filled dots in a row of it flashing and going and the dots above coming down, clearStrips), STARFIELD and TUMBLING DICE (two dice rolling in, faces flicking, resting on a face each), the games and calmer pictures by turns │ │ ├── threeHelpers.tsx # Three.js scene utilities │ │ ├── buildingParts.ts # A building's root and child parts in the root's own frame, centered and measured, so a preview can draw the building alone and fit it to a box - the same arithmetic Buildings.tsx places them with @@ -1287,7 +1287,7 @@ CITY_NET/ │ ├── sheet-choices.spec.ts # A custom system's CHOICES block at the table (7a3b1): a player's class not met off with what it needs, SOLDIER picked with its features and its bonus on Defense, raised to level 2 unlocking the next feature, a use ticked off and given back by a short rest the GM calls; as the server keeps it; the GM on their own sheet adding an option not met, the bonus named on Defense, and a pick taken away, asked first (7a3b2) │ ├── sheet-nested-choices.spec.ts # Lists inside lists (7a5a2): in the builder SPECIALTIES (OPENED BY the Soldier's Specialty) set THROUGH FEATURES and COMMONS EVERYONE HAS IT; at the table a specialty picked under the feature at level 3, kept waiting at 2, back at 3; COMMONS every character's, its Improvement at level 2 │ ├── sheet-subclass-levels.spec.ts # A list a feature opens follows its opener's level (7a5d): in the builder ARCHETYPES set THROUGH FEATURES, told it follows the FIGHTER, Remarkable Athlete put at 7; at the table CHAMPION picked under Martial Archetype at Fighter 3 with Improved Critical only, Remarkable Athlete at Fighter 7, the character level 7 -│ ├── sheet-rest-levels.spec.ts # A rest's amount reading levels: FIGHTER raised to 3 with Tough's +2 Grit at 2, the GM's long rest sets Resolve to @fighter_level + @grit = 8, on the server and the player's open sheet +│ ├── sheet-rest-levels.spec.ts # A rest's amount reading levels: FIGHTER raised to 3 with Tough's +2 Grit at 2, the GM's long rest sets Resolve to @fighter_level + @grit = 8, on the server and the player's open sheet; the long rest rolling no dice leaves the player's tray shut, and a short rest rolling 1d4 opens it │ ├── sheet-token-max.spec.ts # A feature on the token's maximum (7a5a3): a FIGHTER at level 3 picks TOUGH and the token goes from 20 to 26 of 26, the sheet naming the bonus and MAX HEALTH following; a level down, 24; the GM drops TOUGH from the player's sheet and it is back at 20 │ ├── builder-formula-box.spec.ts # The formula box (formula box 1): a misspelt name in red and USE @level, the words with the sample's value, PER CLASS LEVEL and an INSERT name, AT LEAST on REQUIRES, saved as written with no problems; formula box 2: on ROLLS, @thsi in AGAINST fixed with USE @this and read rolling Strength, ADD from ITS MODIFIER; on RESTS, 1d8 + @con_mod from DICE AND A STAT, 3 to 10 for the sample; saved with no problems │ ├── sheet-visibility.spec.ts # Who sees what on a customized sheet: the owner (the GM's field read-only), another player looking them up from GLOBAL_CHAT (public lines only, nothing else sent), the GM opening the whole sheet the same way and setting the GM's field (coverage family 1) diff --git a/backend/__tests__/system_builder_call_rest.test.js b/backend/__tests__/system_builder_call_rest.test.js index cbd91f69..8c60f294 100644 --- a/backend/__tests__/system_builder_call_rest.test.js +++ b/backend/__tests__/system_builder_call_rest.test.js @@ -171,6 +171,10 @@ describe('calling a rest', () => { expect(emitted.filter((e) => e.event === 'diceRollBroadcast').map((e) => e.payload.historyString)).toEqual(lines.map((l) => l.historyString)); // The tray's board names the rest each was rolled for (6c4d3). expect(emitted.filter((e) => e.event === 'diceRollBroadcast').map((e) => e.payload.board)).toEqual(lines.map(() => ({ label: 'LONG REST' }))); + // Vex rolled the short rest's die, so the tray opens for it; Ash and Rook, with no token to + // heal, rolled nothing, so theirs are quiet lines. + expect(emitted.filter((e) => e.event === 'diceRollBroadcast').map((e) => e.payload.quiet)).toEqual([true, true, undefined]); + expect(lines.map((l) => l.results)).toEqual(['{}', '{}', lines[2].results]); expect(emitted.filter((e) => e.event === 'sheetUpdated').map((e) => e.payload.username)).toEqual(['ash', 'Rook', 'vex']); expect(emitted.filter((e) => e.event === 'dataUpdated')).toEqual([{ event: 'dataUpdated', payload: { isRhombusOnly: true } }]); }); @@ -273,3 +277,14 @@ describe('the dice-log line and its dice', () => { expect(restDice({ rolls: [{ dice: [{ sides: 6, rolls: [2, 3] }] }, { dice: [{ sides: 6, rolls: [4] }] }] })).toEqual({ results: { 6: [2, 3, 4] }, total: 9 }); }); }); + +describe('a rest that rolls no dice (quiet rests)', () => { + it('is quiet: its line goes into the dice log and to every screen, marked so no tray opens for it', async () => { + const res = await rest({ rest: 'end_of_session', players: true }); + expect(res.status).toBe(200); + const lines = await all(db, 'SELECT username, results, historyString FROM dice_rolls ORDER BY id'); + expect(lines.map((l) => [l.username, l.results])).toEqual([['ash', '{}'], ['Rook', '{}'], ['vex', '{}']]); + const told = emitted.filter((e) => e.event === 'diceRollBroadcast').map((e) => e.payload); + expect(told.map((p) => [p.historyString, p.quiet])).toEqual(lines.map((l) => [l.historyString, true])); + }); +}); diff --git a/backend/sheets/rests.js b/backend/sheets/rests.js index 5659fd6b..2c0faaf9 100644 --- a/backend/sheets/rests.js +++ b/backend/sheets/rests.js @@ -134,8 +134,10 @@ const callRest = async (db, io, { system, definition, rest, players, npcs, rng = const { results, total } = restDice(result); const account = c.kind === 'player' ? c.username : c.name; await run(db, 'INSERT INTO dice_rolls (username, total, results, color, historyString) VALUES (?, ?, ?, ?, ?)', [account, total, JSON.stringify(results), LOG_COLOR, historyString]); - // The tray's board names the rest (6c4d3). - io.emit('diceRollBroadcast', withThrow({ userName: c.name, account, results, modifiers: [], color: LOG_COLOR, total, historyString, board: boardOf(String(rest.name).toUpperCase()) })); + // The tray's board names the rest (6c4d3). A rest that rolled nothing is quiet (quiet rests, + // 2026-10-30): its line goes into the log, but no tray opens, flashes or throws for it. + const quiet = Object.keys(results).length ? {} : { quiet: true }; + io.emit('diceRollBroadcast', withThrow({ userName: c.name, account, results, modifiers: [], color: LOG_COLOR, total, historyString, board: boardOf(String(rest.name).toUpperCase()), ...quiet })); if (c.kind === 'player') io.emit('sheetUpdated', { username: c.username }); done.push({ ...shown(definition, c, result), line: historyString }); } diff --git a/e2e/COVERAGE.md b/e2e/COVERAGE.md index 86ae51f9..f9b95b5f 100644 --- a/e2e/COVERAGE.md +++ b/e2e/COVERAGE.md @@ -111,6 +111,7 @@ panel), so a control that looks fine but does nothing is caught for all of them. | A condition's end | when removed, after rounds, at a rest | `condition_rounds`, `resting` | | **GAP** | | Rests | a refill up to a value | `rests`, `resting`, `call_rest` | `player-journey` (LONG REST: Resolve up to Grit) | **AT THE TABLE** | | Rests | an amount reading the levels and a stat with its features' modifiers | `rests`, `resting` | `sheet-rest-levels` (LONG REST: Resolve to Fighter level + Grit with Tough) | **AT THE TABLE** | +| Rests | a rest that rolls no dice: a quiet log line, no tray opened or flashed; one that rolls dice opens the tray | `call_rest` | `sheet-rest-levels` (LONG REST shut, SHORT REST 1d4 open) | **AT THE TABLE** | | Rests | by an amount, to the max, full health, a section; counts as another; a standard rest off; the system's own | `rests`, `resting`, `try_rest` | | **GAP** | | House rules | starts on / off, switched at the table | `house_rules`, `house_rules_routes` | `builder-graph` (TRY ON THE MAP only) | **GAP** | | Under a house rule | a part, a sheet section, a condition, a formula's alternative | `house_rules`, `house_rules_routes` | | **GAP** | diff --git a/e2e/README.md b/e2e/README.md index 155e9c55..4eafe049 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -158,7 +158,7 @@ one machine, files in order. | `tests/parts-off.spec.ts` | Parts switched off at the table (coverage family 3): a custom system with every part on shows the player BANK and HIT_POINTS on the rail, MAX HEALTH, ARMOR and SAVE_STATS in their character controls and IMPORT on their sheet, and the GM INITIATIVE_TRACKER, PAY_PLAYERS, GIVE_ITEM and the buy-back rate; a version with bank, shops, initiative, combat, token health, sheet import and GIVE ITEM off hides every one; back on, they return | | `tests/sheet-nested-choices.spec.ts` | Lists inside lists, and a list everyone has (7a5a2): in the builder SPECIALTIES shows OPENED BY · SOLDIER: SPECIALTY (LV 3) and is set THROUGH FEATURES, COMMONS EVERYONE HAS IT, saved with no problems; published, a player's sheet has no SPECIALTIES block, COMMONS every character's with Improvement next at level 2 and then unlocked; SOLDIER at 3 offers PICK A SPECIALTY under its feature, DUELIST picked there with its own feature; lowered to 2 it waits, kept; back at 3 it counts again; as the server keeps it | | `tests/sheet-subclass-levels.spec.ts` | A list a feature opens takes the level of what opened it (7a5d): in the builder ARCHETYPES is set THROUGH FEATURES and says each one's features unlock by the level of the pick that opened it: FIGHTER; Champion's Remarkable Athlete is put at LEVEL 7, saved with no problems; published, a player raises FIGHTER to 3, picks CHAMPION under Martial Archetype (Pick an Archetype) and has Improved Critical with NEXT · LV 7: Remarkable Athlete; at Fighter 7 it unlocks, the archetype's worked level is 7 and the character's levels stay 7 | -| `tests/sheet-rest-levels.spec.ts` | A rest's amount reads the character's levels (rest levels): a published system whose long rest sets Resolve to `@fighter_level + @grit`; a player sets Grit 3 and raises FIGHTER to 3 (Tough, at 2, adds 2 Grit); the GM calls LONG REST from the GAME tab's rests; Resolve becomes 8 on the server and on the player's open sheet | +| `tests/sheet-rest-levels.spec.ts` | A rest's amount reads the character's levels (rest levels): a published system whose long rest sets Resolve to `@fighter_level + @grit`; a player sets Grit 3 and raises FIGHTER to 3 (Tough, at 2, adds 2 Grit); the GM calls LONG REST from the GAME tab's rests; Resolve becomes 8 on the server and on the player's open sheet; with no dice rolled, the player's DICE_TRAY.EXE stays shut (quiet rests), and a SHORT REST rolling 1d4 then opens it | | `tests/sheet-token-max.spec.ts` | A feature on the token's maximum (7a5a3): a player with a 20 HP token, a FIGHTER at level 3, picks TOUGH (+2 HP per level) and sees 26 / 26 on HIT_POINTS, the bonus named on the sheet's HP and MAX HEALTH at 26; a level down, 24; the GM opens their sheet from VIEW_PLAYER_INFO and drops TOUGH, and the token is back at 20 | | `tests/builder-formula-box.spec.ts` | The formula box (formula box 1): on Tough, `2 * @lvl` in red with USE @level, then +2 for each level · sample Level 3: +6 from the real server; PER CLASS LEVEL for FIGHTER at 3 and @con from INSERT at the cursor; AT LEAST on Fighter's REQUIRES (the sample meets it); the draft saved as written with no problems; and formula box 2: on ROLLS, `@thsi` in AGAINST shown in red and fixed with USE @this, then against your rolled stat · sample, rolling Strength, rolled stat 16: target 16; ADD written by ITS MODIFIER; on RESTS, a short rest's amount written by DICE AND A STAT with CON MODIFIER as 1d8 + @con_mod, said 1d8 + CON modifier · sample CON modifier 2: 3 to 10; the draft saved as written with no problems | | `tests/sheet-visibility.spec.ts` | Who sees what on a customized sheet (coverage family 1): its owner fills in a public field, a private one and a combat value and can't change the GM's field; another player, looking them up from GLOBAL_CHAT's user list (VIEW_PLAYER_INFO), sees only the public lines, and the server sends nothing else; the GM opens the whole sheet the same way (OPEN_SHEET) and sets the GM's field, which the owner then sees | diff --git a/e2e/tests/sheet-rest-levels.spec.ts b/e2e/tests/sheet-rest-levels.spec.ts index f0b25a99..e9640724 100644 --- a/e2e/tests/sheet-rest-levels.spec.ts +++ b/e2e/tests/sheet-rest-levels.spec.ts @@ -6,6 +6,9 @@ import { signInAsGm, signInAsPlayer, openGameSystem, gmToken, asGm, runningSyste // long rest sets Resolve to the Fighter's level plus Grit, Grit read with what a feature adds, as a // roll reads it. The player raises FIGHTER to 3 (Tough, at 2, adds 2 Grit) and the GM calls the rest // from the GAME tab: Resolve becomes 3 + 5. Set up through the API, as player-journey is. +// +// Quiet rests (2026-10-30): that long rest rolls no dice, so the player's dice tray stays shut, its +// line only in the log; a short rest that rolls 1d4 opens it, as a roll of theirs does. const definition = (name: string) => ({ format: 1, @@ -19,7 +22,10 @@ const definition = (name: string) => ({ id: 'classes', one: 'Class', many: 'Classes', levelled: true, options: [{ id: 'fighter', label: 'Fighter', features: [{ id: 'tough', label: 'Tough', at: 2, modifiers: [{ target: 'grit', amount: '2' }] }] }], }], - rests: { long_rest: { refills: [{ what: 'resolve', how: 'to', amount: '@fighter_level + @grit' }] } }, + rests: { + long_rest: { refills: [{ what: 'resolve', how: 'to', amount: '@fighter_level + @grit' }] }, + short_rest: { refills: [{ what: 'resolve', how: 'by', amount: '1d4' }] }, + }, }); test('a long rest sets a number from the character\'s class level and a stat with its feature\'s bonus', async ({ page: gmPage, browser, request }, info) => { @@ -62,5 +68,13 @@ test('a long rest sets a number from the character\'s class level and a stat wit // Resolve is the Fighter's 3 plus Grit 3 with Tough's 2. await expect.poll(async () => (await asGm(request, gm, 'get', `/api/sheets/user/${player.username}`)).data.resolve, { timeout: 15_000 }).toBe(8); await expect(sheet.getByLabel('Resolve', { exact: true })).toHaveValue('8'); + // No dice were rolled, so no tray opened for it. + await expect(sheet.getByText('DICE_TRAY.EXE', { exact: true })).toBeHidden(); + + // A short rest rolls 1d4 for Rook: the tray opens on the player's screen. + await rests.getByLabel('Rest', { exact: true }).selectOption('short_rest'); + await rests.getByRole('button', { name: 'CALL SHORT REST', exact: true }).click(); + await expect(rests.getByRole('status')).toHaveText('SHORT REST CALLED · 1 CHARACTER'); + await expect(sheet.getByText('DICE_TRAY.EXE', { exact: true })).toBeVisible(); await context.close(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 28cf0422..ca83e796 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -76,7 +76,7 @@ import { getTemplate } from './sheets'; import { DiceTrayWindow, DiceScene } from './components/DiceTray'; import { useTrayNotify } from './hooks/useTrayNotify'; import { useRollFlash } from './hooks/useRollFlash'; -import { rollerOf } from './utils/rollNotify'; +import { rollOpensTray } from './utils/rollNotify'; import { CustomDieBuilder } from './components/CustomDieBuilder'; import { useCustomDice } from './hooks/useCustomDice'; import { EnemyRhombus, FriendlyRhombus, PlayerRhombus, OverlapChecker } from './components/Rhombuses'; @@ -942,7 +942,8 @@ function App() { // originating from the standalone sheet tab which can't call onRolled(). // Match on the login account: userName in the broadcast is the sheet's // display name (handle), which may differ. - if (rollerOf(data) === userName) { + // A quiet line (a rest that rolled nothing) only joins the log. + if (rollOpensTray(data, userName)) { setIsDiceTrayOpen(true); setActiveSidebarMenu('dice_menu'); } else { diff --git a/frontend/src/components/DiceTray.tsx b/frontend/src/components/DiceTray.tsx index 8620fd68..86fb5e3f 100644 --- a/frontend/src/components/DiceTray.tsx +++ b/frontend/src/components/DiceTray.tsx @@ -240,6 +240,8 @@ interface RollBroadcast { criticalGlitch?: boolean; /** What the board says of it: what it was for, its words, what it came to (6c4d1-3 on the server). */ board?: BoardRoll['board']; + /** A line for the log with nothing to throw: a rest that rolled no dice. */ + quiet?: boolean; } interface DiceTrayWindowProps { @@ -310,6 +312,8 @@ export function DiceTrayWindow({ pos, setPos, onClose, socketRef, authToken, bel }; const handleBroadcast = (data: RollBroadcast) => { + // Nothing to throw: the line joins the record, and the board keeps what it shows. + if (data.quiet) { record([data]); return; } const now = playing.current; // The same group as the throw under way: thrown with it. if (now && !now.told && data.group && now.group === data.group) { diff --git a/frontend/src/components/StreamerOverlay.tsx b/frontend/src/components/StreamerOverlay.tsx index e0999d5c..4914f4cd 100644 --- a/frontend/src/components/StreamerOverlay.tsx +++ b/frontend/src/components/StreamerOverlay.tsx @@ -27,7 +27,9 @@ export function StreamerOverlay({ socket, directorState, selectedLocation, battl useEffect(() => { if (!socket) return; - const onRoll = (data: { userName: string; results: Record; modifiers: number[]; color: string; total: number }) => { + const onRoll = (data: { userName: string; results: Record; modifiers: number[]; color: string; total: number; quiet?: boolean }) => { + // A rest that rolled no dice is a line for the log, not a roll for the stream. + if (data.quiet) return; // Delay matches the DiceTray rolling animation (5s) so the result isn't // spoiled on stream before the roller sees it themselves. setTimeout(() => { diff --git a/frontend/src/components/__tests__/DiceTray.test.tsx b/frontend/src/components/__tests__/DiceTray.test.tsx index 917f9536..e934fb82 100644 --- a/frontend/src/components/__tests__/DiceTray.test.tsx +++ b/frontend/src/components/__tests__/DiceTray.test.tsx @@ -278,6 +278,28 @@ describe('DiceTrayWindow', () => { } }); + it('puts a quiet line (a rest that rolled no dice) in the record at once, leaving a throw under way to land (quiet rests)', async () => { + const user = userEvent.setup(); + const socketRef = makeSocketRef(); + render(); + await user.click(screen.getByTitle('TOGGLE_HISTORY')); + const broadcast = (socketRef.current.on as ReturnType).mock.calls.find((call: any[]) => call[0] === 'diceRollBroadcast')[1]; + vi.useFakeTimers(); + try { + const rook = { total: 15, results: { 20: [15] }, color: '#0f0', historyString: 'rook rolled', group: 'a', throw: { seed: 11, dice: [{ key: '20', sides: 20, landed: [14] }] } }; + const rest = { total: 0, results: {}, color: '#0f0', historyString: 'END OF SESSION · Ash: nothing changes', quiet: true, board: { label: 'END OF SESSION' } }; + act(() => { broadcast(rook); }); + act(() => { broadcast(rest); }); + expect(screen.getByText(/END OF SESSION · Ash/)).toBeInTheDocument(); + // Rook's throw wasn't cut short: told only when its dice stop. + expect(screen.queryByText(/rook rolled/)).not.toBeInTheDocument(); + act(() => { vi.advanceTimersByTime(revealAfter(throwDice(diceOfRoll(rook), 11, trayCamera()))); }); + expect(screen.getByText(/rook rolled/)).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + it("throws one group's rolls together, an attack's to-hit and damage, each die in its roller's color, and tells both as they stop", async () => { const scene = new THREE.Scene(); three.current = { scene, camera: new THREE.PerspectiveCamera() }; diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts index 5e327e1d..4ffda15a 100644 --- a/frontend/src/hooks/useSocket.ts +++ b/frontend/src/hooks/useSocket.ts @@ -47,7 +47,7 @@ interface UseSocketOptions { /** NPC sheet attached to / detached from a token (admin library ATTACH). */ onNpcLinkChanged?: (data: { location_id: number; sheet_id: number | null }) => void; /** Fires whenever diceRollBroadcast is received for the local user — used to pop the dice tray from the standalone sheet tab. */ - onDiceRollBroadcast?: (data: { userName: string; account?: string }) => void; + onDiceRollBroadcast?: (data: { userName: string; account?: string; quiet?: boolean }) => void; onAttackResult?: (data: { hit: boolean; attackerId: string; attackerName: string; targetId: number; targetName: string; attackType: 'melee' | 'ranged'; roll: number; ac: number; attackerPos: { x: number; z: number } | null; targetPos: { x: number; z: number } }) => void; onMusicState?: (state: any) => void; onMusicLoad?: (data: { trackId: number; src: string; name: string }) => void; @@ -223,7 +223,7 @@ export function useSocket({ newSocket.on('customDiceUpdated', (dice: import('../types').CustomDie[]) => onCustomDiceUpdated?.(dice)); newSocket.on('npcSheetGenerated', (data: { location_id: number; sheet_id: number; npc_label: string; system?: string; portrait_url?: string | null; sheet_name?: string | null; sheet_description?: string | null }) => onNpcSheetGenerated?.(data)); newSocket.on('npcLinkChanged', (data: { location_id: number; sheet_id: number | null }) => onNpcLinkChanged?.(data)); - newSocket.on('diceRollBroadcast', (data: { userName: string; account?: string }) => onDiceRollBroadcast?.(data)); + newSocket.on('diceRollBroadcast', (data: { userName: string; account?: string; quiet?: boolean }) => onDiceRollBroadcast?.(data)); newSocket.on('sheetAttackError', (data: { message: string }) => onNotification(data.message)); newSocket.on('attackResult', (data: any) => onAttackResult?.(data)); diff --git a/frontend/src/utils/__tests__/rollNotify.test.ts b/frontend/src/utils/__tests__/rollNotify.test.ts index 2133b6ca..765a0aa7 100644 --- a/frontend/src/utils/__tests__/rollNotify.test.ts +++ b/frontend/src/utils/__tests__/rollNotify.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { rollerOf, rollFlashes, diceRailClick } from '../rollNotify'; +import { rollerOf, rollFlashes, rollOpensTray, diceRailClick } from '../rollNotify'; /** * When a roll flashes DICE_TRAY.EXE's buttons (6c4g): someone else's, while the tray is shut, @@ -45,3 +45,15 @@ describe('a click on the rail\'s DICE_ROLLER', () => { for (const menu of ['none', 'dice_menu', 'bank_menu']) expect(diceRailClick(menu, true), menu).toEqual({ menu: 'dice_menu', openTray: true }); }); }); + +describe('a quiet line (a rest that rolled no dice)', () => { + it('neither flashes the tray nor opens it, whoever it is for', () => { + expect(rollFlashes({ userName: 'rook', quiet: true }, quiet)).toBe(false); + expect(rollOpensTray({ userName: 'KES', account: 'kes', quiet: true }, 'kes')).toBe(false); + }); + + it('a roll of your own opens the tray; someone else\'s doesn\'t', () => { + expect(rollOpensTray({ userName: 'KES', account: 'kes' }, 'kes')).toBe(true); + expect(rollOpensTray({ userName: 'rook' }, 'kes')).toBe(false); + }); +}); diff --git a/frontend/src/utils/rollNotify.ts b/frontend/src/utils/rollNotify.ts index 3fd5a44b..cc27af8c 100644 --- a/frontend/src/utils/rollNotify.ts +++ b/frontend/src/utils/rollNotify.ts @@ -1,12 +1,16 @@ // When a roll flashes DICE_TRAY.EXE's buttons (6c4g): the DICE_ROLLER button on the rail and the // DICE_TRAY.exe button in its menu, until the tray is opened. Like chat's: someone else's roll, // made while the tray is shut, with the tray's bell on. Your own rolls open the tray themselves. -// Only rolls the server sends you can flash, so a roll kept from you never gives itself away. +// Only rolls the server sends you can flash, so a roll kept from you never gives itself away. A +// quiet line (a rest that rolled no dice, 2026-10-30) neither opens the tray nor flashes it: it +// only joins the log. /** A roll as it arrives: whose it is, by login account, and the name it was rolled under. */ export interface ArrivingRoll { userName: string; account?: string; + /** A line for the log with no dice to throw: a rest that rolled nothing. */ + quiet?: boolean; } /** Whose roll it is: the login account, or the name it came under when the server sent none. */ @@ -14,7 +18,10 @@ export const rollerOf = (roll: ArrivingRoll) => roll.account ?? roll.userName; /** Whether `roll` flashes the tray's buttons for `me`. */ export const rollFlashes = (roll: ArrivingRoll, { me, trayOpen, bell }: { me: string; trayOpen: boolean; bell: boolean }) => - bell && !trayOpen && rollerOf(roll) !== me; + bell && !trayOpen && !roll.quiet && rollerOf(roll) !== me; + +/** Whether `roll` opens the tray for `me`: their own, unless it is quiet. */ +export const rollOpensTray = (roll: ArrivingRoll, me: string) => !roll.quiet && rollerOf(roll) === me; /** * What a click on the rail's DICE_ROLLER does: opens or shuts its menu, as ever; but while it