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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions backend/__tests__/system_builder_preview_values.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 additions & 0 deletions backend/routes/systems.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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) => {
Expand Down
40 changes: 39 additions & 1 deletion backend/systemBuilder/derived.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Loading