From f5f534fbded187c4c15d6c829d6aadd88a453966 Mon Sep 17 00:00:00 2001
From: Developer
Date: Sat, 31 Oct 2026 18:21:11 -0500
Subject: [PATCH 1/2] feat: the GAME tab's RESTS shows who has finished a rest
called on the sheets, with FINISH FOR one or everyone left, each asked first
(R4)
---
CHANGELOG.md | 1 +
README.md | 9 +-
.../system_builder_call_rest.test.js | 4 +-
.../system_builder_rest_finish.test.js | 190 ++++++++++++++++++
.../system_builder_rest_on_sheet.test.js | 7 +-
backend/routes/sheets.js | 28 ++-
backend/sheets/rests.js | 57 ++++--
backend/sockets/choices.js | 2 +-
backend/systemBuilder/recovery.js | 57 +++++-
e2e/COVERAGE.md | 2 +-
e2e/README.md | 2 +-
e2e/tests/sheet-rest-on-sheet.spec.ts | 25 ++-
frontend/src/components/AdminPanel.tsx | 8 +-
frontend/src/components/GameRestsPanel.tsx | 95 ++++++++-
.../__tests__/GameRestsPanel.test.tsx | 112 ++++++++++-
.../components/__tests__/RestSection.test.tsx | 3 +
.../src/sheets/__tests__/restCalls.test.ts | 28 ++-
frontend/src/sheets/choicesShown.ts | 7 +-
frontend/src/sheets/restCalls.ts | 33 ++-
19 files changed, 621 insertions(+), 49 deletions(-)
create mode 100644 backend/__tests__/system_builder_rest_finish.test.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b64440bd..c66f8592 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -82,6 +82,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added
+- Where a game's rests are finished on each sheet, the GAME tab's RESTS shows who has finished the rest the GM called: WAITING ON 1 OF 3, a line for each player (finished, or finished by the GM, with what it changed and the dice spent; resting; not finished yet), and the NPCs it rested at once. FINISH FOR one player, or FOR EVERYONE still resting, finishes it as they would have, asked first. The list follows the players as they finish (R4).
- **Resting on the sheet (R3b).** A system whose rests are finished on each sheet gives every
character sheet a RESTS section, after CHOICES: a button for each of the game's rests. Each asks
first, so a stray click rests nobody. A rest the recovery dice are spent at opens its panel, with
diff --git a/README.md b/README.md
index da2a2fdc..b3d5e557 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. 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. And takeRest (R3a): one player character's rest taken from their sheet, doing all a called rest does, and finishing a rest the GM called on that sheet. Where rests are finished on each sheet (R3b), the GM's call opens the rest on each player's sheet instead, answered as called, and NPCs rest at once
+│ │ ├── 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. And takeRest (R3a): one player character's rest taken from their sheet, doing all a called rest does, and finishing a rest the GM called on that sheet. Where rests are finished on each sheet (R3b), the GM's call opens the rest on each player's sheet instead, answered as called, and NPCs rest at once. The GM's list of who has finished the latest call (calledShown), and FINISH FOR one or everyone left, taken as the player would (finishCalled, R4)
│ │ ├── recover.js # Spending recovery dice at the table (R2a): a pool's dice spent and rolled inside the sheet's write queue, the heal through the system's health model onto every token of the player's, a dice-log line for everyone that opens the tray ("HIT DICE · Rook: spent 2 Fighter d10, 6 + 6 + 4 = 16 · HP 21 → 37"); a log line that fails to save is said in the server's log and the roll still goes out
│ │ ├── 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
@@ -462,7 +462,7 @@ CITY_NET/
│ │ ├── tierRolls.js # An NPC tier worked out for a level: each box a number, a formula reading @level, or dice ("3d6", "@level d8 + 4"), rolled and clamped to the limits, with every die kept so the builder's TRY IT can show it. A rest's amounts are read the same way, naming the character's own numbers (rollAmount)
│ │ ├── rests.js # A system's rests: Short rest, Long rest, End of scene and End of session (renamed or off) and its own, twelve in all, stored as changes only; each counts as other rests (no loops) and refills, in order, health (to full, or up by an amount where the model heals by amounts, a track named in a two-track system), a sheet number (to its maximum, up or down by, set to) or a whole section to its maximums; amounts are numbers, formulas or dice naming the character's own numbers. restsOf gives the ones on, whole. An amount may name the levels too (@level, @fighter_level), read where the rest happens
│ │ ├── resting.js # What one rest does to one character: the rests it counts as first, then its own refills in order; health back through the system's own model (up by = its HEAL, to full the model's way: a pool to its max, a second track or marks cleared, harm slots emptied, wounds given back); sheet numbers held between 0 and their maximum; amounts read from the character as it stands; dice pending in a preview until something settles them; the conditions ending at any of those rests taken off. Pure: the route reads, calls it and writes. Also the rest's dice-log line and its dice, by sides. A rest also sets back the uses of features it refills, or that a rest it counts as refills (7a3a). Amounts read the sheet as rolls do: stats with their features' modifiers, and the levels; and the recovery dice it gives back, each pool as left after (R2a)
-│ │ ├── recovery.js # How a system's characters rest (R1a; approved mockup rest-on-sheet): who finishes a rest the GM calls (at the call, or on each sheet), and the recovery dice: their name, how many each has (one per level of each option with a die, or a formula), what one heals (the die, once or per level, plus a formula), the rests they're spent at or any time, and what rests give back (all, half, or by an amount); the checks, restingOf with its defaults, and the options with a die. No presets: the builder's hint names the games it is like. At the table (R2a): SPENDING any time or when the GM opens it (the game-wide recovery_open setting), a character's pools from their picks (one per class with a die, as many as its level; or one of a formula's count, of the largest die), spending them with a roll (the die once or per level, plus the formula), and what rests give back. In the sheet's words (recoveryShown, R2b): the name, what one heals, the rests by name, who says when. For the sheet (R3a): restingShown (who finishes a called rest, each rest by name and whether the dice are spent at it) and REST_CALL_KEY, a rest the GM called on a sheet
+│ │ ├── recovery.js # How a system's characters rest (R1a; approved mockup rest-on-sheet): who finishes a rest the GM calls (at the call, or on each sheet), and the recovery dice: their name, how many each has (one per level of each option with a die, or a formula), what one heals (the die, once or per level, plus a formula), the rests they're spent at or any time, and what rests give back (all, half, or by an amount); the checks, restingOf with its defaults, and the options with a die. No presets: the builder's hint names the games it is like. At the table (R2a): SPENDING any time or when the GM opens it (the game-wide recovery_open setting), a character's pools from their picks (one per class with a die, as many as its level; or one of a formula's count, of the largest die), spending them with a roll (the die once or per level, plus the formula), and what rests give back. In the sheet's words (recoveryShown, R2b): the name, what one heals, the rests by name, who says when. For the sheet (R3a): restingShown (who finishes a called rest, each rest by name and whether the dice are spent at it) and REST_CALL_KEY, a rest the GM called on a sheet, kept with its call once finished (who by, what it changed, the dice spent since) for the GM's list (restCallsShown, R4)
│ │ ├── picking.js # Who may do what to a character's picks (7a3a, the user's calls of 2026-10-28): a player picks for their own while the list has room and the requirement is met, sets their own levels (1 to 30, levelled lists) and spent uses (none to all); only the GM takes a pick away or picks past a requirement, never past the list's room. Pure: the definition and the sheet in, the data to store or why not out; and how many of a pool of recovery dice are spent, ticked off or put right (R2a)
│ │ ├── versionDiff.js # What a new version of a system changes, before PUBLISH: on the sheet (gone, new, renamed, now GM-only, choices taken away), formulas, stat ranges, the health model, currencies, features, conditions, and the sections that touch no character; for one character, the values that stop showing (kept), a picked choice that goes, a stat past its new range and the numbers old to new; and those numbers on the SAMPLE values for a system nobody plays yet. Also a house rule added, removed, renamed or starting the other way, and a formula's alternative added, changed or gone, by the rule's name. Writes nothing
│ │ ├── publishPreview.js # What PUBLISH would change, read off the database: versionDiff.js run over every character playing the system (players, then NPCs), with token health and defense and bank cash overlaid from where the system keeps them (the token row while it runs, token_vitals otherwise), money a removed currency still holds and conditions a removed one leaves on a token; the SAMPLE values when nobody plays it; nothing for a first version. The numbers worked out as the game runs each version, with the table's house-rule choices. Writes nothing
@@ -530,6 +530,7 @@ CITY_NET/
│ ├── system_builder_resting.test.js # One rest on one character: the order of counted rests (loops never revisited), a long rest as the mockup shows it, dice pending, settled and rolled, sheet numbers clamped and read as they stand, no token, and health under every model (pool and its word, both tracks or one named, damage types, harm slots, wounds, hit locations, token health off); an amount reading a class level and a stat with its feature's bonus, and 0 for a level not taken
│ ├── system_builder_recovery.test.js # How characters rest (R1a): D&D's hit dice and 13th Age's recoveries from the same tools; a die only on a levelled list's option, 2 to 100 sides; every part's mistake; spent at the system's rests, given back by them once each; a die needed to roll; the defaults; the system file carrying it all
│ ├── system_builder_recovery_pools.test.js # Recovery dice at the table (R2a): the pools by levels and by a formula, held to what's spent; spending with a roll, once or per level, never healing below 0, and why not; the log line; ticked off and put right; given back by rests, half, all or an amount; through the socket, healing every token with a roll for everyone, refused while the GM has spending closed and the GM any time, a roll that can't be logged still going out, and no token
+│ ├── system_builder_rest_finish.test.js # The GM's side of a rest finished on each sheet (R4): the call as a sheet keeps it, finished and by whom; the GM's view of the latest call (finished, resting, waiting, how many left, the NPCs); through the routes, FINISH FOR one and for everyone, a rest turned off since, and the refusals
│ ├── system_builder_rest_on_sheet.test.js # Resting from the sheet (R3a): the rests by name with where the dice are spent, in the template; a called rest read safely; through the socket, a player's own rest refilling, healing and logging, dice given back at a long rest, a called rest finished by its own rest only, refused at the GM's call but the GM may, unknown or off rests and no sheet refused, a built-in game in its own words; the starter sheet's RESTS section, and the GM's call on each sheet opening it on players' sheets with NPCs at once (R3b)
│ ├── system_builder_call_rest.test.js # Calling a rest through the routes: the rests offered (none for a built-in game), a preview that writes nothing, a long rest written to sheets, every token of a player's and the dice log, dice rolled, players named or none, NPC tokens with and without a sheet of this game, formulas read, two rests at once both landing, an edit kept, refusals, and the log line and its dice
│ ├── system_builder_version_diff.test.js # What a new version changes, each kind of change both ways, a version against itself (nothing), what it means for one sheet (kept values, a picked choice, a stat past its range, numbers old to new, lookup tables), and the numbers on the SAMPLE values; house rules added, gone, renamed or starting the other way, and alternatives by their rule's name
@@ -834,7 +835,7 @@ CITY_NET/
│ │ │ ├── ConditionIconPicker.tsx # A condition's icon picked: the 24 drawn ones, or UPLOAD for a small PNG, WebP or SVG (a refusal said); the builder's CONDITIONS page and the GAME tab's both use it
│ │ │ ├── TableConditionsPanel.tsx # The GAME tab's CONDITIONS under a built-in game, main admin only: the standard set (four shown, the rest folded) and the table's own, added with a name, icon and description (no modifiers or rounds) or removed after asking; the server's refusal said, and every screen asking again once saved
│ │ │ ├── GameFolder.tsx # A folder of the admin GAME tab (R2b): RESTS and BANK, a solid ▾ / ▸ button over what it holds, open at first and kept open or shut in this browser
-│ │ │ ├── GameRestsPanel.tsx # The GAME tab's RESTS under a custom game, for the GM and granted editors: one of the game's rests, who rests (every player character, the NPCs on the map being viewed too, or ticked by hand), what each one will get (dice still to roll shown as such, conditions wearing off), and CALL; everyone previewed once so ticking asks nothing more, previewed again after a call; a refusal said. OPEN SPENDING (R2b), for a game whose recovery dice wait on the GM: on, players spend and tick them off; off, theirs wait; in the GAME tab's RESTS folder, OPEN SPENDING at its top. CALL asks first, saying what will happen (R3b); on each sheet, it opens on the players' sheets and says how many
+│ │ │ ├── GameRestsPanel.tsx # The GAME tab's RESTS under a custom game, for the GM and granted editors: one of the game's rests, who rests (every player character, the NPCs on the map being viewed too, or ticked by hand), what each one will get (dice still to roll shown as such, conditions wearing off), and CALL; everyone previewed once so ticking asks nothing more, previewed again after a call; a refusal said. OPEN SPENDING (R2b), for a game whose recovery dice wait on the GM: on, players spend and tick them off; off, theirs wait; in the GAME tab's RESTS folder, OPEN SPENDING at its top. CALL asks first, saying what will happen (R3b); on each sheet, it opens on the players' sheets and says how many. Then who has finished it (R4): WAITING ON n OF m, a line for each player, FINISH FOR one or for everyone left (asked first), the NPCs rested at once; read again whenever a sheet changes
│ │ │ ├── TryItPage.tsx # TRY IT: the draft as a player would meet it, unsaved changes included. A made-up character on its own sheet with formulas worked out as you type, its health on a pretend token in the HEALTH folder's own panels with what others see, an NPC rolled from a tier, and CALL A REST: one of the draft's rests rolled on the character and its token, the game's line in the log. Saves nothing but SAVE AS THE SAMPLE CHARACTER. A "?" beside HEALTH, WHAT OTHERS SEE and the NPC's level. RULES: the draft tried with each house rule switched on or off, starting as each STARTS, the game's choice untouched
│ │ │ ├── ToggleSwitch.tsx # The one on/off switch (switches 1; approved mockup toggle-switch, 2026-10-29): a pill whose knob slides across and lights up when on, the label beside it as part of one button, colors from the theme, a button's own attributes passed through. Every switch in the builder (CHOICES, PARTS, CONDITIONS, RESTS, TRY IT, ROLLS, TEST ROLL) and at the table (switches 2: the dice tray's ANIMATION, a roll's choices before it is rolled, TRY ON THE MAP on the graph)
│ │ │ ├── ControlledNpcSheetWindow.tsx # A friendly NPC's sheet for the player the GM gave it to: read-only, with rolls made for the NPC through the server
@@ -1115,7 +1116,7 @@ CITY_NET/
│ │ │ ├── tokenConditions.ts # A token's conditions in the windows as logic: the column read as the server reads it, what to draw from the game's list (one it no longer has left out), rounds left and modifiers where sent, putting one on or taking one off without resetting another's rounds, and the map's four icons then +N
│ │ │ ├── tableConditions.ts # The GAME tab's CONDITIONS as logic: the table's own from the game's list, sent back whole, one added under an id no standard or other one has (name and description trimmed, a blank description not sent), one removed, the limit of 60 counting the standard set
│ │ │ ├── tempHp.ts # A token's temporary HP on its sheet: the key the server reads it under beside the linked values, and a linked HP field's current with it added (the HEALTH box draws it in the warning color)
-│ │ │ ├── restCalls.ts # Calling a rest from the GAME tab: the two routes (GET /api/sheets/rests, POST /api/sheets/rest, preview or call), the NPC tokens on the map being viewed (a battle map's floor, or the city), who is chosen, what the server is asked for, and how a change reads ("HP 9 → 22", or "HP 9 + 1d8" before the dice). The recovery dice's name and who says when, and OPEN SPENDING's setting read and written (R2b). What CALL's confirm says, and what the panel says once called (R3b)
+│ │ │ ├── restCalls.ts # Calling a rest from the GAME tab: the two routes (GET /api/sheets/rests, POST /api/sheets/rest, preview or call), the NPC tokens on the map being viewed (a battle map's floor, or the city), who is chosen, what the server is asked for, and how a change reads ("HP 9 → 22", or "HP 9 + 1d8" before the dice). The recovery dice's name and who says when, and OPEN SPENDING's setting read and written (R2b). What CALL's confirm says, and what the panel says once called (R3b). The latest call's list (R4): POST /api/sheets/rest/finish, how many are left, and each player's line (finished, by the GM, what changed, dice spent; resting; not finished yet)
│ │ │ ├── editHistory.ts # UNDO and REDO for the graph view: what each edit replaced, typing in one box as one step, anything else a step of its own, what was undone dropped by a new edit, a hundred steps kept
│ │ │ ├── graphAdds.ts # Adding and removing nodes from the graph view as FORMS' own + and × edits: what can be added (and what comes later), searched by every word, a node added where asked (or the nearest place clear of every node and group, nothing else moving) and opened (a stat as a row of its group, a note pinned to the node picked), a roll from each preset as the ROLLS tab makes it (6b4b); one removed (a note with its place; a roll as REMOVE ROLL; a house rule sent to HOUSE RULES)
│ │ │ ├── graphNotes.ts # Notes on the graph view: read from the graph section, a new one (next free id, up to 200) pinned and placed, its words (to 1000 characters), pinned to a stats group, formula, table or house rule or to nothing (never a note), removed with its place; the clear spot beside a node a new one goes in, with room to grow; what can be pinned to in players' words; the notes FORMS shows under an entry (a group's own and its stats')
diff --git a/backend/__tests__/system_builder_call_rest.test.js b/backend/__tests__/system_builder_call_rest.test.js
index 4449e1fe..f0b77039 100644
--- a/backend/__tests__/system_builder_call_rest.test.js
+++ b/backend/__tests__/system_builder_call_rest.test.js
@@ -112,9 +112,9 @@ const named = (characters) => characters.map((c) => c.name);
describe('the rests the GAME tab offers', () => {
it('are the running custom system\'s that are on; a built-in game has none here', async () => {
const res = await request(app).get('/api/sheets/rests').set('Authorization', `Bearer ${GM}`);
- expect(res.body).toEqual({ system: HEARTH, rests: [{ id: 'short_rest', name: 'Short rest' }, { id: 'long_rest', name: 'Long rest' }, { id: 'end_of_session', name: 'End of session' }], recovery: null, finish: 'call' });
+ expect(res.body).toEqual({ system: HEARTH, rests: [{ id: 'short_rest', name: 'Short rest' }, { id: 'long_rest', name: 'Long rest' }, { id: 'end_of_session', name: 'End of session' }], recovery: null, finish: 'call', call: null });
await run(db, `UPDATE global_settings SET value = 'cities_without_number' WHERE key = 'game_system'`);
- expect((await request(app).get('/api/sheets/rests').set('Authorization', `Bearer ${EDITOR}`)).body).toEqual({ system: 'cities_without_number', rests: [], recovery: null, finish: 'call' });
+ expect((await request(app).get('/api/sheets/rests').set('Authorization', `Bearer ${EDITOR}`)).body).toEqual({ system: 'cities_without_number', rests: [], recovery: null, finish: 'call', call: null });
expect((await request(app).get('/api/sheets/rests').set('Authorization', `Bearer ${PLAYER}`)).status).toBe(403);
});
diff --git a/backend/__tests__/system_builder_rest_finish.test.js b/backend/__tests__/system_builder_rest_finish.test.js
new file mode 100644
index 00000000..8e3c80e1
--- /dev/null
+++ b/backend/__tests__/system_builder_rest_finish.test.js
@@ -0,0 +1,190 @@
+/**
+ * The GM's side of a rest finished on each sheet (R4; approved mockup docs/mockups/rest-on-sheet.html,
+ * pane 4): after calling one, the GAME tab sees each player in it finished (by them or by the GM,
+ * with what it changed and the recovery dice spent), resting (dice spent, not finished) or waiting,
+ * how many are left and how many NPCs rested at once; and FINISH FOR or FOR EVERYONE finishes
+ * it for those still resting, as they would have.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import express from 'express';
+import request from 'supertest';
+import jwt from 'jsonwebtoken';
+import { createRequire } from 'module';
+import { makeTestDb, get, run } from './helpers/testDb.js';
+import sheetsRouteFactory from '../routes/sheets.js';
+
+const require_ = createRequire(import.meta.url);
+const runtime = require_('../systemBuilder/runtime');
+const { REST_CALL_KEY, RECOVERY_KEY, restCallOf, callOn, finishedCall, restCallsShown, spentNow } = require_('../systemBuilder/recovery');
+const { PICKS_KEY } = require_('../systemBuilder/choices');
+const { takeRest, finishCalled } = require_('../sheets/rests');
+const { elevatedUsers } = require_('../middleware/auth');
+
+const sign = (payload) => jwt.sign(payload, 'test-secret');
+const GM = sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false });
+const EDITOR = sign({ username: 'ghost', isTemporary: true });
+const PLAYER = sign({ username: 'rook', role: 'player' });
+
+const ID = 'sys_restfinishfinish';
+const FORGE = {
+ format: 1, name: 'Forge',
+ choices: [{ id: 'classes', one: 'Class', many: 'Classes', levelled: true, options: [{ id: 'fighter', label: 'Fighter', die: 10 }] }],
+ rests: { short_rest: { refills: [{ what: 'health', how: 'by', amount: '2' }] }, long_rest: { refills: [{ what: 'health', how: 'full' }] } },
+ resting: { finish: 'sheet', recovery: { name: 'Hit Dice', spent_at: ['short_rest'], back: [{ rest: 'long_rest', how: 'all' }] } },
+};
+const FIGHTER = (name, spent = 0) => ({ name, [PICKS_KEY]: { classes: [{ option: 'fighter', level: 3 }] }, [RECOVERY_KEY]: { fighter: spent } });
+const SHORT = { id: 'short_rest', name: 'Short rest' };
+
+describe('the call as a sheet keeps it', () => {
+ it('names the call, the rest, the NPCs rested and the dice spent when called', () => {
+ expect(spentNow(FORGE, FIGHTER('Rook', 2))).toBe(2);
+ expect(spentNow(FORGE, { name: 'Nobody' })).toBe(0);
+ expect(callOn(FORGE, SHORT, { id: 'c1', at: '2026-10-31T10:00:00.000Z', npcs: 2 }, FIGHTER('Rook', 1)))
+ .toEqual({ id: 'c1', at: '2026-10-31T10:00:00.000Z', rest: 'short_rest', name: 'Short rest', npcs: 2, before: 1 });
+ });
+
+ it('finished, says by whom, what it changed and the dice spent since; and no longer opens on the sheet', () => {
+ const call = callOn(FORGE, SHORT, { id: 'c1', at: 'x', npcs: 0 }, FIGHTER('Rook', 1));
+ const data = { ...FIGHTER('Rook', 3), [REST_CALL_KEY]: call };
+ expect(restCallOf(data)).toEqual({ rest: 'short_rest', name: 'Short rest' });
+ const done = finishedCall(FORGE, data, 'gm', [{ what: 'health', label: 'HP', from: 3, to: 5 }]);
+ expect(done).toEqual({ ...call, done: true, by: 'gm', changes: [{ what: 'health', label: 'HP', from: 3, to: 5 }], spent: 2 });
+ expect(restCallOf({ ...data, [REST_CALL_KEY]: done })).toBeNull();
+ // Dice given back before it was called can't count as spent.
+ expect(finishedCall(FORGE, { ...FIGHTER('Rook', 0), [REST_CALL_KEY]: call }, 'player', []).spent).toBe(0);
+ });
+});
+
+describe('the GM\'s view of the latest call', () => {
+ const kept = (c, extra = {}) => ({ ...callOn(FORGE, SHORT, c, FIGHTER('x')), ...extra });
+ const one = { id: 'c1', at: '2026-10-31T10:00:00.000Z', npcs: 3 };
+ const two = { id: 'c2', at: '2026-10-31T11:00:00.000Z', npcs: 0 };
+
+ it('each player in it finished, resting or waiting, sorted by name, with how many are left', () => {
+ const view = restCallsShown(FORGE, [
+ { username: 'vex', data: { ...FIGHTER('Vex'), [REST_CALL_KEY]: kept(one) } },
+ { username: 'rook', data: { ...FIGHTER('Rook', 1), [REST_CALL_KEY]: kept(one) } },
+ { username: 'ash', data: { ...FIGHTER(''), [REST_CALL_KEY]: kept(one, { done: true, by: 'player', changes: [{ what: 'health', label: 'HP', from: 9, to: 11 }], spent: 1 }) } },
+ { username: 'juno', data: { name: 'Juno' } },
+ ]);
+ expect(view).toEqual({
+ id: 'c1', rest: 'short_rest', name: 'Short rest', at: one.at, npcs: 3, waiting: 2, players: [
+ { username: 'ash', name: 'ash', state: 'finished', by: 'player', changes: [{ what: 'health', label: 'HP', from: 9, to: 11 }], spent: 1 },
+ { username: 'rook', name: 'Rook', state: 'resting', changes: [], spent: 1 },
+ { username: 'vex', name: 'Vex', state: 'waiting', changes: [], spent: 0 },
+ ],
+ });
+ });
+
+ it('is the latest call only; finished by the GM says so; nothing kept, or only older records, is null', () => {
+ const view = restCallsShown(FORGE, [
+ { username: 'rook', data: { ...FIGHTER('Rook'), [REST_CALL_KEY]: kept(one) } },
+ { username: 'ash', data: { ...FIGHTER('Ash'), [REST_CALL_KEY]: kept(two, { done: true, by: 'gm', spent: 'x' }) } },
+ ]);
+ expect(view.id).toBe('c2');
+ expect(view.players).toEqual([{ username: 'ash', name: 'Ash', state: 'finished', by: 'gm', changes: [], spent: 0 }]);
+ expect(view.waiting).toBe(0);
+ expect(restCallsShown(FORGE, [{ username: 'rook', data: FIGHTER('Rook') }])).toBeNull();
+ // A call kept from before the GM's list (R3b) had no id.
+ expect(restCallsShown(FORGE, [{ username: 'rook', data: { [REST_CALL_KEY]: { rest: 'short_rest', name: 'Short rest' } } }])).toBeNull();
+ expect(restCallsShown(FORGE, [])).toBeNull();
+ });
+});
+
+describe('at the table', () => {
+ let db;
+ let app;
+ let dog;
+ const io = { emit: () => {} };
+
+ afterEach(() => { elevatedUsers.delete('ghost'); });
+ beforeEach(async () => {
+ elevatedUsers.add('ghost');
+ db = await makeTestDb();
+ await run(db, `CREATE TABLE IF NOT EXISTS dice_rolls (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, total INTEGER NOT NULL,
+ results TEXT NOT NULL, color TEXT NOT NULL, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)`);
+ const text = JSON.stringify(FORGE);
+ await run(db, 'INSERT INTO custom_systems (id, name, draft, published, version) VALUES (?, ?, ?, ?, 1)', [ID, 'Forge', text, text]);
+ await new Promise((resolve) => runtime.load(db, resolve));
+ await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [ID]);
+ for (const [who, name] of [['rook', 'Rook'], ['ash', 'Ash']]) {
+ await run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)', [who, ID, JSON.stringify(FIGHTER(name))]);
+ }
+ await run(db, 'INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max, hp_temp, conditions) VALUES (?, 0, 0, 0, ?, ?, ?, ?, 0, ?)', ['ROOK', 'rhombus', 'rook', 10, 30, '[]']);
+ dog = (await run(db, 'INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max, hp_temp, conditions) VALUES (?, 0, 0, 0, ?, ?, ?, ?, 0, ?)', ['DOG', 'enemy_rhombus', 'gm', 3, 9, '[]'])).lastID;
+ app = express();
+ app.use(express.json());
+ app.use('/api/sheets', sheetsRouteFactory(db, io));
+ });
+
+ const call = (body) => request(app).post('/api/sheets/rest').set('Authorization', `Bearer ${GM}`).send(body);
+ const finish = (body, as = GM) => request(app).post('/api/sheets/rest/finish').set('Authorization', `Bearer ${as}`).send(body);
+ const view = async (as = GM) => (await request(app).get('/api/sheets/rests').set('Authorization', `Bearer ${as}`)).body.call;
+ const stored = async (who) => JSON.parse((await get(db, 'SELECT data FROM character_sheets WHERE username = ?', [who])).data);
+ const states = (v) => v.players.map((p) => [p.name, p.state, p.by ?? null, p.spent]);
+
+ it('the GM sees each player waiting, resting, finished by them or by the GM, and how many NPCs rested', async () => {
+ expect(await view()).toBeNull();
+ expect((await call({ rest: 'short_rest', players: true, npcs: [dog] })).status).toBe(200);
+ let v = await view(EDITOR);
+ expect({ name: v.name, rest: v.rest, npcs: v.npcs, waiting: v.waiting }).toEqual({ name: 'Short rest', rest: 'short_rest', npcs: 1, waiting: 2 });
+ expect(states(v)).toEqual([['Ash', 'waiting', null, 0], ['Rook', 'waiting', null, 0]]);
+ expect((await stored('rook'))[REST_CALL_KEY]).toMatchObject({ id: v.id, at: v.at, rest: 'short_rest', npcs: 1, before: 0 });
+
+ // Rook spends a hit die, then finishes on their own sheet.
+ await run(db, 'UPDATE character_sheets SET data = ? WHERE username = ?', [JSON.stringify({ ...(await stored('rook')), [RECOVERY_KEY]: { fighter: 1 } }), 'rook']);
+ expect(states(await view())).toEqual([['Ash', 'waiting', null, 0], ['Rook', 'resting', null, 1]]);
+ expect((await takeRest(db, io, { system: ID, definition: FORGE, restId: 'short_rest', username: 'rook' })).error).toBeUndefined();
+ v = await view();
+ expect(states(v)).toEqual([['Ash', 'waiting', null, 0], ['Rook', 'finished', 'player', 1]]);
+ expect(v.players[1].changes.map((c) => [c.label, c.from, c.to])).toEqual([['HP', 10, 12]]);
+ expect(v.waiting).toBe(1);
+ expect(restCallOf(await stored('rook'))).toBeNull();
+
+ // FINISH FOR ASH: as Ash would, kept as the GM's.
+ const done = await finish({ players: ['ASH'] });
+ expect(done.status).toBe(200);
+ expect(done.body.finished).toBe(1);
+ expect(states(done.body.call)).toEqual([['Ash', 'finished', 'gm', 0], ['Rook', 'finished', 'player', 1]]);
+ expect(done.body.call.waiting).toBe(0);
+ // Nobody left: FOR EVERYONE finishes nobody, and Rook's rest isn't taken twice.
+ expect((await finish({ players: true })).body.finished).toBe(0);
+ expect((await get(db, `SELECT hp_current FROM locations WHERE owner = 'rook'`)).hp_current).toBe(12);
+ });
+
+ it('FINISH FOR one leaves the others resting; FOR EVERYONE finishes those left; the next call starts the list again', async () => {
+ await call({ rest: 'short_rest', players: true, npcs: [] });
+ const one = await finish({ players: ['rook'] });
+ expect([one.body.finished, states(one.body.call)]).toEqual([1, [['Ash', 'waiting', null, 0], ['Rook', 'finished', 'gm', 0]]]);
+ expect((await finish({ players: ['ghost'] })).body.finished).toBe(0);
+ const done = await finish({ players: true });
+ expect(done.body.finished).toBe(1);
+ expect(states(done.body.call)).toEqual([['Ash', 'finished', 'gm', 0], ['Rook', 'finished', 'gm', 0]]);
+ await call({ rest: 'long_rest', players: ['ash'], npcs: [] });
+ const v = await view();
+ expect([v.name, v.npcs, states(v)]).toEqual(['Long rest', 0, [['Ash', 'waiting', null, 0]]]);
+ });
+
+ it('counts only the rests it could take: one turned off since it was called stays waiting', async () => {
+ await call({ rest: 'short_rest', players: ['rook'], npcs: [] });
+ const off = { ...FORGE, rests: { ...FORGE.rests, short_rest: { ...FORGE.rests.short_rest, on: false } } };
+ const done = await finishCalled(db, io, { system: ID, definition: off, players: true });
+ expect([done.finished, states(done.call)]).toEqual([0, [['Rook', 'waiting', null, 0]]]);
+ });
+
+ it('says why not: a bad list, no rest waiting, a player, a built-in game', async () => {
+ for (const bad of [{}, { players: false }, { players: [] }, { players: [3] }, { players: 'all' }]) {
+ const res = await finish(bad);
+ expect(res.status, JSON.stringify(bad)).toBe(400);
+ expect(res.body.error).toBe('players: true, or a list of up to 100 player names');
+ }
+ const none = await finish({ players: true });
+ expect([none.status, none.body.error]).toEqual([409, 'No rest is waiting to be finished']);
+ expect((await finish({ players: true }, PLAYER)).status).toBe(403);
+ await run(db, `UPDATE global_settings SET value = 'cities_without_number' WHERE key = 'game_system'`);
+ const builtIn = await finish({ players: true });
+ expect([builtIn.status, builtIn.body.error]).toEqual([409, 'This game\'s rests are its own buttons']);
+ expect(await view()).toBeNull();
+ });
+});
diff --git a/backend/__tests__/system_builder_rest_on_sheet.test.js b/backend/__tests__/system_builder_rest_on_sheet.test.js
index 900050a5..8dc99fb2 100644
--- a/backend/__tests__/system_builder_rest_on_sheet.test.js
+++ b/backend/__tests__/system_builder_rest_on_sheet.test.js
@@ -195,8 +195,11 @@ describe('through the socket', () => {
const rest = { id: 'short_rest', name: 'Short rest' };
const done = await callRest(db, io, { system: ID, definition: FORGE, rest, players: true, npcs: [dog] });
expect(done.map((d) => [d.name, !!d.called])).toEqual([['Ash', true], ['Rook', true], ['DOG', false]]);
- expect((await stored())[REST_CALL_KEY]).toEqual({ rest: 'short_rest', name: 'Short rest' });
- expect((await stored('ash'))[REST_CALL_KEY]).toEqual({ rest: 'short_rest', name: 'Short rest' });
+ // Kept with the call it came from, for the GM's list (R4): one call, on both sheets.
+ const rookCall = (await stored())[REST_CALL_KEY];
+ expect(rookCall).toMatchObject({ rest: 'short_rest', name: 'Short rest', npcs: 1, before: 2 });
+ expect((await stored('ash'))[REST_CALL_KEY]).toEqual({ ...rookCall });
+ expect(typeof rookCall.id).toBe('string');
expect((await stored())._uses.second_wind).toBe(1);
expect(await token()).toBe(10);
expect((await get(db, 'SELECT hp_current FROM locations WHERE id = ?', [dog])).hp_current).toBe(5);
diff --git a/backend/routes/sheets.js b/backend/routes/sheets.js
index 29316ebc..eaab2c51 100644
--- a/backend/routes/sheets.js
+++ b/backend/routes/sheets.js
@@ -889,9 +889,31 @@ module.exports = (db, io) => {
const definition = customSystems.definitionOf(system);
// The recovery dice's name and who says when they're spent (R2b): the GAME tab's OPEN SPENDING.
const shown = definition ? recoveryShown(definition) : null;
- // And who finishes a called rest (R3b): on each sheet, CALL opens it there.
- res.json({ system, rests: definition ? restsOf(definition).map((r) => ({ id: r.id, name: r.name })) : [], recovery: shown ? { name: shown.name, spend: shown.spend } : null,
- finish: definition ? restingOf(definition).finish : 'call' });
+ // And who finishes a called rest (R3b): on each sheet, CALL opens it there; with the latest
+ // call, who has finished it (R4).
+ const answer = (call) => res.json({ system, rests: definition ? restsOf(definition).map((r) => ({ id: r.id, name: r.name })) : [], recovery: shown ? { name: shown.name, spend: shown.spend } : null,
+ finish: definition ? restingOf(definition).finish : 'call', call });
+ if (!definition) return answer(null);
+ return rests.calledShown(db, system, definition).then(answer).catch((e) => res.status(500).json({ error: e.message }));
+ });
+ });
+
+ /**
+ * Finish the latest rest called on the players' sheets for those still resting (R4): { players:
+ * true | [names] }. The GM and granted editors, as calling it is.
+ */
+ router.post('/rest/finish', authenticate, requireAdmin, (req, res) => {
+ const { players } = req.body || {};
+ if (players !== true && (!Array.isArray(players) || !players.length || rests.whoProblem(players))) {
+ return res.status(400).json({ error: `players: true, or a list of up to ${rests.LIMITS.players} player names` });
+ }
+ getGameSystem((err, system) => {
+ if (err) return res.status(500).json({ error: err.message });
+ const definition = customSystems.definitionOf(system);
+ if (!definition) return res.status(409).json({ error: 'This game\'s rests are its own buttons' });
+ return rests.finishCalled(db, io, { system, definition, players })
+ .then((done) => (done.error ? res.status(409).json({ error: done.error }) : res.json(done)))
+ .catch((e) => res.status(500).json({ error: e.message }));
});
});
diff --git a/backend/sheets/rests.js b/backend/sheets/rests.js
index 19761c1b..a8baf823 100644
--- a/backend/sheets/rests.js
+++ b/backend/sheets/rests.js
@@ -12,6 +12,7 @@
// user's calls of 2026-10-30): a player takes their own with takeRest, from their sheet's REST
// section; the GM may for anyone. In such a system the GM's call rests the NPCs at once and opens the
// rest on each player's sheet instead (REST_CALL_KEY, recovery.js), finished there with takeRest.
+// The GM sees who has finished, and may finish it for one or for everyone left (R4, finishCalled).
const crypto = require('crypto');
const { mutateSheet } = require('./mutate');
@@ -19,7 +20,7 @@ const { applyDerived } = require('./templates');
const { parseTokenConditions } = require('../tokens/conditions');
const { restOn, restLine, restDice } = require('../systemBuilder/resting');
const { conditionsOf } = require('../systemBuilder/conditions');
-const { restingOf, REST_CALL_KEY } = require('../systemBuilder/recovery');
+const { restingOf, REST_CALL_KEY, restCallOf, callOn, finishedCall, restCallsShown } = require('../systemBuilder/recovery');
const { restsOf } = require('../systemBuilder/rests');
const { withThrow, boardOf } = require('../utils/diceThrow');
@@ -106,18 +107,23 @@ const previewRest = async (db, { system, definition, rest, players, npcs }) => {
/**
* Work one character's rest out at write time, and write its sheet. A rest the GM called on it,
- * the same one, is finished with it (R3a).
+ * the same one, is finished with it (R3a), kept as finished by `by` for the GM's list (R4).
*/
-const restSheet = (db, system, definition, rest, c, rng) => new Promise((ok, no) => {
+const restSheet = (db, system, definition, rest, c, rng, by) => new Promise((ok, no) => {
const args = { definition, restId: rest.id, token: tokenOf(c.token), conditions: parseTokenConditions(c.token && c.token.conditions), rng };
if (!c.sheetId) { ok(restOn({ ...args, sheet: {} })); return; }
let result = null;
mutateSheet(db, c.sheetId, (data) => {
result = restOn({ ...args, sheet: worked(system, data) });
- const called = data[REST_CALL_KEY] && data[REST_CALL_KEY].rest === rest.id;
+ const call = restCallOf(data);
+ const called = !!call && call.rest === rest.id;
if (!result.ok || (!Object.keys(result.sheetPatch).length && !called)) return undefined;
const next = { ...data, ...result.sheetPatch };
- if (called) delete next[REST_CALL_KEY];
+ // A call from before the GM's list (R3b) had no id: there's nothing to keep it for.
+ if (called) {
+ if (typeof data[REST_CALL_KEY].id === 'string') next[REST_CALL_KEY] = finishedCall(definition, data, by, result.changes);
+ else delete next[REST_CALL_KEY];
+ }
applyDerived(system, next);
return next;
}, (err) => (err ? no(err) : ok(result)));
@@ -127,8 +133,8 @@ const restSheet = (db, system, definition, rest, c, rng) => new Promise((ok, no)
* One character's rest, rolled and written: its sheet, then its token's health and conditions, then
* a dice-log line for everyone. What it got, as the GAME tab shows it, or null when it couldn't.
*/
-const restCharacter = async (db, io, { system, definition, rest, c, rng }) => {
- const result = await restSheet(db, system, definition, rest, c, rng);
+const restCharacter = async (db, io, { system, definition, rest, c, rng, by = 'gm' }) => {
+ const result = await restSheet(db, system, definition, rest, c, rng, by);
if (!result || !result.ok) return null;
if (c.token) {
const t = result.token;
@@ -157,8 +163,8 @@ const restCharacter = async (db, io, { system, definition, rest, c, rng }) => {
* Open a called rest on a player's sheet, for them to finish (R3b): it waits there, under
* REST_CALL_KEY, until they do. A sheet with one waiting already has it replaced.
*/
-const openOnSheet = (db, io, rest, c) => new Promise((ok, no) => {
- mutateSheet(db, c.sheetId, (data) => ({ ...data, [REST_CALL_KEY]: { rest: rest.id, name: rest.name } }), (err) => {
+const openOnSheet = (db, io, definition, rest, c, call) => new Promise((ok, no) => {
+ mutateSheet(db, c.sheetId, (data) => ({ ...data, [REST_CALL_KEY]: callOn(definition, rest, call, data) }), (err) => {
if (err) { no(err); return; }
io.emit('sheetUpdated', { username: c.username });
ok();
@@ -173,10 +179,12 @@ const openOnSheet = (db, io, rest, c) => new Promise((ok, no) => {
const callRest = async (db, io, { system, definition, rest, players, npcs, rng = cryptoRng }) => {
const characters = await gather(db, system, players, npcs);
const onSheets = restingOf(definition).finish === 'sheet';
+ // The call, kept on each sheet it opens on: the GM's list reads the latest one (R4).
+ const call = { id: crypto.randomUUID(), at: new Date().toISOString(), npcs: characters.filter((c) => c.kind === 'npc').length };
const done = [];
for (const c of characters) {
if (onSheets && c.kind === 'player') {
- await openOnSheet(db, io, rest, c);
+ await openOnSheet(db, io, definition, rest, c, call);
done.push({ kind: 'player', username: c.username, name: c.name, changes: [], gone: [], called: true });
continue;
}
@@ -191,15 +199,38 @@ const callRest = async (db, io, { system, definition, rest, players, npcs, rng =
* One player character's rest, taken from their sheet (R3a): `rest` one the game offers. A rest
* the GM called on that sheet is finished with it. Answers what it got, or { error } in words.
*/
-const takeRest = async (db, io, { system, definition, restId, username, rng = cryptoRng }) => {
+const takeRest = async (db, io, { system, definition, restId, username, by = 'player', rng = cryptoRng }) => {
const rest = restsOf(definition).find((r) => r.id === restId);
if (!rest) return { error: 'Not one of this game\'s rests' };
const [c] = await gather(db, system, [username], []);
if (!c) return { error: 'That character has no sheet yet' };
- const got = await restCharacter(db, io, { system, definition, rest, c, rng });
+ const got = await restCharacter(db, io, { system, definition, rest, c, rng, by });
if (!got) return { error: 'The rest could not be taken' };
io.emit('dataUpdated', { isRhombusOnly: true });
return got;
};
-module.exports = { LIMITS, whoProblem, gather, previewRest, callRest, takeRest };
+/** The GM's view of the latest rest called on the players' sheets (recovery.js restCallsShown), or null. */
+const calledShown = async (db, system, definition) => {
+ const sheets = await all(db, 'SELECT username, data FROM character_sheets WHERE system = ? AND is_npc = 0', [system]);
+ return restCallsShown(definition, sheets.map((s) => ({ username: s.username, data: parse(s.data) })));
+};
+
+/**
+ * The GM finishes the latest called rest for those still resting (R4): `players` true for everyone
+ * left, or the ones named. Each is taken as the player would, kept as finished by the GM. Answers
+ * how many were finished and the view after.
+ */
+const finishCalled = async (db, io, { system, definition, players, rng = cryptoRng }) => {
+ const view = await calledShown(db, system, definition);
+ if (!view) return { error: 'No rest is waiting to be finished' };
+ const named = Array.isArray(players) ? new Set(players.map((p) => p.toLowerCase())) : null;
+ const left = view.players.filter((p) => p.state !== 'finished' && (!named || named.has(p.username.toLowerCase())));
+ for (const p of left) await takeRest(db, io, { system, definition, restId: view.rest, username: p.username, by: 'gm', rng });
+ // Counted from what the sheets say now, so one that couldn't be taken isn't counted.
+ const after = await calledShown(db, system, definition);
+ const done = new Set((after ? after.players : []).filter((p) => p.state === 'finished').map((p) => p.username));
+ return { finished: left.filter((p) => done.has(p.username)).length, call: after };
+};
+
+module.exports = { LIMITS, whoProblem, gather, previewRest, callRest, takeRest, calledShown, finishCalled };
diff --git a/backend/sockets/choices.js b/backend/sockets/choices.js
index 91b6bda3..dfcfbf9b 100644
--- a/backend/sockets/choices.js
+++ b/backend/sockets/choices.js
@@ -82,7 +82,7 @@ const registerChoicesHandlers = (socket, { io, db, userSockets, isAdminSocket, g
withCaller(payload, 'restRefused', async ({ gm, username, system, definition, refuse }) => {
if (!gm && restingOf(definition).finish !== 'sheet') return refuse(REST_AT_CALL);
try {
- const done = await takeRest(db, io, { system, definition, restId: payload.rest, username });
+ const done = await takeRest(db, io, { system, definition, restId: payload.rest, username, by: gm ? 'gm' : 'player' });
if (done.error) refuse(done.error);
} catch (e) {
console.error('[rests] rest not taken:', e.message);
diff --git a/backend/systemBuilder/recovery.js b/backend/systemBuilder/recovery.js
index 25ff1c8a..5c81ce43 100644
--- a/backend/systemBuilder/recovery.js
+++ b/backend/systemBuilder/recovery.js
@@ -158,16 +158,21 @@ const recoveryShown = (definition) => {
};
/**
- * Where a player's sheet keeps a rest the GM has called and they have yet to finish (R3a): { rest,
- * name }. Written when the GM calls a rest in a system whose rests are finished on each sheet;
- * cleared when they finish it. Players can't write it (no _ field is theirs).
+ * Where a player's sheet keeps a rest the GM has called (R3a, R4): { id, at, rest, name, npcs,
+ * before }, the call it came from (when, and how many NPCs it rested at once) and the recovery dice
+ * already spent then. Written when the GM calls a rest in a system whose rests are finished on each
+ * sheet. Once finished it stays, `done`, with who finished it (`by`), what it changed and the dice
+ * spent since, for the GM's list, until the next call replaces it. Players can't write it (no _
+ * field is theirs).
*/
const REST_CALL_KEY = '_rest_call';
+const callRecord = (data) => (isPlainObject(data) && isPlainObject(data[REST_CALL_KEY]) ? data[REST_CALL_KEY] : null);
+
/** The rest a sheet was called to and hasn't finished: { rest, name }, or null. */
const restCallOf = (data) => {
- const c = isPlainObject(data) && isPlainObject(data[REST_CALL_KEY]) ? data[REST_CALL_KEY] : null;
- return c && typeof c.rest === 'string' ? { rest: c.rest, name: typeof c.name === 'string' ? c.name : c.rest } : null;
+ const c = callRecord(data);
+ return c && typeof c.rest === 'string' && c.done !== true ? { rest: c.rest, name: typeof c.name === 'string' ? c.name : c.rest } : null;
};
/**
@@ -274,6 +279,48 @@ const givenBack = (definition, data, ends) => {
return changes.length ? { spent, changes } : null;
};
+/** The recovery dice a character has spent, every pool together. */
+const spentNow = (definition, data) => poolsOf(definition, data).reduce((n, p) => n + p.spent, 0);
+
+/** A call of `rest` as a sheet keeps it: `call` is { id, at, npcs }, the dice spent counted now. */
+const callOn = (definition, rest, call, data) => ({
+ id: call.id, at: call.at, rest: rest.id, name: rest.name, npcs: call.npcs, before: spentNow(definition, data),
+});
+
+/**
+ * A called rest finished (R4): kept, with who finished it ('player' or 'gm'), what it changed and
+ * the dice spent since the call, worked out on the sheet as it was before the rest gave any back.
+ */
+const finishedCall = (definition, data, by, changes) => {
+ const c = callRecord(data);
+ return { ...c, done: true, by, changes, spent: Math.max(0, spentNow(definition, data) - whole(Number(c.before))) };
+};
+
+/**
+ * The GM's view of the latest rest they called on the players' sheets (R4; approved mockup
+ * rest-on-sheet, pane 4), from every player sheet: each player in it finished (and by whom, what it
+ * changed, the dice spent), resting (dice spent since, not finished), or waiting; how many are left;
+ * and how many NPCs rested at once. Null with no call kept.
+ */
+const restCallsShown = (definition, sheets) => {
+ const kept = sheets.map((s) => ({ s, c: callRecord(s.data) })).filter(({ c }) => c && typeof c.id === 'string' && typeof c.at === 'string');
+ if (!kept.length) return null;
+ const latest = kept.reduce((a, b) => (b.c.at > a.c.at ? b : a)).c;
+ const players = kept.filter(({ c }) => c.id === latest.id).map(({ s, c }) => {
+ const name = (isPlainObject(s.data) && typeof s.data.name === 'string' && s.data.name.trim()) || s.username;
+ if (c.done === true) {
+ return { username: s.username, name, state: 'finished', by: c.by === 'gm' ? 'gm' : 'player', changes: list(c.changes), spent: whole(Number(c.spent)) };
+ }
+ const spent = Math.max(0, spentNow(definition, s.data) - whole(Number(c.before)));
+ return { username: s.username, name, state: spent ? 'resting' : 'waiting', changes: [], spent };
+ }).sort((a, b) => a.name.localeCompare(b.name));
+ return {
+ id: latest.id, rest: latest.rest, name: typeof latest.name === 'string' ? latest.name : latest.rest,
+ at: latest.at, npcs: whole(Number(latest.npcs)), players, waiting: players.filter((p) => p.state !== 'finished').length,
+ };
+};
+
module.exports = {
FINISH, HOWS, SPEND, LIMITS, RECOVERY_KEY, ALL_POOL, MOST, OPEN_SETTING, REST_CALL_KEY, checkResting, restingOf, recoveryShown, restingShown, restCallOf, diceOptionsOf, maySpend, poolsOf, spendFrom, givenBack,
+ spentNow, callOn, finishedCall, restCallsShown,
};
diff --git a/e2e/COVERAGE.md b/e2e/COVERAGE.md
index 7b2d3710..a00b7a93 100644
--- a/e2e/COVERAGE.md
+++ b/e2e/COVERAGE.md
@@ -111,7 +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** |
-| Recovery dice | a class's die; how many, what one heals, where spent, what gives them back; spent any time or when the GM opens it; spent with a roll, ticked off; rests finished at the call or on each sheet: REST buttons asked first, a panel with the dice and FINISH, the GM's call opened on the sheets | `recovery`, `recovery_pools`, `rest_on_sheet`, `call_rest`, `choices_import` | `builder-recovery`, `builder-clicks`, `sheet-recovery-dice` (spent, ticked, given back, waiting for the GM), `sheet-rest-on-sheet` (rests on each sheet, the GM's call opened there) | **AT THE TABLE** |
+| Recovery dice | a class's die; how many, what one heals, where spent, what gives them back; spent any time or when the GM opens it; spent with a roll, ticked off; rests finished at the call or on each sheet: REST buttons asked first, a panel with the dice and FINISH, the GM's call opened on the sheets, who has finished it and FINISH FOR one or everyone (R4) | `recovery`, `recovery_pools`, `rest_on_sheet`, `rest_finish`, `call_rest`, `choices_import` | `builder-recovery`, `builder-clicks`, `sheet-recovery-dice` (spent, ticked, given back, waiting for the GM), `sheet-rest-on-sheet` (rests on each sheet, the GM's call opened there) | **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** |
diff --git a/e2e/README.md b/e2e/README.md
index 96ed3caf..76ea9655 100644
--- a/e2e/README.md
+++ b/e2e/README.md
@@ -163,7 +163,7 @@ one machine, files in order.
| `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; 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-recovery-dice.spec.ts` | Recovery dice at the table (R2b): a Fighter 2 has two d10 hit dice on the sheet, which wait while the GM has spending closed; the GM opens it in the GAME tab's RESTS, the player spends one (the dice tray opens) and ticks the other off for a real die, a long rest gives them all back, and once the GM closes spending they wait again |
-| `tests/sheet-rest-on-sheet.spec.ts` | Resting on the sheet (R3b), rests finished on each sheet: a Fighter's SHORT REST asks first, opens its panel, a Hit Die is spent and FINISH (asked too) brings Second Wind back; the GM calls a long rest from the GAME tab, which asks first and opens it on the player's sheet, CALLED BY THE GM; the player finishes it and the dice come back |
+| `tests/sheet-rest-on-sheet.spec.ts` | Resting on the sheet (R3b), rests finished on each sheet: a Fighter's SHORT REST asks first, opens its panel, a Hit Die is spent and FINISH (asked too) brings Second Wind back; the GM calls a long rest from the GAME tab, which asks first and opens it on the player's sheet, CALLED BY THE GM; the player finishes it and the dice come back; the GM's list follows who has finished (R4), and the GM finishes a short rest for Rook, asked first, closing it on Rook's sheet |
| `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; and formula box 3: on STATS & RULES' FORMULAS, Grip's `@strr + 1` in red under its box with USE @str, the VALUE 1; fixed, it reads `@str + 1`, says worked out from Strength, the VALUE 17, and the draft is saved so |
| `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-on-sheet.spec.ts b/e2e/tests/sheet-rest-on-sheet.spec.ts
index b2087dd4..c7199677 100644
--- a/e2e/tests/sheet-rest-on-sheet.spec.ts
+++ b/e2e/tests/sheet-rest-on-sheet.spec.ts
@@ -6,7 +6,8 @@ import { signInAsGm, signInAsPlayer, openGameSystem, gmToken, asGm, runningSyste
// has a button for each rest. SHORT REST asks first, then opens its panel with the Hit Dice; one is
// spent, and FINISH, which asks too, takes the rest: Second Wind comes back. Then the GM calls a long
// rest from the GAME tab, which asks first and opens it on the player's sheet, CALLED BY THE GM; the
-// player finishes it and the dice come back. Set up through the API, as player-journey is.
+// player finishes it and the dice come back. The GM's list follows who has finished (R4), and the
+// GM finishes a short rest for Rook, asked first. Set up through the API, as player-journey is.
const definition = (name: string) => ({
format: 1,
@@ -67,13 +68,31 @@ test('a player rests from their sheet, asked first, and finishes a long rest the
await expect(asking).toContainText('It opens on 1 player\'s sheet for them to finish.');
await asking.getByRole('button', { name: 'CALL', exact: true }).click();
await expect(gameRests.getByRole('status')).toHaveText('LONG REST CALLED · OPEN ON 1 SHEET');
+ // The GM's list of who has finished (R4): Rook, not yet.
+ const list = gameRests.getByRole('region', { name: 'Called rest' });
+ await expect(list.getByText('WAITING ON 1 OF 1')).toBeVisible();
+ await expect(list.getByRole('listitem', { name: 'Rook' })).toContainText('not finished yet');
- // THE PLAYER finishes it: the Hit Dice come back.
+ // THE PLAYER finishes it: the Hit Dice come back, and the GM's list follows.
const called = sheet.getByRole('region', { name: 'Long rest panel' });
await expect(called.getByText('CALLED BY THE GM')).toBeVisible({ timeout: 15_000 });
await called.getByRole('button', { name: 'FINISH LONG REST' }).click();
await sheet.getByRole('group', { name: 'Finish the Long rest?' }).getByRole('button', { name: 'FINISH' }).click();
- await expect.poll(async () => { const d = await stored(); return [d._recovery?.fighter, d._rest_call]; }, { timeout: 15_000 }).toEqual([0, undefined]);
+ await expect.poll(async () => { const d = await stored(); return [d._recovery?.fighter, d._rest_call?.done, d._rest_call?.by]; }, { timeout: 15_000 }).toEqual([0, true, 'player']);
await expect(called).toBeHidden();
+ await expect(list.getByText('EVERYONE FINISHED')).toBeVisible({ timeout: 15_000 });
+ await expect(list.getByRole('listitem', { name: 'Rook' })).toContainText(/^ROOK finished( · |$)/);
+
+ // THE GM calls a short rest, then finishes it for Rook, asked first: it closes on Rook's sheet.
+ await gameRests.getByLabel('Rest', { exact: true }).selectOption('short_rest');
+ await gameRests.getByRole('button', { name: 'CALL SHORT REST', exact: true }).click();
+ await gameRests.getByRole('group', { name: 'Call a Short rest?' }).getByRole('button', { name: 'CALL', exact: true }).click();
+ await expect(sheet.getByRole('region', { name: 'Short rest panel' }).getByText('CALLED BY THE GM')).toBeVisible({ timeout: 15_000 });
+ await expect(list.getByText('WAITING ON 1 OF 1')).toBeVisible();
+ await list.getByRole('button', { name: 'FINISH FOR ROOK' }).click();
+ await list.getByRole('group', { name: 'Finish Rook\'s rest?' }).getByRole('button', { name: 'FINISH', exact: true }).click();
+ await expect(list.getByText('EVERYONE FINISHED')).toBeVisible({ timeout: 15_000 });
+ await expect(list.getByRole('listitem', { name: 'Rook' })).toContainText('finished by the GM');
+ await expect(sheet.getByRole('region', { name: 'Short rest panel' })).toBeHidden({ timeout: 15_000 });
await context.close();
});
diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx
index d1267dbd..336527f0 100644
--- a/frontend/src/components/AdminPanel.tsx
+++ b/frontend/src/components/AdminPanel.tsx
@@ -65,7 +65,7 @@ function BattleAdminPanel({
token, isDeployingEnemy, setIsDeployingEnemy, isDeployingFriendly, setIsDeployingFriendly,
tempBattleMapScale, setTempBattleMapScale, activeBattleMapData, locations, refreshLocations,
handleSaveDefault, handleLoadDefault, setIsAdminPayOpen, setIsAdminXpOpen, setIsCatalogueOpen, setIsGiveOpen, secureModeEnabled, onLogout,
- globalSettings, fetchGlobalSettings, onOpenNpcLibrary, onOpenSystems, activeUsers, isPrimaryAdmin,
+ globalSettings, fetchGlobalSettings, onOpenNpcLibrary, onOpenSystems, activeUsers, isPrimaryAdmin, socket,
}: any) {
const [tab, setTab] = useState<'battle_map' | 'game'>('battle_map');
// A custom system with the bank off has no pay or currency (3b2b).
@@ -154,7 +154,7 @@ function BattleAdminPanel({
{/* A custom game's rests (4f5), in play rather than setup, so out of TTRPG_SYSTEM (R2b). */}
{isCustomSystem(globalSettings?.game_system) && (
-
+
)}
{/* Money, which a custom system with the bank off does not have (3b2b). */}
@@ -225,7 +225,7 @@ export function AdminPanel({
handleSaveDefault={handleSaveDefault} handleLoadDefault={handleLoadDefault}
setIsAdminPayOpen={setIsAdminPayOpen} setIsAdminXpOpen={setIsAdminXpOpen} setIsCatalogueOpen={setIsCatalogueOpen} setIsGiveOpen={setIsGiveOpen} secureModeEnabled={secureModeEnabled} onLogout={onLogout}
globalSettings={globalSettings} fetchGlobalSettings={fetchGlobalSettings}
- onOpenNpcLibrary={onOpenNpcLibrary} onOpenSystems={onOpenSystems} activeUsers={activeUsers} isPrimaryAdmin={isPrimaryAdmin}
+ onOpenNpcLibrary={onOpenNpcLibrary} onOpenSystems={onOpenSystems} activeUsers={activeUsers} isPrimaryAdmin={isPrimaryAdmin} socket={socketRef.current}
/>
);
}
@@ -1071,7 +1071,7 @@ export function AdminPanel({
{/* A custom game's rests (4f5), in play rather than setup, so out of TTRPG_SYSTEM (R2b). */}
{isCustomSystem(gameSystem) && (
-
+
)}
{/* Money, which a custom system with the bank off does not have (3b2b): a folder, the
diff --git a/frontend/src/components/GameRestsPanel.tsx b/frontend/src/components/GameRestsPanel.tsx
index 525b2191..1c967d4e 100644
--- a/frontend/src/components/GameRestsPanel.tsx
+++ b/frontend/src/components/GameRestsPanel.tsx
@@ -1,7 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ToggleSwitch } from './ToggleSwitch';
import {
- restCalls, chosenOf, requestFor, changeText, keyOf, confirmText, calledText, WHO_LABELS, type Rested, type Who,
+ restCalls, chosenOf, requestFor, changeText, keyOf, confirmText, calledText, waitingText, calledLine, WHO_LABELS,
+ type Rested, type Who, type CalledRest,
} from '../sheets/restCalls';
// The GAME tab's RESTS panel under a custom game (4f5; approved mockup docs/mockups/builder-rests.html,
@@ -14,18 +15,25 @@ import {
// on, players spend and tick them off from their sheets; off, theirs wait. Opened for a rest, closed
// after. The panel sits in the GAME tab's RESTS folder (GameFolder.tsx), out of TTRPG_SYSTEM: it is
// for play, not setup (the user, 2026-10-30).
+//
+// Where rests are finished on each sheet (R4; approved mockup rest-on-sheet, pane 4), the latest
+// call stays here: who has finished it and what they got, who is still resting, FINISH FOR one of
+// them or FOR EVERYONE left (each asked first), and the NPCs it rested at once. It is read again
+// whenever a sheet changes.
const label: React.CSSProperties = { fontSize: '0.65rem', opacity: 0.7, letterSpacing: '1px' };
const why: React.CSSProperties = { fontSize: '0.6rem', opacity: 0.6, margin: 0 };
const btn: React.CSSProperties = { fontSize: '0.65rem' };
const select: React.CSSProperties = { fontSize: '0.65rem', background: 'var(--black)', color: 'var(--green)', border: '1px solid var(--green)', padding: '3px 4px' };
-export function GameRestsPanel({ token, system, npcs }: {
+export function GameRestsPanel({ token, system, npcs, socket }: {
token: string;
/** The running game, so the list is asked for again when it changes. */
system: string;
/** The NPC tokens on the map being viewed (restCalls.npcsOnMap). */
npcs: { id: number; name: string }[];
+ /** Heard for sheetUpdated, so a called rest's list follows the players finishing it (R4). */
+ socket?: { on: (event: string, fn: () => void) => void; off: (event: string, fn: () => void) => void } | null;
}) {
const api = useMemo(() => restCalls(token), [token]);
const [rests, setRests] = useState<{ id: string; name: string }[]>([]);
@@ -43,9 +51,20 @@ export function GameRestsPanel({ token, system, npcs }: {
// CALL asks first (the user, 2026-10-30: "so no miss clicks happen").
const [confirming, setConfirming] = useState(false);
const [open, setOpen] = useState(false);
+ // The latest rest called on the sheets, and whose FINISH is being asked (a username, or everyone).
+ const [called, setCalled] = useState(null);
+ const [asking, setAsking] = useState(null);
+ const [heard, setHeard] = useState(0);
const npcIds = npcs.map((n) => n.id);
const npcKey = npcIds.join(',');
+ useEffect(() => {
+ if (!socket) return undefined;
+ const changed = () => setHeard((x) => x + 1);
+ socket.on('sheetUpdated', changed);
+ return () => { socket.off('sheetUpdated', changed); };
+ }, [socket]);
+
useEffect(() => {
let live = true;
api.list().then((r) => {
@@ -56,10 +75,11 @@ export function GameRestsPanel({ token, system, npcs }: {
setRestId((id) => (list.some((x) => x.id === id) ? id : list[0]?.id ?? ''));
setRecovery(r.value && r.value.recovery ? r.value.recovery : null);
setFinish(r.value && r.value.finish === 'sheet' ? 'sheet' : 'call');
+ setCalled(r.value && r.value.call ? r.value.call : null);
});
api.spendingOpen().then((r) => { if (live && r.ok) setOpen(r.value); });
return () => { live = false; };
- }, [api, system]);
+ }, [api, system, heard]);
// Everyone the panel could choose, previewed: every player character and the map's NPCs.
useEffect(() => {
@@ -92,6 +112,17 @@ export function GameRestsPanel({ token, system, npcs }: {
setError(null);
setSaid(calledText(rest.name, r.value.characters));
setRound((x) => x + 1);
+ setHeard((x) => x + 1);
+ };
+ const finishFor = async (who: true | string[]) => {
+ setAsking(null);
+ setBusy(true);
+ const r = await api.finish(who);
+ setBusy(false);
+ if (!r.ok) { setError(r.error); return; }
+ setError(null);
+ setCalled(r.value.call);
+ setRound((x) => x + 1);
};
const players = chosen.filter((r) => r.kind === 'player').length;
const npcCount = chosen.length - players;
@@ -173,7 +204,65 @@ export function GameRestsPanel({ token, system, npcs }: {
: 'Dice are rolled for each character when it is called; everyone sees a line each in the dice log.'}
{said && {said}}
+ {finish === 'sheet' && called && called.players.length > 0 && (
+
+ )}
{error && {error}}
);
}
+
+/**
+ * The latest rest called on the sheets (R4; approved mockup rest-on-sheet, pane 4): how many are
+ * left, a line for each player with FINISH FOR them while they rest, FINISH FOR EVERYONE, and the
+ * NPCs it rested at once. Each FINISH asks first.
+ */
+function CalledList({ called, dice, busy, asking, setAsking, onFinish }: {
+ called: CalledRest; dice: boolean; busy: boolean;
+ asking: string | true | null; setAsking: (who: string | true | null) => void;
+ onFinish: (who: true | string[]) => void;
+}) {
+ const one = asking === null || asking === true ? null : called.players.find((p) => p.username === asking) ?? null;
+ const rest = called.name.toUpperCase();
+ return (
+
+
+
+ {one ? `FINISH ${one.name.toUpperCase()}'S ${rest}?` : `FINISH THE ${rest} FOR ${called.waiting} STILL RESTING?`}
+
+ As if they had pressed FINISH: dice they haven't spent stay unspent.
+