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

### Fixed

- **Linked HP opens the character's health, for the GM too.** On a player's own sheet, HP and HP
MAX open their health window when clicked. On the sheet the GM opens from the dashboard, and on an
NPC's sheet, they did nothing; now they select that character's token on the map and open its
health, as clicking the token would (a player's cash opens their bank). And the LINKED word beside
every linked number is gone: the dashed border says it is kept on the token or in the bank, a small
⇗ marks one that opens its window, and hovering says which.
- **A roll that opens the dice tray is thrown in it.** The tray opens by itself for a roll of yours
made elsewhere: spending recovery dice, a rest the GM called, a roll from a sheet in its own tab.
But that roll arrived before the tray was there to hear it, so its dice were never thrown: it only
showed in the history. Now the tray throws it as it opens, and lists it once. And a rest's line in
the history ("SHORT REST · cody: rolled 1d4 ...") no longer shows its name with two colons.
- **The map's windows stay on the map.** Any window open on the map (a sheet, the dice tray, a
shop) came over the system builder when the GM opened it, drawn on top of the builder's pages.
Now the builder takes over the screen: the map's windows are hidden while it is open, never
Expand Down Expand Up @@ -71,6 +82,20 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **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
the dice to spend and FINISH, which asks too; a rest with nothing to spend applies once asked.
FINISH does everything a called rest does: refills, uses, dice back, health and a line in the log.
The GM can rest anyone from their sheet. In the GAME tab, CALL now asks first; in such a system it
opens the rest on each player's sheet, CALLED BY THE GM, for them to finish, and NPCs rest at once.
The recovery dice move from CHOICES into the rest panel; dice spent any time, or in a system whose
rests are at the GM's call, stay in view in the RESTS section. CUSTOMIZE keeps RESTS as a section
to move like any other.
On the builder's RESTS page, the panel below the rests is headed FOR EVERY REST, as its recovery
dice and RESTS ARE FINISHED are the same whichever rest is picked; and each rest's own panel says
what it does with the dice ("Hit Dice: spent at this rest · gives back nothing"), with SETTINGS
taking you to them.
- **WHAT PLAYERS GET on RESTS (R3a).** Under the recovery dice, a box says in the system's own words
what the settings give a player on their sheet, and what they give the GM: REST buttons or none,
when and where the recovery dice are spent, and who opens spending. It changes as the settings
Expand Down Expand Up @@ -661,6 +686,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Changed

