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 };