Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion backend/__tests__/gm_route_auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {} };
Expand All @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions backend/__tests__/helpers/testDb.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
231 changes: 231 additions & 0 deletions backend/__tests__/system_builder_store.test.js
Original file line number Diff line number Diff line change
@@ -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]);
});
});
13 changes: 13 additions & 0 deletions backend/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion backend/middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -72,6 +81,6 @@ const optionalAuthenticate = (req, res, next) => {
};

module.exports = {
authenticate, authenticatePlayer, optionalAuthenticate, elevatedUsers,
authenticate, authenticatePlayer, optionalAuthenticate, requireMainAdmin, elevatedUsers,
isMainAdmin, isGrantedEditor, canEdit, isPlayer,
};
Loading
Loading