From d41a60afbe82bbce66b9a20a9d3497c76d9ad6e5 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 29 Sep 2026 18:11:48 -0500 Subject: [PATCH] feat(system builder 2d): a published custom system runs the game, its sheet drawn from its definition Definition format gains `sheet` (systemBuilder/sheet.js): tabs, header and sections of text/number/textarea/select fields with visibility, combat sensitivity, max pairs and token/bank links, checked like the rest; a system with no sheet gets a starter one. systemBuilder/runtime.js loads PUBLISHED systems (never drafts) into memory, compiled into the meta the built-in templates carry, and sheets/templates.js reaches it through a hook (a require would be circular). Built-ins are always found first, so they cannot change. Publish and delete reload it; the picker lists published systems and can switch to them; GET /api/systems/render/:id gives browsers layout and words, never formulas. Frontend: sheets/customTemplates.ts turns the render copy into a SheetTemplate for the ordinary SheetRenderer; getTemplate answers for custom systems; a hook redraws when one arrives. Tests mutation-checked (render leak, drafts, reload, writable links, fetch dedupe). Verified end to end on a throwaway secure-mode server: create, publish, switch, a player's socket edit saved with derived values. --- CHANGELOG.md | 5 + README.md | 7 +- .../__tests__/system_builder_runtime.test.js | 229 ++++++++++++++++++ backend/routes/sheets.js | 4 +- backend/routes/systems.js | 22 +- backend/server.js | 2 + backend/sheets/templates.js | 27 ++- backend/systemBuilder/definition.js | 7 +- backend/systemBuilder/runtime.js | 89 +++++++ backend/systemBuilder/sheet.js | 174 +++++++++++++ frontend/src/App.tsx | 4 + frontend/src/SheetPage.tsx | 3 + frontend/src/hooks/useCustomTemplates.ts | 21 ++ .../sheets/__tests__/customTemplates.test.tsx | 127 ++++++++++ frontend/src/sheets/customTemplates.ts | 111 +++++++++ frontend/src/sheets/index.ts | 19 +- 16 files changed, 836 insertions(+), 15 deletions(-) create mode 100644 backend/__tests__/system_builder_runtime.test.js create mode 100644 backend/systemBuilder/runtime.js create mode 100644 backend/systemBuilder/sheet.js create mode 100644 frontend/src/hooks/useCustomTemplates.ts create mode 100644 frontend/src/sheets/__tests__/customTemplates.test.tsx create mode 100644 frontend/src/sheets/customTemplates.ts 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 —