From 1ec1ca0c54e4a35dd8976601b61b3b6d1c490c6f Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 30 Sep 2026 09:42:52 -0500 Subject: [PATCH] feat: custom systems record their setup answers, and the health model shapes the starter sheet A system's core section answers the setup questions: the health model (one pool, two tracks, damage types, harm levels, wound count, hit locations or none) with its settings, how characters advance, the system's common dice and its distance unit. Initiative on or off stays a part. Every answer is checked, each problem reported with where it is. The health model shapes the starter sheet a system has before its GM designs one: two tracks put the first on the token and the second on the sheet, harm levels give a line per slot with the penalty as its hint, and so on. A system that answers nothing gets the same starter sheet it always had, byte for byte. How each model takes damage in play comes in its own piece. --- README.md | 2 + backend/__tests__/system_builder_core.test.js | 223 ++++++++++++++++ backend/systemBuilder/core.js | 252 ++++++++++++++++++ backend/systemBuilder/definition.js | 5 +- backend/systemBuilder/sheet.js | 15 +- 5 files changed, 489 insertions(+), 8 deletions(-) create mode 100644 backend/__tests__/system_builder_core.test.js create mode 100644 backend/systemBuilder/core.js diff --git a/README.md b/README.md index a4ce9dd..b269b6a 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,7 @@ CITY_NET/ │ │ ├── definitions.js # CWN's and Shadowrun's derived values restated as data, entry for entry in the order their functions write them │ │ ├── definition.js # The system definition format (1: name, description, words, parts, lookups, derived) and its server-side checks. Fatal (cannot be stored: not an object, too large, not JSON) vs ordinary problems (saved in a draft, block publishing), all reported with where they are. Also the app's renamable terms and switchable parts, with wordFor / partOn │ │ ├── sheet.js # A custom system's character sheet as data (tabs, header, sections of text/number/textarea/select fields with visibility, combat sensitivity, who edits it (the owner, or only the GM), max pairs and token/bank links) and its checks; a starter sheet for a system that has none +│ │ ├── core.js # A custom system's setup answers: the health model (one pool, two tracks, damage types, harm levels, wound count, hit locations, none) with its settings, advancement styles, common dice and distance unit, and their checks; the health part of the starter sheet each model gives (no answer = the one-pool starter every system had) │ │ ├── npc.js # A custom system's NPCs as data: an optional stat-block layout (checked like a sheet, and linking shared fields the same way) and GENERATE_SHEET tiers (label, token HP and defense, starting values) │ │ ├── runtime.js # Published systems in memory for the running game: compiled once into the meta the built-in templates carry (public/combat/linked/GM-only fields, max pairs, derived recompute), reached by sheets/templates.js through a hook; the NPC tiers, reached by sheets/npcTiers.js the same way; and the render copy the browser draws from, with no formulas │ │ └── 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 @@ -455,6 +456,7 @@ CITY_NET/ │ ├── bank_accounts.test.js # Per-system accounts kept apart; the one-time move (every sheet's system plus the running one, the old table untouched, once only, all or nothing); the database copy and its disk-space check; switching systems in play; and db.js opening a 1.14.4-shaped database file in a child process │ ├── token_vitals.test.js # Switching swaps and restores every token's health (enemies too, buildings untouched), entirely or not at all, and waits for the one-time start; the start's systems and run-once; map clears and loads; the system picker route and the settings route's guard; db.js on a real file │ ├── system_builder_runtime.test.js # The sheet format's checks and starter sheet; a published system known to the game (never a draft), answering the same helpers as the built-ins without changing them, its render copy free of formulas, switched to from the picker, and a player's edit recomputing its derived values +│ ├── system_builder_core.test.js # The setup answers: every health model's starter sheet (and that it passes the sheet checks), the unanswered starter unchanged to the byte, and every mistake reported with where it is │ ├── system_builder_npc_privacy.test.js # GM-only fields refused to the owner by edit, batch and upload but not to the GM or a granted admin; the NPC layout and tier checks; tiers generating a sheet and setting (or keeping) the token's HP and defense; built-ins unchanged │ ├── system_builder_store.test.js # The definition checks (every problem at once, fatal vs ordinary, words and parts), and the routes: main admin only, drafts saved with problems but not published, the published copy untouched while the draft moves on, the running system not deletable │ ├── system_builder_engine.test.js # The formula language (precedence, functions, 0 for NaN, and a list of script-shaped inputs it refuses), limits, and definitions: dependency order, lookups, conditions, rules, and every mistake reported at once diff --git a/backend/__tests__/system_builder_core.test.js b/backend/__tests__/system_builder_core.test.js new file mode 100644 index 0000000..4bfbf9d --- /dev/null +++ b/backend/__tests__/system_builder_core.test.js @@ -0,0 +1,223 @@ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; + +/** + * A custom system's core rules: the setup questions, answered as data. + * + * The health model shapes the starter sheet a system gets before its GM designs one; a system + * that answers nothing gets exactly the starter it always had. Advancement, dice and distance + * are recorded and checked, and take effect in their own pieces. + */ + +const require_ = createRequire(import.meta.url); +const { checkDefinition } = require_('../systemBuilder/definition'); +const { effectiveSheet } = require_('../systemBuilder/sheet'); +const { healthLayout, HEALTH_MODELS, ADVANCEMENT } = require_('../systemBuilder/core'); +const { metaOf, renderOf } = require_('../systemBuilder/runtime'); + +const problems = (definition) => checkDefinition(definition).problems.map((p) => `${p.where}: ${p.message}`); +const withCore = (core, extra = {}) => ({ format: 1, name: 'Test', ...extra, core }); +const healthOf = (health) => effectiveSheet(withCore({ health })); +const section = (sheet, id) => sheet.sections.find((s) => s.id === id); + +/** Every model, answered fully. */ +const MODELS = { + pool: { model: 'pool', label: 'VIGOR' }, + tracks: { model: 'tracks', tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }], overflow: true }, + typed: { model: 'typed', types: [{ id: 'superficial', label: 'SUPERFICIAL' }, { id: 'aggravated', label: 'AGGRAVATED' }] }, + harm: { model: 'harm', levels: [ + { id: 'lesser', label: 'LESSER', slots: 2, penalty: 'Reduced effect' }, + { id: 'moderate', label: 'MODERATE', slots: 2, penalty: '-1d' }, + { id: 'severe', label: 'SEVERE', slots: 1, penalty: 'Need help' }, + ] }, + wounds: { model: 'wounds', count: 3, penalty: -1 }, + locations: { model: 'locations', locations: [{ id: 'head', label: 'HEAD' }, { id: 'body', label: 'BODY' }] }, + none: { model: 'none' }, +}; + +describe('a system that answers nothing', () => { + it('gets exactly the starter sheet it always had', () => { + const expected = { + tabs: ['STATS', 'GEAR', 'NOTES'], + header: { nameField: 'name', subtitleFields: ['concept'], hpField: 'hp', hpMaxField: 'hp_max' }, + sections: [ + { id: 'identity', label: 'IDENTITY', layout: 'list', tab: 'STATS', fields: [ + { id: 'name', label: 'Name', type: 'text', visibility: 'public' }, + { id: 'concept', label: 'Concept', type: 'text' }, + { id: 'description', label: 'Description', type: 'textarea', visibility: 'public' }, + ] }, + { id: 'health', label: 'HEALTH', layout: 'grid', tab: 'STATS', columns: 2, fields: [ + { id: 'hp', label: 'HP', type: 'number', source: 'token_hp', maxField: 'hp_max' }, + { id: 'hp_max', label: 'HP MAX', type: 'number', source: 'token_hp_max' }, + ] }, + { id: 'inventory', label: 'INVENTORY', layout: 'inventory', tab: 'GEAR', fields: [] }, + { id: 'money', label: 'MONEY', layout: 'list', tab: 'GEAR', fields: [{ id: 'cash', label: 'Cash', type: 'number', source: 'bank_balance' }] }, + { id: 'notes', label: 'NOTES', layout: 'notes', tab: 'NOTES', fields: [{ id: 'notes', label: 'Notes', type: 'textarea' }] }, + ], + }; + // Compared as text, so the order of keys counts too. + for (const definition of [{ format: 1, name: 'Bare' }, withCore({}), withCore({ dice: ['d20'] }), withCore({ health: { model: 'pool' } })]) { + expect(JSON.stringify(effectiveSheet(definition))).toBe(JSON.stringify(expected)); + } + }); + + it('is still valid, and a designed sheet still wins over the starter', () => { + expect(problems({ format: 1, name: 'Bare' })).toEqual([]); + const own = { sections: [{ id: 'a', label: 'A', layout: 'list', fields: [] }] }; + expect(effectiveSheet({ ...withCore({ health: MODELS.harm }), sheet: own })).toBe(own); + }); +}); + +describe('the setup answers', () => { + it('accept every model, and every model gives a starter sheet that passes the sheet checks', () => { + for (const [id, health] of Object.entries(MODELS)) { + const definition = withCore({ health, advancement: ['levels', 'spend'], dice: ['d20', '2d6', '4dF', 'd7', 'd100'], distance: 'feet' }); + expect(problems(definition), id).toEqual([]); + // The starter, saved as the system's own sheet, is a sheet like any other. + expect(problems({ ...definition, sheet: effectiveSheet(definition) }), id).toEqual([]); + } + }); + + it('list every model and advancement style the plan names, in its order', () => { + expect(HEALTH_MODELS.map((m) => m.id)).toEqual(['pool', 'tracks', 'typed', 'harm', 'wounds', 'locations', 'none']); + expect(ADVANCEMENT.map((a) => a.id)).toEqual(['levels', 'milestone', 'spend', 'use']); + for (const m of HEALTH_MODELS) expect(m.label && m.worksLike && m.examples, m.id).toBeTruthy(); + }); +}); + +describe('the starter sheet each health model gives', () => { + it('one pool: the token\'s HP, under the system\'s own name', () => { + const sheet = healthOf(MODELS.pool); + expect(section(sheet, 'health').fields.map((f) => [f.id, f.label, f.source])).toEqual([ + ['hp', 'VIGOR', 'token_hp'], ['hp_max', 'VIGOR MAX', 'token_hp_max'], + ]); + expect(sheet.header).toMatchObject({ hpField: 'hp', hpMaxField: 'hp_max' }); + }); + + it('two tracks: the first on the token, the second on the sheet, each with a maximum', () => { + const sheet = healthOf(MODELS.tracks); + expect(section(sheet, 'health').fields).toEqual([ + { id: 'physical', label: 'PHYSICAL', type: 'number', source: 'token_hp', maxField: 'physical_max' }, + { id: 'physical_max', label: 'PHYSICAL MAX', type: 'number', source: 'token_hp_max' }, + { id: 'stun', label: 'STUN', type: 'number', maxField: 'stun_max' }, + { id: 'stun_max', label: 'STUN MAX', type: 'number' }, + ]); + expect(sheet.header).toMatchObject({ hpField: 'physical', hpMaxField: 'physical_max' }); + }); + + it('damage types: one track on the token, and a count of each kind of damage', () => { + const fields = section(healthOf(MODELS.typed), 'health').fields; + expect(fields.map((f) => [f.id, f.label, f.source])).toEqual([ + ['health', 'HEALTH', 'token_hp'], ['health_max', 'HEALTH MAX', 'token_hp_max'], + ['superficial', 'SUPERFICIAL', undefined], ['aggravated', 'AGGRAVATED', undefined], + ]); + }); + + it('harm levels: a line for each slot, with the penalty as its hint, and nothing on the token', () => { + const sheet = healthOf(MODELS.harm); + expect(section(sheet, 'health')).toBeUndefined(); + expect(section(sheet, 'harm').fields.map((f) => [f.id, f.label, f.hint])).toEqual([ + ['lesser_1', 'LESSER', 'Reduced effect'], ['lesser_2', 'LESSER 2', 'Reduced effect'], + ['moderate_1', 'MODERATE', '-1d'], ['moderate_2', 'MODERATE 2', '-1d'], + ['severe_1', 'SEVERE', 'Need help'], + ]); + expect(sheet.header.hpField).toBeUndefined(); + }); + + it('wound count: the wounds left, on the token', () => { + const fields = section(healthOf(MODELS.wounds), 'health').fields; + expect(fields.map((f) => [f.id, f.label, f.source, f.hint])).toEqual([ + ['wounds', 'WOUNDS LEFT', 'token_hp', undefined], ['wounds_max', 'WOUNDS', 'token_hp_max', 'Out after 3'], + ]); + }); + + it('hit locations: an HP pool, and an injury line per location', () => { + const sheet = healthOf(MODELS.locations); + expect(section(sheet, 'health').fields.map((f) => f.id)).toEqual(['hp', 'hp_max']); + expect(section(sheet, 'injuries').fields.map((f) => [f.id, f.label])).toEqual([['head', 'HEAD'], ['body', 'BODY']]); + }); + + it('none: no health on the sheet at all', () => { + const sheet = healthOf(MODELS.none); + expect(sheet.sections.map((s) => s.id)).toEqual(['identity', 'inventory', 'money', 'notes']); + expect(sheet.header).toEqual({ nameField: 'name', subtitleFields: ['concept'] }); + }); + + it("reaches the running game: the token links, and the browser's copy", () => { + const definition = withCore({ health: MODELS.tracks }); + expect(metaOf(definition).linkedFields).toEqual({ physical: 'token_hp', physical_max: 'token_hp_max', cash: 'bank_balance' }); + expect(metaOf(definition).maxPairs).toEqual({ physical_max: 'physical', stun_max: 'stun' }); + expect(renderOf('sys_0123456789abcdef', definition).sheet).toEqual(effectiveSheet(definition)); + }); + + it('falls back to one pool rather than failing on an unfinished answer', () => { + expect(healthLayout({ model: 'tracks', tracks: [{ id: 'a', label: 'A' }] })).toEqual(healthLayout(undefined)); + }); +}); + +describe('mistakes in the answers', () => { + it('are reported with where they are', () => { + expect(problems(withCore({ mood: 'grim', health: { model: 'tracks', tracks: [{ id: 'a', label: 'A' }], overflow: 'yes', count: 3 } }))).toEqual([ + 'core mood: Not a setup question', + 'core health, count: Not part of the tracks model', + 'core health track: Exactly 2', + 'core health, overflow: true or false', + ]); + expect(problems(withCore({ health: { model: 'hearts' } }))).toEqual([ + 'core health, model: One of pool, tracks, typed, harm, wounds, locations, none', + ]); + expect(problems(withCore({ health: 'pool' }))).toEqual(['core health: Must say which health model']); + expect(problems(withCore('all of them'))).toEqual(['core: Must be a set of answers']); + }); + + it('in the lists of tracks, types, levels and locations', () => { + expect(problems(withCore({ health: { model: 'typed', label: '', types: [{ id: 'Light', label: 'LIGHT' }, { id: 'a', label: 'A' }, { id: 'a', label: 'A' }, 'x', { id: 'b', label: 'B' }] } }))).toEqual([ + 'core health, label: Required', + 'core health type: From 2 to 4', + 'core health type Light: Ids use lowercase letters, digits and _, starting with a letter', + 'core health type a: Defined twice', + 'core health type 4: Needs an id and a label', + ]); + expect(problems(withCore({ health: { model: 'harm', levels: [{ id: 'a', label: 'A', slots: 5, penalty: 'x'.repeat(41) }, { id: 'b', slots: 1 }] } }))).toEqual([ + 'core health level a, slots: A whole number from 1 to 4', + 'core health level a, penalty: Longer than 40 characters', + 'core health level b, label: Required', + ]); + expect(problems(withCore({ health: { model: 'wounds', count: 0, penalty: 2 } }))).toEqual([ + 'core health, count: A whole number from 1 to 10', + 'core health, penalty: A whole number from -5 to 0', + ]); + expect(problems(withCore({ health: { model: 'locations', locations: [] } }))).toEqual(['core health location: From 1 to 12']); + expect(problems(withCore({ health: { model: 'pool', label: 'x'.repeat(21) } }))).toEqual(['core health, label: Longer than 20 characters']); + expect(problems(withCore({ health: { model: 'tracks', tracks: [{ id: 'a'.repeat(41), label: 'A' }, { id: 'b', label: 'B' }] } }))) + .toEqual([`core health track ${'a'.repeat(41)}: Ids use lowercase letters, digits and _, starting with a letter`]); + }); + + it('when a health field would clash with the starter sheet, a derived value or itself', () => { + expect(problems(withCore({ health: { model: 'tracks', tracks: [{ id: 'name', label: 'NAME' }, { id: 'stun', label: 'STUN' }] } }))) + .toEqual(['core health name: Clashes with another field on the starter sheet']); + expect(problems(withCore({ health: { model: 'tracks', tracks: [{ id: 'stun', label: 'A' }, { id: 'stun_max', label: 'B' }] } }))) + .toEqual(['core health stun_max: Clashes with another field on the starter sheet']); + expect(problems(withCore({ health: { model: 'typed', types: [{ id: 'health', label: 'A' }, { id: 'b', label: 'B' }] } }))) + .toEqual(['core health health: Clashes with another field on the starter sheet']); + expect(problems(withCore({ health: { model: 'harm', levels: [{ id: 'grit', label: 'GRIT', slots: 1 }] } }, { derived: [{ id: 'grit_1', formula: '1' }] }))) + .toEqual(['core health grit_1: A derived value has this id']); + }); + + it('in advancement, dice and distance', () => { + expect(problems(withCore({ advancement: ['levels', 'luck', 'levels'], dice: ['d20', 'd1', 'd101', '100d6', 'dX', 7, 'd20'], distance: 'leagues' }))).toEqual([ + 'core advancement: luck is not one of levels, milestone, spend, use', + 'core advancement: Named twice', + 'core dice: d1 is not a die (d2 to d100, or dF, with a count: 2d6)', + 'core dice: d101 is not a die (d2 to d100, or dF, with a count: 2d6)', + 'core dice: 100d6 is not a die (d2 to d100, or dF, with a count: 2d6)', + 'core dice: dX is not a die (d2 to d100, or dF, with a count: 2d6)', + 'core dice: 7 is not a die (d2 to d100, or dF, with a count: 2d6)', + 'core dice: Named twice', + 'core distance: One of meters, feet, yards, squares, hexes, zones', + ]); + expect(problems(withCore({ advancement: 'levels', dice: 'd20' }))).toEqual(['core advancement: Must be a list', 'core dice: Must be a list']); + expect(problems(withCore({ dice: ['d2', 'd3', 'd4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100'] }))).toEqual(['core dice: More than 8']); + expect(problems(withCore({ advancement: [] }))).toEqual([]); + }); +}); diff --git a/backend/systemBuilder/core.js b/backend/systemBuilder/core.js new file mode 100644 index 0000000..1befe28 --- /dev/null +++ b/backend/systemBuilder/core.js @@ -0,0 +1,252 @@ +// A custom system's core rules: the setup questions, answered as data. +// +// Creating a system starts by asking how its core works (docs/system-builder-plan.md, "Core +// rules setup"). The answers are kept here: +// +// core: { +// health: { model: 'tracks', tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }] }, +// advancement: ['levels'], // one or several; none at all is allowed +// dice: ['d20', '2d6'], // the system's common dice, a shortcut when adding a roll +// distance: 'meters', +// } +// +// Initiative on or off is not here: it is a part (definition.js PARTS). Currencies come with +// their own piece. +// +// What the answers do today: the health model shapes the starter sheet a system gets before +// its GM designs one (healthLayout, used by sheet.js effectiveSheet). How each model takes +// damage and shows on the token's monitor, how characters advance, and the dice and distance +// in play arrive in their own pieces; until then the answers are recorded and checked. + +// Shorter than a sheet field's 64: the starter sheet builds ids from these (stun_max, serious_2). +const NAME = /^[a-z][a-z0-9_]{0,39}$/; +const LIMITS = { label: 20, penalty: 40, dice: 8 }; + +/** The health models, with what the setup screen says about each. */ +const HEALTH_MODELS = [ + { id: 'pool', label: 'One pool', worksLike: 'A number that goes down', examples: 'D&D, CWN, Cyberpunk RED' }, + { id: 'tracks', label: 'Two tracks', worksLike: 'Two pools, filled by different damage', examples: 'Shadowrun physical and stun; Genesys wounds and strain' }, + { id: 'typed', label: 'Damage types on one track', worksLike: 'Boxes marked lightly or heavily', examples: 'Vampire superficial and aggravated' }, + { id: 'harm', label: 'Harm levels', worksLike: 'Named severities, each with its penalty', examples: 'Blades lesser, moderate, severe' }, + { id: 'wounds', label: 'Wound count', worksLike: 'A few wounds, then out', examples: 'Savage Worlds' }, + { id: 'locations', label: 'Hit locations', worksLike: 'Damage per location, with critical injuries', examples: 'WFRP' }, + { id: 'none', label: 'None', worksLike: 'Consequences are conditions', examples: 'Some narrative games' }, +]; + +/** How characters advance: one or several. */ +const ADVANCEMENT = [ + { id: 'levels', label: 'XP levels', examples: 'D&D, CWN' }, + { id: 'milestone', label: 'Milestone', examples: 'The GM levels the group up' }, + { id: 'spend', label: 'Spend XP', examples: 'Shadowrun karma, World of Darkness, Genesys' }, + { id: 'use', label: 'Improve by use', examples: 'Call of Cthulhu' }, +]; + +const DISTANCE = ['meters', 'feet', 'yards', 'squares', 'hexes', 'zones']; + +/** d2 to d100 (odd sizes too) or Fate dice, with an optional count: d20, 2d6, 4dF, d7. */ +const DIE = /^([1-9][0-9]?)?d([2-9]|[1-9][0-9]|100|F)$/; + +/** Fields the starter sheet always has, which a health setting must not name. */ +const STARTER_IDS = ['name', 'concept', 'description', 'cash', 'notes']; + +const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); + +const label = (value, where, problems, max = LIMITS.label) => { + if (typeof value !== 'string' || !value.trim()) problems.push({ where, message: 'Required' }); + else if (value.length > max) problems.push({ where, message: `Longer than ${max} characters` }); +}; + +const whole = (value, min, max, where, problems) => { + if (!Number.isInteger(value) || value < min || value > max) problems.push({ where, message: `A whole number from ${min} to ${max}` }); +}; + +/** A list of { id, label } entries: count limits, ids, labels, no repeats. */ +const namedList = (list, where, min, max, problems, extra = () => {}) => { + if (!Array.isArray(list)) { problems.push({ where, message: 'Must be a list' }); return; } + if (list.length < min || list.length > max) { + problems.push({ where, message: min === max ? `Exactly ${min}` : `From ${min} to ${max}` }); + } + const ids = new Set(); + list.slice(0, max).forEach((entry, i) => { + const ew = isPlainObject(entry) && typeof entry.id === 'string' ? `${where} ${entry.id}` : `${where} ${i + 1}`; + if (!isPlainObject(entry)) { problems.push({ where: ew, message: 'Needs an id and a label' }); return; } + if (typeof entry.id !== 'string' || !NAME.test(entry.id)) problems.push({ where: ew, message: 'Ids use lowercase letters, digits and _, starting with a letter' }); + else if (ids.has(entry.id)) problems.push({ where: ew, message: 'Defined twice' }); + else ids.add(entry.id); + label(entry.label, `${ew}, label`, problems); + extra(entry, ew); + }); +}; + +/** What each model may say besides `model`. */ +const HEALTH_KEYS = { + pool: ['label'], + tracks: ['tracks', 'overflow'], + typed: ['label', 'types'], + harm: ['levels'], + wounds: ['count', 'penalty'], + locations: ['label', 'locations'], + none: [], +}; + +const checkHealth = (health, problems) => { + if (!isPlainObject(health)) { problems.push({ where: 'core health', message: 'Must say which health model' }); return; } + if (!Object.prototype.hasOwnProperty.call(HEALTH_KEYS, health.model)) { + problems.push({ where: 'core health, model', message: `One of ${HEALTH_MODELS.map((m) => m.id).join(', ')}` }); + return; + } + for (const key of Object.keys(health)) { + if (key !== 'model' && !HEALTH_KEYS[health.model].includes(key)) { + problems.push({ where: `core health, ${key}`, message: `Not part of the ${health.model} model` }); + } + } + const w = (key) => `core health, ${key}`; + if (health.label !== undefined) label(health.label, w('label'), problems); + switch (health.model) { + case 'tracks': + namedList(health.tracks, 'core health track', 2, 2, problems); + if (health.overflow !== undefined && typeof health.overflow !== 'boolean') problems.push({ where: w('overflow'), message: 'true or false' }); + break; + case 'typed': + namedList(health.types, 'core health type', 2, 4, problems); + break; + case 'harm': + namedList(health.levels, 'core health level', 1, 6, problems, (level, lw) => { + whole(level.slots, 1, 4, `${lw}, slots`, problems); + if (level.penalty !== undefined) label(level.penalty, `${lw}, penalty`, problems, LIMITS.penalty); + }); + break; + case 'wounds': + whole(health.count, 1, 10, w('count'), problems); + if (health.penalty !== undefined) whole(health.penalty, -5, 0, w('penalty'), problems); + break; + case 'locations': + namedList(health.locations, 'core health location', 1, 12, problems); + break; + default: + } +}; + +/** + * The health part of a starter sheet for a health model: its sections, and the header's HP + * pair when the model has a pool on the token. A pool with no label is exactly the starter + * sheet every system has had, so a system that answers nothing looks as it did. + */ +const healthLayout = (health) => { + const h = isPlainObject(health) ? health : { model: 'pool' }; + const grid = (fields) => ({ id: 'health', label: 'HEALTH', layout: 'grid', tab: 'STATS', columns: 2, fields }); + const pool = (id, name) => [ + { id, label: name, type: 'number', source: 'token_hp', maxField: `${id}_max` }, + { id: `${id}_max`, label: `${name} MAX`, type: 'number', source: 'token_hp_max' }, + ]; + const text = (v, fallback) => (typeof v === 'string' && v.trim() ? v : fallback); + const list = (v) => (Array.isArray(v) ? v.filter(isPlainObject) : []); + switch (h.model) { + case 'tracks': { + const [first, second] = list(h.tracks); + if (!first || !second) return healthLayout({ model: 'pool' }); + // The first track is the token's; the second lives on the sheet. + return { + sections: [grid([ + ...pool(first.id, first.label), + { id: second.id, label: second.label, type: 'number', maxField: `${second.id}_max` }, + { id: `${second.id}_max`, label: `${second.label} MAX`, type: 'number' }, + ])], + header: { hpField: first.id, hpMaxField: `${first.id}_max` }, + }; + } + case 'typed': + return { + sections: [grid([ + ...pool('health', text(h.label, 'HEALTH')), + ...list(h.types).map((t) => ({ id: t.id, label: t.label, type: 'number', hint: 'Boxes marked with this kind of damage' })), + ])], + header: { hpField: 'health', hpMaxField: 'health_max' }, + }; + case 'harm': + return { + sections: [{ id: 'harm', label: 'HARM', layout: 'list', tab: 'STATS', fields: list(h.levels).flatMap((level) => + Array.from({ length: Number.isInteger(level.slots) ? level.slots : 1 }, (_, i) => ({ + id: `${level.id}_${i + 1}`, + label: i === 0 ? level.label : `${level.label} ${i + 1}`, + type: 'text', + ...(level.penalty ? { hint: level.penalty } : {}), + }))) }], + header: {}, + }; + case 'wounds': + return { + sections: [grid([ + { id: 'wounds', label: 'WOUNDS LEFT', type: 'number', source: 'token_hp', maxField: 'wounds_max' }, + { id: 'wounds_max', label: 'WOUNDS', type: 'number', source: 'token_hp_max', + ...(Number.isInteger(h.count) ? { hint: `Out after ${h.count}` } : {}) }, + ])], + header: { hpField: 'wounds', hpMaxField: 'wounds_max' }, + }; + case 'locations': + return { + sections: [ + grid(pool('hp', text(h.label, 'HP'))), + { id: 'injuries', label: 'INJURIES', layout: 'list', tab: 'STATS', + fields: list(h.locations).map((l) => ({ id: l.id, label: l.label, type: 'text' })) }, + ], + header: { hpField: 'hp', hpMaxField: 'hp_max' }, + }; + case 'none': + return { sections: [], header: {} }; + default: + return { sections: [grid(pool('hp', text(h.label, 'HP')))], header: { hpField: 'hp', hpMaxField: 'hp_max' } }; + } +}; + +/** + * Check the core section. `derivedIds` are the system's derived values: a health setting + * cannot name one, nor a field the starter sheet already has, nor collide with itself (a + * track called `stun` and another called `stun_max`). + */ +const checkCore = (core, derivedIds, problems) => { + if (core === undefined) return; + if (!isPlainObject(core)) { problems.push({ where: 'core', message: 'Must be a set of answers' }); return; } + for (const key of Object.keys(core)) { + if (!['health', 'advancement', 'dice', 'distance'].includes(key)) problems.push({ where: `core ${key}`, message: 'Not a setup question' }); + } + + if (core.health !== undefined) { + const before = problems.length; + checkHealth(core.health, problems); + if (problems.length === before) { + const seen = new Set(STARTER_IDS); + for (const section of healthLayout(core.health).sections) { + for (const f of section.fields) { + if (derivedIds.has(f.id)) problems.push({ where: `core health ${f.id}`, message: 'A derived value has this id' }); + else if (seen.has(f.id)) problems.push({ where: `core health ${f.id}`, message: 'Clashes with another field on the starter sheet' }); + seen.add(f.id); + } + } + } + } + + if (core.advancement !== undefined) { + const ids = ADVANCEMENT.map((a) => a.id); + if (!Array.isArray(core.advancement)) problems.push({ where: 'core advancement', message: 'Must be a list' }); + else { + core.advancement.forEach((a) => { if (!ids.includes(a)) problems.push({ where: 'core advancement', message: `${a} is not one of ${ids.join(', ')}` }); }); + if (new Set(core.advancement).size !== core.advancement.length) problems.push({ where: 'core advancement', message: 'Named twice' }); + } + } + + if (core.dice !== undefined) { + if (!Array.isArray(core.dice)) problems.push({ where: 'core dice', message: 'Must be a list' }); + else { + if (core.dice.length > LIMITS.dice) problems.push({ where: 'core dice', message: `More than ${LIMITS.dice}` }); + core.dice.forEach((d) => { if (typeof d !== 'string' || !DIE.test(d)) problems.push({ where: 'core dice', message: `${d} is not a die (d2 to d100, or dF, with a count: 2d6)` }); }); + if (new Set(core.dice).size !== core.dice.length) problems.push({ where: 'core dice', message: 'Named twice' }); + } + } + + if (core.distance !== undefined && !DISTANCE.includes(core.distance)) { + problems.push({ where: 'core distance', message: `One of ${DISTANCE.join(', ')}` }); + } +}; + +module.exports = { checkCore, healthLayout, HEALTH_MODELS, ADVANCEMENT, DISTANCE, LIMITS }; diff --git a/backend/systemBuilder/definition.js b/backend/systemBuilder/definition.js index c0d3313..741437d 100644 --- a/backend/systemBuilder/definition.js +++ b/backend/systemBuilder/definition.js @@ -15,6 +15,7 @@ // lookups: { ... }, derived: [ ... ], // Layer 3, the Phase 1 engine's format // sheet: { tabs, header, sections }, // the character sheet (sheet.js) // npc: { sheet, tiers }, // NPC layout and power tiers (npc.js) +// core: { health, advancement, dice, distance }, // the setup questions (core.js) // } // // Problems come in two weights. A **fatal** one means the document cannot be stored at all: @@ -29,6 +30,7 @@ const { compileSystem } = require('./derived'); const { checkSheet } = require('./sheet'); const { checkNpc } = require('./npc'); +const { checkCore } = require('./core'); const FORMAT = 1; @@ -68,7 +70,7 @@ const PARTS = [ 'death', 'luck', 'xp', 'npc_tiers', 'sheet_import', ]; -const SECTIONS = new Set(['format', 'name', 'description', 'words', 'parts', 'lookups', 'derived', 'sheet', 'npc']); +const SECTIONS = new Set(['format', 'name', 'description', 'words', 'parts', 'lookups', 'derived', 'sheet', 'npc', 'core']); const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); @@ -155,6 +157,7 @@ const checkDefinition = (definition) => { const derivedIds = new Set(Array.isArray(definition.derived) ? definition.derived.filter((d) => d && typeof d.id === 'string').map((d) => d.id) : []); checkSheet(definition.sheet, derivedIds, problems); + checkCore(definition.core, derivedIds, problems); checkNpc(definition, derivedIds, problems); return { problems }; }; diff --git a/backend/systemBuilder/sheet.js b/backend/systemBuilder/sheet.js index e833d54..7bbcda2 100644 --- a/backend/systemBuilder/sheet.js +++ b/backend/systemBuilder/sheet.js @@ -10,6 +10,8 @@ // server recomputes it on every save. A system with no sheet yet gets a starter one (the // generic layout plus its derived values), so a published system can always be drawn. +const { healthLayout } = require('./core'); + const NAME = /^[a-z][a-z0-9_]{0,63}$/; const LIMITS = { sections: 50, fields: 500, perSection: 100, tabs: 12, label: 60, hint: 300, options: 100 }; @@ -147,22 +149,21 @@ const checkSheet = (sheet, derivedIds, problems) => { /** * The sheet a system is drawn with: its own, or a starter one. The starter is the generic - * layout (name, concept, description, HP, cash, notes) with the system's derived values in a - * section of their own, so a system that has not designed its sheet yet still has one. + * layout (name, concept, description, health, cash, notes) with the system's derived values in + * a section of their own, so a system that has not designed its sheet yet still has one. Its + * health is shaped by the system's health model (core.js); with none, it is one HP pool. */ const effectiveSheet = (definition) => { if (definition && isPlainObject(definition.sheet)) return definition.sheet; const derived = Array.isArray(definition && definition.derived) ? definition.derived.filter((d) => d && typeof d.id === 'string') : []; + const health = healthLayout(definition && isPlainObject(definition.core) ? definition.core.health : undefined); const sections = [ { id: 'identity', label: 'IDENTITY', layout: 'list', tab: 'STATS', fields: [ { id: 'name', label: 'Name', type: 'text', visibility: 'public' }, { id: 'concept', label: 'Concept', type: 'text' }, { id: 'description', label: 'Description', type: 'textarea', visibility: 'public' }, ] }, - { id: 'health', label: 'HEALTH', layout: 'grid', tab: 'STATS', columns: 2, fields: [ - { id: 'hp', label: 'HP', type: 'number', source: 'token_hp', maxField: 'hp_max' }, - { id: 'hp_max', label: 'HP MAX', type: 'number', source: 'token_hp_max' }, - ] }, + ...health.sections, ]; if (derived.length) { sections.push({ id: 'derived', label: 'DERIVED', layout: 'grid', tab: 'STATS', columns: 4, @@ -173,7 +174,7 @@ const effectiveSheet = (definition) => { { id: 'money', label: 'MONEY', layout: 'list', tab: 'GEAR', fields: [{ id: 'cash', label: 'Cash', type: 'number', source: 'bank_balance' }] }, { id: 'notes', label: 'NOTES', layout: 'notes', tab: 'NOTES', fields: [{ id: 'notes', label: 'Notes', type: 'textarea' }] }, ); - return { tabs: ['STATS', 'GEAR', 'NOTES'], header: { nameField: 'name', subtitleFields: ['concept'], hpField: 'hp', hpMaxField: 'hp_max' }, sections }; + return { tabs: ['STATS', 'GEAR', 'NOTES'], header: { nameField: 'name', subtitleFields: ['concept'], ...health.header }, sections }; }; /** Every field on a sheet, in order. */