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 @@ -53,6 +53,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **Formulas on CHOICES say what they do, and help you write them.** Under a modifier's amount, a
feature's USES and an option's REQUIRES, a line now says the formula in words with what it comes
to for the sample character: "+2 for each level · sample Level 3: +6", "needs Strength 13 or
more · the sample (Strength 16) meets it". A mistake shows in red before you publish, including a
name the system hasn't got: `@lvl` offers USE @level to fix it. The new formula button beside each
box opens SHAPE, which writes common ones for you (A NUMBER, PER LEVEL, PER CLASS LEVEL, HALF
LEVEL; AT LEAST and LEVEL AT LEAST on a requirement), and INSERT, the names the box can read, put
where your cursor is. The × that removes a modifier is now red.
- **Features that add to the token's maximum (7a5a3).** A feature whose modifier is on HP MAX (or
WOUNDS, under a wound count) now moves the token's real maximum, not just the number the sheet
names: a level 3 fighter picking Tough (`2 * @level`) goes from 20 to 26 of 26, the current
Expand Down
8 changes: 8 additions & 0 deletions README.md

Large diffs are not rendered by default.

120 changes: 120 additions & 0 deletions backend/__tests__/system_builder_formula_preview.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* The builder's formula box (formula box 1; approved mockup docs/mockups/formula-box.html,
* 2026-10-29): a modifier's amount, a requirement or a feature's uses read as the game reads it,
* worked out for the sample character with every level at 3, or its mistake said before PUBLISH:
* one that doesn't read, a name the system hasn't got (with the nearest real one), and an amount
* reading a formula.
*/

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 { previewFormula, previewFormulas, SAMPLE_LEVEL, MOST, nearest } = require_('../systemBuilder/formulaPreview');
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 DEF = {
format: 1,
name: 'Hearth',
stats: [{ id: 'abilities', label: 'ABILITIES', stats: [{ id: 'str', label: 'Strength' }, { id: 'con', label: 'Constitution' }] }],
samples: { str: 16, con: 14 },
lookups: { mod: { bands: [{ upTo: 9, value: -1 }, { upTo: 13, value: 0 }, { value: 2 }] } },
derived: [{ id: 'con_mod', label: 'CON modifier', formula: 'mod(@con)' }],
core: { health: { model: 'pool' } },
choices: [
{ id: 'classes', one: 'Class', many: 'Classes', levelled: true, options: [{ id: 'fighter', label: 'Fighter', features: [] }] },
],
};

describe('a formula read for the box', () => {
it('works out a level formula with every level at the sample level, saying each name it reads', () => {
expect(SAMPLE_LEVEL).toBe(3);
expect(previewFormula(DEF, '2 * @level', 'amount')).toEqual({ ok: true, value: 6, reads: [{ id: 'level', label: 'Level', value: 3 }] });
expect(previewFormula(DEF, '@fighter_level + @classes_level', 'amount').value).toBe(6);
});

it('reads the sample\'s stats, its formulas worked out, tables and functions', () => {
expect(previewFormula(DEF, '@str', 'amount')).toEqual({ ok: true, value: 16, reads: [{ id: 'str', label: 'Strength', value: 16 }] });
expect(previewFormula(DEF, '@con_mod + 1', 'uses')).toEqual({ ok: true, value: 3, reads: [{ id: 'con_mod', label: 'CON modifier', value: 2 }] });
expect(previewFormula(DEF, 'floor(@level / 2) + mod(@str)', 'amount').value).toBe(3);
expect(previewFormula(DEF, '@str >= 13', 'requires').value).toBe(1);
expect(previewFormula(DEF, '@con >= 15', 'requires').value).toBe(0);
});

it('says a name the system hasn\'t got, and the nearest real one when there is one', () => {
expect(previewFormula(DEF, '2 * @lvl', 'amount')).toEqual({ ok: false, problem: "@lvl isn't one of this system's names", name: 'lvl', suggest: 'level' });
expect(previewFormula(DEF, '@strenght', 'requires')).toEqual({ ok: false, problem: "@strenght isn't one of this system's names", name: 'strenght' });
expect(nearest('fightr_level', ['fighter_level', 'level'])).toBe('fighter_level');
// A tie goes to the first named.
expect(nearest('lvel', ['level', 'lvl'])).toBe('level');
});

it('refuses an amount reading a formula, as PUBLISH does, but not a requirement or uses', () => {
expect(previewFormula(DEF, '@con_mod', 'amount')).toEqual({ ok: false, problem: '@con_mod is a formula: an amount reads stats as typed and levels' });
expect(previewFormula(DEF, '@con_mod', 'requires').ok).toBe(true);
expect(previewFormula(DEF, '@con_mod', 'uses').ok).toBe(true);
});

it('lets an amount read a level even where a formula has the same id', () => {
const both = { ...DEF, derived: [...DEF.derived, { id: 'classes_level', formula: '1' }] };
expect(previewFormula(both, '@classes_level', 'amount')).toMatchObject({ ok: true, value: 3 });
});

it('says what doesn\'t read, and that an empty box is empty', () => {
expect(previewFormula(DEF, '2 * (@level', 'amount')).toMatchObject({ ok: false });
expect(previewFormula(DEF, '2 * (@level', 'amount').problem).not.toBe('Empty');
expect(previewFormula(DEF, ' ', 'amount')).toEqual({ ok: false, problem: 'Empty' });
expect(previewFormula(DEF, 'nope(1)', 'amount').ok).toBe(false);
});

it('has no levels without a levelled list', () => {
const flat = { ...DEF, choices: [] };
expect(previewFormula(flat, '@level', 'amount')).toMatchObject({ ok: false, problem: "@level isn't one of this system's names" });
});

it('reads at most MOST formulas a request, a kind it doesn\'t know as a requirement', () => {
const many = Array.from({ length: MOST + 5 }, () => ({ text: '1', kind: 'amount' }));
expect(previewFormulas(DEF, many)).toHaveLength(MOST);
expect(previewFormulas(DEF, [{ text: '@con_mod', kind: 'nonsense' }])[0].ok).toBe(true);
expect(previewFormulas(DEF, 'not a list')).toEqual([]);
});
});

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 = (body, token = GM) => request(app).post('/api/systems/preview-formulas').set({ Authorization: `Bearer ${token}` }).send(body);

