diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f41167..70894467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **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. + A preview leaves dice unrolled. Calling a rest from the GAME tab comes next. - **Rests, as data.** A custom system has rests: Short rest, Long rest, End of scene and End of session, each renamed or turned off, plus its own, twelve in all. Each one can count as other rests and refills, in order, health, a number on the sheet or a whole section of it. Amounts can diff --git a/README.md b/README.md index 078c7d60..dfda26dd 100644 --- a/README.md +++ b/README.md @@ -435,6 +435,7 @@ CITY_NET/ │ │ ├── stats.js # A system's stats: the numbers players fill in, in groups (a skill may be tied to its ability), names for derived values, and the builder's sample character; until a system designs its own sheet, each group is a section of the starter sheet │ │ ├── tierRolls.js # An NPC tier worked out for a level: each box a number, a formula reading @level, or dice ("3d6", "@level d8 + 4"), rolled and clamped to the limits, with every die kept so the builder's TRY IT can show it. A rest's amounts are read the same way, naming the character's own numbers (rollAmount) │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 " copy", "copy 02"... for DUPLICATE and keep-both installs @@ -485,6 +486,7 @@ CITY_NET/ │ ├── system_builder_preview_sheet.test.js # The CHARACTER SHEET page's preview: a draft's sheet as it stands, and the starter sheet CUSTOMIZE copies │ ├── system_builder_tier_rolls.test.js # NPC tiers that roll for a level: numbers, formulas with @level and dice (fixed rolls, so every result is known), limits, and mistakes said without the server's internal names │ ├── system_builder_rests.test.js # Rests: the standard four, edits, off and own in order; every check (ids, names, the limit of 12, counting as rests that exist, loops named, refills of health per model and track, sheet numbers with and without a maximum, sections, what may not be refilled, amounts and the names they use); conditions ending at a rest; amounts worked out with dice counted by a name +│ ├── 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_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_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 " copy", its own origin and unpublished diff --git a/backend/__tests__/system_builder_resting.test.js b/backend/__tests__/system_builder_resting.test.js new file mode 100644 index 00000000..f9985165 --- /dev/null +++ b/backend/__tests__/system_builder_resting.test.js @@ -0,0 +1,230 @@ +import { describe, it, expect } from 'vitest'; + +const { restOn, restOrder } = require('../systemBuilder/resting'); +const { restsOf } = require('../systemBuilder/rests'); +const { checkDefinition } = require('../systemBuilder/definition'); + +/** + * What one rest does to one character (4f2a). Approved mockup builder-rests (2026-10-09): the rests + * it counts as first, then its own refills in order; health back through the system's own model; + * sheet numbers held between 0 and their maximum; amounts read from the character as they stand; + * dice left pending in a preview; the conditions that end at any of those rests taken off. + */ + +const SHEET = { + sections: [ + { id: 'who', label: 'WHO', layout: 'list', fields: [{ id: 'name', label: 'Name', type: 'text' }, { id: 'level', label: 'Level', type: 'number' }] }, + { 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: 'luck', label: 'Luck', type: 'number', maxField: 'luck_max' }, + { id: 'luck_max', label: 'Luck max', 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' }, + { id: 'focus', label: 'Focus', type: 'number', maxField: 'focus_max' }, + { id: 'focus_max', label: 'Focus max', type: 'number' }, + ] }, + ], +}; +const HEARTH = { + name: 'Hearth', + stats: [{ id: 'abilities', label: 'ABILITIES', stats: [{ id: 'con_mod', label: 'Con mod' }] }], + sheet: SHEET, + 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: 'luck', how: 'max' }] }, + downtime: { name: 'Downtime', counts_as: ['long_rest'], refills: [{ what: 'fatigue', how: 'to', amount: '0' }] }, + }, + conditions: { + exhausted: { ends: 'rest', at: ['long_rest'] }, + frightened: { ends: 'rest', at: ['end_of_scene'] }, + bleeding: { ends: 'rest', at: ['short_rest'] }, + }, +}; +const VEX = { level: 3, con_mod: 2, fatigue: 3, luck: 1, luck_max: 3, slots: 0, slots_max: 4, focus: 1, focus_max: 2 }; +const TOKEN = { current: 9, max: 22, temp: 0 }; +/** Every die shows `face` (1-based) on a die of `sides`: rng answers just above (face - 1) / sides. */ +const showing = (face, sides) => () => (face - 1) / sides + 1e-9; + +const rest = (restId, over = {}, def = HEARTH) => restOn({ definition: def, restId, sheet: VEX, token: TOKEN, conditions: [], ...over }); +const with_ = (rests, extra = {}) => ({ ...HEARTH, rests, ...extra }); + +describe('the fixture', () => { + it('is a system that publishes', () => { + expect(checkDefinition(HEARTH)).toEqual({ problems: [] }); + }); +}); + +describe('the order a rest runs in', () => { + it('is each rest it counts as, theirs first, then its own, each once', () => { + const rests = restsOf(HEARTH); + expect(restOrder(rests, 'downtime')).toEqual(['short_rest', 'long_rest', 'downtime']); + expect(restOrder(rests, 'long_rest')).toEqual(['short_rest', 'long_rest']); + expect(restOrder(rests, 'end_of_scene')).toEqual(['end_of_scene']); + const diamond = restsOf({ rests: { long_rest: { counts_as: ['short_rest'] }, end_of_session: { counts_as: ['long_rest', 'short_rest'] } } }); + expect(restOrder(diamond, 'end_of_session')).toEqual(['short_rest', 'long_rest', 'end_of_session']); + }); + + it('never comes back round a loop, though one can\'t be published', () => { + const looped = [{ id: 'a', counts_as: ['b'] }, { id: 'b', counts_as: ['a'] }]; + expect(restOrder(looped, 'a')).toEqual(['b', 'a']); + expect(restOrder(looped, 'nope')).toEqual([]); + }); + + it('refuses a rest the system hasn\'t got, or has turned off', () => { + expect(rest('nap')).toEqual({ ok: false, error: 'Not one of this system\'s rests' }); + expect(rest('end_of_scene', {}, with_({ end_of_scene: { on: false } }))).toEqual({ ok: false, error: 'Not one of this system\'s rests' }); + }); +}); + +describe('a long rest, as the mockup shows it', () => { + it('previews health to full over the short rest\'s dice, fatigue down, the magic section filled, and what wears off', () => { + const r = rest('long_rest', { conditions: [{ id: 'exhausted' }, { id: 'poisoned', left: 2 }, { id: 'bleeding' }, { id: 'frightened' }] }); + expect(r.ok).toBe(true); + 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 }, + { what: 'focus', label: 'Focus', from: 1, to: 2 }, + ]); + expect(r.token).toEqual({ current: 22, max: 22, temp: 0 }); + expect(r.sheetPatch).toEqual({ fatigue: 2, slots: 4, focus: 2 }); + // Ending at the short rest it counts as counts too; a condition ending at another rest stays. + expect(r.gone).toEqual(['exhausted', 'bleeding']); + expect(r.conditions).toEqual([{ id: 'poisoned', left: 2 }, { id: 'frightened' }]); + }); + + it('leaves out what doesn\'t move, and what is already full', () => { + const r = rest('long_rest', { sheet: { ...VEX, fatigue: 0, slots: 4 }, token: { current: 22, max: 22 } }); + expect(r.changes).toEqual([{ what: 'focus', label: 'Focus', from: 1, to: 2 }]); + expect(r.sheetPatch).toEqual({ fatigue: 0, slots: 4, focus: 2 }); + }); +}); + +describe('dice', () => { + it('are left pending in a preview, with every amount still to come', () => { + const r = rest('short_rest'); + expect(r.changes).toEqual([{ what: 'health', label: 'HP', from: 9, to: null, pending: ['1d8 + @con_mod'] }]); + expect(r.token).toEqual(TOKEN); + const chained = rest('end_of_session', {}, with_({ end_of_session: { refills: [{ what: 'fatigue', how: 'by', amount: '1d4' }, { what: 'fatigue', how: 'by', amount: '-1' }] } })); + expect(chained.changes).toEqual([{ what: 'fatigue', label: 'Fatigue', from: 3, to: null, pending: ['1d4', '-1'] }]); + }); + + it('are settled by a later refill to a set value, after which amounts apply as usual', () => { + const r = rest('end_of_session', {}, with_({ end_of_session: { refills: [{ what: 'fatigue', how: 'by', amount: '1d4' }, { what: 'fatigue', how: 'to', amount: '1' }] } })); + expect(r.changes).toEqual([{ what: 'fatigue', label: 'Fatigue', from: 3, to: 1 }]); + const after = rest('end_of_session', {}, with_({ end_of_session: { refills: [{ what: 'fatigue', how: 'by', amount: '1d4' }, { what: 'fatigue', how: 'to', amount: '2' }, { what: 'fatigue', how: 'by', amount: '-1' }] } })); + expect(after.changes).toEqual([{ what: 'fatigue', label: 'Fatigue', from: 3, to: 1 }]); + }); + + it('are rolled when the rest is called, each kept, and healing stops at the maximum', () => { + const r = rest('short_rest', { rng: showing(5, 8) }); + expect(r.changes).toEqual([{ what: 'health', label: 'HP', from: 9, to: 16 }]); + expect(r.rolls).toEqual([{ amount: '1d8 + @con_mod', value: 7, dice: [{ count: 1, sides: 8, rolls: [5] }] }]); + expect(rest('short_rest', { rng: showing(8, 8), token: { current: 20, max: 22 } }).token.current).toBe(22); + }); +}); + +describe('sheet numbers', () => { + const one = (refills, sheet = VEX) => rest('end_of_session', { sheet }, with_({ end_of_session: { refills } })); + + it('stay between 0 and their maximum', () => { + expect(one([{ what: 'fatigue', how: 'by', amount: '-5' }]).sheetPatch).toEqual({ fatigue: 0 }); + expect(one([{ what: 'luck', how: 'by', amount: '9' }]).sheetPatch).toEqual({ luck: 3 }); + expect(one([{ what: 'luck', how: 'to', amount: '9' }]).sheetPatch).toEqual({ luck: 3 }); + expect(one([{ what: 'luck', how: 'to', amount: '-2' }]).sheetPatch).toEqual({ luck: 0 }); + expect(one([{ what: 'fatigue', how: 'by', amount: '40' }]).sheetPatch).toEqual({ fatigue: 43 }); + expect(one([{ what: 'luck', how: 'max' }], { ...VEX, luck_max: -2 }).sheetPatch).toEqual({ luck: 0 }); + }); + + it('report one change per value, from where it started', () => { + expect(one([{ what: 'fatigue', how: 'by', amount: '-1' }, { what: 'fatigue', how: 'by', amount: '-1' }]).changes).toEqual([{ what: 'fatigue', label: 'Fatigue', from: 3, to: 1 }]); + }); + + it('read the character as it stands when the refill is reached, token-linked fields from the token', () => { + expect(one([{ what: 'luck', how: 'max' }, { what: 'fatigue', how: 'to', amount: '@luck + @level' }]).sheetPatch).toEqual({ luck: 3, fatigue: 6 }); + expect(one([{ what: 'fatigue', how: 'to', amount: '@hp_max - @hp' }]).sheetPatch).toEqual({ fatigue: 13 }); + expect(one([{ what: 'fatigue', how: 'to', amount: '@con_mod' }], { ...VEX, con_mod: undefined }).sheetPatch).toEqual({ fatigue: 0 }); + }); + + it('change with no token on the map; health then has nothing to refill', () => { + const r = rest('long_rest', { token: null }); + expect(r.token).toBeNull(); + expect(r.changes.map((c) => c.what)).toEqual(['fatigue', 'slots', 'focus']); + }); + + it('take a whole section to the maximums of the numbers that have one', () => { + expect(one([{ what: 'section:body', how: 'max' }]).sheetPatch).toEqual({ luck: 3 }); + }); +}); + +describe('health under each model', () => { + const model = (health, refills, over = {}) => rest('end_of_session', over, { name: 'M', core: { health }, rests: { end_of_session: { refills } } }); + + it('fills a pool, keeping temp, and names the system\'s own word for it', () => { + const r = model({ model: 'pool' }, [{ what: 'health', how: 'full' }], { token: { current: 2, max: 10, temp: 3 }, sheet: {} }); + expect(r.token).toEqual({ current: 10, max: 10, temp: 3 }); + expect(r.changes).toEqual([{ what: 'health', label: 'HP', from: 2, to: 10 }]); + const worded = rest('end_of_session', { token: { current: 2, max: 10 }, sheet: {} }, { name: 'W', words: { hp: { singular: 'VIGOR', short: 'VG' } }, rests: { end_of_session: { refills: [{ what: 'health', how: 'full' }] } } }); + expect(worded.changes[0].label).toBe('VG'); + const labeled = model({ model: 'pool', label: 'GRIT' }, [{ what: 'health', how: 'full' }], { token: { current: 2, max: 10 }, sheet: {} }); + expect(labeled.changes[0].label).toBe('GRIT'); + }); + + const TRACKS = { model: 'tracks', tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }] }; + it('clears both tracks to full, and heals the one a refill names', () => { + const full = model(TRACKS, [{ what: 'health', how: 'full' }], { token: { current: 4, max: 10 }, sheet: { stun: 5, stun_max: 9 } }); + expect(full.token.current).toBe(10); + expect(full.sheetPatch).toEqual({ stun: 0 }); + expect(full.changes).toEqual([{ what: 'health', label: 'HP', from: 4, to: 10 }, { what: 'health:stun', label: 'STUN', from: 5, to: 0 }]); + const stun = model(TRACKS, [{ what: 'health', how: 'by', amount: '3', track: 'stun' }], { token: { current: 4, max: 10 }, sheet: { stun: 5, stun_max: 9 } }); + expect(stun.token.current).toBe(4); + expect(stun.changes).toEqual([{ what: 'health:stun', label: 'STUN', from: 5, to: 2 }]); + const first = model(TRACKS, [{ what: 'health', how: 'by', amount: '3' }], { token: { current: 4, max: 10 }, sheet: { stun: 5, stun_max: 9 } }); + expect(first.changes).toEqual([{ what: 'health', label: 'HP', from: 4, to: 7 }]); + }); + + it('clears every mark of a damage-typed track', () => { + const typed = { model: 'typed', types: [{ id: 'superficial', label: 'S' }, { id: 'aggravated', label: 'A' }] }; + const r = model(typed, [{ what: 'health', how: 'full' }], { token: { current: 2, max: 7 }, sheet: { superficial: 3, aggravated: 2 } }); + expect(r.sheetPatch).toEqual({ superficial: 0, aggravated: 0 }); + expect(r.token.current).toBe(7); + const healed = model(typed, [{ what: 'health', how: 'by', amount: '2' }], { token: { current: 2, max: 7 }, sheet: { superficial: 3, aggravated: 2 } }); + expect(healed.sheetPatch).toEqual({ superficial: 1, aggravated: 2 }); + }); + + it('clears every harm slot, its count the token\'s', () => { + const harm = { model: 'harm', levels: [{ id: 'lesser', label: 'LESSER', slots: 2 }, { id: 'severe', label: 'SEVERE', slots: 1 }] }; + // The token's own max is stale here: the slots set it. + const r = model(harm, [{ what: 'health', how: 'full' }], { token: { current: 0, max: 5 }, sheet: { lesser_1: 'Cut', lesser_2: '', severe_1: 'Broken arm' } }); + expect(r.sheetPatch).toEqual({ lesser_1: '', severe_1: '' }); + expect(r.token).toEqual({ current: 3, max: 3, temp: 0 }); + }); + + it('gives back every wound, to the system\'s count where the token sets none', () => { + expect(model({ model: 'wounds', count: 3 }, [{ what: 'health', how: 'full' }], { token: { current: 1, max: 4 }, sheet: {} }).token.current).toBe(4); + expect(model({ model: 'wounds', count: 3 }, [{ what: 'health', how: 'full' }], { token: { current: 0, max: 0 }, sheet: {} }).token).toEqual({ current: 3, max: 3, temp: 0 }); + expect(model({ model: 'wounds', count: 3 }, [{ what: 'health', how: 'by', amount: '1' }], { token: { current: 1, max: 3 }, sheet: {} }).token.current).toBe(2); + }); + + it('fills the pool of hit locations and leaves their notes', () => { + const r = model({ model: 'locations', locations: [{ id: 'head', label: 'HEAD' }] }, [{ what: 'health', how: 'full' }], { token: { current: 1, max: 8 }, sheet: { head: 'Concussed' } }); + expect(r.token.current).toBe(8); + expect(r.sheetPatch).toEqual({}); + }); + + it('heals nothing for an amount of 0 or less', () => { + expect(model({ model: 'pool' }, [{ what: 'health', how: 'by', amount: '@con_mod - 5' }], { token: { current: 2, max: 10 }, sheet: { con_mod: 1 } }).changes).toEqual([]); + }); + + it('has nothing to refill with token health off', () => { + const off = rest('end_of_session', { token: { current: 2, max: 10 }, sheet: {} }, { name: 'Off', parts: { token_health: { on: false } }, rests: { end_of_session: { refills: [{ what: 'health', how: 'full' }] } } }); + expect(off.ok).toBe(true); + expect(off.token).toEqual({ current: 2, max: 10, temp: 0 }); + expect(off.changes).toEqual([]); + }); +}); diff --git a/backend/systemBuilder/resting.js b/backend/systemBuilder/resting.js new file mode 100644 index 00000000..422dbe99 --- /dev/null +++ b/backend/systemBuilder/resting.js @@ -0,0 +1,212 @@ +// What one rest does to one character (4f2a; approved mockup docs/mockups/builder-rests.html, +// 2026-10-09): the rests it counts as first, deepest first and each once, then its own refills in +// order; then the conditions that wear off at any of them. Pure: the route reads the character, +// calls this, and writes what it hands back. +// +// Health comes back through the system's own health model: "up by" is the model's HEAL +// (health.js), and "to full" clears it the model's way (the pool to its max, a second track to +// none, marks and harm cleared). Sheet numbers are held between 0 and their maximum. Amounts are +// worked out from the character's own numbers as they stand when the refill is reached, so a +// refill can read what an earlier one in the same rest changed. +// +// With no `rng` it is a preview: dice are not rolled, and a value they decide is reported as +// pending ("9 + 1d8 + @con_mod") until a later refill settles it. + +const { restsOf, reachOf, amountNames, SECTION } = require('./rests'); +const { conditionsOf } = require('./conditions'); +const { applyHealthAction } = require('./health'); +const { effectiveSheet, fieldsOf } = require('./sheet'); +const { ownWords } = require('./terms'); +const { rollAmount } = require('./tierRolls'); + +const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; }; +const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); +const list = (v) => (Array.isArray(v) ? v.filter(isPlainObject) : []); + +/** The rests `id` runs, in order: each it counts as (theirs first), then itself; each once. */ +const restOrder = (rests, id) => { + const byId = new Map(rests.map((r) => [r.id, r])); + const order = []; + // Marked seen before its own are visited, so a loop (refused on publish) can't come back round. + const seen = new Set(); + const visit = (at) => { + if (seen.has(at) || !byId.has(at)) return; + seen.add(at); + for (const c of byId.get(at).counts_as) visit(c); + order.push(at); + }; + visit(id); + return order; +}; + +/** Health back to full, the model's way: the token's numbers and the sheet's. */ +const fullHealth = (health, token, sheet) => { + const t = { ...token, current: num(token.max) }; + const patch = {}; + switch (health.model) { + case 'tracks': { + const second = list(health.tracks)[1]; + if (second) patch[second.id] = 0; + break; + } + case 'typed': + for (const type of list(health.types)) patch[type.id] = 0; + break; + case 'harm': { + const slots = list(health.levels).flatMap((l) => Array.from({ length: Math.max(1, Math.floor(num(l.slots))) }, (_, i) => `${l.id}_${i + 1}`)); + for (const id of slots) if (sheet[id] !== undefined && String(sheet[id]).trim() !== '') patch[id] = ''; + t.current = slots.length; + t.max = slots.length; + break; + } + case 'wounds': + // No maximum on the token: the system's wound count, as its DAMAGE and HEAL read it. + if (num(token.max) <= 0) { t.current = Math.max(0, Math.floor(num(health.count))); t.max = t.current; } + break; + default: + } + return { token: t, patch }; +}; + +/** + * One rest for one character. + * + * definition the running custom system's + * restId one of its rests that is on + * sheet the character's sheet data, with its formulas worked out ({} when it has none) + * token { current, max, temp } of its token, or null when it has none on the map + * conditions its token's conditions, [{ id, left? }] + * rng rolls dice (0 to below 1); none for a preview + * + * Returns { ok: true, sheetPatch, token, conditions, gone, changes, rolls } or { ok: false, error }. + * `token` is the new one, or null when there is no token. `changes` lists each value that moved, + * once, as { what, label, from, to } with `pending` (the dice still to roll) in a preview. + * `rolls` lists every amount worked out, with its dice. + */ +const restOn = ({ definition, restId, sheet = {}, token = null, conditions = [], rng = null }) => { + const rests = restsOf(definition); + if (!rests.some((r) => r.id === restId)) return { ok: false, error: 'Not one of this system\'s rests' }; + const order = restOrder(rests, restId); + const reach = reachOf(definition); + const names = amountNames(reach.names); + const fields = new Map(fieldsOf(effectiveSheet(definition)).filter((f) => f && typeof f.id === 'string').map((f) => [f.id, f])); + const hpWord = ownWords(definition).hp; + const healthLabel = (reach.health && reach.health.label) || (hpWord && hpWord.short) || 'HP'; + + const data = { ...sheet }; + let t = token ? { current: num(token.current), max: num(token.max), temp: num(token.temp) } : null; + const patch = {}; + const changes = new Map(); + const pending = new Map(); // what -> the dice still to roll, in a preview + const rolls = []; + + /** The numbers an amount may read: the sheet's, with fields linked to the token read from it. */ + const values = () => { + const v = { ...data }; + for (const [id, f] of fields) { + if (f.source === 'token_hp' && t) v[id] = t.current; + if (f.source === 'token_hp_max' && t) v[id] = t.max; + } + return v; + }; + const note = (what, label, from, to) => { + const before = changes.has(what) ? changes.get(what).from : from; + changes.set(what, { what, label, from: before, to }); + }; + const setField = (id, value) => { data[id] = value; patch[id] = value; }; + /** An amount worked out, or null in a preview when dice decide it. */ + const amountOf = (text) => { + let rolled = false; + const r = rollAmount(text, values(), names, rng || (() => { rolled = true; return 0; })); + if (!rng && rolled) return null; + if ('error' in r || 'blank' in r) return 0; + rolls.push({ amount: String(text), value: r.value, dice: r.dice }); + return r.value; + }; + /** In a preview, a value already waiting on dice: an amount up or down joins what is waiting. */ + const waitsOn = (key, amount, label, from) => { + if (!pending.has(key)) return false; + pending.get(key).push(String(amount)); + note(key, label, from, null); + return true; + }; + + const refillField = (id, how, amount) => { + const f = fields.get(id); + const maxId = reach.numbers.get(id); + const max = maxId ? num(data[maxId]) : Infinity; + const label = (f && f.label) || id; + const from = num(data[id]); + if (how === 'max') { pending.delete(id); setField(id, Math.max(0, max)); note(id, label, from, data[id]); return; } + if (how === 'by' && waitsOn(id, amount, label, from)) return; + const value = amountOf(amount); + if (value === null) { + pending.set(id, [String(amount)]); + note(id, label, from, null); + return; + } + pending.delete(id); + const next = Math.max(0, Math.min(max, how === 'to' ? value : from + value)); + setField(id, next); + note(id, label, from, next); + }; + + const refillHealth = (how, amount, track) => { + const health = reach.health; + if (!t || !health || health.model === 'none') return; + const second = health.model === 'tracks' ? list(health.tracks)[1] : null; + const before = { current: t.current, second: second ? num(data[second.id]) : 0 }; + const key = track && second && track === second.id ? `health:${track}` : 'health'; + if (how === 'full') { + const full = fullHealth(health, t, data); + t = full.token; + for (const [id, v] of Object.entries(full.patch)) setField(id, v); + pending.delete('health'); + if (second) pending.delete(`health:${second.id}`); + } else { + const label = key === 'health' ? healthLabel : second.label; + const from = key === 'health' ? before.current : before.second; + if (waitsOn(key, amount, label, from)) return; + const value = amountOf(amount); + if (value === null) { pending.set(key, [String(amount)]); note(key, label, from, null); return; } + // An amount of 0 or less heals nothing: the model refuses it. + const healed = applyHealthAction(health, t, data, { kind: 'heal', amount: value, ...(track ? { track } : {}) }); + if (!healed.ok) return; + t = { ...t, current: num(healed.token.current), max: num(healed.token.max) }; + for (const [id, v] of Object.entries(healed.sheetPatch)) setField(id, v); + } + // Noted whenever it moved or was noted before: dice waiting on it may just have been settled. + if (t.current !== before.current || changes.has('health')) note('health', healthLabel, before.current, pending.has('health') ? null : t.current); + if (second && (num(data[second.id]) !== before.second || changes.has(`health:${second.id}`))) { + note(`health:${second.id}`, second.label, before.second, pending.has(`health:${second.id}`) ? null : num(data[second.id])); + } + }; + + for (const id of order) { + for (const r of rests.find((x) => x.id === id).refills) { + if (r.what === 'health') refillHealth(r.how, r.amount, r.track); + else if (typeof r.what === 'string' && r.what.startsWith(SECTION)) { + for (const f of reach.sections.get(r.what.slice(SECTION.length)) || []) refillField(f, 'max'); + } else if (reach.numbers.has(r.what)) refillField(r.what, r.how, r.amount); + } + } + + const ends = new Set(order); + const wearsOff = new Set(conditionsOf(definition).filter((c) => c.ends === 'rest' && c.at.some((a) => ends.has(a))).map((c) => c.id)); + const kept = list(conditions).filter((c) => !wearsOff.has(c.id)); + const gone = list(conditions).filter((c) => wearsOff.has(c.id)).map((c) => c.id); + + return { + ok: true, + sheetPatch: patch, + token: t, + conditions: kept, + gone, + changes: [...changes.values()] + .filter((c) => c.to === null || c.to !== c.from) + .map((c) => (c.to === null ? { ...c, pending: pending.get(c.what) || [] } : c)), + rolls, + }; +}; + +module.exports = { restOn, restOrder };