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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions README.md

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions backend/__tests__/sheets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
273 changes: 273 additions & 0 deletions backend/__tests__/system_builder_call_rest.test.js

Large diffs are not rendered by default.

40 changes: 39 additions & 1 deletion backend/routes/sheets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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++;
Expand Down Expand Up @@ -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) => {
Expand Down
144 changes: 144 additions & 0 deletions backend/sheets/rests.js
Original file line number Diff line number Diff line change
@@ -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 };
34 changes: 33 additions & 1 deletion backend/systemBuilder/resting.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
8 changes: 7 additions & 1 deletion backend/systemBuilder/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Loading