From 449a57bed9b8817872b491728798f716cdfb4d42 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 22:04:10 -0500 Subject: [PATCH] feat(system-builder): stats, formula names and samples in a definition (4b2a) backend/systemBuilder/stats.js: a stats section of groups of stats (id, label, optional whole-number min and max, optional tie to another stat), checked for ids, labels, ranges, ties, limits (20 groups, 100 stats each) and clashes with formulas, the starter sheet's fields and the health section's; derived values may carry a label; samples holds the builder's sample character (numbers for the system's own stats). The starter sheet shows each stat group as a section (after health, before DERIVED, ranges as hints), and derived values by their names. Decided with the user 2026-10-06 (mockup builder-stats-rules): the starter sheet had no boxes for stats, so a system couldn't work before the sheet designer. --- CHANGELOG.md | 8 ++ .../__tests__/system_builder_stats.test.js | 124 ++++++++++++++++ backend/systemBuilder/definition.js | 20 ++- backend/systemBuilder/sheet.js | 5 +- backend/systemBuilder/stats.js | 135 ++++++++++++++++++ 5 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 backend/__tests__/system_builder_stats.test.js create mode 100644 backend/systemBuilder/stats.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ee948c..6ccdda31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **A custom system's stats.** A system can now list the numbers players fill in, in groups + (abilities, skills, anything), each with a name, an optional lowest and highest value, and a + skill optionally tied to an ability. Until a system designs its own sheet, the starter sheet + shows each group as a section, so formulas like "16 − (level + best of STR, CON)" finally have + boxes to read. Formulas can carry a name players see on the sheet ("Physical save"). The builder + can save a made-up sample character with the draft, for checking formulas. The STATS & RULES + page that edits all this comes next. + - **The builder's FEATURES page: the bank.** BANK opens out to the system's currencies and its celebrations. Each currency is counted in whole numbers, decimals (with its symbol before or after, and a point or a comma) or coins (each with its worth), and shows how an amount will read. diff --git a/backend/__tests__/system_builder_stats.test.js b/backend/__tests__/system_builder_stats.test.js new file mode 100644 index 00000000..13460f60 --- /dev/null +++ b/backend/__tests__/system_builder_stats.test.js @@ -0,0 +1,124 @@ +/** + * A system's stats (4b2a): the numbers players fill in, in groups, shown on the starter sheet until + * the system designs its own; names for derived values; the builder's sample character. Decided with + * the user 2026-10-06 (mockup builder-stats-rules): stats get their own list, skills are a group, + * formulas get display names, samples are saved with the draft. + */ + +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; + +const require_ = createRequire(import.meta.url); +const { checkDefinition } = require_('../systemBuilder/definition'); +const { effectiveSheet } = require_('../systemBuilder/sheet'); +const { statIdsOf, statSections, derivedLabel, rangeHint, LIMITS } = require_('../systemBuilder/stats'); + +const ABILITIES = { id: 'abilities', label: 'ABILITIES', stats: [{ id: 'str', label: 'Strength', min: 3, max: 18 }, { id: 'dex', label: 'Dexterity', min: 3, max: 18 }] }; +const SKILLS = { id: 'skills', label: 'SKILLS', stats: [{ id: 'shoot', label: 'Shoot', min: 0, max: 4, tie: 'dex' }] }; +const HEARTH = { format: 1, name: 'Hearth', stats: [ABILITIES, SKILLS] }; +const problems = (def) => checkDefinition(def).problems.map((p) => `${p.where}: ${p.message}`); + +describe('checking stats', () => { + it('takes groups of stats, with ranges and ties', () => { + expect(problems(HEARTH)).toEqual([]); + expect(problems({ format: 1, name: 'H', stats: [] })).toEqual([]); + expect(problems({ format: 1, name: 'H', stats: [{ id: 'g', label: 'G', stats: [{ id: 'luck', label: 'Luck' }, { id: 'grit', label: 'Grit', min: 0 }] }] })).toEqual([]); + }); + + it('refuses a malformed list, group or stat, saying where', () => { + expect(problems({ format: 1, name: 'H', stats: {} })).toEqual(['stats: Must be a list of groups']); + expect(problems({ format: 1, name: 'H', stats: ['x'] })).toEqual(['stats group 1: Must be a group of stats']); + expect(problems({ format: 1, name: 'H', stats: [{ id: 'Bad', label: '', stats: 'no', extra: 1 }] })).toEqual([ + 'stats Bad, extra: Not part of a group', + 'stats Bad: Ids use lowercase letters, digits and _, starting with a letter', + 'stats Bad, label: Must be some text', + 'stats Bad, stats: Must be a list of stats', + ]); + expect(problems({ format: 1, name: 'H', stats: [{ id: 'g', stats: [7, { id: 's', label: 'S', min: 1.5, max: 'x', color: 'red' }] }] })).toEqual([ + 'stats g, label: Required', + 'stats g, stat 1: Must be a stat', + 'stat s, color: Not part of a stat', + 'stat s, min: Must be a whole number', + 'stat s, max: Must be a whole number', + ]); + }); + + it('needs every stat named, and takes a range of one value', () => { + expect(problems({ format: 1, name: 'H', stats: [{ id: 'g', label: 'G', stats: [{ id: 'nameless' }, { id: 'fixed', label: 'Fixed', min: 5, max: 5 }] }] })) + .toEqual(['stat nameless, label: Required']); + }); + + it('refuses a range upside down, and a tie to nothing or to itself', () => { + expect(problems({ format: 1, name: 'H', stats: [{ id: 'g', label: 'G', stats: [ + { id: 'a', label: 'A', min: 5, max: 2 }, { id: 'b', label: 'B', tie: 'b' }, { id: 'c', label: 'C', tie: 'zzz' }, { id: 'd', label: 'D', tie: 4 }, + ] }] })).toEqual([ + 'stat a: The lowest is above the highest', + 'stat b, tie: Cannot be tied to itself', + 'stat c, tie: Not one of this system\'s stats', + 'stat d, tie: Not one of this system\'s stats', + ]); + }); + + it('refuses an id used twice, or one a formula, the starter sheet or the health section has', () => { + const dup = { format: 1, name: 'H', stats: [ABILITIES, { id: 'abilities', label: 'X', stats: [{ id: 'str', label: 'Again' }] }] }; + expect(problems(dup)).toEqual(['stats abilities: Defined twice', 'stat str: Defined twice']); + expect(problems({ ...HEARTH, derived: [{ id: 'str', formula: '1' }] })).toEqual(['stat str: A formula has this id']); + expect(problems({ format: 1, name: 'H', stats: [{ id: 'g', label: 'G', stats: [{ id: 'notes', label: 'Notes' }, { id: 'hp', label: 'HP' }] }] })) + .toEqual(['stat notes: The starter sheet already has a field with this id', 'stat hp: The health section already has a field with this id']); + expect(problems({ format: 1, name: 'H', core: { health: { model: 'wounds', count: 3 } }, stats: [{ id: 'g', label: 'G', stats: [{ id: 'hp', label: 'HP' }, { id: 'wounds', label: 'W' }] }] })) + .toEqual(['stat wounds: The health section already has a field with this id']); + }); + + it('caps groups and stats', () => { + const many = Array.from({ length: LIMITS.stats + 1 }, (_, i) => ({ id: `s${i}`, label: `S${i}` })); + expect(problems({ format: 1, name: 'H', stats: [{ id: 'g', label: 'G', stats: many }] })).toEqual([`stats g, stats: At most ${LIMITS.stats} stats`]); + const groups = Array.from({ length: LIMITS.groups + 1 }, (_, i) => ({ id: `g${i}`, label: 'G', stats: [] })); + expect(problems({ format: 1, name: 'H', stats: groups })).toEqual([`stats: At most ${LIMITS.groups} groups`]); + }); +}); + +describe('formula names and samples', () => { + it('takes a name for a derived value, as text', () => { + expect(problems({ ...HEARTH, derived: [{ id: 'save', label: 'Physical save', formula: '16 - @str' }] })).toEqual([]); + expect(problems({ ...HEARTH, derived: [{ id: 'save', label: '', formula: '1' }, { id: 'b', label: 'x'.repeat(41), formula: '1' }] })) + .toEqual(['derived save, label: Must be some text', 'derived b, label: Longer than 40 characters']); + }); + + it('takes sample numbers for the system\'s own stats', () => { + expect(problems({ ...HEARTH, samples: { str: 16, shoot: 1 } })).toEqual([]); + expect(problems({ ...HEARTH, samples: { str: 'high', luck: 3 } })).toEqual(['samples str: Must be a number', 'samples luck: Not one of this system\'s stats']); + expect(problems({ ...HEARTH, samples: [] })).toEqual(['samples: Must be a set of stat values']); + }); +}); + +describe('the starter sheet', () => { + it('shows each group as a section, after health and before the formulas, with names and ranges', () => { + const sheet = effectiveSheet({ ...HEARTH, derived: [{ id: 'save_physical', label: 'Physical save', formula: '16 - @str' }, { id: 'str_mod', formula: '1' }] }); + expect(sheet.sections.map((s) => s.id)).toEqual(['identity', 'health', 'stats_abilities', 'stats_skills', 'derived', 'inventory', 'money', 'notes']); + expect(sheet.sections[2]).toEqual({ id: 'stats_abilities', label: 'ABILITIES', layout: 'grid', tab: 'STATS', columns: 3, fields: [ + { id: 'str', label: 'Strength', type: 'number', hint: '3 to 18' }, { id: 'dex', label: 'Dexterity', type: 'number', hint: '3 to 18' }, + ] }); + expect(sheet.sections[4].fields).toEqual([{ id: 'save_physical', label: 'Physical save', type: 'number' }, { id: 'str_mod', label: 'STR MOD', type: 'number' }]); + }); + + it('is unchanged for a system with no stats', () => { + expect(effectiveSheet({ format: 1, name: 'H' }).sections.map((s) => s.id)).toEqual(['identity', 'health', 'inventory', 'money', 'notes']); + }); + + it('a designed sheet is the system\'s own, stats or not', () => { + const own = { tabs: ['ONE'], sections: [{ id: 'x', label: 'X', layout: 'list', tab: 'ONE', fields: [] }] }; + expect(effectiveSheet({ ...HEARTH, sheet: own }).sections.map((s) => s.id)).toEqual(['x']); + }); +}); + +describe('helpers', () => { + it('reads ranges, names and ids, ignoring what it cannot read', () => { + expect([rangeHint({ min: 0, max: 4 }), rangeHint({ min: 1 }), rangeHint({ max: 9 }), rangeHint({})]).toEqual(['0 to 4', 'At least 1', 'At most 9', null]); + expect(derivedLabel({ id: 'save_physical' })).toBe('SAVE PHYSICAL'); + expect(derivedLabel({ id: 'a', label: ' ' })).toBe('A'); + expect([...statIdsOf(HEARTH)]).toEqual(['str', 'dex', 'shoot']); + expect([...statIdsOf({ stats: ['x', { id: 'g', stats: [3, { id: 'ok' }] }] })]).toEqual(['ok']); + expect(statSections({ stats: [{ id: 'g', label: ' ', stats: [{ id: 'a' }, 'x'] }, { label: 'no id' }] })) + .toEqual([{ id: 'stats_g', label: 'G', layout: 'grid', tab: 'STATS', columns: 3, fields: [{ id: 'a', label: 'A', type: 'number' }] }]); + }); +}); diff --git a/backend/systemBuilder/definition.js b/backend/systemBuilder/definition.js index bc7f5eba..38f33147 100644 --- a/backend/systemBuilder/definition.js +++ b/backend/systemBuilder/definition.js @@ -15,6 +15,8 @@ // parts: { vehicles: { on: false }, ... }, // Layer 2 // buildings: { ... }, currencies: [ ... ], // building names, money (buildings.js, currencies.js) // bank: { celebrations, whale }, // the bank window's easter eggs (bank.js) +// stats: [ { id, label, stats: [...] } ], // what players fill in, in groups (stats.js) +// samples: { str: 16, ... }, // the builder's sample character (stats.js) // 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) @@ -33,7 +35,11 @@ const { compileSystem } = require('./derived'); const { checkSheet } = require('./sheet'); const { checkNpc } = require('./npc'); -const { checkCore } = require('./core'); +const { checkCore, healthLayout } = require('./core'); +const { checkStats, checkDerivedLabels, checkSamples, statIdsOf } = require('./stats'); + +/** Fields the starter sheet always has (sheet.js designedOrStarter), which a stat must not name. */ +const STARTER_IDS = ['name', 'concept', 'description', 'cash', 'notes']; const { TERMS, WORD_FORMS, wordFor, resolveWords, ownWords } = require('./terms'); const { PARTS, partOn } = require('./parts'); const { checkBuildings } = require('./buildings'); @@ -54,7 +60,7 @@ const LIMITS = { word: 40, }; -const SECTIONS = new Set(['format', 'name', 'description', 'author', 'license', 'words', 'parts', 'buildings', 'currencies', 'bank', 'lookups', 'derived', 'sheet', 'npc', 'core']); +const SECTIONS = new Set(['format', 'name', 'description', 'author', 'license', 'words', 'parts', 'buildings', 'currencies', 'bank', 'stats', 'samples', '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); @@ -146,6 +152,16 @@ const checkDefinition = (definition) => { } const derivedIds = new Set(Array.isArray(definition.derived) ? definition.derived.filter((d) => d && typeof d.id === 'string').map((d) => d.id) : []); + checkDerivedLabels(definition.derived, problems); + // A stat may not share an id with a formula or a field the starter sheet already has. + const taken = new Map([ + ...STARTER_IDS.map((id) => [id, 'The starter sheet already has a field with this id']), + ...healthLayout(isPlainObject(definition.core) ? definition.core.health : undefined).sections + .flatMap((s) => s.fields.map((f) => [f.id, 'The health section already has a field with this id'])), + ...[...derivedIds].map((id) => [id, 'A formula has this id']), + ]); + checkStats(definition.stats, taken, problems); + checkSamples(definition.samples, statIdsOf(definition), problems); checkSheet(definition.sheet, derivedIds, problems); checkCore(definition.core, derivedIds, problems); checkNpc(definition, derivedIds, problems); diff --git a/backend/systemBuilder/sheet.js b/backend/systemBuilder/sheet.js index 86f9e624..33bb5b6b 100644 --- a/backend/systemBuilder/sheet.js +++ b/backend/systemBuilder/sheet.js @@ -13,6 +13,7 @@ const { healthLayout } = require('./core'); const { ownWords } = require('./terms'); const { partOn } = require('./parts'); +const { statSections, derivedLabel } = require('./stats'); const NAME = /^[a-z][a-z0-9_]{0,63}$/; @@ -205,10 +206,12 @@ const designedOrStarter = (definition) => { { id: 'description', label: 'Description', type: 'textarea', visibility: 'public' }, ] }, ...health.sections, + // What players fill in, a section per group (stats.js, 4b2a). + ...statSections(definition), ]; if (derived.length) { sections.push({ id: 'derived', label: 'DERIVED', layout: 'grid', tab: 'STATS', columns: 4, - fields: derived.map((d) => ({ id: d.id, label: d.id.replace(/_/g, ' ').toUpperCase(), type: 'number' })) }); + fields: derived.map((d) => ({ id: d.id, label: derivedLabel(d), type: 'number' })) }); } sections.push( { id: 'inventory', label: 'INVENTORY', layout: 'inventory', tab: 'GEAR', fields: [] }, diff --git a/backend/systemBuilder/stats.js b/backend/systemBuilder/stats.js new file mode 100644 index 00000000..05e1ecec --- /dev/null +++ b/backend/systemBuilder/stats.js @@ -0,0 +1,135 @@ +// A system's stats: the numbers players fill in, in groups (4b2a). +// +// stats: [ +// { id: 'abilities', label: 'ABILITIES', stats: [ +// { id: 'str', label: 'Strength', min: 3, max: 18 }, +// { id: 'dex', label: 'Dexterity', min: 3, max: 18 } ] }, +// { id: 'skills', label: 'SKILLS', stats: [{ id: 'shoot', label: 'Shoot', min: 0, max: 4, tie: 'dex' }] }, +// ], +// samples: { str: 16, dex: 14, shoot: 1 }, +// +// A formula reads a stat as @str (derived.js). Until a system designs its own sheet, the starter +// sheet shows each group as a section, so a system works end to end before the sheet designer +// (decided with the user, 2026-10-06). Skills are a group like any other; a stat may be tied to +// another (a skill to its ability), which later pieces read. `samples` are a made-up character +// the builder's STATS & RULES page checks formulas against, saved with the draft (same day). +// +// A derived value may also carry a `label`, the name players see on the sheet ("Physical save"); +// its id stays what formulas read (same day). +// +// Pure: the definition checks and the starter sheet both use it. + +const NAME = /^[a-z][a-z0-9_]{0,63}$/; +const LIMITS = { groups: 20, stats: 100, label: 40 }; +const GROUP_KEYS = new Set(['id', 'label', 'stats']); +const STAT_KEYS = new Set(['id', 'label', 'min', 'max', 'tie']); + +const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); + +const text = (value, where, problems, { required = false } = {}) => { + if (value === undefined) { if (required) problems.push({ where, message: 'Required' }); return; } + if (typeof value !== 'string' || !value.trim()) { problems.push({ where, message: 'Must be some text' }); return; } + if (value.length > LIMITS.label) problems.push({ where, message: `Longer than ${LIMITS.label} characters` }); +}; + +/** Every stat in a stats list, in order, from groups that are well formed enough to read. */ +const allStats = (stats) => (Array.isArray(stats) ? stats : []) + .filter((g) => isPlainObject(g) && Array.isArray(g.stats)) + .flatMap((g) => g.stats.filter((s) => isPlainObject(s) && typeof s.id === 'string')); + +/** The ids of a definition's stats. */ +const statIdsOf = (definition) => new Set(allStats(definition && definition.stats).map((s) => s.id)); + +/** + * Problems with a definition's `stats`, pushed onto `problems`. `taken` are ids a stat may not + * have, each with why: the system's derived values, and the starter sheet's own fields. + */ +const checkStats = (stats, taken, problems) => { + if (stats === undefined) return; + if (!Array.isArray(stats)) { problems.push({ where: 'stats', message: 'Must be a list of groups' }); return; } + if (stats.length > LIMITS.groups) problems.push({ where: 'stats', message: `At most ${LIMITS.groups} groups` }); + const groupIds = new Set(); + const statIds = new Set(); + const ties = []; + stats.slice(0, LIMITS.groups).forEach((g, gi) => { + const gw = isPlainObject(g) && typeof g.id === 'string' ? `stats ${g.id}` : `stats group ${gi + 1}`; + if (!isPlainObject(g)) { problems.push({ where: gw, message: 'Must be a group of stats' }); return; } + for (const key of Object.keys(g)) if (!GROUP_KEYS.has(key)) problems.push({ where: `${gw}, ${key}`, message: 'Not part of a group' }); + if (typeof g.id !== 'string' || !NAME.test(g.id)) problems.push({ where: gw, message: 'Ids use lowercase letters, digits and _, starting with a letter' }); + else if (groupIds.has(g.id)) problems.push({ where: gw, message: 'Defined twice' }); + else groupIds.add(g.id); + text(g.label, `${gw}, label`, problems, { required: true }); + if (!Array.isArray(g.stats)) { problems.push({ where: `${gw}, stats`, message: 'Must be a list of stats' }); return; } + if (g.stats.length > LIMITS.stats) problems.push({ where: `${gw}, stats`, message: `At most ${LIMITS.stats} stats` }); + g.stats.slice(0, LIMITS.stats).forEach((s, si) => { + const sw = isPlainObject(s) && typeof s.id === 'string' ? `stat ${s.id}` : `${gw}, stat ${si + 1}`; + if (!isPlainObject(s)) { problems.push({ where: sw, message: 'Must be a stat' }); return; } + for (const key of Object.keys(s)) if (!STAT_KEYS.has(key)) problems.push({ where: `${sw}, ${key}`, message: 'Not part of a stat' }); + if (typeof s.id !== 'string' || !NAME.test(s.id)) problems.push({ where: sw, message: 'Ids use lowercase letters, digits and _, starting with a letter' }); + else if (statIds.has(s.id)) problems.push({ where: sw, message: 'Defined twice' }); + else if (taken.has(s.id)) problems.push({ where: sw, message: taken.get(s.id) }); + else statIds.add(s.id); + text(s.label, `${sw}, label`, problems, { required: true }); + for (const end of ['min', 'max']) { + if (s[end] !== undefined && !Number.isSafeInteger(s[end])) problems.push({ where: `${sw}, ${end}`, message: 'Must be a whole number' }); + } + if (Number.isSafeInteger(s.min) && Number.isSafeInteger(s.max) && s.min > s.max) problems.push({ where: sw, message: 'The lowest is above the highest' }); + if (s.tie !== undefined) ties.push([sw, s.id, s.tie]); + }); + }); + for (const [sw, id, tie] of ties) { + if (tie === id) problems.push({ where: `${sw}, tie`, message: 'Cannot be tied to itself' }); + else if (typeof tie !== 'string' || !statIds.has(tie)) problems.push({ where: `${sw}, tie`, message: 'Not one of this system\'s stats' }); + } +}; + +/** Problems with derived values' names, pushed onto `problems`; the rest of each is derived.js's. */ +const checkDerivedLabels = (derived, problems) => { + if (!Array.isArray(derived)) return; + derived.forEach((d, i) => { + if (!isPlainObject(d) || d.label === undefined) return; + text(d.label, `${typeof d.id === 'string' ? `derived ${d.id}` : `derived value ${i + 1}`}, label`, problems); + }); +}; + +/** Problems with the sample character, pushed onto `problems`: numbers, for stats the system has. */ +const checkSamples = (samples, statIds, problems) => { + if (samples === undefined) return; + if (!isPlainObject(samples)) { problems.push({ where: 'samples', message: 'Must be a set of stat values' }); return; } + for (const [id, value] of Object.entries(samples)) { + if (!statIds.has(id)) problems.push({ where: `samples ${id}`, message: 'Not one of this system\'s stats' }); + else if (typeof value !== 'number' || !Number.isFinite(value)) problems.push({ where: `samples ${id}`, message: 'Must be a number' }); + } +}; + +/** What a stat's range reads as on the sheet: "3 to 18", "at least 0", or nothing. */ +const rangeHint = (s) => { + const lo = Number.isSafeInteger(s.min) ? s.min : null; + const hi = Number.isSafeInteger(s.max) ? s.max : null; + if (lo !== null && hi !== null) return `${lo} to ${hi}`; + if (lo !== null) return `At least ${lo}`; + if (hi !== null) return `At most ${hi}`; + return null; +}; + +/** The starter sheet's sections for a definition's stats: one per group, on STATS. */ +const statSections = (definition) => (Array.isArray(definition && definition.stats) ? definition.stats : []) + .filter((g) => isPlainObject(g) && typeof g.id === 'string' && Array.isArray(g.stats)) + .map((g) => ({ + id: `stats_${g.id}`, + label: typeof g.label === 'string' && g.label.trim() ? g.label : g.id.toUpperCase(), + layout: 'grid', + tab: 'STATS', + columns: 3, + fields: g.stats.filter((s) => isPlainObject(s) && typeof s.id === 'string').map((s) => ({ + id: s.id, + label: typeof s.label === 'string' && s.label.trim() ? s.label : s.id.toUpperCase(), + type: 'number', + ...(rangeHint(s) ? { hint: rangeHint(s) } : {}), + })), + })); + +/** A derived value's name on the sheet: its label, or its id in capitals as before. */ +const derivedLabel = (d) => (typeof d.label === 'string' && d.label.trim() ? d.label : d.id.replace(/_/g, ' ').toUpperCase()); + +module.exports = { checkStats, checkDerivedLabels, checkSamples, statIdsOf, statSections, derivedLabel, rangeHint, LIMITS };