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. */