it('reads each formula against the draft sent', async () => {
const res = await post({ definition: DEF, formulas: [{ text: '2 * @level', kind: 'amount' }, { text: '@lvl', kind: 'amount' }] });
expect(res.status).toBe(200);
expect(res.body.sampleLevel).toBe(3);
expect(res.body.results.map((r) => r.ok)).toEqual([true, false]);
expect(res.body.results[0].value).toBe(6);
});

it('refuses a draft it can\'t read at all', async () => {
expect((await post({ definition: 'nope', formulas: [] })).status).toBe(400);
});

it('is the main admin\'s alone', async () => {
expect((await post({ definition: DEF, formulas: [] }, EDITOR)).status).toBe(403);
expect((await request(app).post('/api/systems/preview-formulas').send({ definition: DEF })).status).toBe(401);
});
});
13 changes: 13 additions & 0 deletions backend/routes/systems.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const { buildCustomTemplate } = require('../sheets/pdfTemplate');
const runtime = require('../systemBuilder/runtime');
const { checkDefinition, NEW_SYSTEM_SEEN } = require('../systemBuilder/definition');
const { previewDerived } = require('../systemBuilder/derived');
const { previewFormulas, SAMPLE_LEVEL } = require('../systemBuilder/formulaPreview');
const tableRolls = require('../systemBuilder/tableRolls');
const { conditionsOf } = require('../systemBuilder/conditions');
const { diceOf } = require('../systemBuilder/diceFaces');
Expand Down Expand Up @@ -288,6 +289,18 @@ module.exports = (db, io = null) => {
res.json(previewDerived({ lookups: definition.lookups, derived: definition.derived }, samples));
});

/**
* The builder's formula box (formula box 1): up to 20 formulas, `{ text, kind }` each (a
* modifier's amount, a requirement, a feature's uses), each read against a draft as the game
* reads it: its mistake, or its value for the sample character with each name it reads.
*/
router.post('/preview-formulas', gm, (req, res) => {
const { definition, formulas } = req.body || {};
const checked = checkDefinition(definition);
if (checked.fatal) return res.status(400).json({ error: checked.fatal });
res.json({ results: previewFormulas(definition, formulas), sampleLevel: SAMPLE_LEVEL });
});

