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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Under the hood

- **Trying a draft's rest.** The server can run one of a draft's rests on a made-up character,
previewed or rolled, through the game's own rules, for TRY IT and the RESTS page to show next.
- **Calling a rest from the GAME tab.** Under a custom game, TTRPG_SYSTEM has a RESTS panel for the
GM and granted editors: pick a rest, rest every player character, the NPCs on the map too, or
the ones ticked, see what each will get, and CALL it. Everyone sees a dice-log line each.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ CITY_NET/
│ │ ├── signs.js # Custom sign CRUD (GET all / POST / PATCH :id / DELETE :id); text optional when image_url set; rotation_x/y/z persisted, non-finite angles rejected
│ │ ├── 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)
│ │ ├── 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); a draft's rest tried on a made-up character (POST /try-rest). 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, 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
Expand Down Expand Up @@ -438,6 +438,7 @@ CITY_NET/
│ │ ├── 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. 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
│ │ ├── tryRest.js # TRY IT's rests: one of a draft's rests on a made-up character through the game's own rules (resting.js), its formulas worked out before and after (a draft whose formulas don't compile yet runs as typed), previewed with dice waiting or rolled, answering the character after and the dice-log line the game would write
│ │ ├── 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 "<name> copy", "copy 02"... for DUPLICATE and keep-both installs
│ │ └── store.js # `custom_systems`: a draft the builder edits and the published copy a game runs. Ids are sys_ + hex, never a built-in id; publishing refuses a draft with problems; the running system cannot be deleted, and deleting hides a system so reinstalling its file brings it back with its characters; export, preview and install (new, update when unchanged here, keep both with a new origin; never a merge)
Expand Down Expand Up @@ -490,6 +491,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)
│ ├── 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_try_rest.test.js # A draft's rest tried: a long rest previewed on the sample with the character after and the line, dice waiting then rolled, formulas read and worked out again, a draft that doesn't compile, no token, token numbers held, refusals; the route rolling only for roll: true, 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 "<name> copy", its own origin and unpublished
│ ├── system_builder_names.test.js # The same name whatever its case and spaces, and "<name> copy", "copy 02"... fitted inside the name limit
Expand Down
144 changes: 144 additions & 0 deletions backend/__tests__/system_builder_try_rest.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* TRY IT's rests (4f6a). Approved mockup builder-rests (2026-10-09): one of a draft's rests on a
* made-up character through the game's own rules, previewed (the RESTS page's ON THE SAMPLE) or
* rolled (TRY IT's CALL A REST), with the character as it stands after and the dice-log line the game
* would write. The draft as it stands, nothing saved.
*/

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 } from './helpers/testDb.js';

process.env.JWT_SECRET = 'test-secret';
const require_ = createRequire(import.meta.url);
const { tryRest } = require_('../systemBuilder/tryRest');
const { elevatedUsers } = require_('../middleware/auth');
const systemsRoute = require_('../routes/systems.js');

const HEARTH = {
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: '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_session: { refills: [{ what: 'slots', how: 'to', amount: '@ward - 10' }] },
end_of_scene: { on: false },
},
conditions: { exhausted: { ends: 'rest', at: ['long_rest'] } },
};
const SAMPLE = { con_mod: 2, fatigue: 3, slots: 0, slots_max: 4 };
const TOKEN = { current: 9, max: 22 };
/** Every die shows `face` on a die of `sides`. */
const showing = (face, sides) => () => (face - 1) / sides + 1e-9;

describe('a rest tried', () => {
it('previews a long rest on the sample: the character after, what wore off, and the game\'s line', () => {
const r = tryRest(HEARTH, { rest: 'long_rest', sheet: SAMPLE, token: TOKEN, conditions: [{ id: 'exhausted' }, { id: 'prone' }] });
expect(r.ok).toBe(true);
expect(r.rest).toEqual({ id: 'long_rest', name: 'Long rest' });
expect(r.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 },
]);
// Formulas worked out again after: ward follows fatigue.
expect(r.sheet).toEqual({ con_mod: 2, fatigue: 2, slots: 4, slots_max: 4, ward: 12 });
expect(r.token).toEqual({ current: 22, max: 22, temp: 0 });
expect(r.conditions).toEqual([{ id: 'prone' }]);
expect(r.gone).toEqual(['Exhausted']);
// Previewed: the short rest's die is never rolled.
expect(r.rolls.every((x) => x.dice.length === 0)).toBe(true);
expect(r.line).toBe('LONG REST · Sample: HP 9 → 22, Fatigue 3 → 2, Spell slots 0 → 4; Exhausted wore off');
});

it('leaves dice waiting in a preview, and rolls them when asked', () => {
expect(tryRest(HEARTH, { rest: 'short_rest', sheet: SAMPLE, token: TOKEN }).changes)
.toEqual([{ what: 'health', label: 'HP', from: 9, to: null, pending: ['1d8 + @con_mod'] }]);
const rolled = tryRest(HEARTH, { rest: 'short_rest', sheet: SAMPLE, token: TOKEN, roll: true }, showing(5, 8));
expect(rolled.changes).toEqual([{ what: 'health', label: 'HP', from: 9, to: 16 }]);
expect(rolled.rolls).toEqual([{ amount: '1d8 + @con_mod', value: 7, dice: [{ count: 1, sides: 8, rolls: [5] }] }]);
expect(rolled.line).toBe('SHORT REST · Sample: HP 9 → 16; rolled 1d8 + @con_mod = 7 (1d8: 5)');
});

it('reads the draft\'s formulas worked out from what was typed', () => {
// Ward is fatigue + 10 = 13, never typed: slots become 3.
expect(tryRest(HEARTH, { rest: 'end_of_session', sheet: SAMPLE, token: TOKEN }).sheet).toMatchObject({ slots: 3, ward: 13 });
});

