From 3678af5e4f1f5af38ba0b1eff29040be45b95d6e Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 22:33:43 -0500 Subject: [PATCH] feat(system-builder): live formula values from the sample character (4b2b) derived.previewDerived works out a draft's derived values from data (the sample character), leaving out each value with a problem and everything that reads one, round by round, so one unfinished formula doesn't blank the rest; it returns compileSystem's problems too. A problem it can't pin to one named value (a broken table) gives no values, only the problems. POST /api/systems/preview-values {definition}, main admin only: refuses what can't be a definition, uses the draft's own samples, changes nothing. One engine for the builder and the game, rather than a second copy in the browser (which would also need the code-backed $rules). --- CHANGELOG.md | 6 + .../system_builder_preview_values.test.js | 117 ++++++++++++++++++ backend/routes/systems.js | 14 +++ backend/systemBuilder/derived.js | 40 +++++- 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 backend/__tests__/system_builder_preview_values.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ccdda31..0e6fb5b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **Formulas worked out while you write them.** The server can now work out a draft's formulas + from its sample character without saving anything, for the STATS & RULES page to show each + formula's value as you type. A formula with a mistake, and any formula that reads it, is left + out so the others still show; a loop is reported with its path. The game itself only ever runs a + published system, so none of this reaches play. + - **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 diff --git a/backend/__tests__/system_builder_preview_values.test.js b/backend/__tests__/system_builder_preview_values.test.js new file mode 100644 index 00000000..eff162e2 --- /dev/null +++ b/backend/__tests__/system_builder_preview_values.test.js @@ -0,0 +1,117 @@ +/** + * The builder's live values (4b2b): a draft's formulas worked out from its sample character while a + * GM is still writing them. Decided with the user 2026-10-06 (mockup builder-stats-rules): every + * formula shows its value as you type, a mistake shown under it without blanking the others. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { createRequire } from 'module'; +import { makeTestDb } from './helpers/testDb.js'; + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const { previewDerived, compileSystem } = require_('../systemBuilder/derived'); +const { elevatedUsers } = require_('../middleware/auth'); +const systemsRoute = require_('../routes/systems.js'); + +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); +const EDITOR = jwt.sign({ username: 'ghost', isTemporary: true }, 'test-secret'); +const PLAYER = jwt.sign({ username: 'vex', role: 'player' }, 'test-secret'); + +const LOOKUPS = { mod: { bands: [{ upTo: 3, value: -2 }, { upTo: 7, value: -1 }, { upTo: 13, value: 0 }, { upTo: 17, value: 1 }, { value: 2 }] } }; +const DERIVED = [ + { id: 'str_mod', formula: 'mod(@str)' }, + { id: 'con_mod', formula: 'mod(@con)' }, + { id: 'save_physical', label: 'Physical save', formula: '16 - (@level + max(@str_mod, @con_mod))' }, +]; +const SAMPLE = { str: 16, con: 12, level: 3 }; + +describe('working values out while writing', () => { + it('gives every value when nothing is wrong, exactly as the engine does', () => { + const r = previewDerived({ lookups: LOOKUPS, derived: DERIVED }, SAMPLE); + expect(r).toEqual({ values: { str_mod: 1, con_mod: 0, save_physical: 12 }, problems: [] }); + expect(r.values).toEqual(compileSystem({ lookups: LOOKUPS, derived: DERIVED }).system.evaluate(SAMPLE)); + }); + + it('leaves out an unfinished formula and what reads it, keeping the rest', () => { + const derived = [...DERIVED.slice(0, 2), { id: 'dex_mod', formula: 'mod(@dex' }, { id: 'evasion', formula: '16 - @dex_mod' }, + { id: 'twice', formula: '@evasion * 2' }, { id: 'save_physical', formula: DERIVED[2].formula }]; + const r = previewDerived({ lookups: LOOKUPS, derived }, SAMPLE); + expect(r.values).toEqual({ str_mod: 1, con_mod: 0, save_physical: 12 }); + expect(r.problems.map((p) => p.where)).toEqual(['derived dex_mod, formula']); + }); + + it('leaves out a loop, saying its path, and keeps the rest', () => { + const derived = [{ id: 'a', formula: '@b + 1' }, { id: 'b', formula: '@a + 1' }, { id: 'c', formula: '@str * 2' }]; + const r = previewDerived({ derived }, SAMPLE); + expect(r.values).toEqual({ c: 32 }); + expect(r.problems[0].message).toMatch(/^Depends on itself: /); + }); + + it('does not mistake a longer name for the broken one', () => { + const derived = [{ id: 'mod', formula: 'oops(' }, { id: 'mod_total', formula: '@str + 1' }, { id: 'uses', formula: '@mod_total + 1' }]; + expect(previewDerived({ derived }, SAMPLE).values).toEqual({ mod_total: 17, uses: 18 }); + }); + + it('a condition reading a broken value is left out too', () => { + const derived = [{ id: 'bad', formula: '(' }, { id: 'hurt', kind: 'condition', when: '@bad > 1', then: '1', else: '0' }, { id: 'ok', formula: '2' }]; + expect(previewDerived({ derived }, SAMPLE).values).toEqual({ ok: 2 }); + }); + + it('never takes a nameless broken entry for a formula called "value"', () => { + const derived = [{ id: 'value', formula: '2' }, { formula: '1' }]; + const r = previewDerived({ derived }, SAMPLE); + expect(r.problems.map((p) => p.where)).toEqual(['derived value 2']); + // The nameless entry is dropped, since nothing can read it; "value" is fine and is kept. + expect(r.values).toEqual({ value: 2 }); + }); + + it('gives nothing it can\'t pin to one formula, but still the problems', () => { + const r = previewDerived({ lookups: { mod: { bands: [] } }, derived: DERIVED }, SAMPLE); + expect(r.values).toEqual({}); + expect(r.problems.length).toBeGreaterThan(0); + }); + + it('reads stats the sample doesn\'t have as 0, as the game does', () => { + expect(previewDerived({ derived: [{ id: 'x', formula: '@missing + 5' }] }, {}).values).toEqual({ x: 5 }); + }); +}); + +describe('the route', () => { + let app; + beforeEach(async () => { + elevatedUsers.add('ghost'); + const db = await makeTestDb(); + app = express(); + app.use(express.json()); + app.use('/api/systems', systemsRoute(db)); + }); + afterEach(() => elevatedUsers.delete('ghost')); + const post = (definition, token = GM) => request(app).post('/api/systems/preview-values').set({ Authorization: `Bearer ${token}` }).send({ definition }); + + it('works out a draft from its own sample character', async () => { + const res = await post({ format: 1, name: 'Hearth', samples: SAMPLE, lookups: LOOKUPS, derived: DERIVED }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ values: { str_mod: 1, con_mod: 0, save_physical: 12 }, problems: [] }); + }); + + it('uses no sample when the draft has none, or one that isn\'t a set', async () => { + expect((await post({ format: 1, name: 'H', derived: [{ id: 'x', formula: '@str + 1' }] })).body.values).toEqual({ x: 1 }); + expect((await post({ format: 1, name: 'H', samples: [5], derived: [{ id: 'x', formula: '@str + 1' }] })).body.values).toEqual({ x: 1 }); + }); + + it('refuses what isn\'t a definition at all', async () => { + const res = await post('nope'); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'A system definition must be an object' }); + }); + + it('is the main admin\'s alone', async () => { + expect((await post({ format: 1, name: 'H' }, EDITOR)).status).toBe(403); + expect((await post({ format: 1, name: 'H' }, PLAYER)).status).toBe(403); + expect((await request(app).post('/api/systems/preview-values').send({ definition: {} })).status).toBe(401); + }); +}); diff --git a/backend/routes/systems.js b/backend/routes/systems.js index c58a6b1f..993a5cf5 100644 --- a/backend/routes/systems.js +++ b/backend/routes/systems.js @@ -7,6 +7,8 @@ const { authenticate, requireMainAdmin } = require('../middleware/auth'); const { LIMITS, rejectFormat, uploadErrors } = require('../middleware/uploadConstraints'); const store = require('../systemBuilder/store'); const runtime = require('../systemBuilder/runtime'); +const { checkDefinition } = require('../systemBuilder/definition'); +const { previewDerived } = require('../systemBuilder/derived'); /** What a currency's icon may be uploaded as (decided with the user, 2026-10-01). */ const ICON_EXT = new Set(['.png', '.webp', '.svg']); @@ -137,6 +139,18 @@ module.exports = (db, io = null) => { }); }); + /** + * The builder's live values (4b2b): a draft's derived values worked out from its sample character, + * leaving out what can't be worked out yet, with the formulas' problems. Changes nothing. + */ + router.post('/preview-values', gm, (req, res) => { + const definition = req.body && req.body.definition; + const checked = checkDefinition(definition); + if (checked.fatal) return res.status(400).json({ error: checked.fatal }); + const samples = definition.samples && typeof definition.samples === 'object' && !Array.isArray(definition.samples) ? definition.samples : {}; + res.json(previewDerived({ lookups: definition.lookups, derived: definition.derived }, samples)); + }); + // What installing a file would do, changing nothing. The file arrives as text so its size is // checked before it is parsed. router.post('/install/preview', gm, (req, res) => { diff --git a/backend/systemBuilder/derived.js b/backend/systemBuilder/derived.js index 168673b9..aa4cd7d2 100644 --- a/backend/systemBuilder/derived.js +++ b/backend/systemBuilder/derived.js @@ -224,4 +224,42 @@ const compileSystem = (definition) => { }; }; -module.exports = { compileSystem, LIMITS }; +/** The id a problem is about, when it is one derived value's: "derived save, formula" → "save". */ +const problemId = (p) => { + const m = /^derived ([a-z][a-z0-9_]*)(,|$)/.exec(p.where || ''); + return m ? m[1] : null; +}; + +/** + * Every derived value the builder can show while a GM is still writing them (4b2b): worked out from + * `data` (the sample character), leaving out each value with a problem and every value that reads + * one, so one unfinished formula doesn't blank the rest. Returns { values, problems }, the problems + * all of compileSystem's. The running game never uses this: it runs only a published, whole system. + */ +const previewDerived = ({ lookups, derived }, data) => { + const all = Array.isArray(derived) ? derived : []; + const first = compileSystem({ lookups, derived: all }); + if (first.ok) return { values: first.system.evaluate(data), problems: [] }; + let kept = all; + let compiled = first; + // Each round drops what failed and what reads it; bounded, since each drops at least one. + for (let round = 0; round <= all.length && !compiled.ok; round += 1) { + const out = new Set(compiled.problems.map(problemId).filter(Boolean)); + let grew = true; + while (grew) { + grew = false; + for (const d of kept) { + if (!d || out.has(d.id)) continue; + const text = [d.formula, d.when, d.then, d.else].filter((s) => typeof s === 'string').join(' '); + if ([...out].some((id) => new RegExp(`@${id}(?![a-z0-9_])`).test(text))) { out.add(d.id); grew = true; } + } + } + const next = kept.filter((d) => d && typeof d.id === 'string' && !out.has(d.id)); + if (next.length === kept.length) break; + kept = next; + compiled = compileSystem({ lookups, derived: kept }); + } + return { values: compiled.ok ? compiled.system.evaluate(data) : {}, problems: first.problems }; +}; + +module.exports = { compileSystem, previewDerived, LIMITS };