diff --git a/CHANGELOG.md b/CHANGELOG.md
index 519b06aa..f2d4a801 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Under the hood
+- **A published custom game system can run the game.** It appears in the game system picker
+ beside the built-in ones, players' sheets are drawn from its own layout by the same sheet
+ window, and its derived values are worked out on every save. Players' browsers receive the
+ sheet's layout and wording, never its formulas. There is no editor for building one yet.
+
- **Custom game systems can be stored.** Each one keeps a draft the GM edits and a published
copy a game would run. A draft can be saved half-built, but it cannot be published until
its problems are fixed, and the system a game is running cannot be deleted. Only the main
diff --git a/README.md b/README.md
index 0b290b6e..dfb598da 100644
--- a/README.md
+++ b/README.md
@@ -412,6 +412,8 @@ CITY_NET/
│ │ ├── rules.js # Code-backed rule values a formula can name as $name, for what arithmetic cannot read (installed armor mods, fitted chrome, adept powers). A GM picks from this list, never adds to it
│ │ ├── definitions.js # CWN's and Shadowrun's derived values restated as data, entry for entry in the order their functions write them
│ │ ├── definition.js # The system definition format (1: name, description, words, parts, lookups, derived) and its server-side checks. Fatal (cannot be stored: not an object, too large, not JSON) vs ordinary problems (saved in a draft, block publishing), all reported with where they are. Also the app's renamable terms and switchable parts, with wordFor / partOn
+│ │ ├── sheet.js # A custom system's character sheet as data (tabs, header, sections of text/number/textarea/select fields with visibility, combat sensitivity, max pairs and token/bank links) and its checks; a starter sheet for a system that has none
+│ │ ├── runtime.js # Published systems in memory for the running game: compiled once into the meta the built-in templates carry (public/combat/linked fields, max pairs, derived recompute), reached by sheets/templates.js through a hook; and the render copy the browser draws from, with no formulas
│ │ └── store.js # `custom_systems`: a draft the builder edits and the published copy a game runs. Ids are sys_ + hex, never a built-in id; publishing refuses a draft with problems; the running system cannot be deleted
│ ├── startup/
│ │ ├── backup.js # A whole copy of the database (VACUUM INTO, beside it) before a migration changes real data; skipped, and logged, when the disk lacks room
@@ -451,6 +453,7 @@ CITY_NET/
│ ├── system_builder_parity.test.js # CWN and Shadowrun as data against cwnRecompute and sr6Recompute over 3,000 seeded sheets each (blank, text, decimal, huge and stale values, broken JSON): same sheet, same changed fields, same order
│ ├── bank_accounts.test.js # Per-system accounts kept apart; the one-time move (every sheet's system plus the running one, the old table untouched, once only, all or nothing); the database copy and its disk-space check; switching systems in play; and db.js opening a 1.14.4-shaped database file in a child process
│ ├── token_vitals.test.js # Switching swaps and restores every token's health (enemies too, buildings untouched), entirely or not at all, and waits for the one-time start; the start's systems and run-once; map clears and loads; the system picker route and the settings route's guard; db.js on a real file
+│ ├── system_builder_runtime.test.js # The sheet format's checks and starter sheet; a published system known to the game (never a draft), answering the same helpers as the built-ins without changing them, its render copy free of formulas, switched to from the picker, and a player's edit recomputing its derived values
│ ├── system_builder_store.test.js # The definition checks (every problem at once, fatal vs ordinary, words and parts), and the routes: main admin only, drafts saved with problems but not published, the published copy untouched while the draft moves on, the running system not deletable
│ ├── system_builder_engine.test.js # The formula language (precedence, functions, 0 for NaN, and a list of script-shaped inputs it refuses), limits, and definitions: dependency order, lookups, conditions, rules, and every mistake reported at once
│ ├── npc_privacy.test.js # The map list and token card as anonymous, player and revoked-editor callers see them: no NPC sheet, no silhouetted face, even in the raw response text; the GM and a granted editor still get both
@@ -680,6 +683,7 @@ CITY_NET/
│ │ │ ├── useSocket.ts # Socket.IO connection and all event listeners
│ │ │ ├── useApi.ts # Fetch helpers
│ │ │ ├── useMapExport.ts # PNG/WebM city export — one cached off-screen renderer for the session, shared ortho camera, GPU size clamp, per-frame render loop for video, MediaRecorder with codec fallback; never touches the live camera
+│ │ │ ├── useCustomTemplates.ts # Redraws when a custom system's sheet template arrives, and fetches the running system's ahead of need
│ │ │ ├── useMapData.ts # Location/district/road/overpass/water body/sign data fetching. Sends the GM's sign-in with the location list, held in a ref so signing in does not give fetchLocations a new identity
│ │ │ ├── useCustomDice.ts # Custom dice state — fetches GM dice and the active system's built-ins, merges them (built-ins first, flagged `locked`), and applies `customDiceUpdated` broadcasts
│ │ │ ├── useEnemyVehicles.ts # The GM's enemy vehicles, and the tokens on the map level that could fill their seats. Asked for rather than pushed, and refused to anyone but the GM — so a player's client never holds enemy pools or armour at all, which is what keeps "what may players see" from being a question the feature has to answer
@@ -699,7 +703,8 @@ CITY_NET/
│ │ │ └── shopRules.ts # The overdraft house rule, the buy-back rate and how it resolves, and the words for every refusal. Mirrored from backend/shops and compared against it value for value, since the window quoting one price and the server paying another is the failure worth fearing
│ │ ├── sheets/
│ │ │ ├── types.ts # Sheet template type system (fields, sections, header, death saves, NPC tiers)
-│ │ │ ├── index.ts # Template registry, getMaxPairs, GATED_TABS/hiddenTabsFor (house-rule-gated sheet tabs)
+│ │ │ ├── index.ts # Template registry, getMaxPairs, GATED_TABS/hiddenTabsFor (house-rule-gated sheet tabs). getTemplate also answers for published custom systems
+│ │ │ ├── customTemplates.ts # Custom systems' sheets: the server's render copy turned into a SheetTemplate for the ordinary SheetRenderer (derived values read-only, only armor writing through to the token), fetched once and cached, with an event the app redraws on
│ │ │ ├── SheetPage.tsx # Standalone browser-tab sheet (?sheet=true); reads theme from auth handshake or localStorage; shares logic via usePlayerSheet
│ │ │ ├── vehiclePresets.ts # The CWN vehicle table (p.82) — picking a TYPE fills the stat block. Armour left unset on the * and ** vehicles: those are immunities the GM rules on, not numbers
│ │ │ ├── vehicleWeapons.ts # The ten weapons a hardpoint can carry (p.81). Damage stored as clean dice; the book's ! rides on the trauma value, since only marked weapons can traumatise a vehicle
diff --git a/backend/__tests__/system_builder_runtime.test.js b/backend/__tests__/system_builder_runtime.test.js
new file mode 100644
index 00000000..d217ef41
--- /dev/null
+++ b/backend/__tests__/system_builder_runtime.test.js
@@ -0,0 +1,229 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import express from 'express';
+import request from 'supertest';
+import jwt from 'jsonwebtoken';
+import { createRequire } from 'module';
+import { makeTestDb, get, run } from './helpers/testDb.js';
+import { untilValue, drain } from './helpers/until.js';
+
+/**
+ * A published custom system, running in the game.
+ *
+ * Its sheet is plain data drawn by the ordinary renderer; the server answers for it through the
+ * same helpers the built-in systems use - which fields are public, linked, paired - and
+ * recomputes its derived values on every save. The browser gets a render copy with no formulas.
+ */
+
+process.env.JWT_SECRET = 'test-secret';
+process.env.DICE_ANIM_MS = '0';
+const require_ = createRequire(import.meta.url);
+const { checkSheet, effectiveSheet } = require_('../systemBuilder/sheet');
+const { checkDefinition } = require_('../systemBuilder/definition');
+const runtime = require_('../systemBuilder/runtime');
+const templates = require_('../sheets/templates');
+const socketsFactory = require_('../sockets/index.js');
+
+const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret');
+const gm = { Authorization: `Bearer ${GM}` };
+
+/** A small but real system: two abilities, a lookup, derived values, and a sheet that uses them. */
+const VAULT = {
+ format: 1,
+ name: 'Vault Knights',
+ words: { hp: { singular: 'WOUND', plural: 'WOUNDS' } },
+ lookups: { mod: { bands: [{ upTo: 9, value: -1 }, { upTo: 13, value: 0 }, { value: 1 }] } },
+ derived: [
+ { id: 'might_mod', formula: 'mod(@might)' },
+ { id: 'guard', formula: '10 + @might_mod + @armor' },
+ ],
+ sheet: {
+ tabs: ['KNIGHT', 'NOTES'],
+ header: { nameField: 'name', hpField: 'wounds', hpMaxField: 'wounds_max', chips: [{ field: 'guard', label: 'GUARD' }] },
+ sections: [
+ { id: 'who', label: 'WHO', layout: 'list', tab: 'KNIGHT', fields: [
+ { id: 'name', label: 'Name', type: 'text', visibility: 'public' },
+ { id: 'order', label: 'Order', type: 'select', options: [{ value: 'dawn', label: 'Dawn' }, { value: 'dusk', label: 'Dusk' }] },
+ ] },
+ { id: 'stats', label: 'STATS', layout: 'grid', tab: 'KNIGHT', columns: 4, fields: [
+ { id: 'might', label: 'MIGHT', type: 'number' },
+ { id: 'might_mod', label: 'MOD', type: 'number' },
+ { id: 'armor', label: 'ARMOR', type: 'number', sensitivity: 'combat', source: 'token_ac' },
+ { id: 'guard', label: 'GUARD', type: 'number', sensitivity: 'combat' },
+ { id: 'wounds', label: 'WOUNDS', type: 'number', source: 'token_hp', maxField: 'wounds_max' },
+ { id: 'wounds_max', label: 'MAX', type: 'number', source: 'token_hp_max' },
+ ] },
+ { id: 'purse', label: 'PURSE', layout: 'list', tab: 'KNIGHT', fields: [{ id: 'gold', label: 'Gold', type: 'number', source: 'bank_balance' }] },
+ { id: 'notes', label: 'NOTES', layout: 'notes', tab: 'NOTES', fields: [{ id: 'notes', label: 'Notes', type: 'textarea' }] },
+ ],
+ },
+};
+
+const problemsOf = (sheet, derived = []) => {
+ const problems = [];
+ checkSheet(sheet, new Set(derived), problems);
+ return problems.map((p) => `${p.where}: ${p.message}`);
+};
+
+describe('the sheet format', () => {
+ it('accepts a real sheet', () => {
+ expect(checkDefinition(VAULT)).toEqual({ problems: [] });
+ });
+
+ it('gives a system with no sheet a starter one that passes its own checks and shows its derived values', () => {
+ const bare = { format: 1, name: 'Bare', derived: [{ id: 'speed', formula: '6' }] };
+ const sheet = effectiveSheet(bare);
+ expect(checkDefinition({ ...bare, sheet })).toEqual({ problems: [] });
+ const derived = sheet.sections.find((s) => s.id === 'derived');
+ expect(derived.fields.map((f) => f.id)).toEqual(['speed']);
+ expect(effectiveSheet(VAULT)).toBe(VAULT.sheet);
+ });
+
+ it('reports every mistake, with where it is', () => {
+ expect(problemsOf({
+ tabs: ['A'],
+ header: { nameField: 'nope', chips: [{ field: 'x' }] },
+ sections: [
+ { id: 'one', label: 'ONE', layout: 'cards', tab: 'B', columns: 9, fields: [
+ { id: 'a', label: 'A', type: 'dice' },
+ { id: 'a', label: 'Again', type: 'text' },
+ { id: 'b', label: 'B', type: 'text', options: [{ value: 'x', label: 'X' }] },
+ { id: 'c', label: 'C', type: 'select' },
+ { id: 'd', label: 'D', type: 'number', sensitivity: 'combat', visibility: 'public', maxField: 'zz' },
+ { id: 'e', label: 'E', type: 'number', source: 'token_mana' },
+ { id: 'f', label: 'F', type: 'number', source: 'token_hp' },
+ { id: 'g', label: 'G', type: 'number', onClick: 'x' },
+ ] },
+ { id: 'one', label: '', layout: 'list', fields: [] },
+ ],
+ }, ['f'])).toEqual([
+ 'sheet section one, layout: One of list, grid, notes, inventory',
+ 'sheet section one, tab: Not one of the sheet\'s tabs',
+ 'sheet section one, columns: A whole number from 1 to 8',
+ 'sheet field a, type: One of number, text, textarea, select',
+ 'sheet field a: Defined twice',
+ 'sheet field b, options: Only a select field has options',
+ 'sheet field c, options: A select field needs options',
+ 'sheet field d: A combat value is never public',
+ 'sheet field e, source: One of token_hp, token_hp_max, token_ac, bank_balance',
+ 'sheet field f: Cannot be both a derived value and a linked one',
+ 'sheet field g, onClick: Not part of a field',
+ 'sheet section one: Defined twice',
+ 'sheet section one, label: Cannot be blank',
+ 'sheet field d, maxField: Not a field on this sheet',
+ 'sheet header nameField: Not a field on this sheet',
+ 'sheet header chip 1: Must name a field on this sheet',
+ ]);
+ });
+
+ it('refuses a sheet that is not a layout', () => {
+ expect(problemsOf('columns')).toEqual(['sheet: Must be a layout']);
+ expect(problemsOf({ sections: 'x' })).toEqual(['sheet sections: Must be a list of sections']);
+ });
+});
+
+describe('a published system, in the running game', () => {
+ let db;
+ let app;
+ let id;
+ const emitted = [];
+
+ beforeEach(async () => {
+ db = await makeTestDb();
+ await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`);
+ emitted.length = 0;
+ const io = { emit: (event, data) => emitted.push({ event, data }), to: () => ({ emit: () => {} }) };
+ app = express();
+ app.use(express.json({ limit: '2mb' }));
+ app.use('/api/systems', require_('../routes/systems.js')(db));
+ app.use('/api/sheets', require_('../routes/sheets.js')(db, io));
+ id = (await request(app).post('/api/systems').set(gm).send({ definition: VAULT })).body.id;
+ await new Promise((resolve) => runtime.load(db, resolve));
+ });
+ afterEach(() => vi.restoreAllMocks());
+
+ const publish = () => request(app).post(`/api/systems/${id}/publish`).set(gm);
+
+ it('is only known to the game once published', async () => {
+ expect(templates.isValidSystem(id)).toBe(false);
+ expect((await request(app).get(`/api/systems/render/${id}`)).status).toBe(404);
+ expect((await request(app).put('/api/sheets/system').set(gm).send({ system: id })).status).toBe(400);
+
+ await publish();
+ expect(templates.isValidSystem(id)).toBe(true);
+ expect((await request(app).get('/api/sheets/system')).body.systems).toContainEqual({ id, name: 'Vault Knights', custom: true });
+ });
+
+ it('answers the same questions the built-in systems do', async () => {
+ await publish();
+ expect(templates.getLinkedFields(id)).toEqual({ armor: 'token_ac', wounds: 'token_hp', wounds_max: 'token_hp_max', gold: 'bank_balance' });
+ expect(templates.getMaxPairs(id)).toEqual({ wounds_max: 'wounds' });
+ expect(templates.filterPublicData(id, { name: 'Sir Ash', might: 15, guard: 12, notes: 'secret' })).toEqual({ name: 'Sir Ash' });
+ const data = { might: 15, armor: 3, guard: 99 };
+ expect(templates.applyDerived(id, data)).toEqual(['might_mod', 'guard']);
+ expect(data).toMatchObject({ might_mod: 1, guard: 14 });
+ });
+
+ it('leaves the built-in systems exactly as they were', async () => {
+ const before = JSON.stringify(['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic']
+ .map((s) => [templates.getLinkedFields(s), templates.getMaxPairs(s)]));
+ await publish();
+ const after = JSON.stringify(['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic']
+ .map((s) => [templates.getLinkedFields(s), templates.getMaxPairs(s)]));
+ expect(after).toBe(before);
+ });
+
+ it('gives the browser its layout and words, never its formulas', async () => {
+ await publish();
+ const res = await request(app).get(`/api/systems/render/${id}`);
+ expect(res.status).toBe(200);
+ expect(res.body).toMatchObject({ id, name: 'Vault Knights', derived: ['might_mod', 'guard'], words: VAULT.words });
+ expect(res.body.sheet).toEqual(VAULT.sheet);
+ const text = JSON.stringify(res.body);
+ expect(text).not.toContain('@might');
+ expect(text).not.toContain('bands');
+ });
+
+ it('can be switched to from the system picker', async () => {
+ await publish();
+ const res = await request(app).put('/api/sheets/system').set(gm).send({ system: id });
+ expect(res.status).toBe(200);
+ expect((await get(db, `SELECT value FROM global_settings WHERE key = 'game_system'`)).value).toBe(id);
+ });
+
+ it('goes away from the game when deleted', async () => {
+ await publish();
+ expect((await request(app).delete(`/api/systems/${id}`).set(gm)).status).toBe(200);
+ expect(templates.isValidSystem(id)).toBe(false);
+ expect((await request(app).get(`/api/systems/render/${id}`)).status).toBe(404);
+ });
+
+ it('runs the published copy, not the draft being worked on', async () => {
+ await publish();
+ const draft = { ...VAULT, derived: [{ id: 'might_mod', formula: '100' }, { id: 'guard', formula: '0' }] };
+ await request(app).put(`/api/systems/${id}/draft`).set(gm).send({ definition: draft });
+ const data = { might: 15, armor: 3 };
+ templates.applyDerived(id, data);
+ expect(data.might_mod).toBe(1);
+ });
+
+ it("recomputes a player's derived values when they edit their sheet", async () => {
+ await publish();
+ await request(app).put('/api/sheets/system').set(gm).send({ system: id });
+ await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', ?, '{"might":10}', 0)`, [id]);
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ let connect;
+ socketsFactory({ on: (e, cb) => { if (e === 'connection') connect = cb; }, emit: () => {}, to: () => ({ emit: () => {} }) },
+ db, { elevatedUsers: new Set(), emitUpdate: vi.fn(), recordAction: vi.fn() });
+ const handlers = {};
+ connect({ id: `rt-${Math.random()}`, on: (e, fn) => { handlers[e] = fn; }, emit: () => {}, broadcast: { emit: () => {} }, use: () => {}, join: () => {}, disconnect: () => {} });
+ handlers.identify('GHOST');
+ await drain(db);
+ // Armor lives on the token (a linked field), so it is never in the saved data: the save
+ // works from what is stored, and a read lays the token's armor over it and recomputes.
+ handlers.updateSheetField({ fieldId: 'might', value: 16 });
+ const stored = await untilValue(
+ async () => JSON.parse((await get(db, 'SELECT data FROM character_sheets WHERE username = ? AND system = ?', ['GHOST', id])).data),
+ (d) => d.might === 16, { label: 'the saved sheet' });
+ expect(stored).toMatchObject({ might: 16, might_mod: 1, guard: 11 });
+ });
+});
diff --git a/backend/routes/sheets.js b/backend/routes/sheets.js
index 8558dcc3..96de85e4 100644
--- a/backend/routes/sheets.js
+++ b/backend/routes/sheets.js
@@ -8,6 +8,7 @@ const { authenticate, authenticatePlayer, optionalAuthenticate } = require('../m
const { canReadNpcSheets, redactTokenCard } = require('../sheets/npcPrivacy');
const bankAccounts = require('../bank/accounts');
const tokenVitals = require('../tokens/vitals');
+const customSystems = require('../systemBuilder/runtime');
const {
TEMPLATES, DEFAULT_SYSTEM, isValidSystem, getLinkedFields, applyDerived, cwnEffectiveAc,
TOKEN_SOURCES, rangedAcOf, acColumns,
@@ -65,7 +66,8 @@ module.exports = (db, io) => {
router.get('/system', (req, res) => {
getGameSystem((err, system) => {
if (err) return res.status(500).json({ error: err.message });
- res.json({ system, systems: Object.entries(TEMPLATES).map(([id, t]) => ({ id, name: t.name })) });
+ // Built-in systems, then published custom ones (systemBuilder/runtime.js).
+ res.json({ system, systems: [...Object.entries(TEMPLATES).map(([id, t]) => ({ id, name: t.name })), ...customSystems.list()] });
});
});
diff --git a/backend/routes/systems.js b/backend/routes/systems.js
index 17bce693..4fdab814 100644
--- a/backend/routes/systems.js
+++ b/backend/routes/systems.js
@@ -1,6 +1,7 @@
const express = require('express');
const { authenticate, requireMainAdmin } = require('../middleware/auth');
const store = require('../systemBuilder/store');
+const runtime = require('../systemBuilder/runtime');
// Custom game systems: the builder's storage (see systemBuilder/store.js).
//
@@ -35,12 +36,29 @@ module.exports = (db) => {
store.saveDraft(db, req.params.id, definition, (err, saved) => answer(res, err, saved));
});
+ // Publishing and deleting change what the game can run, so the running copy is reloaded.
router.post('/:id/publish', gm, (req, res) => {
- store.publishSystem(db, req.params.id, (err, done) => answer(res, err, done));
+ store.publishSystem(db, req.params.id, (err, done) => {
+ if (err) return answer(res, err);
+ runtime.refresh(db, req.params.id, () => answer(res, null, done));
+ });
});
router.delete('/:id', gm, (req, res) => {
- store.deleteSystem(db, req.params.id, (err) => answer(res, err, { deleted: true }));
+ store.deleteSystem(db, req.params.id, (err) => {
+ if (err) return answer(res, err);
+ runtime.refresh(db, req.params.id, () => answer(res, null, { deleted: true }));
+ });
+ });
+
+ /**
+ * What a published system's sheet is drawn from. Public: every player draws their own sheet.
+ * Layout and words only - never the formulas, lookups or rule names (systemBuilder/runtime.js).
+ */
+ router.get('/render/:id', (req, res) => {
+ const render = runtime.render(req.params.id);
+ if (!render) return res.status(404).json({ error: 'No such published system' });
+ res.json(render);
});
return router;
diff --git a/backend/server.js b/backend/server.js
index 3db3d2b2..ef25a35f 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -14,6 +14,8 @@ const path = require('path');
const http = require('http');
const { Server } = require('socket.io');
const db = require('./db');
+// Published custom game systems, into memory for the running game (systemBuilder/runtime.js).
+require('./systemBuilder/runtime').load(db);
const app = express();
const server = http.createServer(app);
diff --git a/backend/sheets/templates.js b/backend/sheets/templates.js
index 64f1228c..3650b3ab 100644
--- a/backend/sheets/templates.js
+++ b/backend/sheets/templates.js
@@ -325,11 +325,24 @@ const TEMPLATES = {
const DEFAULT_SYSTEM = 'generic';
-const isValidSystem = (system) => Object.prototype.hasOwnProperty.call(TEMPLATES, system);
+const isBuiltIn = (system) => Object.prototype.hasOwnProperty.call(TEMPLATES, system);
+
+/**
+ * Published custom systems (systemBuilder/runtime.js), looked up through a hook it sets:
+ * requiring it here would make a circle. Built-in systems are always found first, so nothing
+ * a custom system does can change how a built-in one behaves.
+ */
+let customMeta = () => null;
+const setCustomMeta = (fn) => { customMeta = typeof fn === 'function' ? fn : () => null; };
+
+/** A system's meta: built-in, published custom, or the default's for anything unknown. */
+const metaFor = (system) => (isBuiltIn(system) ? TEMPLATES[system] : customMeta(system) || TEMPLATES[DEFAULT_SYSTEM]);
+
+const isValidSystem = (system) => isBuiltIn(system) || !!customMeta(system);
// Strip a sheet's data down to what non-owners may see.
const filterPublicData = (system, data) => {
- const meta = TEMPLATES[system] || TEMPLATES[DEFAULT_SYSTEM];
+ const meta = metaFor(system);
const parsed = typeof data === 'string' ? JSON.parse(data || '{}') : (data || {});
const out = {};
meta.publicFields.forEach((f) => {
@@ -391,12 +404,10 @@ const acColumns = (linked, fields) => {
return sets.length ? { sets: sets.join(', '), values } : null;
};
-const getLinkedFields = (system) =>
- (TEMPLATES[system] || TEMPLATES[DEFAULT_SYSTEM]).linkedFields || {};
+const getLinkedFields = (system) => metaFor(system).linkedFields || {};
// Returns a map of maxFieldId → currentFieldId for the system.
-const getMaxPairs = (system) =>
- (TEMPLATES[system] || TEMPLATES[DEFAULT_SYSTEM]).maxPairs || {};
+const getMaxPairs = (system) => metaFor(system).maxPairs || {};
// Recompute derived fields after a write. Mutates data; returns the ids of
// fields it changed (empty when the changed field derives nothing).
@@ -405,7 +416,7 @@ const getMaxPairs = (system) =>
// - recompute: whole-sheet function for systems whose derived fields have
// multiple sources (CWN mods, saves, effort maxes)
const applyDerived = (system, data, changedFieldId) => {
- const meta = TEMPLATES[system] || TEMPLATES[DEFAULT_SYSTEM];
+ const meta = metaFor(system);
const changed = [];
const rule = (meta.derived || {})[changedFieldId];
if (rule) {
@@ -420,7 +431,7 @@ const applyDerived = (system, data, changedFieldId) => {
};
module.exports = {
- TEMPLATES, DEFAULT_SYSTEM, isValidSystem, filterPublicData, getLinkedFields, getMaxPairs,
+ TEMPLATES, DEFAULT_SYSTEM, isValidSystem, isBuiltIn, metaFor, setCustomMeta, filterPublicData, getLinkedFields, getMaxPairs,
applyDerived, cwnEffectiveAc, cwnImplantAc, cwnMoveBonus, CWN_BASE_MOVE,
TOKEN_SOURCES, rangedAcOf, acColumns,
};
diff --git a/backend/systemBuilder/definition.js b/backend/systemBuilder/definition.js
index b92f7e23..595f2609 100644
--- a/backend/systemBuilder/definition.js
+++ b/backend/systemBuilder/definition.js
@@ -13,6 +13,7 @@
// words: { hp: { singular: 'WOUND', plural: 'WOUNDS', short: 'W' }, ... }, // Layer 1
// parts: { vehicles: { on: false }, ... }, // Layer 2
// lookups: { ... }, derived: [ ... ], // Layer 3, the Phase 1 engine's format
+// sheet: { tabs, header, sections }, // the character sheet (sheet.js)
// }
//
// Problems come in two weights. A **fatal** one means the document cannot be stored at all:
@@ -25,6 +26,7 @@
// are upgraded on read rather than refused.
const { compileSystem } = require('./derived');
+const { checkSheet } = require('./sheet');
const FORMAT = 1;
@@ -64,7 +66,7 @@ const PARTS = [
'death', 'luck', 'xp', 'npc_tiers', 'sheet_import',
];
-const SECTIONS = new Set(['format', 'name', 'description', 'words', 'parts', 'lookups', 'derived']);
+const SECTIONS = new Set(['format', 'name', 'description', 'words', 'parts', 'lookups', 'derived', 'sheet']);
const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
@@ -148,6 +150,9 @@ const checkDefinition = (definition) => {
const compiled = compileSystem({ lookups: definition.lookups, derived: definition.derived ?? [] });
if (!compiled.ok) problems.push(...compiled.problems);
}
+ const derivedIds = new Set(Array.isArray(definition.derived)
+ ? definition.derived.filter((d) => d && typeof d.id === 'string').map((d) => d.id) : []);
+ checkSheet(definition.sheet, derivedIds, problems);
return { problems };
};
diff --git a/backend/systemBuilder/runtime.js b/backend/systemBuilder/runtime.js
new file mode 100644
index 00000000..e53e8d3b
--- /dev/null
+++ b/backend/systemBuilder/runtime.js
@@ -0,0 +1,89 @@
+// Published custom systems, held in memory for the running game.
+//
+// The game asks about a system many times a second - which fields are public, which live on
+// the token, how to recompute derived values on a save - through sheets/templates.js. Those
+// answers for a custom system come from here: each PUBLISHED definition (never a draft) is
+// compiled once, on load and whenever it is published or deleted, into the same shape the
+// built-in templates have. templates.js asks through a hook rather than requiring this file,
+// which would make a circle (templates -> here -> the engine -> rules -> templates).
+//
+// Also the render copy the browser draws a sheet from: layout and words, never formulas.
+
+const { compileSystem } = require('./derived');
+const { effectiveSheet, fieldsOf } = require('./sheet');
+const templates = require('../sheets/templates');
+
+/** id -> { name, definition, meta, render } */
+const loaded = new Map();
+
+const parse = (text) => { try { return JSON.parse(text); } catch { return null; } };
+
+/** The server-side meta the built-in templates carry, worked out from a definition. */
+const metaOf = (definition) => {
+ const sheet = effectiveSheet(definition);
+ const fields = fieldsOf(sheet);
+ const compiled = compileSystem({ lookups: definition.lookups, derived: definition.derived ?? [] });
+ const linkedFields = {};
+ const maxPairs = {};
+ for (const f of fields) {
+ if (f.source) linkedFields[f.id] = f.source;
+ if (f.maxField) maxPairs[f.maxField] = f.id;
+ }
+ return {
+ name: definition.name,
+ custom: true,
+ publicFields: fields.filter((f) => f.visibility === 'public' && f.sensitivity !== 'combat').map((f) => f.id),
+ combatFields: fields.filter((f) => f.sensitivity === 'combat').map((f) => f.id),
+ linkedFields,
+ maxPairs,
+ // Derived values on every save, as the built-in recompute functions do. A published
+ // definition compiles (publishing refuses one with problems); should one not, nothing is
+ // recomputed rather than a save failing.
+ recompute: compiled.ok ? (data) => compiled.system.apply(data) : () => [],
+ };
+};
+
+/** What the browser draws a sheet from: no formulas, lookups or rule names. */
+const renderOf = (id, definition) => ({
+ id,
+ name: definition.name,
+ words: definition.words || {},
+ parts: definition.parts || {},
+ derived: (Array.isArray(definition.derived) ? definition.derived : []).map((d) => d.id),
+ sheet: effectiveSheet(definition),
+});
+
+const put = (id, publishedText) => {
+ const definition = parse(publishedText);
+ if (!definition) { loaded.delete(id); return; }
+ loaded.set(id, { name: definition.name, definition, meta: metaOf(definition), render: renderOf(id, definition) });
+};
+
+/** Load every published system. cb(err, count). */
+const load = (db, cb = () => {}) => {
+ db.all('SELECT id, published FROM custom_systems WHERE published IS NOT NULL', [], (err, rows) => {
+ if (err) { console.error('[systems] Could not load custom systems:', err.message); return cb(err); }
+ loaded.clear();
+ for (const r of rows) put(r.id, r.published);
+ cb(null, loaded.size);
+ });
+};
+
+/** Reload one system after it is published or deleted. cb(err). */
+const refresh = (db, id, cb = () => {}) => {
+ db.get('SELECT published FROM custom_systems WHERE id = ?', [id], (err, row) => {
+ if (err) return cb(err);
+ if (row && row.published) put(id, row.published); else loaded.delete(id);
+ cb(null);
+ });
+};
+
+const meta = (id) => (loaded.has(id) ? loaded.get(id).meta : null);
+const render = (id) => (loaded.has(id) ? loaded.get(id).render : null);
+/** Every published system, for the picker. */
+const list = () => [...loaded.entries()].map(([id, s]) => ({ id, name: s.name, custom: true }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+templates.setCustomMeta(meta);
+
+module.exports = { load, refresh, meta, render, list, metaOf, renderOf };
diff --git a/backend/systemBuilder/sheet.js b/backend/systemBuilder/sheet.js
new file mode 100644
index 00000000..b5f34fe1
--- /dev/null
+++ b/backend/systemBuilder/sheet.js
@@ -0,0 +1,174 @@
+// A custom system's character sheet: its layout, as data.
+//
+// The built-in systems' sheets are TypeScript templates (frontend/src/sheets/templates) drawn by
+// SheetRenderer. A custom system's sheet is the same shape, cut down to what can be plain data -
+// sections of text, number and pick-one fields, with the flags the server needs (public or
+// private, combat-sensitive, current/max pairs, values that live on the token or in the bank).
+// The same renderer draws it.
+//
+// A field whose id matches one of the system's derived values shows that value, read-only: the
+// server recomputes it on every save. A system with no sheet yet gets a starter one (the
+// generic layout plus its derived values), so a published system can always be drawn.
+
+const NAME = /^[a-z][a-z0-9_]{0,63}$/;
+
+const LIMITS = { sections: 50, fields: 500, perSection: 100, tabs: 12, label: 60, hint: 300, options: 100 };
+const LAYOUTS = ['list', 'grid', 'notes', 'inventory'];
+const TYPES = ['number', 'text', 'textarea', 'select'];
+/** Values that live elsewhere and are overlaid at read time (sheets/templates.js linkedFields). */
+const SOURCES = ['token_hp', 'token_hp_max', 'token_ac', 'bank_balance'];
+
+const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
+
+const text = (value, where, max, problems, { required = false } = {}) => {
+ if (value === undefined || value === null) { if (required) problems.push({ where, message: 'Required' }); return; }
+ if (typeof value !== 'string') { problems.push({ where, message: 'Must be text' }); return; }
+ if (required && !value.trim()) problems.push({ where, message: 'Cannot be blank' });
+ if (value.length > max) problems.push({ where, message: `Longer than ${max} characters` });
+};
+
+const FIELD_KEYS = new Set(['id', 'label', 'type', 'visibility', 'sensitivity', 'maxField', 'hint', 'placeholder', 'unit', 'options', 'source']);
+const SECTION_KEYS = new Set(['id', 'label', 'layout', 'tab', 'columns', 'fields']);
+const HEADER_KEYS = new Set(['nameField', 'subtitleFields', 'hpField', 'hpMaxField', 'chips']);
+
+/** Check a sheet layout. `derivedIds` are the system's derived values, which fields may show. */
+const checkSheet = (sheet, derivedIds, problems) => {
+ if (sheet === undefined) return;
+ if (!isPlainObject(sheet)) { problems.push({ where: 'sheet', message: 'Must be a layout' }); return; }
+ for (const key of Object.keys(sheet)) {
+ if (!['tabs', 'header', 'sections'].includes(key)) problems.push({ where: `sheet ${key}`, message: 'Not part of a sheet' });
+ }
+
+ let tabs = null;
+ if (sheet.tabs !== undefined) {
+ if (!Array.isArray(sheet.tabs) || !sheet.tabs.length) problems.push({ where: 'sheet tabs', message: 'Must be a list of tab names' });
+ else {
+ if (sheet.tabs.length > LIMITS.tabs) problems.push({ where: 'sheet tabs', message: `More than ${LIMITS.tabs} tabs` });
+ sheet.tabs.forEach((t, i) => text(t, `sheet tab ${i + 1}`, 20, problems, { required: true }));
+ tabs = new Set(sheet.tabs);
+ }
+ }
+
+ const fieldIds = new Set();
+ const maxRefs = [];
+ if (!Array.isArray(sheet.sections)) {
+ problems.push({ where: 'sheet sections', message: 'Must be a list of sections' });
+ } else {
+ if (sheet.sections.length > LIMITS.sections) problems.push({ where: 'sheet sections', message: `More than ${LIMITS.sections} sections` });
+ const sectionIds = new Set();
+ let total = 0;
+ sheet.sections.slice(0, LIMITS.sections).forEach((section, si) => {
+ const sw = isPlainObject(section) && typeof section.id === 'string' ? `sheet section ${section.id}` : `sheet section ${si + 1}`;
+ if (!isPlainObject(section)) { problems.push({ where: sw, message: 'Must be a section' }); return; }
+ for (const key of Object.keys(section)) if (!SECTION_KEYS.has(key)) problems.push({ where: `${sw}, ${key}`, message: 'Not part of a section' });
+ if (typeof section.id !== 'string' || !NAME.test(section.id)) problems.push({ where: sw, message: 'Ids use lowercase letters, digits and _, starting with a letter' });
+ else if (sectionIds.has(section.id)) problems.push({ where: sw, message: 'Defined twice' });
+ else sectionIds.add(section.id);
+ text(section.label, `${sw}, label`, LIMITS.label, problems, { required: true });
+ if (!LAYOUTS.includes(section.layout)) problems.push({ where: `${sw}, layout`, message: `One of ${LAYOUTS.join(', ')}` });
+ if (section.tab !== undefined) {
+ text(section.tab, `${sw}, tab`, 20, problems, { required: true });
+ if (tabs && typeof section.tab === 'string' && !tabs.has(section.tab)) problems.push({ where: `${sw}, tab`, message: 'Not one of the sheet\'s tabs' });
+ }
+ if (section.columns !== undefined && !(Number.isInteger(section.columns) && section.columns >= 1 && section.columns <= 8)) {
+ problems.push({ where: `${sw}, columns`, message: 'A whole number from 1 to 8' });
+ }
+ if (!Array.isArray(section.fields)) { problems.push({ where: `${sw}, fields`, message: 'Must be a list of fields' }); return; }
+ if (section.fields.length > LIMITS.perSection) problems.push({ where: `${sw}, fields`, message: `More than ${LIMITS.perSection} fields` });
+ section.fields.slice(0, LIMITS.perSection).forEach((field, fi) => {
+ total += 1;
+ const fw = isPlainObject(field) && typeof field.id === 'string' ? `sheet field ${field.id}` : `${sw}, field ${fi + 1}`;
+ if (!isPlainObject(field)) { problems.push({ where: fw, message: 'Must be a field' }); return; }
+ for (const key of Object.keys(field)) if (!FIELD_KEYS.has(key)) problems.push({ where: `${fw}, ${key}`, message: 'Not part of a field' });
+ if (typeof field.id !== 'string' || !NAME.test(field.id)) problems.push({ where: fw, message: 'Ids use lowercase letters, digits and _, starting with a letter' });
+ else if (fieldIds.has(field.id)) problems.push({ where: fw, message: 'Defined twice' });
+ else fieldIds.add(field.id);
+ text(field.label, `${fw}, label`, LIMITS.label, problems, { required: true });
+ if (!TYPES.includes(field.type)) problems.push({ where: `${fw}, type`, message: `One of ${TYPES.join(', ')}` });
+ if (field.visibility !== undefined && !['public', 'private'].includes(field.visibility)) problems.push({ where: `${fw}, visibility`, message: 'public or private' });
+ if (field.sensitivity !== undefined && field.sensitivity !== 'combat') problems.push({ where: `${fw}, sensitivity`, message: 'Only "combat"' });
+ if (field.sensitivity === 'combat' && field.visibility === 'public') problems.push({ where: fw, message: 'A combat value is never public' });
+ text(field.hint, `${fw}, hint`, LIMITS.hint, problems);
+ text(field.placeholder, `${fw}, placeholder`, LIMITS.label, problems);
+ text(field.unit, `${fw}, unit`, 20, problems);
+ if (field.source !== undefined && !SOURCES.includes(field.source)) problems.push({ where: `${fw}, source`, message: `One of ${SOURCES.join(', ')}` });
+ if (field.source !== undefined && derivedIds.has(field.id)) problems.push({ where: fw, message: 'Cannot be both a derived value and a linked one' });
+ if (field.maxField !== undefined) maxRefs.push({ fw, ref: field.maxField });
+ if (field.options !== undefined || field.type === 'select') {
+ if (field.type !== 'select') problems.push({ where: `${fw}, options`, message: 'Only a select field has options' });
+ else if (!Array.isArray(field.options) || !field.options.length) problems.push({ where: `${fw}, options`, message: 'A select field needs options' });
+ else {
+ if (field.options.length > LIMITS.options) problems.push({ where: `${fw}, options`, message: `More than ${LIMITS.options} options` });
+ field.options.forEach((o, oi) => {
+ if (!isPlainObject(o)) { problems.push({ where: `${fw}, option ${oi + 1}`, message: 'Needs a value and a label' }); return; }
+ text(o.value, `${fw}, option ${oi + 1} value`, LIMITS.label, problems);
+ text(o.label, `${fw}, option ${oi + 1} label`, LIMITS.label, problems, { required: true });
+ });
+ }
+ }
+ });
+ });
+ if (total > LIMITS.fields) problems.push({ where: 'sheet', message: `More than ${LIMITS.fields} fields` });
+ }
+
+ const known = (id) => fieldIds.has(id) || derivedIds.has(id);
+ for (const { fw, ref } of maxRefs) {
+ if (typeof ref !== 'string' || !known(ref)) problems.push({ where: `${fw}, maxField`, message: 'Not a field on this sheet' });
+ }
+ if (sheet.header !== undefined) {
+ const h = sheet.header;
+ if (!isPlainObject(h)) { problems.push({ where: 'sheet header', message: 'Must be a header' }); return; }
+ for (const key of Object.keys(h)) if (!HEADER_KEYS.has(key)) problems.push({ where: `sheet header ${key}`, message: 'Not part of a header' });
+ for (const key of ['nameField', 'hpField', 'hpMaxField']) {
+ if (h[key] !== undefined && !known(h[key])) problems.push({ where: `sheet header ${key}`, message: 'Not a field on this sheet' });
+ }
+ if (h.subtitleFields !== undefined) {
+ if (!Array.isArray(h.subtitleFields)) problems.push({ where: 'sheet header subtitleFields', message: 'Must be a list of fields' });
+ else h.subtitleFields.forEach((f) => { if (!known(f)) problems.push({ where: 'sheet header subtitleFields', message: `${f} is not a field on this sheet` }); });
+ }
+ if (h.chips !== undefined) {
+ if (!Array.isArray(h.chips)) problems.push({ where: 'sheet header chips', message: 'Must be a list' });
+ else h.chips.forEach((c, ci) => {
+ if (!isPlainObject(c) || !known(c.field)) problems.push({ where: `sheet header chip ${ci + 1}`, message: 'Must name a field on this sheet' });
+ else text(c.label, `sheet header chip ${ci + 1}, label`, 20, problems, { required: true });
+ });
+ }
+ }
+};
+
+/**
+ * The sheet a system is drawn with: its own, or a starter one. The starter is the generic
+ * layout (name, concept, description, HP, cash, notes) with the system's derived values in a
+ * section of their own, so a system that has not designed its sheet yet still has one.
+ */
+const effectiveSheet = (definition) => {
+ if (definition && isPlainObject(definition.sheet)) return definition.sheet;
+ const derived = Array.isArray(definition && definition.derived) ? definition.derived.filter((d) => d && typeof d.id === 'string') : [];
+ const sections = [
+ { id: 'identity', label: 'IDENTITY', layout: 'list', tab: 'STATS', fields: [
+ { id: 'name', label: 'Name', type: 'text', visibility: 'public' },
+ { id: 'concept', label: 'Concept', type: 'text' },
+ { id: 'description', label: 'Description', type: 'textarea', visibility: 'public' },
+ ] },
+ { id: 'health', label: 'HEALTH', layout: 'grid', tab: 'STATS', columns: 2, fields: [
+ { id: 'hp', label: 'HP', type: 'number', source: 'token_hp', maxField: 'hp_max' },
+ { id: 'hp_max', label: 'HP MAX', type: 'number', source: 'token_hp_max' },
+ ] },
+ ];
+ 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' })) });
+ }
+ sections.push(
+ { id: 'inventory', label: 'INVENTORY', layout: 'inventory', tab: 'GEAR', fields: [] },
+ { id: 'money', label: 'MONEY', layout: 'list', tab: 'GEAR', fields: [{ id: 'cash', label: 'Cash', type: 'number', source: 'bank_balance' }] },
+ { id: 'notes', label: 'NOTES', layout: 'notes', tab: 'NOTES', fields: [{ id: 'notes', label: 'Notes', type: 'textarea' }] },
+ );
+ return { tabs: ['STATS', 'GEAR', 'NOTES'], header: { nameField: 'name', subtitleFields: ['concept'], hpField: 'hp', hpMaxField: 'hp_max' }, sections };
+};
+
+/** Every field on a sheet, in order. */
+const fieldsOf = (sheet) => (Array.isArray(sheet && sheet.sections) ? sheet.sections : [])
+ .flatMap((s) => (s && Array.isArray(s.fields) ? s.fields : []));
+
+module.exports = { checkSheet, effectiveSheet, fieldsOf, LAYOUTS, TYPES, SOURCES, LIMITS };
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 97f16e6d..bcd6c079 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -18,6 +18,7 @@ import type {
} from './types';
import rhombusIcon from './assets/rhombus.svg';
import { useMapData } from './hooks/useMapData';
+import { useCustomTemplates } from './hooks/useCustomTemplates';
import { useSocket } from './hooks/useSocket';
import { THEMES, ThemeContext } from './theme/themes';
import type { ThemeName } from './theme/themes';
@@ -241,6 +242,9 @@ function App() {
.then(d => { if (d?.system) setGameSystem(d.system); })
.catch(() => {});
}, []);
+ // A published custom system's sheet template, ready before a sheet opens, and a redraw when
+ // one arrives (sheets/customTemplates.ts).
+ useCustomTemplates(gameSystem);
// Custom dice: GM-authored (DB) plus any built-ins the active system ships.
const { customDice, applyDice, addDie, updateDie, deleteDie, error: customDiceError, setError: setCustomDiceError } = useCustomDice(token, gameSystem);
diff --git a/frontend/src/SheetPage.tsx b/frontend/src/SheetPage.tsx
index c4cb7548..e1490f43 100644
--- a/frontend/src/SheetPage.tsx
+++ b/frontend/src/SheetPage.tsx
@@ -3,6 +3,7 @@ import { io } from 'socket.io-client';
import './App.css';
import { SheetRenderer } from './components/SheetRenderer';
import { usePlayerSheet, uploadSheetPortrait } from './hooks/usePlayerSheet';
+import { useCustomTemplates } from './hooks/useCustomTemplates';
import { THEMES } from './theme/themes';
// Standalone character-sheet tab (?sheet=true). Gives the player a full
@@ -78,6 +79,8 @@ export default function SheetPage() {
const { sheet, template, handleFieldChange, handleFieldsChange, allowFumbleShield, xpRate, encumbranceEnforced, hiddenTabs, actions } =
usePlayerSheet(socket, userName);
+ // A custom system's sheet is drawn once its template arrives (sheets/customTemplates.ts).
+ useCustomTemplates(sheet?.system);
const handlePortraitUpload = useCallback(
(file: File) => uploadSheetPortrait(adminToken || playerToken, file),
diff --git a/frontend/src/hooks/useCustomTemplates.ts b/frontend/src/hooks/useCustomTemplates.ts
new file mode 100644
index 00000000..fae87cdb
--- /dev/null
+++ b/frontend/src/hooks/useCustomTemplates.ts
@@ -0,0 +1,21 @@
+import { useEffect, useReducer } from 'react';
+import { CUSTOM_TEMPLATE_EVENT, isCustomSystem, loadCustomTemplate } from '../sheets';
+
+/**
+ * Keep custom game systems' sheets drawable.
+ *
+ * getTemplate() answers from a cache, and a custom system's template is fetched the first time
+ * it is asked for (sheets/customTemplates.ts). This redraws the component using it when a
+ * template arrives, and fetches the running system's ahead of need so its sheets open ready.
+ */
+export function useCustomTemplates(runningSystem?: string | null) {
+ const [, redraw] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => {
+ const onLoaded = () => redraw();
+ window.addEventListener(CUSTOM_TEMPLATE_EVENT, onLoaded);
+ return () => window.removeEventListener(CUSTOM_TEMPLATE_EVENT, onLoaded);
+ }, []);
+ useEffect(() => {
+ if (isCustomSystem(runningSystem)) void loadCustomTemplate(runningSystem);
+ }, [runningSystem]);
+}
diff --git a/frontend/src/sheets/__tests__/customTemplates.test.tsx b/frontend/src/sheets/__tests__/customTemplates.test.tsx
new file mode 100644
index 00000000..619a0813
--- /dev/null
+++ b/frontend/src/sheets/__tests__/customTemplates.test.tsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { SheetRenderer } from '../../components/SheetRenderer';
+import {
+ templateFromRender, loadCustomTemplate, registerCustomTemplate, clearCustomTemplates,
+ isCustomSystem, CUSTOM_TEMPLATE_EVENT, type CustomRender,
+} from '../customTemplates';
+import { getTemplate } from '../index';
+
+/**
+ * A published custom system's sheet, drawn by the ordinary renderer.
+ *
+ * The server sends a render copy (layout and words, no formulas); it becomes a template that
+ * getTemplate() hands out like a built-in one. Until it has loaded, the generic template stands
+ * in, and the load tells the app to redraw.
+ */
+
+const ID = 'sys_0123456789abcdef';
+
+const VAULT: CustomRender = {
+ id: ID,
+ name: 'Vault Knights',
+ words: {},
+ parts: {},
+ derived: ['might_mod', 'guard'],
+ sheet: {
+ tabs: ['KNIGHT', 'NOTES'],
+ header: { nameField: 'name', hpField: 'wounds', hpMaxField: 'wounds_max' },
+ sections: [
+ { id: 'who', label: 'WHO', layout: 'list', tab: 'KNIGHT', fields: [
+ { id: 'name', label: 'Name', type: 'text', visibility: 'public' },
+ { id: 'order', label: 'Order', type: 'select', options: [{ value: 'dawn', label: 'Dawn' }, { value: 'dusk', label: 'Dusk' }] },
+ ] },
+ { id: 'stats', label: 'STATS', layout: 'grid', tab: 'KNIGHT', columns: 4, fields: [
+ { id: 'might', label: 'MIGHT', type: 'number' },
+ { id: 'might_mod', label: 'MOD', type: 'number' },
+ { id: 'armor', label: 'ARMOR', type: 'number', sensitivity: 'combat', source: 'token_ac' },
+ { id: 'gold', label: 'GOLD', type: 'number', source: 'bank_balance' },
+ ] },
+ { id: 'notes', label: 'NOTES', layout: 'notes', tab: 'NOTES', fields: [{ id: 'notes', label: 'Notes', type: 'textarea' }] },
+ ],
+ },
+};
+
+beforeEach(() => clearCustomTemplates());
+afterEach(() => vi.unstubAllGlobals());
+
+describe('the render copy as a template', () => {
+ it('keeps the layout, marks derived values read-only, and lets only armor write to the token', () => {
+ const t = templateFromRender(VAULT);
+ expect(t).toMatchObject({ id: ID, name: 'Vault Knights', tabs: ['KNIGHT', 'NOTES'] });
+ expect(t.header).toEqual({ nameField: 'name', hpField: 'wounds', hpMaxField: 'wounds_max' });
+ const fields = Object.fromEntries(t.sections.flatMap((s) => s.fields).map((f) => [f.id, f]));
+ expect(fields.might_mod.derived).toBe(true);
+ expect(fields.might.derived).toBeUndefined();
+ expect(fields.armor).toMatchObject({ source: 'token_ac', sourceWritable: true, sensitivity: 'combat' });
+ expect(fields.gold).toMatchObject({ source: 'bank_balance' });
+ expect(fields.gold.sourceWritable).toBeUndefined();
+ expect(fields.order.options).toHaveLength(2);
+ expect(t.sections.find((s) => s.id === 'stats')!.columns).toBe(4);
+ });
+
+ it('always has a name field in its header', () => {
+ const t = templateFromRender({ ...VAULT, sheet: { sections: [] } });
+ expect(t.header).toEqual({ nameField: 'name' });
+ });
+
+ it('knows a custom system by its id', () => {
+ expect(isCustomSystem(ID)).toBe(true);
+ for (const id of ['cities_without_number', 'generic', 'sys_123', undefined, null]) expect(isCustomSystem(id)).toBe(false);
+ });
+});
+
+describe('loading', () => {
+ it('stands the generic template in, fetches the custom one, then hands it out and says so', async () => {
+ const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(VAULT) } as Response));
+ vi.stubGlobal('fetch', fetchMock);
+ const loaded = vi.fn();
+ window.addEventListener(CUSTOM_TEMPLATE_EVENT, loaded);
+
+ expect(getTemplate(ID).id).toBe('generic');
+ expect(fetchMock).toHaveBeenCalledWith(`/api/systems/render/${ID}`);
+ await vi.waitFor(() => expect(loaded).toHaveBeenCalled());
+ expect(getTemplate(ID).name).toBe('Vault Knights');
+ window.removeEventListener(CUSTOM_TEMPLATE_EVENT, loaded);
+ });
+
+ it('asks the server once however many times it is asked for', async () => {
+ const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(VAULT) } as Response));
+ await Promise.all([loadCustomTemplate(ID, fetchMock), loadCustomTemplate(ID, fetchMock), loadCustomTemplate(ID, fetchMock)]);
+ await loadCustomTemplate(ID, fetchMock);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps nothing from a failed load, and tries again next time', async () => {
+ const failing = vi.fn(() => Promise.resolve({ ok: false } as Response));
+ expect(await loadCustomTemplate(ID, failing)).toBeNull();
+ const working = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(VAULT) } as Response));
+ expect((await loadCustomTemplate(ID, working))!.name).toBe('Vault Knights');
+ });
+
+ it('does not ask the server about a built-in system', async () => {
+ const fetchMock = vi.fn();
+ expect(await loadCustomTemplate('cities_without_number', fetchMock as never)).toBeNull();
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(getTemplate('cities_without_number').id).toBe('cities_without_number');
+ });
+});
+
+describe('drawn by the ordinary renderer', () => {
+ it("shows the system's own sections and fields, and edits a field", () => {
+ const template = registerCustomTemplate(VAULT);
+ const onFieldChange = vi.fn();
+ render();
+ // Section titles are drawn as "▾ ─── WHO ───".
+ expect(screen.getByText(/─── WHO ───/)).toBeTruthy();
+ expect(screen.getByText(/─── STATS ───/)).toBeTruthy();
+ expect(screen.getByText('MIGHT')).toBeTruthy();
+ // The system's own tabs.
+ expect(screen.getByText('KNIGHT')).toBeTruthy();
+ expect(screen.getByText('Dawn')).toBeTruthy();
+ expect(screen.getByDisplayValue('Sir Ash')).toBeTruthy();
+ fireEvent.change(screen.getByDisplayValue('15'), { target: { value: '16' } });
+ expect(onFieldChange).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/sheets/customTemplates.ts b/frontend/src/sheets/customTemplates.ts
new file mode 100644
index 00000000..4ce54173
--- /dev/null
+++ b/frontend/src/sheets/customTemplates.ts
@@ -0,0 +1,111 @@
+// Custom game systems' sheets, as templates the ordinary SheetRenderer draws.
+//
+// A published custom system comes from the server as a render copy (GET /api/systems/render/:id,
+// built by backend/systemBuilder/runtime.js): its layout and words, never its formulas. This turns
+// that into a SheetTemplate and keeps it, so getTemplate() answers for a custom system exactly as
+// it does for a built-in one. A template not loaded yet is fetched the first time it is asked for;
+// until it arrives the generic one stands in, and a window event tells the app to redraw.
+
+import type { SheetTemplate, SheetSection, SheetField, SheetFieldType, SheetLinkSource, SectionLayout } from './types';
+
+export interface CustomRenderField {
+ id: string;
+ label: string;
+ type: SheetFieldType;
+ visibility?: 'public' | 'private';
+ sensitivity?: 'combat';
+ maxField?: string;
+ hint?: string;
+ placeholder?: string;
+ unit?: string;
+ options?: { value: string; label: string }[];
+ source?: SheetLinkSource;
+}
+
+export interface CustomRender {
+ id: string;
+ name: string;
+ words: Record;
+ parts: Record;
+ derived: string[];
+ sheet: {
+ tabs?: string[];
+ header?: { nameField?: string; subtitleFields?: string[]; hpField?: string; hpMaxField?: string; chips?: { field: string; label: string }[] };
+ sections: { id: string; label: string; layout: SectionLayout; tab?: string; columns?: number; fields: CustomRenderField[] }[];
+ };
+}
+
+/** Fired on window when a custom template has been loaded, so the app can redraw. */
+export const CUSTOM_TEMPLATE_EVENT = 'citynet:custom-template-loaded';
+
+const cache = new Map();
+const pending = new Map>();
+
+/** Custom systems' ids: sys_ and sixteen hex digits (backend/systemBuilder/store.js). */
+export const isCustomSystem = (id: string | null | undefined): id is string =>
+ typeof id === 'string' && /^sys_[0-9a-f]{16}$/.test(id);
+
+/** A render copy as a template. Derived values read-only; only armor writes through to the token,
+ * as on the built-in sheets - HP and cash change on the token and in the bank. */
+export const templateFromRender = (render: CustomRender): SheetTemplate => {
+ const derived = new Set(render.derived || []);
+ const sections: SheetSection[] = (render.sheet?.sections || []).map((s) => ({
+ id: s.id,
+ label: s.label,
+ layout: s.layout,
+ ...(s.tab ? { tab: s.tab } : {}),
+ ...(s.columns ? { columns: s.columns } : {}),
+ fields: (s.fields || []).map((f): SheetField => ({
+ id: f.id,
+ label: f.label,
+ type: f.type,
+ ...(f.visibility ? { visibility: f.visibility } : {}),
+ ...(f.sensitivity ? { sensitivity: f.sensitivity } : {}),
+ ...(f.maxField ? { maxField: f.maxField } : {}),
+ ...(f.hint ? { hint: f.hint } : {}),
+ ...(f.placeholder ? { placeholder: f.placeholder } : {}),
+ ...(f.unit ? { unit: f.unit } : {}),
+ ...(f.options ? { options: f.options } : {}),
+ ...(f.source ? { source: f.source, ...(f.source === 'token_ac' ? { sourceWritable: true } : {}) } : {}),
+ ...(derived.has(f.id) ? { derived: true as const } : {}),
+ })),
+ }));
+ const header = render.sheet?.header;
+ return {
+ id: render.id,
+ name: render.name,
+ tabs: render.sheet?.tabs,
+ ...(header && header.nameField ? { header: { ...header, nameField: header.nameField } } : { header: { nameField: 'name' } }),
+ sections,
+ };
+};
+
+/** A loaded custom template, or undefined. */
+export const customTemplate = (id: string): SheetTemplate | undefined => cache.get(id);
+
+/** Put a template in place, e.g. from a render copy already in hand. */
+export const registerCustomTemplate = (render: CustomRender): SheetTemplate => {
+ const template = templateFromRender(render);
+ cache.set(render.id, template);
+ try { window.dispatchEvent(new CustomEvent(CUSTOM_TEMPLATE_EVENT, { detail: { id: render.id } })); } catch { /* no window in some tests */ }
+ return template;
+};
+
+/** Fetch a custom system's template once; later calls share the same request. */
+export const loadCustomTemplate = (id: string, fetcher: typeof fetch = fetch): Promise => {
+ if (!isCustomSystem(id)) return Promise.resolve(null);
+ const have = cache.get(id);
+ if (have) return Promise.resolve(have);
+ const inFlight = pending.get(id);
+ if (inFlight) return inFlight;
+ const request = fetcher(`/api/systems/render/${id}`)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((render: CustomRender | null) => (render ? registerCustomTemplate(render) : null))
+ .catch(() => null)
+ .finally(() => { pending.delete(id); });
+ pending.set(id, request);
+ return request;
+};
+
+/** For tests: forget every loaded template. */
+export const clearCustomTemplates = () => { cache.clear(); pending.clear(); };
diff --git a/frontend/src/sheets/index.ts b/frontend/src/sheets/index.ts
index c24b4609..4cc6654d 100644
--- a/frontend/src/sheets/index.ts
+++ b/frontend/src/sheets/index.ts
@@ -3,8 +3,10 @@ import { generic } from './templates/generic';
import { cyberpunkRed } from './templates/cyberpunk_red';
import { citiesWithoutNumber } from './templates/cities_without_number';
import { shadowrun6e } from './templates/shadowrun_6e';
+import { isCustomSystem, customTemplate, loadCustomTemplate } from './customTemplates';
export * from './types';
+export { isCustomSystem, loadCustomTemplate, CUSTOM_TEMPLATE_EVENT } from './customTemplates';
export const TEMPLATES: Record = {
generic,
@@ -13,8 +15,21 @@ export const TEMPLATES: Record = {
shadowrun_6e: shadowrun6e,
};
-export const getTemplate = (system: string): SheetTemplate =>
- TEMPLATES[system] ?? generic;
+/**
+ * A system's sheet template: built-in, or a published custom system's (customTemplates.ts). A
+ * custom one not loaded yet is fetched now and the generic template stands in until it
+ * arrives; the load fires an event the app redraws on.
+ */
+export const getTemplate = (system: string): SheetTemplate => {
+ const builtIn = TEMPLATES[system];
+ if (builtIn) return builtIn;
+ if (isCustomSystem(system)) {
+ const custom = customTemplate(system);
+ if (custom) return custom;
+ void loadCustomTemplate(system);
+ }
+ return generic;
+};
/** House-rule-gated sheet tabs, per system: tab name → the settings key
* that unlocks it. Adding a gated tab for a new system is one entry here —