Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions backend/__tests__/system_builder_call_rest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }]);
});
Expand Down Expand Up @@ -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]));
});
});
6 changes: 4 additions & 2 deletions backend/sheets/rests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
1 change: 1 addition & 0 deletions e2e/COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** |
Expand Down
2 changes: 1 addition & 1 deletion e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading