Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
124 changes: 124 additions & 0 deletions backend/__tests__/system_builder_stats.test.js
Original file line number Diff line number Diff line change
@@ -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' }] }]);
});
});
20 changes: 18 additions & 2 deletions backend/systemBuilder/definition.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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');
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion backend/systemBuilder/sheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/;

Expand Down Expand Up @@ -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: [] },
Expand Down
Loading
Loading