/**
* TEST ROLL (6b1a; approved mockup docs/mockups/builder-rolls.html): one of a draft's rolls, unsaved
* changes and all, made for its sample character (its samples, and its formulas worked out on
Expand Down
103 changes: 103 additions & 0 deletions backend/systemBuilder/formulaPreview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// One formula as the builder's formula box shows it (formula box 1; approved mockup
// docs/mockups/formula-box.html, 2026-10-29): read by the game's own parser, its mistake said
// before PUBLISH, or worked out for the sample character, with each name it reads and that name's
// value, so the box can say it in words (+2 for each level · sample Level 3: +6).
//
// Stricter than publishing, which only asks that a formula reads: a name the system hasn't got
// (@lvl) quietly counts as 0 at the table, so here it is a mistake, with the nearest real name. A
// modifier's amount reads the sheet as typed and the levels, never a formula (choices.js), so one
// reading a formula is the same mistake PUBLISH refuses.
//
// The sample character has stats only (stats.js `samples`); every level name reads SAMPLE_LEVEL,
// so a formula on levels shows something worth reading.

const { parse, evaluate, references } = require('./expression');
const { ruleValue } = require('./rules');
const { tablesOf, lookupIn, previewDerived } = require('./derived');
const { namesOf, levelNamesOf } = require('./choices');

/** What every level name reads for the sample character. */
const SAMPLE_LEVEL = 3;
/** What a formula box holds: a modifier's amount, an option's requirement, a feature's uses. */
const KINDS = new Set(['amount', 'requires', 'uses']);
/** The most formulas one request reads. */
const MOST = 20;

const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);

/** How many single-letter changes turn `a` into `b`. */
const distance = (a, b) => {
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
for (let i = 1; i <= a.length; i += 1) {
let diagonal = row[0];
row[0] = i;
for (let j = 1; j <= b.length; j += 1) {
const above = row[j];
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
diagonal = above;
}
}
return row[b.length];
};

/** The known name nearest `name`, two changes at most, or null. */
const nearest = (name, known) => {
let best = null;
let bestAt = 3;
for (const k of known) {
const d = distance(name, k);
if (d < bestAt) { best = k; bestAt = d; }
}
return best;
};

/** The sample character as formulas read it: its stats, its formulas worked out, every level at SAMPLE_LEVEL. */
const sampleOf = (definition) => {
const samples = isPlainObject(definition.samples) ? definition.samples : {};
const { values } = previewDerived({ lookups: definition.lookups, derived: definition.derived }, samples);
const levels = Object.fromEntries([...levelNamesOf(definition)].map((n) => [n, SAMPLE_LEVEL]));
return { ...samples, ...values, ...levels };
};

/**
* One formula read against `definition`: `{ ok: false, problem, suggest? }` when it can't be used,
* else `{ ok: true, value, reads }`, `reads` naming each @name it reads with its label and value.
*/
const previewFormula = (definition, text, kind, sample = sampleOf(definition)) => {
if (typeof text !== 'string' || !text.trim()) return { ok: false, problem: 'Empty' };
const tables = tablesOf(isPlainObject(definition.lookups) ? definition.lookups : undefined);
const isTable = (name) => own(tables, name);
let tree;
try { tree = parse(text, isTable); } catch (e) { return { ok: false, problem: e.message }; }
const names = namesOf(definition);
const fields = [...references(tree).fields];
const unknown = fields.find((n) => !names.has(n));
if (unknown) {
const suggest = nearest(unknown, names.keys());
return { ok: false, problem: `@${unknown} isn't one of this system's names`, name: unknown, ...(suggest ? { suggest } : {}) };
}
if (kind === 'amount') {
const formulas = new Set((Array.isArray(definition.derived) ? definition.derived : []).filter(isPlainObject).map((d) => d.id));
const levels = levelNamesOf(definition);
const formula = fields.find((n) => formulas.has(n) && !levels.has(n));
if (formula) return { ok: false, problem: `@${formula} is a formula: an amount reads stats as typed and levels` };
}
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
const value = evaluate(tree, {
field: (name) => num(sample[name]),
rule: (name) => ruleValue(name, sample),
table: (name, x) => lookupIn(isTable(name) ? tables[name] : [], x),
});
return { ok: true, value, reads: fields.map((id) => ({ id, label: names.get(id), value: num(sample[id]) })) };
};

/** Up to MOST formulas, `{ text, kind }` each, read against one sample: the box's request. */
const previewFormulas = (definition, formulas) => {
const sample = sampleOf(definition);
return (Array.isArray(formulas) ? formulas : []).slice(0, MOST).map((f) => previewFormula(
definition, isPlainObject(f) ? f.text : undefined, isPlainObject(f) && KINDS.has(f.kind) ? f.kind : 'requires', sample,
));
};

