From f52a68765ad9b274cd66b77b31b3c4d81d8d526c Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 29 Sep 2026 15:46:14 -0500 Subject: [PATCH] feat(system builder 2a): store custom systems, with the definition format and its checks custom_systems keeps a draft the builder edits and the published copy a game runs. Definition format 1 (name, description, words, parts, lookups, derived) is checked on the server: fatal problems (not an object, too large, not JSON) refuse storage; ordinary ones are saved in a draft and block publishing; all reported with where they are. Ids are sys_ + hex and never collide with a built-in id. GM-only routes at /api/systems (requireMainAdmin: granted editors are refused): list, create from a name or a definition, read, save draft, publish, delete (refused for the running system). Nothing in the game reads them yet (2d). Tests: definition checks, words and parts defaults, the routes' rules, and the auth walk now covers the systems routes; mutation-checked. --- CHANGELOG.md | 5 + README.md | 6 +- backend/__tests__/gm_route_auth.test.js | 3 +- backend/__tests__/helpers/testDb.js | 11 + .../__tests__/system_builder_store.test.js | 231 ++++++++++++++++++ backend/db.js | 13 + backend/middleware/auth.js | 11 +- backend/routes/systems.js | 47 ++++ backend/server.js | 1 + backend/systemBuilder/definition.js | 175 +++++++++++++ backend/systemBuilder/store.js | 140 +++++++++++ 11 files changed, 640 insertions(+), 3 deletions(-) create mode 100644 backend/__tests__/system_builder_store.test.js create mode 100644 backend/routes/systems.js create mode 100644 backend/systemBuilder/definition.js create mode 100644 backend/systemBuilder/store.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7010578d..04500110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **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 + admin can reach any of it, and nothing in the game uses these yet. + - **The first piece of the system builder.** An engine that works out a sheet's derived values (modifiers, saves, maximums) from a written description instead of code, with a safe formula language that can only do arithmetic. It is not switched on for anything: diff --git a/README.md b/README.md index a3e77efd..fbc5b1d2 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,7 @@ CITY_NET/ │ │ ├── signs.js # Custom sign CRUD (GET all / POST / PATCH :id / DELETE :id); text optional when image_url set; rotation_x/y/z persisted, non-finite angles rejected │ │ ├── fonts.js # Font file upload/list/delete (.ttf .otf .woff .woff2); served as static under /uploads/fonts/ │ │ ├── player.js # Player auth (register, login, forgot, reset, registration status poll) +│ │ ├── systems.js # Custom game systems: list, create (from a name or a whole definition), read, save a draft, publish, delete. Main admin only, reading included; delete refused for the running system │ │ └── sheets.js # Character sheets — admin sheet access, NPC library, portraits, LUCK/Edge reset & grant, import preview. The table-wide resets scan to decide who is affected and then work out each value as that sheet is written, rather than writing back a scan that has already gone stale │ ├── dice/ │ │ └── systemDice.js # Built-in dice manifest keyed by game system (ids namespaced `builtin:`); lives in code, not the DB, so app updates change definitions with no migration and nothing is mutable through the API @@ -405,7 +406,9 @@ CITY_NET/ │ │ ├── expression.js # The formula language, parsed and evaluated with no eval: numbers, @fields, $rules, a fixed list of functions and a system's lookup tables. Length, size and nesting capped; anything infinite or NaN comes out 0 │ │ ├── derived.js # Checks a definition (every problem at once, with where it is, loops shown as a path), orders values by what they read, and works them out. apply() keeps the contract of the hand-written recompute functions │ │ ├── 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 +│ │ ├── 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 +│ │ └── 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/ │ │ └── sanity_checks.js # In-memory DB checks on boot │ ├── utils/ @@ -439,6 +442,7 @@ CITY_NET/ │ ├── signs.test.js # Sign API (GET / POST / PATCH / DELETE, auth, image-only, filter_intensity clamping, XSS) │ ├── sheets.test.js # Sheet routes (system switch, admin access, portraits, derived fields, GET /own player self-fetch) │ ├── 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 +│ ├── 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 │ ├── npc_sheets.test.js # NPC library routes (CRUD, links, folders, LUCK reset, HP overlay) diff --git a/backend/__tests__/gm_route_auth.test.js b/backend/__tests__/gm_route_auth.test.js index 80e7858e..31efe5b8 100644 --- a/backend/__tests__/gm_route_auth.test.js +++ b/backend/__tests__/gm_route_auth.test.js @@ -113,6 +113,7 @@ const MOUNTS = [ ['/api', '../routes/admin.js', 'full'], ['/api/music', '../routes/music.js', 'io'], ['/api/sheets', '../routes/sheets.js', 'io'], + ['/api/systems', '../routes/systems.js', 'db'], ]; const helpers = { emitUpdate: () => {}, recordAction: () => {} }; @@ -124,7 +125,7 @@ const mountAll = (db) => { const routes = []; for (const [prefix, file, kind] of MOUNTS) { const factory = require_(file); - const router = kind === 'full' ? factory(db, io, helpers) : factory(db, io); + const router = kind === 'full' ? factory(db, io, helpers) : kind === 'db' ? factory(db) : factory(db, io); app.use(prefix, router); for (const layer of router.stack) { if (!layer.route) continue; diff --git a/backend/__tests__/helpers/testDb.js b/backend/__tests__/helpers/testDb.js index f1656715..85856973 100644 --- a/backend/__tests__/helpers/testDb.js +++ b/backend/__tests__/helpers/testDb.js @@ -223,6 +223,17 @@ function makeTestDb() { FOREIGN KEY(sheet_id) REFERENCES character_sheets(id) ON DELETE CASCADE )`); + db.run(`CREATE TABLE IF NOT EXISTS custom_systems ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + draft TEXT NOT NULL, + published TEXT, + version INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + published_at DATETIME + )`); + db.run(`CREATE TABLE sqlite_sequence (name TEXT, seq INTEGER)`, () => { // ignore error — it may already exist resolve(db); diff --git a/backend/__tests__/system_builder_store.test.js b/backend/__tests__/system_builder_store.test.js new file mode 100644 index 00000000..50ad8d66 --- /dev/null +++ b/backend/__tests__/system_builder_store.test.js @@ -0,0 +1,231 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { createRequire } from 'module'; +import { makeTestDb, get, run } from './helpers/testDb.js'; + +/** + * Custom game systems: the definition format, its checks, and storage. + * + * A definition is typed into the builder or installed from someone else's file, so it is + * checked on the server. A draft may hold problems - a system half-built is normal - but + * cannot be published until it has none, so a game never runs a broken system. Only the main + * admin reaches any of it. + */ + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const def = require_('../systemBuilder/definition'); +const store = require_('../systemBuilder/store'); +const { CITIES_WITHOUT_NUMBER } = require_('../systemBuilder/definitions'); +const { elevatedUsers } = require_('../middleware/auth'); +const systemsRoute = require_('../routes/systems.js'); + +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); +const EDITOR = jwt.sign({ username: 'ghost', isTemporary: true }, 'test-secret'); +const PLAYER = jwt.sign({ username: 'vex', role: 'player' }, 'test-secret'); +const bearer = (t) => ({ Authorization: `Bearer ${t}` }); + +const messages = (checked) => checked.problems.map((p) => `${p.where}: ${p.message}`); + +describe('the definition format', () => { + it('a name is enough to be valid', () => { + expect(def.checkDefinition({ format: 1, name: 'Vault Knights' })).toEqual({ problems: [] }); + expect(def.checkDefinition(def.blankDefinition(' Vault Knights '))).toEqual({ problems: [] }); + }); + + it('carries a whole built-in system as data without a problem', () => { + const cwn = { format: 1, name: 'CWN, as data', ...CITIES_WITHOUT_NUMBER }; + expect(def.checkDefinition(cwn)).toEqual({ problems: [] }); + }); + + it('refuses, as fatal, what cannot be stored at all', () => { + for (const bad of [null, 'text', 42, [], [{ name: 'x' }]]) { + expect(def.checkDefinition(bad).fatal, JSON.stringify(bad)).toBeTruthy(); + } + const huge = { format: 1, name: 'Big', description: 'x'.repeat(def.LIMITS.bytes) }; + expect(def.checkDefinition(huge).fatal).toMatch(/Larger than/); + }); + + it('reads files and bodies as text, refusing what is not JSON or too large', () => { + expect(def.parseDefinition('{"format":1,"name":"A"}')).toEqual({ ok: true, definition: { format: 1, name: 'A' } }); + expect(def.parseDefinition('{nope').fatal).toBe('Not valid JSON'); + expect(def.parseDefinition('x'.repeat(def.LIMITS.bytes + 1)).fatal).toMatch(/Larger than/); + expect(def.parseDefinition(null).fatal).toBe('Not text'); + }); + + it('reports every problem at once, each with where it is', () => { + const checked = def.checkDefinition({ + format: 2, + name: ' ', + description: 'd'.repeat(def.LIMITS.description + 1), + skills: [], + words: { hp: { singular: 'WOUNDS', feminine: 'X' }, mana: { singular: 'MANA' }, xp: 'GLORY', money: { short: '' } }, + parts: { vehicles: { on: false }, cyberware: { on: 'no' }, dragons: { on: true }, bank: { on: true, colour: 'red' } }, + derived: [{ id: 'a', formula: '@a + 1' }], + }); + expect(messages(checked)).toEqual([ + 'skills: Not a section this version knows', + 'format: This version reads format 1', + 'name: Cannot be blank', + `description: Longer than ${def.LIMITS.description} characters`, + 'words hp, feminine: Only singular, plural and short', + 'words mana: Not a term the app uses', + 'words xp: Must give singular, plural or short', + 'words money, short: Cannot be blank', + 'parts cyberware: Must say on: true or on: false', + 'parts dragons: Not a part of the app', + 'parts bank, colour: Only "on" is set here', + 'derived a: Depends on itself: a → a', + ]); + }); + + it('a missing name, or one that is not text, is a problem', () => { + expect(messages(def.checkDefinition({ format: 1 }))).toEqual(['name: Required']); + expect(messages(def.checkDefinition({ name: 7 }))).toEqual(['name: Must be text']); + }); + + it("names things in the system's own words, or the app's when it has none", () => { + const d = { words: { hp: { singular: 'WOUND', plural: 'WOUNDS' }, money: { short: 'GP' } } }; + expect(def.wordFor(d, 'hp')).toBe('WOUND'); + expect(def.wordFor(d, 'hp', 'plural')).toBe('WOUNDS'); + expect(def.wordFor(d, 'money', 'short')).toBe('GP'); + expect(def.wordFor(d, 'money')).toBe('CREDIT'); + expect(def.wordFor(d, 'class', 'short')).toBe('CLASS'); + expect(def.wordFor(null, 'level', 'short')).toBe('LVL'); + }); + + it('has every part on unless the system turns it off', () => { + const d = { parts: { vehicles: { on: false }, bank: { on: true } } }; + expect(def.partOn(d, 'vehicles')).toBe(false); + expect(def.partOn(d, 'bank')).toBe(true); + expect(def.partOn(d, 'shops')).toBe(true); + expect(def.partOn(undefined, 'cyberware')).toBe(true); + }); +}); + +describe('the systems routes', () => { + let db; + let app; + beforeEach(async () => { + db = await makeTestDb(); + app = express(); + app.use(express.json({ limit: '2mb' })); + app.use('/api/systems', systemsRoute(db)); + }); + afterEach(() => elevatedUsers.clear()); + + const create = (body) => request(app).post('/api/systems').set(bearer(GM)).send(body); + const list = async () => (await request(app).get('/api/systems').set(bearer(GM))).body; + + describe('who may use them', () => { + it('only the main admin: not a granted editor, not a player, not anyone', async () => { + elevatedUsers.add('ghost'); + for (const [who, headers, status] of [ + ['editor', bearer(EDITOR), 403], ['player', bearer(PLAYER), 403], ['nobody', {}, 401], + ]) { + const res = await request(app).get('/api/systems').set(headers); + expect(res.status, who).toBe(status); + } + expect((await request(app).get('/api/systems').set(bearer(GM))).status).toBe(200); + }); + }); + + it('creates a system from a name, with a stable id of its own', async () => { + const res = await create({ name: 'Vault Knights' }); + expect(res.status).toBe(200); + expect(res.body.id).toMatch(/^sys_[0-9a-f]{16}$/); + expect(res.body.problems).toEqual([]); + const [only] = await list(); + expect(only).toMatchObject({ id: res.body.id, name: 'Vault Knights', version: 0, published: false, unpublishedChanges: true }); + }); + + it('never collides with a built-in system id', () => { + for (const builtin of ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic']) { + expect(store.isCustomId(builtin)).toBe(false); + } + }); + + it('creates from a whole definition (an import), problems and all', async () => { + const res = await create({ definition: { format: 1, name: 'Imported', words: { mana: { singular: 'MANA' } } } }); + expect(res.status).toBe(200); + expect(res.body.problems).toEqual([{ where: 'words mana', message: 'Not a term the app uses' }]); + }); + + it('refuses to create what cannot be stored, or has no name', async () => { + expect((await create({ definition: ['not', 'a', 'system'] })).status).toBe(400); + expect((await create({ name: ' ' })).status).toBe(400); + expect((await create({})).status).toBe(400); + expect(await list()).toEqual([]); + }); + + it('saves a draft with problems, and will not publish it until they are fixed', async () => { + const { id } = (await create({ name: 'Draft' })).body; + const broken = { format: 1, name: 'Draft', derived: [{ id: 'hp', formula: '@hp + 1' }] }; + const saved = await request(app).put(`/api/systems/${id}/draft`).set(bearer(GM)).send({ definition: broken }); + expect(saved.status).toBe(200); + expect(saved.body.problems).toEqual([{ where: 'derived hp', message: 'Depends on itself: hp → hp' }]); + + const refused = await request(app).post(`/api/systems/${id}/publish`).set(bearer(GM)); + expect(refused.status).toBe(409); + expect(refused.body.problems).toHaveLength(1); + expect((await get(db, 'SELECT published, version FROM custom_systems WHERE id = ?', [id]))).toEqual({ published: null, version: 0 }); + + const fixed = { format: 1, name: 'Draft', derived: [{ id: 'hp', formula: '@con + 10' }] }; + await request(app).put(`/api/systems/${id}/draft`).set(bearer(GM)).send({ definition: fixed }); + const published = await request(app).post(`/api/systems/${id}/publish`).set(bearer(GM)); + expect(published.status).toBe(200); + expect(published.body).toEqual({ version: 1 }); + }); + + it('keeps the published copy as it was while the draft moves on', async () => { + const { id } = (await create({ name: 'Live' })).body; + await request(app).post(`/api/systems/${id}/publish`).set(bearer(GM)); + expect((await list())[0]).toMatchObject({ published: true, unpublishedChanges: false, version: 1 }); + + await request(app).put(`/api/systems/${id}/draft`).set(bearer(GM)) + .send({ definition: { format: 1, name: 'Live, renamed', parts: { vehicles: { on: false } } } }); + const sys = (await request(app).get(`/api/systems/${id}`).set(bearer(GM))).body; + expect(sys.name).toBe('Live, renamed'); + expect(sys.draft.parts).toEqual({ vehicles: { on: false } }); + expect(sys.published).toEqual({ format: 1, name: 'Live' }); + expect((await list())[0]).toMatchObject({ unpublishedChanges: true, version: 1 }); + + await request(app).post(`/api/systems/${id}/publish`).set(bearer(GM)); + const after = (await request(app).get(`/api/systems/${id}`).set(bearer(GM))).body; + expect(after.published.name).toBe('Live, renamed'); + expect(after.version).toBe(2); + }); + + it('refuses a draft that cannot be stored, leaving the old one', async () => { + const { id } = (await create({ name: 'Keep' })).body; + const res = await request(app).put(`/api/systems/${id}/draft`).set(bearer(GM)).send({ definition: 'gibberish' }); + expect(res.status).toBe(400); + expect((await request(app).get(`/api/systems/${id}`).set(bearer(GM))).body.draft).toEqual({ format: 1, name: 'Keep' }); + }); + + it('answers 404 for a system that is not there, or an id that is not one', async () => { + for (const id of ['sys_0000000000000000', 'cities_without_number', '1; DROP TABLE custom_systems']) { + const res = await request(app).get(`/api/systems/${encodeURIComponent(id)}`).set(bearer(GM)); + expect(res.status, id).toBe(404); + } + }); + + it('will not delete the system the game is running; deletes any other', async () => { + const { id } = (await create({ name: 'Running' })).body; + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [id]); + expect((await request(app).delete(`/api/systems/${id}`).set(bearer(GM))).status).toBe(409); + + await run(db, `UPDATE global_settings SET value = 'cities_without_number' WHERE key = 'game_system'`); + expect((await request(app).delete(`/api/systems/${id}`).set(bearer(GM))).status).toBe(200); + expect((await request(app).get(`/api/systems/${id}`).set(bearer(GM))).status).toBe(404); + }); + + it('lists the most recently changed first', async () => { + const a = (await create({ name: 'A' })).body.id; + const b = (await create({ name: 'B' })).body.id; + await run(db, `UPDATE custom_systems SET updated_at = '2020-01-01' WHERE id = ?`, [b]); + expect((await list()).map((s) => s.id)).toEqual([a, b]); + }); +}); diff --git a/backend/db.js b/backend/db.js index f68733a4..3ee40721 100644 --- a/backend/db.js +++ b/backend/db.js @@ -474,6 +474,19 @@ db.serialize(() => { )`); db.run(`ALTER TABLE initiative_scene ADD COLUMN sides TEXT NOT NULL DEFAULT '[]'`, () => {}); + // Game systems a GM built: a draft the builder edits and the published copy a game runs. + // See systemBuilder/store.js. Nothing in the running game reads it yet. + db.run(`CREATE TABLE IF NOT EXISTS custom_systems ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + draft TEXT NOT NULL, + published TEXT, + version INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + published_at DATETIME + )`); + // Migration: CP:R's name field was stored as 'handle'; it is now 'name' // (uniform across systems — the sheet is the source of truth for player // identity, see backend/sheets/identity.js). Copy handle → name once. diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 6aeb236b..b1814424 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -60,6 +60,15 @@ const authenticatePlayer = (req, res, next) => { return res.status(403).json({ error: 'Not allowed' }); }; +/** + * After `authenticate`: the GM's own login only, not a granted editor. For what is the GM's + * alone (building game systems). + */ +const requireMainAdmin = (req, res, next) => { + if (isMainAdmin(req.user)) return next(); + return res.status(403).json({ error: 'Only the main admin can do that' }); +}; + /** * Public routes that show the GM more: `req.user` is set only for someone `authenticate` * would accept. Anyone else, players included, is treated as anonymous. @@ -72,6 +81,6 @@ const optionalAuthenticate = (req, res, next) => { }; module.exports = { - authenticate, authenticatePlayer, optionalAuthenticate, elevatedUsers, + authenticate, authenticatePlayer, optionalAuthenticate, requireMainAdmin, elevatedUsers, isMainAdmin, isGrantedEditor, canEdit, isPlayer, }; diff --git a/backend/routes/systems.js b/backend/routes/systems.js new file mode 100644 index 00000000..17bce693 --- /dev/null +++ b/backend/routes/systems.js @@ -0,0 +1,47 @@ +const express = require('express'); +const { authenticate, requireMainAdmin } = require('../middleware/auth'); +const store = require('../systemBuilder/store'); + +// Custom game systems: the builder's storage (see systemBuilder/store.js). +// +// Main admin only, reading included. Building systems is the GM's (decided with the user, +// 2026-09-29), and a draft can hold the GM's unannounced rules. Granted editors pass +// `authenticate` but not `requireMainAdmin`. + +module.exports = (db) => { + const router = express.Router(); + // On every route rather than router.use, so the route walk in gm_route_auth.test.js sees them. + const gm = [authenticate, requireMainAdmin]; + + /** The status a store error carries, or 500. */ + const answer = (res, err, body) => { + if (!err) return res.json(body); + if (err.status) return res.status(err.status).json({ error: err.message, ...(err.problems ? { problems: err.problems } : {}) }); + console.error('[systems]', err.message); + return res.status(500).json({ error: 'Could not reach the systems store' }); + }; + + router.get('/', gm, (req, res) => store.listSystems(db, (err, systems) => answer(res, err, systems))); + + router.post('/', gm, (req, res) => { + const { name, definition } = req.body || {}; + store.createSystem(db, { name, definition }, (err, made) => answer(res, err, made)); + }); + + router.get('/:id', gm, (req, res) => store.getSystem(db, req.params.id, (err, sys) => answer(res, err, sys))); + + router.put('/:id/draft', gm, (req, res) => { + const { definition } = req.body || {}; + store.saveDraft(db, req.params.id, definition, (err, saved) => answer(res, err, saved)); + }); + + router.post('/:id/publish', gm, (req, res) => { + store.publishSystem(db, req.params.id, (err, done) => answer(res, err, done)); + }); + + router.delete('/:id', gm, (req, res) => { + store.deleteSystem(db, req.params.id, (err) => answer(res, err, { deleted: true })); + }); + + return router; +}; diff --git a/backend/server.js b/backend/server.js index 9b5ac207..3db3d2b2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -62,6 +62,7 @@ app.use('/api/player', require('./routes/player')(db, io)); app.use('/api', require('./routes/admin')(db, io, helpers)); app.use('/api/music', require('./routes/music')(db, io)); app.use('/api/sheets', require('./routes/sheets')(db, io)); +app.use('/api/systems', require('./routes/systems')(db)); // Frontend static serving const frontendDist = path.join(__dirname, '../frontend/dist'); diff --git a/backend/systemBuilder/definition.js b/backend/systemBuilder/definition.js new file mode 100644 index 00000000..b92f7e23 --- /dev/null +++ b/backend/systemBuilder/definition.js @@ -0,0 +1,175 @@ +// The system definition: what a custom game system is, as stored and as checked. +// +// A GM's system is one JSON document. This file says what a valid one looks like and checks +// a document against it, on the server, because a definition is typed into the builder or +// installed from someone else's file and is untrusted either way. See the plan +// (docs/system-builder-plan.md) for the whole format; this is the part that exists so far, +// format 1: +// +// { +// format: 1, +// name: 'Vault Knights', // what the system picker shows +// description: '...', // optional +// 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 +// } +// +// Problems come in two weights. A **fatal** one means the document cannot be stored at all: +// not an object, too large, or not JSON. Anything else is an ordinary problem: a draft is +// saved with it, since a system half-built in the editor is normal, but it cannot be +// published until the list is empty. Every problem says where it is, and all of them are +// reported at once, for the builder to show as a list. +// +// Later pieces add sections (skills, rolls, choices...) and raise FORMAT; older documents +// are upgraded on read rather than refused. + +const { compileSystem } = require('./derived'); + +const FORMAT = 1; + +const LIMITS = { + /** A whole definition, as JSON. Generous: a large system is a few hundred KB. */ + bytes: 512 * 1024, + name: 80, + description: 2000, + /** One glossary word. */ + word: 40, +}; + +/** + * The app's own words a system may rename (Layer 1). The key is the stable id the app looks + * up; the default is what shows when a system says nothing. + */ +const TERMS = { + character: { singular: 'CHARACTER', plural: 'CHARACTERS' }, + hp: { singular: 'HP', plural: 'HP', short: 'HP' }, + money: { singular: 'CREDIT', plural: 'CREDITS', short: 'CR' }, + level: { singular: 'LEVEL', plural: 'LEVELS', short: 'LVL' }, + xp: { singular: 'XP', plural: 'XP', short: 'XP' }, + class: { singular: 'CLASS', plural: 'CLASSES' }, + initiative: { singular: 'INITIATIVE', plural: 'INITIATIVE', short: 'INIT' }, + round: { singular: 'ROUND', plural: 'ROUNDS' }, + turn: { singular: 'TURN', plural: 'TURNS' }, + gm: { singular: 'GM', plural: 'GMS', short: 'GM' }, + shop: { singular: 'SHOP', plural: 'SHOPS' }, + bank: { singular: 'BANK', plural: 'BANKS' }, + vehicle: { singular: 'VEHICLE', plural: 'VEHICLES' }, +}; +const WORD_FORMS = ['singular', 'plural', 'short']; + +/** The parts of the app a system can turn off (Layer 2). All on unless a system says. */ +const PARTS = [ + 'bank', 'shops', 'vehicles', 'cyberware', 'initiative', 'combat', 'token_health', + 'death', 'luck', 'xp', 'npc_tiers', 'sheet_import', +]; + +const SECTIONS = new Set(['format', 'name', 'description', 'words', 'parts', 'lookups', 'derived']); + +const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); +const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); + +/** Parse text into a definition, or say why not. For files and request bodies alike. */ +const parseDefinition = (text) => { + if (typeof text !== 'string') return { ok: false, fatal: 'Not text' }; + if (Buffer.byteLength(text, 'utf8') > LIMITS.bytes) { + return { ok: false, fatal: `Larger than ${Math.round(LIMITS.bytes / 1024)} KB` }; + } + try { + return { ok: true, definition: JSON.parse(text) }; + } catch { + return { ok: false, fatal: 'Not valid JSON' }; + } +}; + +const checkText = (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 checkWords = (words, problems) => { + if (words === undefined) return; + if (!isPlainObject(words)) { problems.push({ where: 'words', message: 'Must be a set of terms' }); return; } + for (const [term, forms] of Object.entries(words)) { + const where = `words ${term}`; + if (!has(TERMS, term)) { problems.push({ where, message: 'Not a term the app uses' }); continue; } + if (!isPlainObject(forms)) { problems.push({ where, message: 'Must give singular, plural or short' }); continue; } + for (const [form, value] of Object.entries(forms)) { + if (!WORD_FORMS.includes(form)) { problems.push({ where: `${where}, ${form}`, message: 'Only singular, plural and short' }); continue; } + checkText(value, `${where}, ${form}`, LIMITS.word, problems, { required: true }); + } + } +}; + +const checkParts = (parts, problems) => { + if (parts === undefined) return; + if (!isPlainObject(parts)) { problems.push({ where: 'parts', message: 'Must be a set of parts' }); return; } + for (const [part, setting] of Object.entries(parts)) { + const where = `parts ${part}`; + if (!PARTS.includes(part)) { problems.push({ where, message: 'Not a part of the app' }); continue; } + if (!isPlainObject(setting) || typeof setting.on !== 'boolean') { + problems.push({ where, message: 'Must say on: true or on: false' }); + continue; + } + for (const key of Object.keys(setting)) { + if (key !== 'on') problems.push({ where: `${where}, ${key}`, message: 'Only "on" is set here' }); + } + } +}; + +/** + * Check a definition. Returns `{ fatal }` when it cannot be stored at all, otherwise + * `{ problems }` (empty when it can be published). + */ +const checkDefinition = (definition) => { + if (!isPlainObject(definition)) return { fatal: 'A system definition must be an object' }; + let size; + try { size = Buffer.byteLength(JSON.stringify(definition), 'utf8'); } catch { return { fatal: 'Cannot be stored as JSON' }; } + if (size > LIMITS.bytes) return { fatal: `Larger than ${Math.round(LIMITS.bytes / 1024)} KB` }; + + const problems = []; + for (const key of Object.keys(definition)) { + if (!SECTIONS.has(key)) problems.push({ where: key, message: 'Not a section this version knows' }); + } + if (definition.format !== undefined && definition.format !== FORMAT) { + problems.push({ where: 'format', message: `This version reads format ${FORMAT}` }); + } + checkText(definition.name, 'name', LIMITS.name, problems, { required: true }); + checkText(definition.description, 'description', LIMITS.description, problems); + checkWords(definition.words, problems); + checkParts(definition.parts, problems); + + if (definition.lookups !== undefined || definition.derived !== undefined) { + const compiled = compileSystem({ lookups: definition.lookups, derived: definition.derived ?? [] }); + if (!compiled.ok) problems.push(...compiled.problems); + } + return { problems }; +}; + +/** A new system's starting point: a name and nothing else. */ +const blankDefinition = (name) => ({ format: FORMAT, name: String(name || '').trim() }); + +/** What the app calls `term` in this system: the system's word, or the app's own. */ +const wordFor = (definition, term, form = 'singular') => { + const own = definition && isPlainObject(definition.words) && isPlainObject(definition.words[term]) + ? definition.words[term][form] : undefined; + if (typeof own === 'string' && own.trim()) return own; + const fallback = TERMS[term]; + return fallback ? (fallback[form] || fallback.singular) : term; +}; + +/** Is `part` on in this system? Everything is, unless the system turns it off. */ +const partOn = (definition, part) => { + const setting = definition && isPlainObject(definition.parts) ? definition.parts[part] : undefined; + return !(isPlainObject(setting) && setting.on === false); +}; + +module.exports = { + FORMAT, LIMITS, TERMS, PARTS, + parseDefinition, checkDefinition, blankDefinition, wordFor, partOn, +}; diff --git a/backend/systemBuilder/store.js b/backend/systemBuilder/store.js new file mode 100644 index 00000000..6bbfc8c3 --- /dev/null +++ b/backend/systemBuilder/store.js @@ -0,0 +1,140 @@ +// Custom game systems, stored. +// +// One row per system in `custom_systems`. Each keeps two copies of its definition: the +// **draft**, which the builder edits and saves as often as it likes, and the **published** +// copy, which is what a game runs. A draft can hold problems (a system half-built is normal); +// publishing refuses until it has none, so tinkering never breaks a game mid-session. +// +// Ids are `sys_` plus random hex and never change. The built-in systems' ids +// (cities_without_number, cyberpunk_red...) never start with `sys_`, so the two can never +// collide, and the active system - `game_system` in global_settings - can name either. +// +// Nothing in the running game reads these yet: loading a published system into the game is +// a later piece (2d). This is storage and its rules only. + +const crypto = require('crypto'); +const { checkDefinition, blankDefinition } = require('./definition'); + +const PREFIX = 'sys_'; + +const newId = () => PREFIX + crypto.randomBytes(8).toString('hex'); +const isCustomId = (id) => typeof id === 'string' && /^sys_[0-9a-f]{16}$/.test(id); + +const parse = (text) => { + if (text == null) return null; + try { return JSON.parse(text); } catch { return null; } +}; + +/** A stored error: `status` is what a route answers with. */ +const fail = (status, message, extra) => Object.assign(new Error(message), { status, ...(extra || {}) }); + +/** Every system, newest change first, without their definitions. */ +const listSystems = (db, cb) => { + db.all( + `SELECT id, name, version, draft, published, updated_at, published_at + FROM custom_systems ORDER BY updated_at DESC, name`, + [], + (err, rows) => { + if (err) return cb(err); + cb(null, rows.map((r) => ({ + id: r.id, + name: r.name, + version: r.version, + updatedAt: r.updated_at, + publishedAt: r.published_at, + published: r.published != null, + // Compared as stored text: the draft is written exactly as publishing copies it. + unpublishedChanges: r.published == null || r.draft !== r.published, + }))); + }, + ); +}; + +/** One system: both copies of its definition, and the draft's current problems. */ +const getSystem = (db, id, cb) => { + if (!isCustomId(id)) return cb(fail(404, 'No such system')); + db.get('SELECT * FROM custom_systems WHERE id = ?', [id], (err, row) => { + if (err) return cb(err); + if (!row) return cb(fail(404, 'No such system')); + const draft = parse(row.draft); + const checked = checkDefinition(draft); + cb(null, { + id: row.id, + name: row.name, + version: row.version, + updatedAt: row.updated_at, + publishedAt: row.published_at, + draft, + published: parse(row.published), + problems: checked.fatal ? [{ where: 'definition', message: checked.fatal }] : checked.problems, + }); + }); +}; + +/** + * Create a system from a name, or from a whole definition (an import, a copy). Refused only + * when the definition cannot be stored at all; its ordinary problems come back with it. + */ +const createSystem = (db, { name, definition } = {}, cb) => { + const def = definition === undefined ? blankDefinition(name) : definition; + const checked = checkDefinition(def); + if (checked.fatal) return cb(fail(400, checked.fatal)); + if (typeof def.name !== 'string' || !def.name.trim()) return cb(fail(400, 'A system needs a name')); + const id = newId(); + db.run( + `INSERT INTO custom_systems (id, name, draft, version, created_at, updated_at) + VALUES (?, ?, ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, + [id, def.name.trim(), JSON.stringify(def)], + (err) => (err ? cb(err) : cb(null, { id, problems: checked.problems })), + ); +}; + +/** Replace a system's draft. Stored even with problems, which come back to show. */ +const saveDraft = (db, id, definition, cb) => { + if (!isCustomId(id)) return cb(fail(404, 'No such system')); + const checked = checkDefinition(definition); + if (checked.fatal) return cb(fail(400, checked.fatal)); + const name = typeof definition.name === 'string' && definition.name.trim() ? definition.name.trim() : null; + db.run( + `UPDATE custom_systems SET draft = ?, name = COALESCE(?, name), updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [JSON.stringify(definition), name, id], + function (err) { + if (err) return cb(err); + if (this.changes === 0) return cb(fail(404, 'No such system')); + cb(null, { problems: checked.problems }); + }, + ); +}; + +/** Make the draft the version a game runs. Refused, with the list, while it has problems. */ +const publishSystem = (db, id, cb) => { + getSystem(db, id, (err, sys) => { + if (err) return cb(err); + if (sys.problems.length) return cb(fail(409, 'Fix these before publishing', { problems: sys.problems })); + const text = JSON.stringify(sys.draft); + db.run( + `UPDATE custom_systems SET published = ?, version = version + 1, published_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [text, id], + (err2) => (err2 ? cb(err2) : cb(null, { version: sys.version + 1 })), + ); + }); +}; + +/** Delete a system. Refused while it is the one the game runs. */ +const deleteSystem = (db, id, cb) => { + if (!isCustomId(id)) return cb(fail(404, 'No such system')); + db.get(`SELECT value FROM global_settings WHERE key = 'game_system'`, [], (err, row) => { + if (err) return cb(err); + if (row && row.value === id) return cb(fail(409, 'This is the system the game is running. Switch to another first.')); + db.run('DELETE FROM custom_systems WHERE id = ?', [id], function (err2) { + if (err2) return cb(err2); + if (this.changes === 0) return cb(fail(404, 'No such system')); + cb(null); + }); + }); +}; + +module.exports = { + PREFIX, isCustomId, listSystems, getSystem, createSystem, saveDraft, publishSystem, deleteSystem, +};