- The character sheet lists CHARACTER first and still opens on STATS. A built sheet can name the tab it opens on; renaming that tab in the builder keeps it, removing it opens on the first.
- On a sheet's CHOICES, each list (CLASSES, FEATS) folds away on its own, saying how many are picked.
- The HEALTH box shows its name alone, with CURRENT and MAX each under its number. Temporary HP is added to the current, in the warning color, and a bonus to the maximum (like Tough) colors it and names itself on hover.
- A built system's automatic character sheet has a CHARACTER tab beside STATS: the name, concept, description and the picks (classes, feats) are there, and STATS keeps the numbers, health and RESTS. A customized sheet keeps its own layout.
- **The GAME tab's RESTS and BANK are folders.** A custom game's RESTS moved out of TTRPG_SYSTEM,
which is for setting the game up, into a folder of its own on the GAME tab, with OPEN SPENDING at
its top when the GM has the say over recovery dice. The bank's CURRENCY_ICON, PAY_PLAYERS and
Expand Down
35 changes: 20 additions & 15 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions backend/__tests__/system_builder_call_rest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
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' });
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 });
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 ${PLAYER}`)).status).toBe(403);
});

Expand Down
6 changes: 4 additions & 2 deletions backend/__tests__/system_builder_core.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ const MODELS = {
describe('a system that answers nothing', () => {
it('gets exactly the starter sheet it always had', () => {
const expected = {
tabs: ['STATS', 'GEAR', 'NOTES'],
// A CHARACTER tab of its own for who they are (the user, 2026-10-31); the values stay as they were.
tabs: ['CHARACTER', 'STATS', 'GEAR', 'NOTES'],
opens: 'STATS',
header: { nameField: 'name', subtitleFields: ['concept'], hpField: 'hp', hpMaxField: 'hp_max' },
sections: [
{ id: 'identity', label: 'IDENTITY', layout: 'list', tab: 'STATS', fields: [
{ id: 'identity', label: 'IDENTITY', layout: 'list', tab: 'CHARACTER', fields: [
{ id: 'name', label: 'Name', type: 'text', visibility: 'public' },
{ id: 'concept', label: 'Concept', type: 'text' },
{ id: 'description', label: 'Description', type: 'textarea', visibility: 'public' },
Expand Down
2 changes: 1 addition & 1 deletion backend/__tests__/system_builder_npc_privacy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('the format', () => {
npcSheet.sections[0].fields.push({ id: 'might', label: 'Again', type: 'number' });
npcSheet.sections[0].fields[1].source = 'bank_balance';
expect(problems({ ...VAULT, npc: { sheet: npcSheet } })).toEqual([
'npc sheet section block, layout: One of list, grid, notes, inventory, choices',
'npc sheet section block, layout: One of list, grid, notes, inventory, choices, rests',
'npc sheet field might: Defined twice',
'npc sheet field might: Linked differently on the character sheet',
]);
Expand Down
6 changes: 4 additions & 2 deletions backend/__tests__/system_builder_picking.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -277,10 +277,12 @@ describe('through the socket', () => {
describe('the sheet\'s CHOICES block (7a3b1)', () => {
const block = (def) => effectiveSheet(def).sections.find((s) => s.layout === 'choices');

it('the starter sheet has one when the system has lists, on STATS after IDENTITY, named for the one list', () => {
it('the starter sheet has one when the system has lists, on CHARACTER after IDENTITY, named for the one list', () => {
const sheet = effectiveSheet({ ...HEARTH, choices: [HEARTH.choices[0]] });
expect(sheet.sections.slice(0, 2).map((s) => s.id)).toEqual(['identity', 'choices']);
expect(block({ ...HEARTH, choices: [HEARTH.choices[0]] })).toEqual({ id: 'choices', label: 'CLASSES', layout: 'choices', tab: 'STATS', fields: [] });
expect(sheet.tabs).toEqual(['CHARACTER', 'STATS', 'GEAR', 'NOTES']);
expect(sheet.opens).toBe('STATS');
expect(block({ ...HEARTH, choices: [HEARTH.choices[0]] })).toEqual({ id: 'choices', label: 'CLASSES', layout: 'choices', tab: 'CHARACTER', fields: [] });
expect(block(HEARTH).label).toBe('CHOICES');
expect(block({ ...HEARTH, choices: undefined })).toBeUndefined();
expect(block({ ...HEARTH, choices: ['junk'] })).toBeUndefined();
Expand Down
39 changes: 39 additions & 0 deletions backend/__tests__/system_builder_rest_on_sheet.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ describe('what the sheet is told', () => {
expect(runtime.renderOf('sys_x', FORGE).resting).toEqual(restingShown(FORGE));
});

it('the starter sheet has a RESTS section after CHOICES, with recovery dice or rests on each sheet (R3b)', () => {
const { starterSheet } = require_('../systemBuilder/sheet');
const ids = (def) => starterSheet(def).sections.map((s) => s.id);
expect(ids(FORGE).slice(0, 3)).toEqual(['identity', 'choices', 'rests']);
expect(starterSheet(FORGE).sections[2]).toEqual({ id: 'rests', label: 'RESTS', layout: 'rests', tab: 'STATS', fields: [] });
expect(ids({ ...FORGE, resting: { finish: 'sheet' } })).toContain('rests');
expect(ids({ ...FORGE, resting: { recovery: FORGE.resting.recovery } })).toContain('rests');
expect(ids({ ...FORGE, resting: { finish: 'call' } })).not.toContain('rests');
expect(ids({ ...FORGE, resting: undefined })).not.toContain('rests');
expect(checkDefinition({ ...FORGE, sheet: starterSheet(FORGE) }).problems).toEqual([]);
});

it('a rest called on a sheet, read safely', () => {
expect(restCallOf({ [REST_CALL_KEY]: { rest: 'short_rest', name: 'Short rest' } })).toEqual({ rest: 'short_rest', name: 'Short rest' });
expect(restCallOf({ [REST_CALL_KEY]: { rest: 'camp' } })).toEqual({ rest: 'camp', name: 'camp' });
Expand Down Expand Up @@ -174,6 +186,33 @@ describe('through the socket', () => {
expect(await lines()).toEqual([]);
});

it('the GM\'s call, on each sheet: opened on every player\'s sheet, nothing applied; NPCs at once (R3b)', async () => {
const { callRest } = require_('../sheets/rests');
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', null, 3, 9, '[]']);
const dog = (await get(db, `SELECT id FROM locations WHERE name = 'DOG'`)).id;
const shared = [];
const io = { emit: (event, data) => shared.push({ event, data }) };
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' });
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);
expect((await lines()).map((l) => l.username)).toEqual(['DOG']);
expect(shared.filter((e) => e.event === 'sheetUpdated').map((e) => e.data.username).sort()).toEqual(['ash', 'rook']);
expect(shared.some((e) => e.event === 'dataUpdated')).toBe(true);
// At the GM's call, it applies to the players at once, as before.
const atOnce = await callRest(db, { emit: () => {} }, { system: ID, definition: atCall, rest, players: ['rook'], npcs: [] });
expect(atOnce.map((d) => !!d.called)).toEqual([false]);
expect((await stored())._uses.second_wind).toBe(0);
// Only players called, on each sheet: no map redraw for nothing changed.
shared.length = 0;
await callRest(db, io, { system: ID, definition: FORGE, rest, players: ['ash'], npcs: [] });
expect(shared.some((e) => e.event === 'dataUpdated')).toBe(false);
});

it('a game on a built-in system has no rests to take here', async () => {
await run(db, `UPDATE global_settings SET value = 'cities_without_number' WHERE key = 'game_system'`);
const { rook } = await table();
Expand Down
27 changes: 26 additions & 1 deletion backend/__tests__/system_builder_runtime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ describe('the sheet format', () => {
{ id: 'one', label: '', layout: 'list', fields: [] },
],
}, ['f'])).toEqual([
'sheet section one, layout: One of list, grid, notes, inventory, choices',
'sheet section one, layout: One of list, grid, notes, inventory, choices, rests',
'sheet section one, tab: Not one of the sheet\'s tabs',
'sheet section one, columns: A whole number from 1 to 8',
'sheet field a, type: One of number, text, textarea, select',
Expand All @@ -119,6 +119,31 @@ describe('the sheet format', () => {
expect(problemsOf('columns')).toEqual(['sheet: Must be a layout']);
expect(problemsOf({ sections: 'x' })).toEqual(['sheet sections: Must be a list of sections']);
});

it('opens on one of its own tabs, when it names one (2026-10-31)', () => {
expect(problemsOf({ tabs: ['A', 'B'], opens: 'B', sections: [] })).toEqual([]);
expect(problemsOf({ tabs: ['A', 'B'], opens: 'C', sections: [] })).toEqual(['sheet opens: Not one of the sheet\'s tabs']);
expect(problemsOf({ opens: 'A', sections: [] })).toEqual(['sheet opens: Not one of the sheet\'s tabs']);
});
});

describe('a sheet\'s token-linked values (fillTokenFields)', () => {
const linked = { hp: 'token_hp', hp_max: 'token_hp_max', armor: 'token_ac', far: 'token_ac_ranged' };
const row = { hp_current: 21, hp_max: 35, hp_temp: 5, melee_ac: 14, ranged_ac: null };

it('reads each from the token, with its temporary HP beside the HP', () => {
expect(templates.fillTokenFields(linked, row, { name: 'Ash' })).toEqual({
name: 'Ash', hp: 21, hp_max: 35, armor: 14, far: 14, [templates.HP_TEMP_KEY]: 5,
});
expect(templates.HP_TEMP_KEY).toBe('_hp_temp');
});

it('gives no temporary HP when there is none, nor without an HP field, and nulls with no token', () => {
expect(templates.fillTokenFields(linked, { ...row, hp_temp: 0 }, { _hp_temp: 9 })).not.toHaveProperty('_hp_temp');
expect(templates.fillTokenFields({ armor: 'token_ac' }, row, {})).toEqual({ armor: 14 });
expect(templates.fillTokenFields(linked, null, {})).toEqual({ hp: null, hp_max: null, armor: null, far: null });
expect(templates.fillTokenFields({ armor: 'token_ac' }, { ...row, melee_ac: null }, {})).toEqual({ armor: 10 });
});
});

describe('a published system, in the running game', () => {
Expand Down
28 changes: 9 additions & 19 deletions backend/routes/sheets.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const customSystems = require('../systemBuilder/runtime');
const { syncTokenMax } = require('../systemBuilder/tokenMax');
const {
TEMPLATES, DEFAULT_SYSTEM, isValidSystem, getLinkedFields, applyDerived, cwnEffectiveAc,
TOKEN_SOURCES, rangedAcOf, acColumns,
TOKEN_SOURCES, acColumns, fillTokenFields,
} = require('../sheets/templates');
const sheetImporters = require('../sheets/importers');
const charwn = require('../sheets/charwn');
Expand All @@ -26,7 +26,7 @@ const identity = require('../sheets/identity');
const tokenAccess = require('../tokens/tokenAccess');
const rests = require('../sheets/rests');
const { restsOf } = require('../systemBuilder/rests');
const { recoveryShown } = require('../systemBuilder/recovery');
const { recoveryShown, restingOf } = require('../systemBuilder/recovery');
const { jsonTemplate } = require('../systemBuilder/sheetImport');

// Admin-facing character sheet routes. Player self-service (open/edit own
Expand Down Expand Up @@ -160,16 +160,11 @@ module.exports = (db, io) => {
};
if (!Object.values(linked).some(s => TOKEN_SOURCES.has(s))) return overlayCash();
db.get(
`SELECT hp_current, hp_max, melee_ac, ranged_ac FROM locations WHERE shape = 'rhombus' AND owner = ?
`SELECT hp_current, hp_max, hp_temp, melee_ac, ranged_ac FROM locations WHERE shape = 'rhombus' AND owner = ?
ORDER BY (battle_map_id IS NULL) DESC LIMIT 1`,
[req.params.username],
(e2, hpRow) => {
Object.entries(linked).forEach(([fieldId, source]) => {
if (source === 'token_hp') data[fieldId] = hpRow ? hpRow.hp_current : null;
if (source === 'token_hp_max') data[fieldId] = hpRow ? hpRow.hp_max : null;
if (source === 'token_ac') data[fieldId] = hpRow ? (hpRow.melee_ac ?? 10) : null;
if (source === 'token_ac_ranged') data[fieldId] = hpRow ? rangedAcOf(hpRow) : null;
});
fillTokenFields(linked, hpRow, data);
overlayCash();
}
);
Expand Down Expand Up @@ -418,19 +413,12 @@ module.exports = (db, io) => {
const wantsHp = Object.values(linked).some(s => TOKEN_SOURCES.has(s));
if (!wantsHp) return res.json({ ...row, data });
db.get(
`SELECT loc.hp_current, loc.hp_max, loc.melee_ac, loc.ranged_ac FROM npc_sheet_links l
`SELECT loc.hp_current, loc.hp_max, loc.hp_temp, loc.melee_ac, loc.ranged_ac FROM npc_sheet_links l
JOIN locations loc ON loc.id = l.location_id
WHERE l.sheet_id = ? LIMIT 1`,
[row.id],
(err2, hpRow) => {
if (!err2 && hpRow) {
Object.entries(linked).forEach(([fieldId, source]) => {
if (source === 'token_hp') data[fieldId] = hpRow.hp_current;
if (source === 'token_hp_max') data[fieldId] = hpRow.hp_max;
if (source === 'token_ac') data[fieldId] = hpRow.melee_ac ?? 10;
if (source === 'token_ac_ranged') data[fieldId] = rangedAcOf(hpRow);
});
}
if (!err2 && hpRow) fillTokenFields(linked, hpRow, data);
res.json({ ...row, data });
}
);
Expand Down Expand Up @@ -901,7 +889,9 @@ 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;
res.json({ system, rests: definition ? restsOf(definition).map((r) => ({ id: r.id, name: r.name })) : [], recovery: shown ? { name: shown.name, spend: shown.spend } : 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' });
});
});

Expand Down
Loading
Loading