module.exports = { previewFormula, previewFormulas, SAMPLE_LEVEL, MOST, distance, nearest };
1 change: 1 addition & 0 deletions e2e/COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ panel), so a control that looks fine but does nothing is caught for all of them.
| A bonus named on the field it changes | a stat as typed and with it; a formula with it | `choices_numbers` | `sheet-choices` (10 → 11, Guard Stance +1) | **AT THE TABLE** |
| A rest refilling a feature's uses | the rest itself; one it counts as | `picking` | `sheet-choices` (a short rest) | **AT THE TABLE** (the rest itself) |
| A list filled from a file | IMPORT: new and changed options and features, empty cells and `-`, skipped lines, same-named features | `choices_import` | `builder-choices-import` (this list downloaded, changed with one wrong line, uploaded, warned of, applied; played on a sheet) | **AT THE TABLE** |
| Writing a formula | the formula box on REQUIRES, modifier amounts and USES: words and the sample's value, a mistake with its fix, SHAPE, INSERT | `formula_preview` | `builder-formula-box` | **BUILDER ONLY** (a builder aid: what it writes is played at the table by `sheet-choices` and `sheet-token-max`) |

## Sheets and NPCs

Expand Down
1 change: 1 addition & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ frontend suites.
| `tests/parts-off.spec.ts` | Parts switched off at the table (coverage family 3): a custom system with every part on shows the player BANK and HIT_POINTS on the rail, MAX HEALTH, ARMOR and SAVE_STATS in their character controls and IMPORT on their sheet, and the GM INITIATIVE_TRACKER, PAY_PLAYERS, GIVE_ITEM and the buy-back rate; a version with bank, shops, initiative, combat, token health, sheet import and GIVE ITEM off hides every one; back on, they return |
| `tests/sheet-nested-choices.spec.ts` | Lists inside lists, and a list everyone has (7a5a2): in the builder SPECIALTIES shows OPENED BY · SOLDIER: SPECIALTY (LV 3) and is set THROUGH FEATURES, COMMONS EVERYONE HAS IT, saved with no problems; published, a player's sheet has no SPECIALTIES block, COMMONS every character's with Improvement next at level 2 and then unlocked; SOLDIER at 3 offers PICK A SPECIALTY under its feature, DUELIST picked there with its own feature; lowered to 2 it waits, kept; back at 3 it counts again; as the server keeps it |
| `tests/sheet-token-max.spec.ts` | A feature on the token's maximum (7a5a3): a player with a 20 HP token, a FIGHTER at level 3, picks TOUGH (+2 HP per level) and sees 26 / 26 on HIT_POINTS, the bonus named on the sheet's HP and MAX HEALTH at 26; a level down, 24; the GM opens their sheet from VIEW_PLAYER_INFO and drops TOUGH, and the token is back at 20 |
| `tests/builder-formula-box.spec.ts` | The formula box (formula box 1): on Tough, `2 * @lvl` in red with USE @level, then +2 for each level · sample Level 3: +6 from the real server; PER CLASS LEVEL for FIGHTER at 3 and @con from INSERT at the cursor; AT LEAST on Fighter's REQUIRES (the sample meets it); the draft saved as written with no problems |
| `tests/sheet-visibility.spec.ts` | Who sees what on a customized sheet (coverage family 1): its owner fills in a public field, a private one and a combat value and can't change the GM's field; another player, looking them up from GLOBAL_CHAT's user list (VIEW_PLAYER_INFO), sees only the public lines, and the server sends nothing else; the GM opens the whole sheet the same way (OPEN_SHEET) and sets the GM's field, which the owner then sees |
| `tests/player-journey.spec.ts` | A custom system running (set up through the API), played in two browsers at once: a player makes a character and sees the system's words and its formula worked out; the GM types in a catalogue, gives the player something from it with a note, and calls a rest from the GAME tab; the player sees the gift arrive and the rest change their sheet |
| `tests/builder-smoke.spec.ts` | The GM signs in, opens the System Builder, meets and skips the first-time tour (reset first, so it always shows), and goes back to the map |
3 changes: 3 additions & 0 deletions e2e/tests/builder-clicks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ test('every control in the builder takes a click, on every page', async ({ page
await feature.getByRole('button', { name: '+ MODIFIER' }).click();
await feature.getByRole('combobox', { name: 'OPENS A LIST' }).selectOption({ label: 'NEW CHOICE' });
await expect(feature.getByRole('spinbutton', { name: 'HOW MANY' })).toBeVisible();
// The modifier's formula box with its help open, so SHAPE and INSERT are there (formula box 1).
await feature.getByRole('button', { name: 'Help writing Modifier 1: by' }).click();
await expect(feature.getByRole('group', { name: 'Help writing Modifier 1: by' })).toBeVisible();
}
missed[p] = await unreachable(page);
if (p === 'CHOICES') {
Expand Down
Loading
Loading