it('still runs on a draft whose formulas don\'t work yet, the sheet as typed', () => {
const broken = { ...HEARTH, derived: [{ id: 'ward', label: 'Ward', formula: '@fatigue +' }] };
const r = tryRest(broken, { rest: 'long_rest', sheet: SAMPLE, token: TOKEN });
expect(r.ok).toBe(true);
expect(r.sheet).toEqual({ con_mod: 2, fatigue: 2, slots: 4, slots_max: 4 });
});

it('changes the sheet alone for a character with no token', () => {
const r = tryRest(HEARTH, { rest: 'long_rest', sheet: SAMPLE });
expect(r.token).toBeNull();
expect(r.changes.map((c) => c.what)).toEqual(['fatigue', 'slots']);
expect(tryRest(HEARTH, { rest: 'long_rest' }).sheet).toMatchObject({ fatigue: 0, ward: 10 });
});

it('holds a pretend token\'s numbers and temp as the game would', () => {
expect(tryRest(HEARTH, { rest: 'long_rest', token: { current: '4', max: 'x', temp: -3 } }).token).toEqual({ current: 0, max: 0, temp: 0 });
expect(tryRest(HEARTH, { rest: 'long_rest', token: { current: 4, max: 10, temp: 2 } }).token).toEqual({ current: 10, max: 10, temp: 2 });
});

it('refuses a rest the draft hasn\'t got or has turned off', () => {
expect(tryRest(HEARTH, { rest: 'nap' })).toEqual({ ok: false, error: 'Not one of this system\'s rests' });
expect(tryRest(HEARTH, { rest: 'end_of_scene' })).toEqual({ ok: false, error: 'Not one of this system\'s rests' });
expect(tryRest(HEARTH)).toEqual({ ok: false, error: 'Not one of this system\'s rests' });
});
});

describe('the route', () => {
const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret');
const EDITOR = jwt.sign({ username: 'ghost', isTemporary: true }, 'test-secret');
const PLAYER = jwt.sign({ username: 'vex', role: 'player' }, 'test-secret');
let app;
beforeEach(async () => {
elevatedUsers.add('ghost');
const db = await makeTestDb();
app = express();
app.use(express.json());
app.use('/api/systems', systemsRoute(db));
});
afterEach(() => elevatedUsers.delete('ghost'));
const post = (body, token = GM) => request(app).post('/api/systems/try-rest').set({ Authorization: `Bearer ${token}` }).send(body);

it('tries the draft\'s rest as sent, rolling only when asked to in so many words', async () => {
const preview = await post({ definition: HEARTH, rest: 'short_rest', sheet: SAMPLE, token: TOKEN, roll: 'yes' });
expect(preview.status).toBe(200);
expect(preview.body.changes).toEqual([{ what: 'health', label: 'HP', from: 9, to: null, pending: ['1d8 + @con_mod'] }]);
const rolled = await post({ definition: HEARTH, rest: 'short_rest', sheet: SAMPLE, token: TOKEN, roll: true });
expect(rolled.body.changes[0].to).toBeGreaterThanOrEqual(9 + 1 + 2);
expect(rolled.body.changes[0].to).toBeLessThanOrEqual(9 + 8 + 2);
expect(rolled.body.rolls[0].dice[0].rolls).toHaveLength(1);
});

it('refuses a rest the draft hasn\'t got, what isn\'t a definition, and is the main admin\'s alone', async () => {
expect(await post({ definition: HEARTH, rest: 'nap' })).toMatchObject({ status: 400, body: { error: 'Not one of this system\'s rests' } });
expect(await post({ definition: 'nope', rest: 'long_rest' })).toMatchObject({ status: 400, body: { error: 'A system definition must be an object' } });
expect((await post({ definition: HEARTH, rest: 'long_rest' }, EDITOR)).status).toBe(403);
expect((await post({ definition: HEARTH, rest: 'long_rest' }, PLAYER)).status).toBe(403);
expect((await request(app).post('/api/systems/try-rest').send({ definition: HEARTH })).status).toBe(401);
});
});
14 changes: 14 additions & 0 deletions backend/routes/systems.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const { effectiveSheet, starterSheet, fieldsOf } = require('../systemBuilder/she
const { npcSheetOf, LIMITS: NPC_LIMITS } = require('../systemBuilder/npc');
const { rollTier } = require('../systemBuilder/tierRolls');
const { tryHealth } = require('../systemBuilder/tryHealth');
const { tryRest } = require('../systemBuilder/tryRest');
const { exampleList, exampleDefinition, exampleKind, KINDS } = require('../systemBuilder/examples');
const { checkTableConditions, saveTableConditions } = require('../systemBuilder/tableConditions');
const { isBuiltIn } = require('../sheets/templates');
Expand Down Expand Up @@ -193,6 +194,19 @@ module.exports = (db, io = null) => {
res.json(tryHealth(definition, { token, sheet, action }));
});

/**
* A draft's rest on a made-up character (4f6a): TRY IT's CALL A REST, rolled, and the RESTS page's
* ON THE SAMPLE, not. Through the game's own rules; changes nothing.
*/
router.post('/try-rest', gm, (req, res) => {
const { definition, rest, sheet, token, conditions, roll } = req.body || {};
const checked = checkDefinition(definition);
if (checked.fatal) return res.status(400).json({ error: checked.fatal });
const tried = tryRest(definition, { rest, sheet, token, conditions, roll: roll === true });
if (!tried.ok) return res.status(400).json({ error: tried.error });
res.json(tried);
});

/**
* The builder's NPCS page, TRY IT (4b4a2): one of a draft's tiers worked out and rolled for a
* level, each box with its value and dice, or what is wrong with it. Changes nothing.
Expand Down
Loading
Loading