diff --git a/CHANGELOG.md b/CHANGELOG.md index 70894467..a70dd406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed +- **CWN's LONG_REST recovers System Strain again.** Since an earlier change to how table-wide + resets write, it had been writing Shadowrun's Edge onto CWN sheets instead, so strain never went + down. It now takes 1 System Strain off every CWN sheet that has some, as it always meant to, and + has a test. - **No IMPORT button in a Generic game.** Generic has no PDF importer, so the character and NPC sheets' IMPORT button always failed there. It's now offered only in Cities Without Number, Cyberpunk RED and Shadowrun, the systems that can import. @@ -103,6 +107,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **Calling a rest.** The GM, or a granted editor, can preview or call one of a custom system's + rests for every player character, the ones named, or chosen NPC tokens. Each sheet is written + safely alongside anything a player types, health and conditions follow on every token, and + everyone sees one dice-log line per character. The GAME tab's panel for it comes next. - **What a rest does to a character.** The server can now work out a rest for one character: the rests it counts as first, then its own refills, health back through the system's own health model, sheet numbers kept between 0 and their maximum, and the conditions that end there taken off. diff --git a/README.md b/README.md index dfda26dd..3bb63bd0 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ CITY_NET/ │ │ ├── fonts.js # Font file upload/list/delete (.ttf .otf .woff .woff2); served as static under /uploads/fonts/ │ │ ├── player.js # Player auth (register, login, forgot, reset, registration status poll) │ │ ├── systems.js # Custom game systems: list, create (from a name, a whole definition, or a copy of a built-in example or genre starter), read, save a draft, publish, rename, duplicate, delete (a hide, refused for the running system); export a published system as a .citysys file, preview a file, install it as new, an update or a second copy; upload a currency or condition icon (PNG, WebP or SVG, 0.25MB, stored by content hash, one route for both) and set or clear a currency's icon in the draft and running copy alike; the builder's previews, which change nothing (a draft's values from its sample, its sheet as drawn, an NPC tier rolled for a level, health tried on a pretend token); the examples and starters, listed and opened; a built-in game's own conditions replaced (PUT /table-conditions/:system). Main admin only, reading included, except the two public reads every player needs: a published system's render copy, and the conditions a system offers (GET /conditions/:system, modifiers left out for anyone but the GM) -│ │ └── sheets.js # Character sheets — admin sheet access, NPC library, portraits, LUCK/Edge reset & grant, import preview; a friendly NPC's sheet, read-only, for the player the GM gave it to (GET /npcs/controlled/:location_id). The table-wide resets scan to decide who is affected and then work out each value as that sheet is written, rather than writing back a scan that has already gone stale +│ │ └── sheets.js # Character sheets — admin sheet access, NPC library, portraits, LUCK/Edge reset & grant, CWN's long rest (System Strain down 1), a custom system's rests listed and called or previewed (GET /rests, POST /rest, GM and granted editors), import preview; a friendly NPC's sheet, read-only, for the player the GM gave it to (GET /npcs/controlled/:location_id). The table-wide resets scan to decide who is affected and then work out each value as that sheet is written, rather than writing back a scan that has already gone stale │ ├── dice/ │ │ └── systemDice.js # Built-in dice manifest keyed by game system (ids namespaced `builtin:`); lives in code, not the DB, so app updates change definitions with no migration and nothing is mutable through the API │ ├── sheets/ @@ -374,6 +374,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 │ │ ├── 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 │ │ ├── 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 │ │ ├── headshots.js # Stock NPC headshot pools (enemy/friendly), random assignment, URL validation │ │ ├── vehicleSystems.js # Which game systems have vehicles, and the field-id contract the shared machinery assumes. Small because the templates agree on ids: SDP is a damage pool and SP is armour, whatever a system calls them on screen @@ -428,14 +429,14 @@ CITY_NET/ │ │ ├── health.js # A custom system's health model in play, as pure rules: what DAMAGE and HEAL do under each model (second tracks with overflow, damage types turning heavier on a full track, harm moving up a level, wound penalties, hit-location notes), worked out from the token and the sheet behind it; the HIT_POINTS route (routes/locations.js) uses it for a custom system whose health is not one pool │ │ ├── healthView.js # What a token's HEALTH folder is sent under a custom health model: the full detail (a second track's numbers, box marks, harm notes, the wound penalty, location notes) for the GM, a granted editor or the token's owner, and only a description (fills, the worst harm's name, WOUNDED, which locations are hurt) for everyone else; sent by the socket's requestHealthView │ │ ├── npc.js # A custom system's NPCs as data: an optional stat-block layout (checked like a sheet, and linking shared fields the same way) and GENERATE_SHEET tiers (label, token HP and defense, starting values), each box a number, a formula with @level, or dice (rolled by tierRolls.js) -│ │ ├── runtime.js # Published systems in memory for the running game: compiled once into the meta the built-in templates carry (public/combat/linked/GM-only fields, max pairs, derived recompute), reached by sheets/templates.js through a hook; the NPC tiers, reached by sheets/npcTiers.js the same way; the render copy the browser draws from, with no formulas and every word resolved; and wordIn(system, term, form, today's text) for text the server writes; partIn(system, part) for whether a part of the app is on (always, under a built-in system); conditionsIn(system) for the conditions its tokens can have (a custom system's own, or under a built-in one the standard set and the table's own, with no modifiers) +│ │ ├── runtime.js # Published systems in memory for the running game: compiled once into the meta the built-in templates carry (public/combat/linked/GM-only fields, max pairs, derived recompute), reached by sheets/templates.js through a hook; the NPC tiers, reached by sheets/npcTiers.js the same way; the render copy the browser draws from, with no formulas and every word resolved; and wordIn(system, term, form, today's text) for text the server writes; partIn(system, part) for whether a part of the app is on (always, under a built-in system); conditionsIn(system) for the conditions its tokens can have (a custom system's own, or under a built-in one the standard set and the table's own, with no modifiers); definitionOf(system) for rules that read a published system whole (a rest) │ │ ├── citysys.js # A system as a file to share (.citysys): plain JSON with a cover (name, author, version, builder, license, origin); read as untrusted input, capped, and checked like the editor's work; never carries characters │ │ ├── conditions.js # A system's conditions: the standard set every system starts with (Blinded, Bleeding, Poisoned... ten in all), each renamed, described, given modifiers and an end (when removed, after rounds, or at the rests it names) or turned off, and the system's own, up to 60 in all. A definition stores only its changes; conditionsOf gives each one whole. Icons are one of 24 drawn for CITY_NET or an uploaded picture; modifiers name a stat, a formula or all rolls and are recorded until a system's rolls are built │ │ ├── tableConditions.js # A built-in game's own conditions, added from the GAME tab beside the standard set: a name, label, icon and description only (no modifiers or rounds, so the game's rules don't change), stored per game in global_settings and kept in memory once loaded │ │ ├── stats.js # A system's stats: the numbers players fill in, in groups (a skill may be tied to its ability), names for derived values, and the builder's sample character; until a system designs its own sheet, each group is a section of the starter sheet │ │ ├── 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 -│ │ ├── 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 +│ │ ├── 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 │ │ ├── tryHealth.js # TRY IT's health: a made-up character's damage and healing on a pretend token under a draft's own health model, through the same rules the game runs (health.js), drawn as the HEALTH folder draws it for the owner and for everyone else. Nothing read or written │ │ ├── examples.js # The built-in games as whole systems to read and copy (Cities Without Number, Cyberpunk RED, Shadowrun, around their parity-tested formulas) and the genre starters (Sword & Spell, Starfarer, Story First), each ready to publish as it stands; listed by kind and copied under a new name. Generic has no example, since a copy would be BLANK │ │ ├── names.js # Whether two system names are the same name ("hearth " and "Hearth" are), and the next free " copy", "copy 02"... for DUPLICATE and keep-both installs @@ -476,7 +477,7 @@ CITY_NET/ │ ├── system_dice.test.js # Built-in dice manifest integrity; asserts no write route exists │ ├── sockets.customdice.test.js # Roll handler: DB vs builtin resolution, numeric summing, count clamp, forged-payload rejection │ ├── signs.test.js # Sign API (GET / POST / PATCH / DELETE, auth, image-only, filter_intensity clamping, XSS) -│ ├── sheets.test.js # Sheet routes (system switch, admin access, portraits, derived fields, GET /own player self-fetch) +│ ├── sheets.test.js # Sheet routes (system switch, admin access, portraits, derived fields, GET /own player self-fetch; the table-wide resets, CWN's long rest included, keeping an edit made at the same moment) │ ├── system_builder_parity.test.js # CWN and Shadowrun as data against cwnRecompute and sr6Recompute over 3,000 seeded sheets each (blank, text, decimal, huge and stale values, broken JSON): same sheet, same changed fields, same order. Cyberpunk RED against applyDerived wherever Humanity is written as a number, its three differences pinned; Generic changing nothing │ ├── system_builder_examples.test.js # The built-in examples ready to publish, their formulas the parity-tested ones and their samples worked out as each game's own code does; the genre starters' formulas, table, NPC rolls, health and credit; the routes listing each kind, opening one and copying it under a new name, main admin only │ ├── system_builder_conditions.test.js # The standard set, a system's edits and its own resolved in order; every check (names, chip labels, icons drawn or uploaded, ends and rounds, modifiers on stats, formulas or all rolls, the limit of 60); the section in a definition; uploading a condition's icon, main admin only @@ -487,6 +488,7 @@ CITY_NET/ │ ├── system_builder_tier_rolls.test.js # NPC tiers that roll for a level: numbers, formulas with @level and dice (fixed rolls, so every result is known), limits, and mistakes said without the server's internal names │ ├── system_builder_rests.test.js # Rests: the standard four, edits, off and own in order; every check (ids, names, the limit of 12, counting as rests that exist, loops named, refills of health per model and track, sheet numbers with and without a maximum, sections, what may not be refilled, amounts and the names they use); conditions ending at a rest; amounts worked out with dice counted by a name │ ├── 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) +│ ├── 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_try_health.test.js # TRY IT's health: every model's DAMAGE and HEAL on a pretend token through the game's own rules, both views of the result, refusals changing nothing, and the route main admin only │ ├── system_builder_rename.test.js # RENAME at once in the draft and the running copy, a name another system has refused so another can be picked straight away │ ├── system_builder_duplicate.test.js # DUPLICATE from the draft, unpublished changes included, named " copy", its own origin and unpublished diff --git a/backend/__tests__/sheets.test.js b/backend/__tests__/sheets.test.js index 8fc32a93..e4e790a1 100644 --- a/backend/__tests__/sheets.test.js +++ b/backend/__tests__/sheets.test.js @@ -469,6 +469,36 @@ describe('two writers, one sheet, through the routes', () => { expect(data.notes).toBe('keep me'); }); + it('rests every CWN sheet one System Strain down, players and NPCs, without discarding an edit made at the same moment', async () => { + // Untested until 4f2b: 821dae0 wrote SR6's Edge here instead, and nothing noticed. + await run(db, `DELETE FROM global_settings WHERE key = 'game_system'`); + await setSystem(db, 'cities_without_number'); + await run(db, `UPDATE character_sheets SET system = 'cities_without_number', data = ? WHERE username = 'GHOST'`, + [JSON.stringify({ name: 'Ghost', system_strain: 3, notes: 'keep me' })]); + await insertSheet(db, { username: 'VEX', system: 'cities_without_number', data: JSON.stringify({ system_strain: 0 }) }); + await insertSheet(db, { username: 'GANGER', system: 'cities_without_number', data: JSON.stringify({ system_strain: 1 }), is_npc: 1, npc_label: 'Ganger' }); + await insertSheet(db, { username: 'JUNO', system: 'cyberpunk_red', data: JSON.stringify({ system_strain: 2 }) }); + + const [rested] = await Promise.all([ + request(app).post('/api/sheets/cwn-rest').set('Authorization', `Bearer ${ADMIN_TOKEN}`), + patchAs({ notes: 'edited mid-rest' }), + ]); + expect(rested.body).toEqual({ rested: 2 }); + + const dataOf = async (username) => JSON.parse((await get(db, `SELECT data FROM character_sheets WHERE username = ?`, [username])).data); + const ghost = await dataOf('GHOST'); + expect(ghost.system_strain).toBe(2); + expect(ghost.notes).toBe('edited mid-rest'); + expect(ghost).not.toHaveProperty('edge'); + expect((await dataOf('GANGER')).system_strain).toBe(0); + // No strain to recover, or another system's sheet: untouched. + expect(await dataOf('VEX')).toEqual({ system_strain: 0 }); + expect(await dataOf('JUNO')).toEqual({ system_strain: 2 }); + // Only a player's own window is told; an NPC's has no player. + expect(emitted.filter((e) => e.event === 'sheetUpdated').map((e) => e.payload.username)).toContain('GHOST'); + expect(emitted.filter((e) => e.event === 'sheetUpdated').map((e) => e.payload.username)).not.toContain('GANGER'); + }); + it('resets LUCK table-wide without discarding an edit made at the same moment', async () => { // The bulk reset scans every sheet, so it is the writer most likely to be holding a // stale copy by the time it reaches the last player. diff --git a/backend/__tests__/system_builder_call_rest.test.js b/backend/__tests__/system_builder_call_rest.test.js new file mode 100644 index 00000000..5cd16f35 --- /dev/null +++ b/backend/__tests__/system_builder_call_rest.test.js @@ -0,0 +1,273 @@ +/** + * Calling a rest at the table (4f2b). Approved mockup builder-rests (2026-10-09): the GM, or a granted + * editor, picks one of the running custom system's rests and who rests (every player character, the + * ones named, and chosen NPC tokens); a preview says what each will get without rolling or writing; + * calling it writes each sheet through its queue, the token's health and conditions after, and one + * dice-log line per character for everyone. + */ + +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, all, run } from './helpers/testDb.js'; +import sheetsRouteFactory from '../routes/sheets.js'; + +const require_ = createRequire(import.meta.url); +const runtime = require_('../systemBuilder/runtime'); +const { whoProblem } = require_('../sheets/rests'); +const { restLine, restDice } = require_('../systemBuilder/resting'); +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: 'vex', role: 'player' }); + +const HEARTH = 'sys_aaaaaaaaaaaaaaaa'; +const HEARTH_DEF = { + format: 1, name: 'Hearth', + stats: [{ id: 'abilities', label: 'ABILITIES', stats: [{ id: 'con_mod', label: 'Con mod' }] }], + derived: [{ id: 'ward', label: 'Ward', formula: '@fatigue + 10' }], + sheet: { + sections: [ + { id: 'who', label: 'WHO', layout: 'list', fields: [{ id: 'name', label: 'Name', type: 'text' }, { id: 'notes', label: 'Notes', type: 'textarea' }] }, + { id: 'body', label: 'BODY', layout: 'grid', fields: [ + { id: 'hp', label: 'HP', type: 'number', source: 'token_hp', maxField: 'hp_max' }, + { id: 'hp_max', label: 'MAX', type: 'number', source: 'token_hp_max' }, + { id: 'fatigue', label: 'Fatigue', type: 'number' }, + { id: 'ward', label: 'Ward', type: 'number' }, + ] }, + { id: 'magic', label: 'MAGIC', layout: 'grid', fields: [ + { id: 'slots', label: 'Spell slots', type: 'number', maxField: 'slots_max' }, + { id: 'slots_max', label: 'Slots max', type: 'number' }, + ] }, + ], + }, + rests: { + short_rest: { refills: [{ what: 'health', how: 'by', amount: '1d8 + @con_mod' }] }, + long_rest: { counts_as: ['short_rest'], refills: [{ what: 'health', how: 'full' }, { what: 'fatigue', how: 'by', amount: '-1' }, { what: 'section:magic', how: 'max' }] }, + end_of_scene: { on: false }, + end_of_session: { refills: [{ what: 'slots', how: 'to', amount: '@ward - 10' }] }, + }, + conditions: { exhausted: { ends: 'rest', at: ['long_rest'] }, bleeding: { ends: 'rest', at: ['short_rest'] } }, +}; + +let db; +let app; +let emitted; +let ids; + +// A granted editor is one the server has on its list (middleware/auth.js). +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(HEARTH_DEF); + await run(db, 'INSERT INTO custom_systems (id, name, draft, published, version) VALUES (?, ?, ?, ?, 1)', [HEARTH, 'Hearth', text, text]); + await new Promise((resolve) => runtime.load(db, resolve)); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [HEARTH]); + emitted = []; + app = express(); + app.use(express.json()); + app.use('/api/sheets', sheetsRouteFactory(db, { emit: (event, payload) => emitted.push({ event, payload }) })); + + const sheet = async (username, system, data, isNpc = 0) => (await run(db, + 'INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES (?, ?, ?, ?, ?)', + [username, system, JSON.stringify(data), isNpc, isNpc ? username : null])).lastID; + const token = async (name, shape, owner, hp, conditions = []) => (await run(db, + 'INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max, hp_temp, conditions) VALUES (?, 0, 0, 0, ?, ?, ?, ?, 0, ?)', + [name, shape, owner, hp[0], hp[1], JSON.stringify(conditions)])).lastID; + + ids = { + vexSheet: await sheet('vex', HEARTH, { name: 'Vex', con_mod: 2, fatigue: 3, slots: 0, slots_max: 4, notes: 'keep me' }), + ashSheet: await sheet('ash', HEARTH, { name: '', fatigue: 1, slots: 1, slots_max: 2 }), + junoOther: await sheet('juno', 'cyberpunk_red', { fatigue: 5 }), + gangerSheet: await sheet('Ganger', HEARTH, { fatigue: 2 }, 1), + rookSheet: await sheet('Rook', HEARTH, { name: 'Rook', fatigue: 4 }), + dogOtherSheet: await sheet('Dog', 'cyberpunk_red', { fatigue: 3 }, 1), + vex: await token('VEX', 'rhombus', 'vex', [9, 22], [{ id: 'exhausted' }, { id: 'prone' }, { id: 'bleeding' }]), + vexOnMap: await token('VEX', 'rhombus', 'vex', [9, 22], [{ id: 'exhausted' }, { id: 'prone' }, { id: 'bleeding' }]), + juno: await token('JUNO', 'rhombus', 'juno', [3, 10]), + ganger: await token('GANGER', 'enemy_rhombus', 'gm', [4, 12], [{ id: 'exhausted' }]), + dog: await token('DOG', 'friendly_rhombus', 'gm', [1, 6]), + shop: await token('SHOP', 'box', null, [0, 0]), + }; + await run(db, 'INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (?, ?)', [ids.ganger, ids.gangerSheet]); + await run(db, 'INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (?, ?)', [ids.dog, ids.dogOtherSheet]); +}); + +const rest = (body, as = GM) => request(app).post('/api/sheets/rest').set('Authorization', `Bearer ${as}`).send(body); +const dataOf = async (id) => JSON.parse((await get(db, 'SELECT data FROM character_sheets WHERE id = ?', [id])).data); +const tokenOf = async (id) => { + const row = await get(db, 'SELECT hp_current, hp_max, conditions FROM locations WHERE id = ?', [id]); + return { hp: [row.hp_current, row.hp_max], conditions: JSON.parse(row.conditions) }; +}; +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' }] }); + 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: [] }); + expect((await request(app).get('/api/sheets/rests').set('Authorization', `Bearer ${PLAYER}`)).status).toBe(403); + }); +}); + +describe('a preview', () => { + it('says what each player character would get, dice unrolled, and writes nothing', async () => { + const res = await rest({ rest: 'short_rest', players: true, preview: true }); + expect(res.status).toBe(200); + expect(res.body.rest).toEqual({ id: 'short_rest', name: 'Short rest' }); + expect(res.body.preview).toBe(true); + // In this system only, a player with no token on the map included, by name or else login. + expect(res.body.characters).toEqual([ + { kind: 'player', username: 'ash', name: 'ash', changes: [], gone: [] }, + { kind: 'player', username: 'Rook', name: 'Rook', changes: [], gone: [] }, + { kind: 'player', username: 'vex', name: 'Vex', changes: [{ what: 'health', label: 'HP', from: 9, to: null, pending: ['1d8 + @con_mod'] }], gone: ['Bleeding'] }, + ]); + expect(await tokenOf(ids.vex)).toEqual({ hp: [9, 22], conditions: [{ id: 'exhausted' }, { id: 'prone' }, { id: 'bleeding' }] }); + expect(await all(db, 'SELECT * FROM dice_rolls')).toEqual([]); + expect(emitted).toEqual([]); + }); +}); + +describe('calling a rest', () => { + it('writes each player\'s sheet, every token of theirs, and a dice-log line each, telling every screen', async () => { + const res = await rest({ rest: 'long_rest', players: true }); + expect(res.status).toBe(200); + expect(named(res.body.characters)).toEqual(['ash', 'Rook', 'Vex']); + const vex = res.body.characters[2]; + expect(vex.changes).toEqual([ + { what: 'health', label: 'HP', from: 9, to: 22 }, + { what: 'fatigue', label: 'Fatigue', from: 3, to: 2 }, + { what: 'slots', label: 'Spell slots', from: 0, to: 4 }, + ]); + expect(vex.gone).toEqual(['Exhausted', 'Bleeding']); + + // The sheet, its formulas worked out again (ward reads fatigue), and nothing else touched. + expect(await dataOf(ids.vexSheet)).toEqual({ name: 'Vex', con_mod: 2, fatigue: 2, slots: 4, slots_max: 4, notes: 'keep me', ward: 12 }); + expect(await dataOf(ids.ashSheet)).toMatchObject({ fatigue: 0, slots: 2 }); + for (const id of [ids.vex, ids.vexOnMap]) expect(await tokenOf(id)).toEqual({ hp: [22, 22], conditions: [{ id: 'prone' }] }); + // Another game's character and every NPC rest only when asked. + expect(await dataOf(ids.junoOther)).toEqual({ fatigue: 5 }); + expect(await tokenOf(ids.juno)).toEqual({ hp: [3, 10], conditions: [] }); + expect(await tokenOf(ids.ganger)).toEqual({ hp: [4, 12], conditions: [{ id: 'exhausted' }] }); + + const lines = await all(db, 'SELECT username, total, results, color, historyString FROM dice_rolls ORDER BY id'); + expect(lines.map((l) => l.username)).toEqual(['ash', 'Rook', 'vex']); + expect(lines[2].historyString).toMatch(/^LONG REST · Vex: HP 9 → 22, Fatigue 3 → 2, Spell slots 0 → 4; Exhausted, Bleeding wore off; rolled 1d8 \+ @con_mod = \d+ \(1d8: \d\)$/); + expect(res.body.characters[2].line).toBe(lines[2].historyString); + // The short rest's die was rolled even though full health settled it: the log shows it. + const vexDie = JSON.parse(lines[2].results); + expect(Object.keys(vexDie)).toEqual(['8']); + expect(lines[2].total).toBe(vexDie[8][0]); + + expect(emitted.filter((e) => e.event === 'diceRollBroadcast').map((e) => e.payload.historyString)).toEqual(lines.map((l) => l.historyString)); + 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 } }]); + }); + + it('rolls the short rest\'s dice for health, up to the maximum', async () => { + await rest({ rest: 'short_rest', players: ['VEX'] }); + const [line] = await all(db, 'SELECT results, total, historyString FROM dice_rolls'); + const die = JSON.parse(line.results)[8][0]; + expect(die).toBeGreaterThanOrEqual(1); + expect(die).toBeLessThanOrEqual(8); + expect((await tokenOf(ids.vex)).hp).toEqual([9 + die + 2, 22]); + expect(line.historyString).toBe(`SHORT REST · Vex: HP 9 → ${9 + die + 2}; Bleeding wore off; rolled 1d8 + @con_mod = ${die + 2} (1d8: ${die})`); + }); + + it('rests only the players named, whatever their case, and no player at all for false', async () => { + const res = await rest({ rest: 'long_rest', players: ['ASH', 'rook'] }); + expect(named(res.body.characters)).toEqual(['ash', 'Rook']); + expect((await tokenOf(ids.vex)).hp).toEqual([9, 22]); + const npcsOnly = await rest({ rest: 'long_rest', players: false, npcs: [ids.ganger] }); + expect(named(npcsOnly.body.characters)).toEqual(['GANGER']); + }); + + it('reads the character\'s formulas worked out', async () => { + // Ward is fatigue + 10, never stored on Vex's sheet: slots become 3 + 10 - 10. + await rest({ rest: 'end_of_session', players: ['vex'] }); + expect(await dataOf(ids.vexSheet)).toMatchObject({ slots: 3 }); + }); + + it('works each rest out from the sheet as it stands when written, so two at once both land', async () => { + await Promise.all([rest({ rest: 'long_rest', players: ['vex'] }), rest({ rest: 'long_rest', players: ['vex'] })]); + expect(await dataOf(ids.vexSheet)).toMatchObject({ fatigue: 1 }); + }); + + it('rests the chosen NPC tokens, with their linked sheet or none, and nothing that isn\'t an NPC', async () => { + const res = await rest({ rest: 'long_rest', npcs: [ids.ganger, ids.dog, ids.shop, ids.vex] }); + expect(named(res.body.characters)).toEqual(['GANGER', 'DOG']); + expect(res.body.characters[0]).toMatchObject({ kind: 'npc', location_id: ids.ganger, gone: ['Exhausted'] }); + expect(await dataOf(ids.gangerSheet)).toMatchObject({ fatigue: 1 }); + expect(await tokenOf(ids.ganger)).toEqual({ hp: [12, 12], conditions: [] }); + expect(await tokenOf(ids.dog)).toEqual({ hp: [6, 6], conditions: [] }); + // The dog's sheet is another game's: not this game's to rest. + expect(await dataOf(ids.dogOtherSheet)).toEqual({ fatigue: 3 }); + expect((await tokenOf(ids.vex)).hp).toEqual([9, 22]); + expect(emitted.filter((e) => e.event === 'sheetUpdated')).toEqual([]); + expect((await all(db, 'SELECT username FROM dice_rolls ORDER BY id')).map((l) => l.username)).toEqual(['GANGER', 'DOG']); + }); + + it('keeps an edit a player makes to their sheet while the rest is written', async () => { + const [rested, edited] = await Promise.all([ + rest({ rest: 'long_rest', players: ['vex'] }), + request(app).put('/api/sheets/user/vex').set('Authorization', `Bearer ${GM}`).send({ fields: { notes: 'edited mid-rest' } }), + ]); + expect(rested.status).toBe(200); + expect(edited.status).toBe(200); + expect(await dataOf(ids.vexSheet)).toMatchObject({ notes: 'edited mid-rest', fatigue: 2, slots: 4 }); + }); + + it('tells nobody when nobody rests', async () => { + const res = await rest({ rest: 'long_rest', players: ['nobody'] }); + expect(res.body.characters).toEqual([]); + expect(emitted).toEqual([]); + }); +}); + +describe('refusals', () => { + it('are the GM\'s and granted editors\' to call', async () => { + expect((await rest({ rest: 'long_rest', players: true }, PLAYER)).status).toBe(403); + expect((await rest({ rest: 'long_rest', players: true }, EDITOR)).status).toBe(200); + }); + + it('name a rest the game hasn\'t got, has turned off, or a built-in game\'s', async () => { + expect((await rest({ rest: 'nap', players: true })).body).toEqual({ error: 'Not one of this game\'s rests' }); + expect((await rest({ rest: 'end_of_scene', players: true })).status).toBe(400); + await run(db, `UPDATE global_settings SET value = 'cities_without_number' WHERE key = 'game_system'`); + const builtIn = await rest({ rest: 'long_rest', players: true }); + expect(builtIn.status).toBe(409); + expect(builtIn.body).toEqual({ error: 'This game\'s rests are its own buttons' }); + }); + + it('read who rests only as true or a list of names, and NPCs only as token ids', async () => { + for (const [players, npcs] of [['all', undefined], [[1], undefined], [Array(101).fill('x'), undefined], [true, 'all'], [true, [0]], [true, [1.5]], [true, Array(101).fill(1)]]) { + expect(whoProblem(players, npcs), JSON.stringify([players, npcs]).slice(0, 40)).not.toBeNull(); + } + for (const [players, npcs] of [[undefined, undefined], [true, undefined], [false, [1, 2]], [['vex'], []]]) { + expect(whoProblem(players, npcs)).toBeNull(); + } + expect((await rest({ rest: 'long_rest', players: 'all' })).body).toEqual({ error: 'players: true, or a list of up to 100 player names' }); + expect((await rest({ rest: 'long_rest', npcs: ['x'] })).body).toEqual({ error: 'npcs: a list of up to 100 token ids' }); + }); +}); + +describe('the dice-log line and its dice', () => { + it('reads as the mockup\'s, with nothing changed said so', () => { + const result = { changes: [{ label: 'HP', from: 9, to: 16 }, { label: 'Focus', from: 1, to: null, pending: ['1d4', '-1'] }], gone: ['poisoned'], rolls: [ + { amount: '1d8 + 2', value: 7, dice: [{ count: 1, sides: 8, rolls: [5] }] }, { amount: '-1', value: -1, dice: [] }, { amount: '0d6', value: 0, dice: [{ count: 0, sides: 6, rolls: [] }] }, + ] }; + expect(restLine({}, 'Long rest', 'Vex', result)).toBe('LONG REST · Vex: HP 9 → 16, Focus 1 → 1 + 1d4 + -1; Poisoned wore off; rolled 1d8 + 2 = 7 (1d8: 5), 0d6 = 0 (0d6: -)'); + expect(restLine({}, 'Nap', 'Ash', { changes: [], gone: [], rolls: [] })).toBe('NAP · Ash: nothing changed'); + expect(restDice(result)).toEqual({ results: { 8: [5], 6: [] }, total: 5 }); + expect(restDice({ rolls: [{ dice: [{ sides: 6, rolls: [2, 3] }] }, { dice: [{ sides: 6, rolls: [4] }] }] })).toEqual({ results: { 6: [2, 3, 4] }, total: 9 }); + }); +}); diff --git a/backend/routes/sheets.js b/backend/routes/sheets.js index 0f5aaf6c..53961d37 100644 --- a/backend/routes/sheets.js +++ b/backend/routes/sheets.js @@ -23,6 +23,8 @@ const sheetAttack = require('../sheets/attack'); const headshots = require('../sheets/headshots'); const identity = require('../sheets/identity'); const tokenAccess = require('../tokens/tokenAccess'); +const rests = require('../sheets/rests'); +const { restsOf } = require('../systemBuilder/rests'); // Admin-facing character sheet routes. Player self-service (open/edit own // sheet, quick-sheet lookups) goes through socket events, matching how the @@ -809,7 +811,9 @@ module.exports = (db, io) => { if (updates.length === 0) return res.json({ rested: 0 }); let done = 0; for (const u of updates) { - patchSheet(db, u.id, (d) => ({ edge: Number(d['edge_max']) }), + // Strain read again at write time, as LUCK and EDGE are. (821dae0 wrote SR6's Edge + // here by mistake, so a long rest recovered no strain until 4f2b.) + patchSheet(db, u.id, (d) => ({ system_strain: Math.max(0, (Number(d.system_strain) || 0) - 1) }), () => { if (!u.isNpc) io.emit('sheetUpdated', { username: u.username }); done++; @@ -857,6 +861,40 @@ module.exports = (db, io) => { }); }); + /** + * A custom system's rests (4f2b; approved mockup docs/mockups/builder-rests.html, 2026-10-09): + * the running game's rests that are on, for the GAME tab. A built-in game has none here; its + * rests are the buttons above. + */ + router.get('/rests', authenticate, requireAdmin, (req, res) => { + getGameSystem((err, system) => { + if (err) return res.status(500).json({ error: err.message }); + const definition = customSystems.definitionOf(system); + res.json({ system, rests: definition ? restsOf(definition).map((r) => ({ id: r.id, name: r.name })) : [] }); + }); + }); + + /** + * Call a rest, or preview one: { rest, players: true | [names], npcs: [token ids], preview }. + * The GM and granted editors, as with the built-in games' rest buttons (sheets/rests.js). + */ + router.post('/rest', authenticate, requireAdmin, (req, res) => { + const { rest: restId, players, npcs, preview } = req.body || {}; + const problem = rests.whoProblem(players, npcs); + if (problem) return res.status(400).json({ error: problem }); + 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' }); + const rest = restsOf(definition).find((r) => r.id === restId); + if (!rest) return res.status(400).json({ error: 'Not one of this game\'s rests' }); + const args = { system, definition, rest, players, npcs }; + (preview === true ? rests.previewRest(db, args) : rests.callRest(db, io, args)) + .then((characters) => res.json({ rest: { id: rest.id, name: rest.name }, preview: preview === true, characters })) + .catch((e) => res.status(500).json({ error: e.message })); + }); + }); + // SR6 Edge grant: give 1 Edge to a specific player (capped at edge_max). // Body: { username: string } router.post('/grant-edge', authenticate, requireAdmin, (req, res) => { diff --git a/backend/sheets/rests.js b/backend/sheets/rests.js new file mode 100644 index 00000000..0d0fc04c --- /dev/null +++ b/backend/sheets/rests.js @@ -0,0 +1,144 @@ +// Calling a rest at the table (4f2b; approved mockup docs/mockups/builder-rests.html, 2026-10-09): +// the GM picks a rest of the running custom system and who rests (every player character, or the +// ones named, and the NPC tokens chosen); each is worked out by systemBuilder/resting.js and written +// back. A preview rolls nothing and writes nothing. +// +// Each character's sheet is written through its write queue (mutateSheet), the rest worked out +// from the sheet as it stands at write time, so a player typing into it then loses nothing. The +// token's health and conditions are written after: a player's onto every token of theirs, as the +// health route does, an NPC's onto its own. One dice-log line per character, for everyone. + +const crypto = require('crypto'); +const { mutateSheet } = require('./mutate'); +const { applyDerived } = require('./templates'); +const { parseTokenConditions } = require('../tokens/conditions'); +const { restOn, restLine, restDice } = require('../systemBuilder/resting'); +const { conditionsOf } = require('../systemBuilder/conditions'); + +const LIMITS = { players: 100, npcs: 100 }; +const NPC_SHAPES = ['enemy_rhombus', 'friendly_rhombus']; +const LOG_COLOR = '#00ff00'; + +const parse = (text) => { try { const v = JSON.parse(text || '{}'); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; } catch { return {}; } }; +const all = (db, sql, params) => new Promise((ok, no) => db.all(sql, params, (e, rows) => (e ? no(e) : ok(rows || [])))); +const run = (db, sql, params) => new Promise((ok, no) => db.run(sql, params, (e) => (e ? no(e) : ok()))); +const cryptoRng = () => crypto.randomInt(0, 2 ** 32) / 2 ** 32; + +/** Why a request's `players` and `npcs` can't be read, or null. */ +const whoProblem = (players, npcs) => { + if (players !== undefined && players !== true && players !== false + && !(Array.isArray(players) && players.length <= LIMITS.players && players.every((p) => typeof p === 'string'))) { + return `players: true, or a list of up to ${LIMITS.players} player names`; + } + if (npcs !== undefined && !(Array.isArray(npcs) && npcs.length <= LIMITS.npcs && npcs.every((n) => Number.isInteger(n) && n > 0))) { + return `npcs: a list of up to ${LIMITS.npcs} token ids`; + } + return null; +}; + +/** + * Who rests: player characters in `system` (all of them for `players: true`, the named ones for a + * list), each with the first of its tokens; and the chosen NPC tokens with the sheets linked to + * them in `system`. Each as { kind, username?, location_id?, name, sheetId, data, token, conditions }. + */ +const gather = async (db, system, players, npcs) => { + const out = []; + if (players === true || (Array.isArray(players) && players.length)) { + const sheets = await all(db, 'SELECT id, username, data FROM character_sheets WHERE system = ? AND is_npc = 0 ORDER BY username COLLATE NOCASE', [system]); + const wanted = Array.isArray(players) ? new Set(players.map((p) => p.toLowerCase())) : null; + for (const s of sheets.filter((x) => !wanted || wanted.has(String(x.username).toLowerCase()))) { + const [token] = await all(db, `SELECT id, hp_current, hp_max, hp_temp, conditions FROM locations WHERE shape = 'rhombus' AND owner = ? ORDER BY id LIMIT 1`, [s.username]); + const data = parse(s.data); + out.push({ kind: 'player', username: s.username, name: (typeof data.name === 'string' && data.name.trim()) || s.username, sheetId: s.id, data, token: token || null }); + } + } + if (Array.isArray(npcs) && npcs.length) { + const rows = await all(db, + `SELECT l.id, l.name, l.hp_current, l.hp_max, l.hp_temp, l.conditions, cs.id AS sheet_id, cs.data + FROM locations l + LEFT JOIN npc_sheet_links k ON k.location_id = l.id + LEFT JOIN character_sheets cs ON cs.id = k.sheet_id AND cs.system = ? + WHERE l.id IN (${npcs.map(() => '?').join(',')}) AND l.shape IN (${NPC_SHAPES.map(() => '?').join(',')}) + ORDER BY l.id`, + [system, ...npcs, ...NPC_SHAPES]); + const seen = new Set(); + for (const r of rows) { + if (seen.has(r.id)) continue; + seen.add(r.id); + out.push({ kind: 'npc', location_id: r.id, name: r.name || 'NPC', sheetId: r.sheet_id || null, data: r.sheet_id ? parse(r.data) : {}, token: r }); + } + } + return out; +}; + +const tokenOf = (row) => (row ? { current: row.hp_current, max: row.hp_max, temp: row.hp_temp } : null); + +/** The sheet as a rest reads it: with its formulas worked out. */ +const worked = (system, data) => { const d = { ...data }; applyDerived(system, d); return d; }; + +/** What one character sends back to the GAME tab. */ +const shown = (definition, c, result) => { + const names = new Map(conditionsOf(definition).map((x) => [x.id, x.name])); + return { + kind: c.kind, + ...(c.kind === 'player' ? { username: c.username } : { location_id: c.location_id }), + name: c.name, + changes: result.changes, + gone: result.gone.map((id) => names.get(id) || id), + }; +}; + +/** What a rest would do to each one: nothing rolled, nothing written. */ +const previewRest = async (db, { system, definition, rest, players, npcs }) => { + const characters = await gather(db, system, players, npcs); + return characters.map((c) => shown(definition, c, restOn({ + definition, restId: rest.id, sheet: worked(system, c.data), token: tokenOf(c.token), conditions: parseTokenConditions(c.token && c.token.conditions), + }))); +}; + +/** Work one character's rest out at write time, and write its sheet. */ +const restSheet = (db, system, definition, rest, c, rng) => 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) }); + if (!result.ok || !Object.keys(result.sheetPatch).length) return undefined; + const next = { ...data, ...result.sheetPatch }; + applyDerived(system, next); + return next; + }, (err) => (err ? no(err) : ok(result))); +}); + +/** + * Call a rest: each character worked out and written in turn, a dice-log line each, and every + * screen told. Answers what each one got. + */ +const callRest = async (db, io, { system, definition, rest, players, npcs, rng = cryptoRng }) => { + const characters = await gather(db, system, players, npcs); + const done = []; + for (const c of characters) { + const result = await restSheet(db, system, definition, rest, c, rng); + if (!result || !result.ok) continue; + if (c.token) { + const t = result.token; + const conditions = JSON.stringify(result.conditions); + if (c.kind === 'player') { + await run(db, `UPDATE locations SET hp_current = ?, hp_max = ?, hp_temp = ?, conditions = ? WHERE shape = 'rhombus' AND owner = ?`, [t.current, t.max, t.temp, conditions, c.username]); + } else { + await run(db, 'UPDATE locations SET hp_current = ?, hp_max = ?, hp_temp = ?, conditions = ? WHERE id = ?', [t.current, t.max, t.temp, conditions, c.location_id]); + } + } + const historyString = restLine(definition, rest.name, c.name, result); + 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]); + io.emit('diceRollBroadcast', { userName: c.name, account, results, modifiers: [], color: LOG_COLOR, total, historyString }); + if (c.kind === 'player') io.emit('sheetUpdated', { username: c.username }); + done.push({ ...shown(definition, c, result), line: historyString }); + } + if (done.length) io.emit('dataUpdated', { isRhombusOnly: true }); + return done; +}; + +module.exports = { LIMITS, whoProblem, gather, previewRest, callRest }; diff --git a/backend/systemBuilder/resting.js b/backend/systemBuilder/resting.js index 422dbe99..bbec15d4 100644 --- a/backend/systemBuilder/resting.js +++ b/backend/systemBuilder/resting.js @@ -209,4 +209,36 @@ const restOn = ({ definition, restId, sheet = {}, token = null, conditions = [], }; }; -module.exports = { restOn, restOrder }; +/** + * A rest as one line of the dice log, for everyone: "LONG REST · Vex: HP 9 → 16, Fatigue 3 → 2; + * Poisoned wore off; rolled 1d8 + @con_mod = 7 (1d8: 5)". `result` is restOn's. + */ +const restLine = (definition, restName, who, result) => { + const names = new Map(conditionsOf(definition).map((c) => [c.id, c.name])); + const parts = []; + const moved = result.changes.map((c) => `${c.label} ${c.from} → ${c.to === null ? `${c.from} + ${(c.pending || []).join(' + ')}` : c.to}`); + if (moved.length) parts.push(moved.join(', ')); + if (result.gone.length) parts.push(`${result.gone.map((id) => names.get(id) || id).join(', ')} wore off`); + const rolled = result.rolls.filter((r) => r.dice.length) + .map((r) => `${r.amount} = ${r.value} (${r.dice.map((d) => `${d.count}d${d.sides}: ${d.rolls.join('+') || '-'}`).join(', ')})`); + if (rolled.length) parts.push(`rolled ${rolled.join(', ')}`); + return `${String(restName).toUpperCase()} · ${who}: ${parts.join('; ') || 'nothing changed'}`; +}; + +/** + * Every die a rest rolled, by its sides, as the dice tray keeps a roll ({ 8: [5], 4: [3, 1] }), + * and their total: what the log's roll shows. + */ +const restDice = (result) => { + const results = {}; + let total = 0; + for (const r of result.rolls) { + for (const d of r.dice) { + results[d.sides] = [...(results[d.sides] || []), ...d.rolls]; + total += d.rolls.reduce((a, b) => a + b, 0); + } + } + return { results, total }; +}; + +module.exports = { restOn, restOrder, restLine, restDice }; diff --git a/backend/systemBuilder/runtime.js b/backend/systemBuilder/runtime.js index ba18e68b..7ddb6ef5 100644 --- a/backend/systemBuilder/runtime.js +++ b/backend/systemBuilder/runtime.js @@ -226,4 +226,10 @@ const conditionsIn = (system) => conditionsOf(loaded.has(system) ? loaded.get(system).definition : { conditions: tableConditionsOf(system) }); -module.exports = { load, refresh, meta, render, list, tiers, health, wordIn, partIn, buildingIn, buildingNameIn, currenciesIn, catalogueCurrencyIn, conditionsIn, metaOf, renderOf }; +/** + * A published custom system's definition, for rules that read it whole (a rest, resting.js, 4f2b); + * null for a built-in system, a draft or an unknown id. Read, never changed. + */ +const definitionOf = (system) => (loaded.has(system) ? loaded.get(system).definition : null); + +module.exports = { load, refresh, meta, render, list, tiers, health, wordIn, partIn, buildingIn, buildingNameIn, currenciesIn, catalogueCurrencyIn, conditionsIn, definitionOf, metaOf, renderOf };