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

### Added

- **Recovery dice on the sheet (R2b).** A character's recovery dice now head their sheet's CHOICES
block: the system's name for them, what one heals and where they're spent, and each pool (a d10
for each Fighter level, say) as pips lit while left. SPEND rolls as many as − and + say: the dice
tray opens, the heal goes onto the character's tokens, and everyone sees the line in the log. For
real dice, click a pip to tick one off, or back. A rest that gives them back lights them again.
In a system that leaves spending to the GM, the GAME tab's RESTS has OPEN SPENDING: while it is
off, players' dice wait ("THE GM OPENS SPENDING"); the GM can spend them on any sheet any time.
- **Recovery dice can be left to the GM (R2a).** RESTS' RECOVERY DICE has SPENDING: ANY TIME, where
players spend them from their sheet whenever they like, or WHEN THE GM OPENS IT, for a GM who wants
control, where players spend or tick them off only while the GM has spending open. Under it, the
Expand Down Expand Up @@ -634,6 +641,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Changed

- **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
sounds are a folder too, with a line under it before GIVE_ITEM and what the GM hands out. Each
folder starts open and stays open or shut as the GM leaves it, in that browser. Folder buttons
have a solid border, TTRPG_SYSTEM's too, where the buttons inside are dashed.
- **A rest that rolls no dice no longer pops the dice tray.** When the GM called a rest, every
character's line opened the dice tray for that player, and flashed it for everyone else, even when
the rest rolled nothing and the tray had only an empty board to show. Now only a rest that rolls
Expand Down
27 changes: 17 additions & 10 deletions README.md

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions backend/__tests__/system_builder_call_rest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,20 @@ 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' }] });
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 });
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 ${EDITOR}`)).body).toEqual({ system: 'cities_without_number', rests: [], recovery: null });
expect((await request(app).get('/api/sheets/rests').set('Authorization', `Bearer ${PLAYER}`)).status).toBe(403);
});

it('say the recovery dice\'s name and who says when they\'re spent, for OPEN SPENDING (R2b)', async () => {
const def = JSON.stringify({ ...HEARTH_DEF,
choices: [{ id: 'classes', one: 'Class', many: 'Classes', levelled: true, options: [{ id: 'fighter', label: 'Fighter', die: 10 }] }],
resting: { recovery: { name: 'Hit Dice', spent_at: 'any', spend: 'gm' } } });
await run(db, 'UPDATE custom_systems SET draft = ?, published = ? WHERE id = ?', [def, def, HEARTH]);
await new Promise((resolve) => runtime.load(db, resolve));
expect((await request(app).get('/api/sheets/rests').set('Authorization', `Bearer ${GM}`)).body.recovery).toEqual({ name: 'Hit Dice', spend: 'gm' });
});
});

describe('a preview', () => {
Expand Down
17 changes: 14 additions & 3 deletions backend/__tests__/system_builder_recovery_pools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ describe('spending', () => {
});
});

describe('in the sheet\'s words (R2b)', () => {
it('the name, what one heals with its formula named, the rests by name, and who says when', () => {
const { recoveryShown } = require_('../systemBuilder/recovery');
expect(recoveryShown(FORGE)).toEqual({ name: 'Hit Dice', heals: 'its die + CON MODIFIER', spentAt: ['Short rest'], spend: 'any' });
expect(recoveryShown(AGE)).toEqual({ name: 'Recoveries', heals: 'its die for each level', spentAt: 'any', spend: 'any' });
const own = { ...withRecovery({ spend: 'gm', plus: ' ', spent_at: ['camp', 'short_rest'] }), rests: { camp: { name: 'Camp' }, short_rest: { name: 'Breather' } } };
expect(recoveryShown(own)).toEqual({ name: 'Hit Dice', heals: 'its die', spentAt: ['Camp', 'Breather'], spend: 'gm' });
expect(recoveryShown({ ...FORGE, resting: undefined })).toBeNull();
});
});

describe('ticking off and putting right', () => {
it('a pool\'s spent set from none to all of them, by the player or the GM', () => {
expect(pickAction(FORGE, ROOK, { kind: 'recovery', pool: 'fighter', spent: 3 }).data[RECOVERY_KEY]).toEqual({ fighter: 3 });
Expand Down Expand Up @@ -230,9 +241,9 @@ describe('through the socket', () => {
const { rook, shared } = await table();
rook.handlers.choicesAction({ kind: 'recovery', pool: 'wizard', spent: 0 });
await until(async () => !!(await stored())[WORKED_KEY]?.recovery, { label: 'the pools shown' });
expect((await stored())[WORKED_KEY].recovery).toEqual([
expect((await stored())[WORKED_KEY].recovery).toEqual({ name: 'Hit Dice', heals: 'its die + CON MODIFIER', spentAt: ['Short rest'], spend: 'any', pools: [
{ id: 'fighter', label: 'Fighter', die: 10, total: 3, spent: 0 }, { id: 'wizard', label: 'Wizard', die: 6, total: 2, spent: 0 },
]);
] });
rook.handlers.recoverySpend({ pool: 'fighter', count: 2 });
await until(async () => (await stored())[RECOVERY_KEY]?.fighter === 2, { label: 'spent' });
await until(() => shared.some((e) => e.event === 'diceRollBroadcast'), { label: 'the roll' });
Expand All @@ -246,7 +257,7 @@ describe('through the socket', () => {
expect(tokens.map((t) => t.hp_current)).toEqual(Array(2).fill(Math.min(37, 21 + roll.total)));
expect((await get(db, 'SELECT historyString FROM dice_rolls')).historyString).toBe(roll.historyString);
expect(shared.filter((e) => e.event === 'sheetUpdated').map((e) => e.data)).toContainEqual({ username: 'rook', system: ID });
expect((await stored())[WORKED_KEY].recovery[0].spent).toBe(2);
expect((await stored())[WORKED_KEY].recovery.pools[0].spent).toBe(2);
expect(rook.refused()).toEqual([]);
});

Expand Down
5 changes: 4 additions & 1 deletion backend/routes/sheets.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +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 { jsonTemplate } = require('../systemBuilder/sheetImport');

// Admin-facing character sheet routes. Player self-service (open/edit own
Expand Down Expand Up @@ -898,7 +899,9 @@ module.exports = (db, io) => {
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 })) : [] });
// 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 });
});
});

Expand Down
23 changes: 20 additions & 3 deletions backend/systemBuilder/recovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@

const { parse, references } = require('./expression');
const { tablesOf } = require('./derived');
const { namesOf, levelNamesOf, choicesOf, picksOf, valueOn } = require('./choices');
const { restIdsOf } = require('./rests');
const { namesOf, levelNamesOf, choicesOf, picksOf, valueOn, needsText } = require('./choices');
const { restIdsOf, restsOf } = require('./rests');

const FINISH = ['call', 'sheet'];
const SPEND = ['any', 'gm'];
Expand Down Expand Up @@ -140,6 +140,23 @@ const restingOf = (definition) => {
};
};

/**
* The recovery dice in the sheet's words (R2b), never a formula: their name, what one heals ("its die
* for each level + CON MODIFIER"), the rests they're spent at by name (or 'any'), and who says when.
* Null for a system without them.
*/
const recoveryShown = (definition) => {
const r = restingOf(definition).recovery;
if (!r) return null;
const restName = new Map(restsOf(definition).map((x) => [x.id, x.name]));
return {
name: r.name,
heals: `its die${r.perLevel ? ' for each level' : ''}${typeof r.plus === 'string' && r.plus.trim() ? ` + ${needsText(definition, r.plus)}` : ''}`,
spentAt: r.spentAt === 'any' ? 'any' : r.spentAt.map((id) => restName.get(id) || id),
spend: r.spend,
};
};

/** The game-wide setting the GM opens spending with ('1' open), for recovery dice spent at the GM's say. */
const OPEN_SETTING = 'recovery_open';

Expand Down Expand Up @@ -230,5 +247,5 @@ const givenBack = (definition, data, ends) => {
};

module.exports = {
FINISH, HOWS, SPEND, LIMITS, RECOVERY_KEY, ALL_POOL, MOST, OPEN_SETTING, checkResting, restingOf, diceOptionsOf, maySpend, poolsOf, spendFrom, givenBack,
FINISH, HOWS, SPEND, LIMITS, RECOVERY_KEY, ALL_POOL, MOST, OPEN_SETTING, checkResting, restingOf, recoveryShown, diceOptionsOf, maySpend, poolsOf, spendFrom, givenBack,
};
6 changes: 3 additions & 3 deletions backend/systemBuilder/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

const { compileSystem } = require('./derived');
const { overlayOf, choicesOf, workedOf, choicesShown, WORKED_KEY } = require('./choices');
const { poolsOf } = require('./recovery');
const { poolsOf, recoveryShown } = require('./recovery');
const { fieldsRolledBy } = require('./tableRolls');
const { effectiveSheet, fieldsOf } = require('./sheet');
const { npcSheetOf, tiersOf, LIMITS: NPC_LIMITS } = require('./npc');
Expand Down Expand Up @@ -72,9 +72,9 @@ const metaOf = (definition) => {
const changed = compiled.system.apply(data, overlayOf(definition, data));
if (choicesOf(definition).length) {
data[WORKED_KEY] = workedOf(definition, data);
// The recovery dice (R2a): each pool's die, how many and how many spent.
// The recovery dice (R2a, R2b): in the sheet's words, with each pool's die, how many and how many spent.
const pools = poolsOf(definition, data);
if (pools.length) data[WORKED_KEY].recovery = pools.map(({ id, label, die, total, spent }) => ({ id, label, die, total, spent }));
if (pools.length) data[WORKED_KEY].recovery = { ...recoveryShown(definition), pools: pools.map(({ id, label, die, total, spent }) => ({ id, label, die, total, spent })) };
} else delete data[WORKED_KEY];
return changed;
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion e2e/COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ panel), so a control that looks fine but does nothing is caught for all of them.
| A condition's end | when removed, after rounds, at a rest | `condition_rounds`, `resting` | | **GAP** |
| Rests | a refill up to a value | `rests`, `resting`, `call_rest` | `player-journey` (LONG REST: Resolve up to Grit) | **AT THE TABLE** |
| Rests | an amount reading the levels and a stat with its features' modifiers | `rests`, `resting` | `sheet-rest-levels` (LONG REST: Resolve to Fighter level + Grit with Tough) | **AT THE TABLE** |
| Recovery dice | a class's die; how many, what one heals, where spent, what gives them back; spent any time or when the GM opens it; rests finished at the call or on each sheet | `recovery`, `recovery_pools`, `choices_import` | `builder-recovery`, `builder-clicks` | **BUILDER ONLY** until R2b to R4 bring them to the table (R2a is the server's side) |
| Recovery dice | a class's die; how many, what one heals, where spent, what gives them back; spent any time or when the GM opens it; spent with a roll, ticked off; rests finished at the call or on each sheet | `recovery`, `recovery_pools`, `call_rest`, `choices_import` | `builder-recovery`, `builder-clicks`, `sheet-recovery-dice` (a class's dice on the sheet, waiting for the GM, spent with the tray, ticked off, given back by a long rest) | **AT THE TABLE**, but for rests finished on each sheet: **GAP** until R3 |
| Rests | a rest that rolls no dice: a quiet log line, no tray opened or flashed; one that rolls dice opens the tray | `call_rest` | `sheet-rest-levels` (LONG REST shut, SHORT REST 1d4 open) | **AT THE TABLE** |
| Rests | by an amount, to the max, full health, a section; counts as another; a standard rest off; the system's own | `rests`, `resting`, `try_rest` | | **GAP** |
| House rules | starts on / off, switched at the table | `house_rules`, `house_rules_routes` | `builder-graph` (TRY ON THE MAP only) | **GAP** |
Expand Down
1 change: 1 addition & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ one machine, files in order.
| `tests/sheet-nested-choices.spec.ts` | Lists inside lists, and a list everyone has (7a5a2): in the builder SPECIALTIES shows OPENED BY · SOLDIER: SPECIALTY (LV 3) and is set THROUGH FEATURES, COMMONS EVERYONE HAS IT, saved with no problems; published, a player's sheet has no SPECIALTIES block, COMMONS every character's with Improvement next at level 2 and then unlocked; SOLDIER at 3 offers PICK A SPECIALTY under its feature, DUELIST picked there with its own feature; lowered to 2 it waits, kept; back at 3 it counts again; as the server keeps it |
| `tests/sheet-subclass-levels.spec.ts` | A list a feature opens takes the level of what opened it (7a5d): in the builder ARCHETYPES is set THROUGH FEATURES and says each one's features unlock by the level of the pick that opened it: FIGHTER; Champion's Remarkable Athlete is put at LEVEL 7, saved with no problems; published, a player raises FIGHTER to 3, picks CHAMPION under Martial Archetype (Pick an Archetype) and has Improved Critical with NEXT · LV 7: Remarkable Athlete; at Fighter 7 it unlocks, the archetype's worked level is 7 and the character's levels stay 7 |
| `tests/sheet-rest-levels.spec.ts` | A rest's amount reads the character's levels (rest levels): a published system whose long rest sets Resolve to `@fighter_level + @grit`; a player sets Grit 3 and raises FIGHTER to 3 (Tough, at 2, adds 2 Grit); the GM calls LONG REST from the GAME tab's rests; Resolve becomes 8 on the server and on the player's open sheet; with no dice rolled, the player's DICE_TRAY.EXE stays shut (quiet rests), and a SHORT REST rolling 1d4 then opens it |
| `tests/sheet-recovery-dice.spec.ts` | Recovery dice at the table (R2b): a Fighter 2 has two d10 hit dice on the sheet, which wait while the GM has spending closed; the GM opens it in the GAME tab's RESTS, the player spends one (the dice tray opens) and ticks the other off for a real die, a long rest gives them all back, and once the GM closes spending they wait again |
| `tests/sheet-token-max.spec.ts` | A feature on the token's maximum (7a5a3): a player with a 20 HP token, a FIGHTER at level 3, picks TOUGH (+2 HP per level) and sees 26 / 26 on HIT_POINTS, the bonus named on the sheet's HP and MAX HEALTH at 26; a level down, 24; the GM opens their sheet from VIEW_PLAYER_INFO and drops TOUGH, and the token is back at 20 |
| `tests/builder-formula-box.spec.ts` | The formula box (formula box 1): on Tough, `2 * @lvl` in red with USE @level, then +2 for each level · sample Level 3: +6 from the real server; PER CLASS LEVEL for FIGHTER at 3 and @con from INSERT at the cursor; AT LEAST on Fighter's REQUIRES (the sample meets it); the draft saved as written with no problems; and formula box 2: on ROLLS, `@thsi` in AGAINST shown in red and fixed with USE @this, then against your rolled stat · sample, rolling Strength, rolled stat 16: target 16; ADD written by ITS MODIFIER; on RESTS, a short rest's amount written by DICE AND A STAT with CON MODIFIER as 1d8 + @con_mod, said 1d8 + CON modifier · sample CON modifier 2: 3 to 10; the draft saved as written with no problems; and formula box 3: on STATS & RULES' FORMULAS, Grip's `@strr + 1` in red under its box with USE @str, the VALUE 1; fixed, it reads `@str + 1`, says worked out from Strength, the VALUE 17, and the draft is saved so |
| `tests/sheet-visibility.spec.ts` | Who sees what on a customized sheet (coverage family 1): its owner fills in a public field, a private one and a combat value and can't change the GM's field; another player, looking them up from GLOBAL_CHAT's user list (VIEW_PLAYER_INFO), sees only the public lines, and the server sends nothing else; the GM opens the whole sheet the same way (OPEN_SHEET) and sets the GM's field, which the owner then sees |
Expand Down
Loading
Loading