From 3fd911cf976f11788b8da8cd635368df70fc5e5a Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 10 Oct 2026 00:55:57 -0500 Subject: [PATCH] feat(system-builder): rests for the examples and genre starters (4f6b) As approved (docs/mockups/builder-rests.html, decision 8). The game examples copy their built-in rest buttons, each resource kept in a new RESOURCES stat: CWN's long rest takes 1 System Strain off (system_strain, as the built-in names it), Cyberpunk RED's end of session sets LUCK points back to LUCK, Shadowrun's sets Edge points back to Edge. The starters suit their genre: Sword & Spell heals 1d8 + CON mod on a short rest and everything plus exhaustion on a long one; Starfarer gives a wound back after a short rest and all of them after a long one; Story First turns the short and long rests off, clears harm and exhaustion in a Downtime of its own, and ends fear with the scene. The built-in games themselves are unchanged. The example cards' stat counts (and their tests) gain the one stat each. --- CHANGELOG.md | 4 ++ README.md | 4 +- .../__tests__/system_builder_examples.test.js | 54 +++++++++++++++++++ backend/systemBuilder/examples.js | 37 +++++++++++-- .../__tests__/MySystemsPage.test.tsx | 2 +- .../src/sheets/__tests__/examples.test.ts | 6 +-- 6 files changed, 96 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55edecc1..7219cda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **Rests for the examples and starters.** A copy of CWN takes 1 System Strain off on a long rest, + Cyberpunk RED gives LUCK back and Shadowrun gives Edge back each session, as their own buttons + do, each kept in a new RESOURCES stat. Sword & Spell, Starfarer and Story First get rests that + suit them. The built-in games themselves are unchanged. - **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 diff --git a/README.md b/README.md index b8e83ad1..d1cdb239 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,7 @@ CITY_NET/ │ │ ├── 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 +│ │ ├── 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; each with rests: the games' copy their built-in rest buttons (CWN's long rest takes 1 System Strain, Cyberpunk RED's LUCK and Shadowrun's Edge come back each session, each kept in a RESOURCES stat), the starters suit their genre (a fantasy breather and night's sleep, a sci-fi wound back, a story's downtime with no rests by the clock) │ │ ├── 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 │ │ └── 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) │ ├── startup/ @@ -480,7 +480,7 @@ CITY_NET/ │ ├── 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; 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_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; each one's rests tried on its own sample character │ ├── 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 │ ├── system_builder_table_conditions.test.js # A built-in game's own conditions: the checks (no standard ids, no modifiers or rounds, the condition checks and the limit), added after the standard ones and told to every screen, kept apart per game, taken by tokens and refused once gone, kept across a restart, a custom system refused, main admin only │ ├── system_builder_stats.test.js # Stats in groups (ids, labels, ranges, ties to an ability), names for derived values, the sample character, and the starter sheet's sections from them diff --git a/backend/__tests__/system_builder_examples.test.js b/backend/__tests__/system_builder_examples.test.js index 842497b0..91cc4b64 100644 --- a/backend/__tests__/system_builder_examples.test.js +++ b/backend/__tests__/system_builder_examples.test.js @@ -18,6 +18,8 @@ const { CITIES_WITHOUT_NUMBER, SHADOWRUN_6E, CYBERPUNK_RED } = require_('../syst const { checkDefinition } = require_('../systemBuilder/definition'); const { previewDerived } = require_('../systemBuilder/derived'); const { rollTier } = require_('../systemBuilder/tierRolls'); +const { tryRest } = require_('../systemBuilder/tryRest'); +const { restsOf } = require_('../systemBuilder/rests'); const { TEMPLATES, applyDerived } = require_('../sheets/templates'); const { elevatedUsers } = require_('../middleware/auth'); const systemsRoute = require_('../routes/systems.js'); @@ -164,6 +166,58 @@ describe('the genre starters (4d3)', () => { }); }); +/** + * Rests (4f6b). The examples' rests do what the built-in games' own rest buttons do; the starters' + * suit their genre. Each tried on its own sample character through TRY IT's rule (tryRest.js). + */ +describe('the examples\' and starters\' rests', () => { + const tried = (id, rest, over = {}) => { + const d = exampleDefinition(id); + return tryRest(d, { rest, sheet: d.samples, ...over }); + }; + const restIds = (id) => restsOf(exampleDefinition(id)).map((r) => r.id); + + it('take a CWN character\'s System Strain down by 1 on a long rest, never below 0, as LONG_REST does', () => { + expect(tried('cwn', 'long_rest').changes).toEqual([{ what: 'system_strain', label: 'System strain', from: 2, to: 1 }]); + expect(tried('cwn', 'long_rest', { sheet: { ...exampleDefinition('cwn').samples, system_strain: 0 } }).changes).toEqual([]); + }); + + it('give a Cyberpunk RED character their LUCK back each session, as RESET_ALL_LUCK does', () => { + expect(tried('cpr', 'end_of_session').changes).toEqual([{ what: 'luck_points', label: 'LUCK points', from: 2, to: 5 }]); + }); + + it('give a Shadowrun character their Edge back each session, as REPLENISH ALL EDGE does', () => { + expect(tried('sr6', 'end_of_session').changes).toEqual([{ what: 'edge_points', label: 'Edge points', from: 1, to: 3 }]); + }); + + it('heal a die on a fantasy breather, and all of it and exhaustion with a night\'s sleep', () => { + const token = { current: 3, max: 20 }; + expect(tried('fantasy', 'short_rest', { token }).changes).toEqual([{ what: 'health', label: 'HP', from: 3, to: null, pending: ['1d8 + @con_mod'] }]); + const night = tried('fantasy', 'long_rest', { token, conditions: [{ id: 'exhausted' }] }); + expect(night.changes).toEqual([{ what: 'health', label: 'HP', from: 3, to: 20 }]); + expect(night.gone).toEqual(['Exhausted']); + }); + + it('give a starfarer a wound back after a breather, and all of them after proper rest', () => { + expect(tried('scifi', 'short_rest', { token: { current: 1, max: 3 } }).token.current).toBe(2); + expect(tried('scifi', 'long_rest', { token: { current: 0, max: 3 } }).token.current).toBe(3); + }); + + it('clear a story\'s harm in downtime and its fear at the end of a scene, with no rests by the clock', () => { + expect(restIds('narrative')).toEqual(['end_of_scene', 'end_of_session', 'downtime']); + const down = tried('narrative', 'downtime', { sheet: { lesser_1: 'Bruised', severe_1: 'Broken arm' }, token: { current: 3, max: 5 }, conditions: [{ id: 'exhausted' }, { id: 'frightened' }] }); + expect(down.sheet).toMatchObject({ lesser_1: '', severe_1: '' }); + expect(down.token.current).toBe(5); + expect(down.gone).toEqual(['Exhausted']); + expect(tried('narrative', 'end_of_scene', { conditions: [{ id: 'exhausted' }, { id: 'frightened' }] }).gone).toEqual(['Frightened']); + }); + + it('keep every example and starter one that publishes, with the four standard rests otherwise on', () => { + for (const { id, definition } of EXAMPLES) expect(checkDefinition(definition), id).toEqual({ problems: [] }); + for (const id of ['cwn', 'cpr', 'sr6', 'fantasy', 'scifi']) expect(restIds(id), id).toEqual(['short_rest', 'long_rest', 'end_of_scene', 'end_of_session']); + }); +}); + describe('the routes', () => { const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); const EDITOR = jwt.sign({ username: 'ghost', isTemporary: true }, 'test-secret'); diff --git a/backend/systemBuilder/examples.js b/backend/systemBuilder/examples.js index 6162ad59..22864e5d 100644 --- a/backend/systemBuilder/examples.js +++ b/backend/systemBuilder/examples.js @@ -5,8 +5,8 @@ // // Their formulas are the ones in definitions.js, word for word, which the parity test // (__tests__/system_builder_parity.test.js) holds to the hand-written recompute functions. Only -// the names players see, the stats those formulas read, a sample character and the setup answers -// are added here. Mechanics and structure only, never a book's wording or item lists (the plan, +// the names players see, the stats those formulas read, a sample character, the setup answers and +// a rest doing what the built-in game's own rest button does (4f6b) are added here. Mechanics and structure only, never a book's wording or item lists (the plan, // "Built-in systems as examples"). // // NOT what the built-in games run. A game running CWN keeps running CWN's code; these are copies @@ -36,10 +36,11 @@ const CWN = { group('armor', 'ARMOR', [stat('armor_soak', 'Damage soak', 0, 99), stat('armor_trauma_mod', 'Trauma target mod', -10, 10)]), group('magic', 'MAGIC', [stat('cast_skill', 'Cast skill', 0, 4), stat('summon_skill', 'Summon skill', 0, 4)]), group('adjustments', 'TABLE ADJUSTMENTS', [stat('strain_mod', 'Strain adjustment', -10, 10), stat('move_mod', 'Move adjustment', -99, 99)]), + group('resources', 'RESOURCES', [stat('system_strain', 'System strain', 0, 20)]), ], samples: { str: 14, dex: 12, con: 13, int: 10, wis: 9, cha: 11, level: 3, - armor_soak: 0, armor_trauma_mod: 0, cast_skill: 0, summon_skill: 0, strain_mod: 0, move_mod: 0, + armor_soak: 0, armor_trauma_mod: 0, cast_skill: 0, summon_skill: 0, strain_mod: 0, move_mod: 0, system_strain: 2, }, lookups: CITIES_WITHOUT_NUMBER.lookups, derived: labelled(CITIES_WITHOUT_NUMBER.derived, { @@ -51,6 +52,8 @@ const CWN = { }), npc: { tiers: [{ id: 'by_hit_dice', label: 'BY HIT DICE', hp: '@level d8', values: { level: '@level' } }] }, core: { health: { model: 'pool' }, advancement: ['levels'], dice: ['d20', '2d6'], distance: 'meters' }, + // As the built-in LONG_REST button: 1 System Strain off, never below 0. + rests: { long_rest: { refills: [{ what: 'system_strain', how: 'by', amount: '-1' }] } }, }; /** The ten stats at one level, as the built-in NPC generator sets them (sheets/npcTiers.js cprTier). */ @@ -69,8 +72,9 @@ const CPR = { stat('body', 'BODY', 1, 10), stat('emp_max', 'EMP', 1, 10), ]), group('humanity', 'HUMANITY', [stat('humanity', 'Humanity', 0, 120)]), + group('resources', 'RESOURCES', [stat('luck_points', 'LUCK points', 0, 10)]), ], - samples: { int: 6, ref: 7, dex: 6, tech: 5, cool: 6, will: 6, luck: 5, move: 6, body: 6, emp_max: 5, humanity: 50 }, + samples: { int: 6, ref: 7, dex: 6, tech: 5, cool: 6, will: 6, luck: 5, move: 6, body: 6, emp_max: 5, humanity: 50, luck_points: 2 }, derived: labelled(CYBERPUNK_RED.derived, { emp: 'Current EMP' }), words: { money: { singular: 'EURODOLLAR', plural: 'EURODOLLARS', short: 'eb' }, @@ -85,6 +89,8 @@ const CPR = { ], }, core: { health: { model: 'pool' }, advancement: ['spend'], dice: ['d10', 'd6'], distance: 'meters' }, + // As the built-in RESET_ALL_LUCK button: LUCK points back to the LUCK stat. + rests: { end_of_session: { refills: [{ what: 'luck_points', how: 'to', amount: '@luck' }] } }, }; const SR6 = { @@ -98,8 +104,9 @@ const SR6 = { stat('willpower', 'Willpower', 1, 9), stat('logic', 'Logic', 1, 9), stat('intuition', 'Intuition', 1, 9), stat('charisma', 'Charisma', 1, 9), ]), group('special', 'SPECIAL', [stat('edge', 'Edge', 1, 7), stat('magic', 'Magic', 0, 9)]), + group('resources', 'RESOURCES', [stat('edge_points', 'Edge points', 0, 7)]), ], - samples: { body: 4, agility: 5, reaction: 4, strength: 3, willpower: 3, logic: 3, intuition: 4, charisma: 2, edge: 3, magic: 0 }, + samples: { body: 4, agility: 5, reaction: 4, strength: 3, willpower: 3, logic: 3, intuition: 4, charisma: 2, edge: 3, magic: 0, edge_points: 1 }, derived: labelled(SHADOWRUN_6E.derived, { physical_monitor: 'Physical monitor', stun_monitor: 'Stun monitor', initiative_score: 'Initiative', composure: 'Composure', power_points_spent: 'Power points spent', power_points_remaining: 'Power points left', @@ -111,6 +118,8 @@ const SR6 = { dice: ['d6'], distance: 'meters', }, + // As the built-in REPLENISH ALL EDGE button: Edge points back to Edge. + rests: { end_of_session: { refills: [{ what: 'edge_points', how: 'to', amount: '@edge' }] } }, }; // ─── Genre starters (4d3) ─────────────────────────────────────────────────── @@ -149,6 +158,12 @@ const FANTASY = { ], }, core: { health: { model: 'pool' }, advancement: ['levels'], dice: ['d20', 'd4', 'd6', 'd8', 'd10', 'd12'], distance: 'feet' }, + // A breather heals a die's worth; a night's sleep heals the rest and shakes off exhaustion. + rests: { + short_rest: { refills: [{ what: 'health', how: 'by', amount: '1d8 + @con_mod' }] }, + long_rest: { counts_as: ['short_rest'], refills: [{ what: 'health', how: 'full' }] }, + }, + conditions: { exhausted: { ends: 'rest', at: ['long_rest'] } }, }; const CHARACTERISTICS = [['str', 'Strength'], ['dex', 'Dexterity'], ['end', 'Endurance'], ['int', 'Intellect'], ['edu', 'Education'], ['soc', 'Standing']]; @@ -174,6 +189,11 @@ const SCIFI = { tiers: [scifiTier('crew', 'CREW', 1, 7, 6), scifiTier('veteran', 'VETERAN', 2, 8, 8), scifiTier('elite', 'ELITE', 3, 9, 10)], }, core: { health: { model: 'wounds', count: 3, penalty: -1 }, advancement: ['spend'], dice: ['2d6', 'd6'], distance: 'meters' }, + // A wound back after a breather, all of them after proper rest. + rests: { + short_rest: { refills: [{ what: 'health', how: 'by', amount: '1' }] }, + long_rest: { counts_as: ['short_rest'], refills: [{ what: 'health', how: 'full' }] }, + }, }; const APPROACHES = [['forceful', 'Forceful'], ['careful', 'Careful'], ['clever', 'Clever'], ['quick', 'Quick'], ['flashy', 'Flashy'], ['sneaky', 'Sneaky']]; @@ -200,6 +220,13 @@ const NARRATIVE = { dice: ['d6'], distance: 'zones', }, + // No rests by the clock: harm clears in downtime, and fear passes when the scene does. + rests: { + short_rest: { on: false }, + long_rest: { on: false }, + downtime: { name: 'Downtime', refills: [{ what: 'health', how: 'full' }] }, + }, + conditions: { frightened: { ends: 'rest', at: ['end_of_scene'] }, exhausted: { ends: 'rest', at: ['downtime'] } }, }; /** The examples and starters, in the order the builder lists them. */ diff --git a/frontend/src/components/__tests__/MySystemsPage.test.tsx b/frontend/src/components/__tests__/MySystemsPage.test.tsx index 85175810..8ab849fd 100644 --- a/frontend/src/components/__tests__/MySystemsPage.test.tsx +++ b/frontend/src/components/__tests__/MySystemsPage.test.tsx @@ -294,7 +294,7 @@ describe('+ NEW from a built-in example (4d1b)', () => { it('shows a card for each, saying what it holds, the first picked and named on the button', async () => { await pickExamples(); await screen.findByTestId('example-cwn'); - expect(within(card('cwn')).getByText('13 in 5 groups')).toBeTruthy(); + expect(within(card('cwn')).getByText('14 in 6 groups')).toBeTruthy(); expect(within(card('cwn')).getByText('One pool')).toBeTruthy(); expect(within(card('sr6')).getByText('Two tracks, overflow')).toBeTruthy(); expect(within(card('sr6')).getByText('Spend KARMA')).toBeTruthy(); diff --git a/frontend/src/sheets/__tests__/examples.test.ts b/frontend/src/sheets/__tests__/examples.test.ts index 4c19b941..f8183bc7 100644 --- a/frontend/src/sheets/__tests__/examples.test.ts +++ b/frontend/src/sheets/__tests__/examples.test.ts @@ -13,13 +13,13 @@ const { exampleDefinition } = createRequire(import.meta.url)('../../../../backen describe('what an example\'s card says', () => { it('reads the server\'s own examples', () => { expect(exampleFacts(exampleDefinition('cwn'))).toEqual([ - ['HEALTH', 'One pool'], ['ADVANCES', 'XP levels'], ['STATS', '13 in 5 groups'], ['FORMULAS', '17'], ['DISTANCE', 'Meters'], + ['HEALTH', 'One pool'], ['ADVANCES', 'XP levels'], ['STATS', '14 in 6 groups'], ['FORMULAS', '17'], ['DISTANCE', 'Meters'], ]); expect(exampleFacts(exampleDefinition('cpr'))).toEqual([ - ['HEALTH', 'One pool'], ['ADVANCES', 'Spend IMPROVEMENT POINTS'], ['STATS', '11 in 2 groups'], ['FORMULAS', '1'], ['DISTANCE', 'Meters'], + ['HEALTH', 'One pool'], ['ADVANCES', 'Spend IMPROVEMENT POINTS'], ['STATS', '12 in 3 groups'], ['FORMULAS', '1'], ['DISTANCE', 'Meters'], ]); expect(exampleFacts(exampleDefinition('sr6'))).toEqual([ - ['HEALTH', 'Two tracks, overflow'], ['ADVANCES', 'Spend KARMA'], ['STATS', '10 in 2 groups'], ['FORMULAS', '6'], ['DISTANCE', 'Meters'], + ['HEALTH', 'Two tracks, overflow'], ['ADVANCES', 'Spend KARMA'], ['STATS', '11 in 3 groups'], ['FORMULAS', '6'], ['DISTANCE', 'Meters'], ]); });