From a70dcb2de42f6b23a2a9304b9c47ac7f83b1cfd9 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 29 Sep 2026 10:23:57 -0500 Subject: [PATCH 01/26] feat: system builder Phase 1, the derived-value engine (not wired in) A safe formula language (no eval: numbers, @fields, $rules, fixed functions, lookup tables, capped size and nesting), a definition checker that reports every problem with its place and shows loops as a path, dependency ordering, and a small registry of code-backed rules. Nothing live calls it. CWN's and Shadowrun's derived values are restated as data and held to cwnRecompute and sr6Recompute by a parity test over 3,000 seeded sheets each; mutation checks confirm the test catches a wrong band, a dropped rule, a wrong rounding and a wrong minimum. --- CHANGELOG.md | 9 + README.md | 7 + .../__tests__/system_builder_engine.test.js | 297 ++++++++++++++++++ .../__tests__/system_builder_parity.test.js | 175 +++++++++++ backend/systemBuilder/definitions.js | 63 ++++ backend/systemBuilder/derived.js | 227 +++++++++++++ backend/systemBuilder/expression.js | 275 ++++++++++++++++ backend/systemBuilder/rules.js | 61 ++++ 8 files changed, 1114 insertions(+) create mode 100644 backend/__tests__/system_builder_engine.test.js create mode 100644 backend/__tests__/system_builder_parity.test.js create mode 100644 backend/systemBuilder/definitions.js create mode 100644 backend/systemBuilder/derived.js create mode 100644 backend/systemBuilder/expression.js create mode 100644 backend/systemBuilder/rules.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a43ac48a..bb4879f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,15 @@ NPC sheets and hidden faces stay with the GM. one by hand. Their tests now run against the checkout, so every case they covered is still checked. +### Under the hood + +- **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: + every sheet is still worked out exactly as before. It is proven by restating CWN's and + Shadowrun's derived values as data and checking the results match the existing code on + thousands of generated sheets. + --- ## [1.14.3] - 2026-09-29 diff --git a/README.md b/README.md index 854e1828..70e66948 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,11 @@ CITY_NET/ │ │ ├── tokenControl.js # Who may move a token, in one place because two move handlers ask it. An admin always may; the owner may; a friendly NPC may name players, or open to everyone. Only friendly NPCs can carry a grant, enforced here rather than by the caller, so one that reaches an enemy row through an import or a restore is inert — and anything unreadable in the column means nobody, since a malformed grant must never open a token up │ │ ├── index.js # All Socket.IO event handlers. Every write to a character sheet goes through sheets/mutate.js: rolls, damage, death saves, stabilisation, spell effort and vehicle hulls all touch sheets their owner is very likely looking at, and anything relative is worked out inside the write so two of them landing together both count │ │ └── initiative.js # Initiative tracker socket events (start, roll, next, remove, reorder, end); individual and side-based modes; SR6 pass-decay on wrap; CWN side auto-create, PC-side score derivation, friendly-NPC routing; roll history broadcast +│ ├── systemBuilder/ # The system builder's engine (Phase 1): derived values from a written definition instead of code. NOT used by the app yet - every sheet is still worked out by sheets/templates.js, and this is held to that code by a parity test before any system moves onto it +│ │ ├── 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 │ ├── startup/ │ │ └── sanity_checks.js # In-memory DB checks on boot │ ├── utils/ @@ -432,6 +437,8 @@ CITY_NET/ │ ├── sockets.customdice.test.js # Roll handler: DB vs builtin resolution, numeric summing, count clamp, forged-payload rejection │ ├── 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_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) │ ├── cpr_attack.test.js # CP:R attack module (to-hit, armor, shield, crits, death saves) diff --git a/backend/__tests__/system_builder_engine.test.js b/backend/__tests__/system_builder_engine.test.js new file mode 100644 index 00000000..c8cbc241 --- /dev/null +++ b/backend/__tests__/system_builder_engine.test.js @@ -0,0 +1,297 @@ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; + +/** + * The system builder's expression language and derived-value engine, on their own. + * + * Two things matter most. A GM's formula runs on the server, so the language must not be able + * to do anything but arithmetic, however it is written. And a GM's mistakes must come back as + * readable problems - all of them, with where they are - rather than a crash or a hang. + */ + +const require_ = createRequire(import.meta.url); +const { parse, evaluate, ExpressionError, LIMITS } = require_('../systemBuilder/expression'); +const { compileSystem, LIMITS: SYSTEM_LIMITS } = require_('../systemBuilder/derived'); +const { RULES, ruleValue } = require_('../systemBuilder/rules'); + +/** Work out one expression with plain field values and no tables. */ +const calc = (src, fields = {}) => evaluate(parse(src), { + field: (n) => (n in fields ? fields[n] : 0), + rule: () => 0, + table: () => 0, +}); + +describe('the expression language', () => { + it('does arithmetic with the usual precedence', () => { + expect(calc('1 + 2 * 3')).toBe(7); + expect(calc('(1 + 2) * 3')).toBe(9); + expect(calc('10 - 4 - 3')).toBe(3); + expect(calc('20 / 4 / 5')).toBe(1); + expect(calc('-2 * -3')).toBe(6); + expect(calc('7 % 3')).toBe(1); + expect(calc('.5 + 1.25')).toBe(1.75); + }); + + it('reads sheet fields', () => { + expect(calc('@str + @level', { str: 14, level: 3 })).toBe(17); + }); + + it('has the built-in functions', () => { + expect(calc('min(4, 2, 9)')).toBe(2); + expect(calc('max(4, 2, 9)')).toBe(9); + expect(calc('floor(2.7) + ceil(2.1) + abs(-3)')).toBe(8); + expect(calc('round(2.5)')).toBe(3); + expect(calc('clamp(15, 1, 10)')).toBe(10); + expect(calc('if(@x > 3, 100, 200)', { x: 5 })).toBe(100); + expect(calc('if(@x > 3, 100, 200)', { x: 1 })).toBe(200); + }); + + it('compares and combines to 1 or 0', () => { + expect(calc('3 < 4')).toBe(1); + expect(calc('3 >= 4')).toBe(0); + expect(calc('2 == 2 and 3 != 4')).toBe(1); + expect(calc('0 or 5')).toBe(1); + expect(calc('not 0')).toBe(1); + expect(calc('not 1 or 1')).toBe(1); + }); + + it('turns a result that is not a number into 0, rather than breaking a sheet', () => { + expect(calc('1 / 0')).toBe(0); + expect(calc('0 / 0')).toBe(0); + expect(calc('5 % 0')).toBe(0); + expect(calc('@x * 2', { x: NaN })).toBe(0); + }); + + it('only evaluates the branch of if() it takes', () => { + let reads = 0; + const tree = parse('if(1, 5, @expensive)'); + evaluate(tree, { field: () => { reads += 1; return 0; }, rule: () => 0, table: () => 0 }); + expect(reads).toBe(0); + }); + + describe('refuses, with where the problem is', () => { + const refuses = (src, message) => { + let error; + try { parse(src); } catch (err) { error = err; } + expect(error, src).toBeInstanceOf(ExpressionError); + expect(error.message, src).toMatch(message); + }; + + it('bad syntax', () => { + refuses('', /Empty/); + refuses('1 +', /Ends too soon/); + refuses('(1 + 2', /Expected "\)"/); + refuses('1 2', /after a complete expression/); + refuses('2 # 3', /Unexpected "#"/); + refuses('1 < 2 < 3', /do not chain/); + }); + + it('a bare word, and says how to write a field', () => { + refuses('str + 1', /written @str/); + }); + + it('functions that do not exist, and the wrong number of values', () => { + refuses('sqrt(4)', /No function or table called "sqrt"/); + refuses('floor(1, 2)', /takes 1 value, not 2/); + refuses('clamp(1, 2)', /takes 3 values, not 2/); + refuses('max()', /takes 1 to 32 values, not 0/); + }); + + it('names with capitals or symbols', () => { + refuses('@Str', /not a valid name/); + }); + + it('the position of the problem', () => { + let error; + try { parse('1 + 2 # 3'); } catch (err) { error = err; } + expect(error.at).toBe(6); + expect(error.message).toMatch(/character 7/); + }); + }); + + describe('cannot be used to do anything but arithmetic', () => { + // Everything here is something a script would try. None of it is in the language. + for (const src of [ + 'constructor', '@constructor.name', 'process.exit(1)', 'require("fs")', 'this', + '@x["y"]', '"text"', "'text'", '@x = 1', 'x => 1', '`${1}`', '@__proto__', 'eval(1)', + 'Function("return 1")()', '1; 2', '[1,2]', '{a: 1}', 'globalThis', + ]) { + it(`refuses ${src}`, () => { + expect(() => parse(src)).toThrow(ExpressionError); + }); + } + }); + + describe('limits', () => { + it('refuses an expression longer than the limit', () => { + expect(() => parse('1+'.repeat(LIMITS.source) + '1')).toThrow(/Longer than/); + }); + + it('refuses more parts than the limit', () => { + const src = Array.from({ length: LIMITS.nodes }, () => '1').join('+'); + expect(src.length).toBeLessThanOrEqual(LIMITS.source); + expect(() => parse(src)).toThrow(/More than \d+ parts/); + }); + + it('refuses nesting deeper than the limit', () => { + const deep = `${'('.repeat(LIMITS.depth + 2)}1${')'.repeat(LIMITS.depth + 2)}`; + expect(() => parse(deep)).toThrow(/Nested more than/); + const deepNeg = `${'-'.repeat(LIMITS.depth + 2)}1`; + expect(() => parse(deepNeg)).toThrow(/Nested more than/); + const deepNot = `${'not '.repeat(LIMITS.depth + 2)}1`; + expect(() => parse(deepNot)).toThrow(/Nested more than/); + }); + + it('accepts nesting up to the limit', () => { + const ok = `${'('.repeat(LIMITS.depth - 1)}1${')'.repeat(LIMITS.depth - 1)}`; + expect(() => parse(ok)).not.toThrow(); + }); + }); +}); + +describe('a system definition', () => { + const MODS = { bands: [{ upTo: 3, value: -1 }, { upTo: 10, value: 0 }, { value: 1 }] }; + + it('works values out in dependency order, whatever order they are listed in', () => { + const { ok, system } = compileSystem({ + lookups: { mod: MODS }, + derived: [ + { id: 'total', formula: '@half + @str_mod' }, + { id: 'half', formula: 'floor(@level / 2)' }, + { id: 'str_mod', formula: 'mod(@str)' }, + ], + }); + expect(ok).toBe(true); + expect(system.evaluate({ level: 5, str: 12 })).toEqual({ half: 2, str_mod: 1, total: 3 }); + expect(system.order.indexOf('total')).toBeGreaterThan(system.order.indexOf('half')); + }); + + it('reads a lookup table band by band, the last band catching the rest', () => { + const { system } = compileSystem({ lookups: { mod: MODS }, derived: [{ id: 'm', formula: 'mod(@x)' }] }); + const m = (x) => system.evaluate({ x }).m; + expect([m(-5), m(3), m(3.5), m(10), m(11), m(1e9)]).toEqual([-1, -1, 0, 0, 1, 1]); + }); + + it('decides a condition', () => { + const { system } = compileSystem({ + derived: [{ id: 'penalty', kind: 'condition', when: '@load > @str', then: '-2', else: '0' }], + }); + expect(system.evaluate({ load: 12, str: 10 }).penalty).toBe(-2); + expect(system.evaluate({ load: 8, str: 10 }).penalty).toBe(0); + }); + + it('uses a code-backed rule', () => { + const { system } = compileSystem({ derived: [{ id: 'soak', formula: '4 + $cwn_armor_soak' }] }); + expect(system.evaluate({ armor_mods: JSON.stringify(['absorption_pads']) }).soak).toBe(9); + }); + + it('writes values and reports what changed, as the hand-written recompute functions do', () => { + const { system } = compileSystem({ derived: [{ id: 'a', formula: '@x + 1' }, { id: 'b', formula: '2' }] }); + const data = { x: 1, a: 2, b: '5' }; + expect(system.apply(data)).toEqual(['b']); + expect(data).toEqual({ x: 1, a: 2, b: 2 }); + }); + + it('treats text, blanks and missing fields as 0, the way sheets always have', () => { + const { system } = compileSystem({ derived: [{ id: 'sum', formula: '@a + @b + @c + @d' }] }); + expect(system.evaluate({ a: '3', b: '', c: 'abc' }).sum).toBe(3); + }); + + describe('reports every problem at once, with where it is', () => { + const problemsOf = (def) => { + const out = compileSystem(def); + expect(out.ok).toBe(false); + return out.problems; + }; + + it('a loop, with its path', () => { + const problems = problemsOf({ + derived: [ + { id: 'a', formula: '@b + 1' }, + { id: 'b', formula: '@c + 1' }, + { id: 'c', formula: '@a + 1' }, + ], + }); + expect(problems).toEqual([{ where: 'derived a', message: 'Depends on itself: a → b → c → a' }]); + }); + + it('a value that reads itself', () => { + expect(problemsOf({ derived: [{ id: 'hp', formula: '@hp + 1' }] })) + .toEqual([{ where: 'derived hp', message: 'Depends on itself: hp → hp' }]); + }); + + it('several mistakes together', () => { + const problems = problemsOf({ + lookups: { max: MODS, bad: { bands: [{ upTo: 5, value: 1 }, { upTo: 2, value: 0 }] } }, + derived: [ + { id: 'ok', formula: '1' }, + { id: 'ok', formula: '2' }, + { id: 'Bad Id', formula: '1' }, + { id: 'broken', formula: '1 +' }, + { id: 'ruled', formula: '$no_such_rule' }, + { id: 'odd', kind: 'script', formula: '1' }, + { id: 'cond', kind: 'condition', when: '@x >', then: '1', else: 'nope(1)' }, + ], + }); + const text = problems.map((p) => `${p.where}: ${p.message}`); + expect(text).toEqual([ + 'lookup max: "max" is a built-in function; pick another name', + 'lookup bad, band 2: "Up to" must rise from band to band', + 'derived ok: Defined twice', + 'derived Bad Id: Ids use lowercase letters, digits and _, starting with a letter', + 'derived broken, formula: Ends too soon (at character 4)', + 'derived ruled: No rule called $no_such_rule', + 'derived odd: Unknown kind "script"', + 'derived cond, when: Ends too soon (at character 5)', + 'derived cond, else: No function or table called "nope" (at character 1)', + ]); + }); + + it('a lookup table that is empty or open in the middle', () => { + const problems = problemsOf({ + lookups: { empty: { bands: [] }, gap: { bands: [{ value: 1 }, { upTo: 5, value: 2 }] } }, + derived: [], + }); + expect(problems.map((p) => p.message)).toEqual([ + 'A table needs at least one band', + 'Only the last band may leave "up to" open', + ]); + }); + + it('something that is not a definition at all', () => { + expect(problemsOf(null)).toEqual([{ where: 'derived', message: 'Derived values must be a list' }]); + expect(problemsOf({ derived: 'x' })[0].message).toMatch(/must be a list/); + }); + + it('more derived values than the limit', () => { + const derived = Array.from({ length: SYSTEM_LIMITS.derived + 1 }, (_, i) => ({ id: `v${i}`, formula: '1' })); + expect(problemsOf({ derived }).map((p) => p.message)).toContain(`More than ${SYSTEM_LIMITS.derived} derived values`); + }); + }); + + it('handles a long chain of values quickly', () => { + const derived = Array.from({ length: SYSTEM_LIMITS.derived }, (_, i) => ({ + id: `v${i}`, formula: i === 0 ? '1' : `@v${i - 1} + 1`, + })).reverse(); + const started = Date.now(); + const { ok, system } = compileSystem({ derived }); + expect(ok).toBe(true); + expect(system.evaluate({})[`v${SYSTEM_LIMITS.derived - 1}`]).toBe(SYSTEM_LIMITS.derived); + expect(Date.now() - started).toBeLessThan(1000); + }); +}); + +describe('code-backed rules', () => { + it('each has a description the builder can show', () => { + for (const [name, rule] of Object.entries(RULES)) { + expect(typeof rule.describe, name).toBe('string'); + expect(typeof rule.value, name).toBe('function'); + } + }); + + it('an unknown name, or one inherited from Object, is 0 rather than a crash', () => { + expect(ruleValue('no_such_rule', {})).toBe(0); + expect(ruleValue('constructor', {})).toBe(0); + expect(ruleValue('__proto__', {})).toBe(0); + }); +}); diff --git a/backend/__tests__/system_builder_parity.test.js b/backend/__tests__/system_builder_parity.test.js new file mode 100644 index 00000000..33e0cbc6 --- /dev/null +++ b/backend/__tests__/system_builder_parity.test.js @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; + +/** + * The system builder's engine, held to the hand-written code it would one day replace. + * + * CWN's and Shadowrun's derived values are written out as data (systemBuilder/definitions.js) + * and worked out by the engine, then compared with cwnRecompute and sr6Recompute - the + * functions every sheet actually uses - over thousands of generated sheets. Blank fields, + * text where a number belongs, decimals, negatives, huge values, broken JSON, stale derived + * values: every sheet must come out identical, with the same list of changed fields in the + * same order. This is the proof the engine can carry a real system before any is moved onto + * it; until then the app does not call it at all. + */ + +const require_ = createRequire(import.meta.url); +const { TEMPLATES } = require_('../sheets/templates'); +const { ARMOR_MODS } = require_('../sheets/cwnGearMods'); +const { compileSystem } = require_('../systemBuilder/derived'); +const { CITIES_WITHOUT_NUMBER, SHADOWRUN_6E } = require_('../systemBuilder/definitions'); + +const SHEETS = 3000; + +/** A small seeded generator, so a failure names a sheet that can be made again. */ +const mulberry32 = (seed) => () => { + let t = (seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +}; + +const makeGen = (seed) => { + const r = mulberry32(seed); + const int = (lo, hi) => lo + Math.floor(r() * (hi - lo + 1)); + const pick = (list) => list[Math.floor(r() * list.length)]; + const chance = (p) => r() < p; + /** A value as a sheet might really hold one - usually a sensible number, sometimes not. */ + const value = (lo, hi) => { + if (chance(0.65)) return int(lo, hi); + return pick([ + undefined, null, '', ' ', 'abc', true, false, 0, -1, -0.5, 2.5, 13.999, + String(int(lo, hi)), ` ${int(lo, hi)} `, '1e2', 1e9, -1e9, Infinity, NaN, [], [7], {}, + ]); + }; + return { r, int, pick, chance, value }; +}; + +const cwnSheet = (g) => { + const sheet = {}; + for (const s of ['str', 'dex', 'con', 'int', 'wis', 'cha']) sheet[s] = g.value(1, 20); + for (const [f, lo, hi] of [ + ['level', 0, 12], ['strain_mod', -2, 2], ['armor_trauma_mod', 0, 3], ['armor_soak', 0, 20], + ['move_mod', -5, 5], ['cast_skill', 0, 4], ['summon_skill', 0, 4], + ]) { + if (g.chance(0.85)) sheet[f] = g.value(lo, hi); + } + if (g.chance(0.5)) { + const ids = Array.from({ length: g.int(0, 4) }, () => g.pick(ARMOR_MODS).id); + sheet.armor_mods = g.pick([JSON.stringify(ids), ids, ids.join(','), '{broken', null]); + } + if (g.chance(0.5)) { + sheet.cyberware = Array.from({ length: g.int(0, 3) }, () => { + const mods = Array.from({ length: g.int(0, 3) }, () => ({ + target: g.pick(['Move (meters)', 'Move (metres)', 'move', 'base AC', 'Base AC', 'strain', '']), + value: g.value(0, 12), + })); + return { + name: 'Implant', + equipped: g.chance(0.8), + placed: g.chance(0.8), + mods: g.chance(0.3) ? JSON.stringify(mods) : mods, + }; + }); + if (g.chance(0.1)) sheet.cyberware.push(null, 'junk'); + } + return sheet; +}; + +const sr6Sheet = (g) => { + const sheet = {}; + for (const f of ['body', 'willpower', 'reaction', 'intuition', 'charisma', 'magic']) { + if (g.chance(0.9)) sheet[f] = g.value(1, 9); + } + if (g.chance(0.6)) { + const powers = Array.from({ length: g.int(0, 5) }, () => ({ + name: 'Power', + cost: g.pick([0.25, 0.5, 1, 1.5, '0.25', 'x', null, 0.1, 0.2]), + })); + sheet.adept_powers = g.pick([JSON.stringify(powers), '{broken', '', '"text"', '{}']); + } + return sheet; +}; + +/** + * Derived fields as a sheet would carry them before a write: missing, already right, stale, + * or the right number stored as text. `truth` is the sheet as the real code leaves it. + */ +const withStoredDerived = (g, sheet, ids, truth) => { + for (const id of ids) { + const roll = g.r(); + if (roll < 0.25) continue; + if (roll < 0.5) sheet[id] = truth[id]; + else if (roll < 0.7) sheet[id] = String(truth[id]); + else sheet[id] = g.value(-5, 25); + } + return sheet; +}; + +const clone = (x) => structuredClone(x); + +const holdsTo = (name, definition, recompute, makeSheet, seed) => { + describe(`${name}: the engine against the hand-written function`, () => { + const compiled = compileSystem(definition); + + it('compiles', () => { + expect(compiled.problems).toBeUndefined(); + expect(compiled.ok).toBe(true); + }); + + it(`gives the same sheet and the same changed fields on ${SHEETS} generated sheets`, () => { + const g = makeGen(seed); + const { ids } = compiled.system; + for (let n = 0; n < SHEETS; n += 1) { + const base = makeSheet(g); + const truth = clone(base); + recompute(truth); + const sheet = withStoredDerived(g, base, ids, truth); + + const expected = clone(sheet); + const expectedChanged = recompute(expected); + const actual = clone(sheet); + const actualChanged = compiled.system.apply(actual); + + const where = `sheet ${n} (seed ${seed}): ${JSON.stringify(sheet)}`; + expect(actualChanged, where).toEqual(expectedChanged); + expect(actual, where).toEqual(expected); + } + }); + + it('agrees on a sheet with nothing filled in at all', () => { + const expected = {}; + const actual = {}; + expect(compiled.system.apply(actual)).toEqual(recompute(expected)); + expect(actual).toEqual(expected); + }); + }); +}; + +holdsTo('Cities Without Number', CITIES_WITHOUT_NUMBER, + TEMPLATES.cities_without_number.recompute, cwnSheet, 20260929); +holdsTo('Shadowrun 6E', SHADOWRUN_6E, + TEMPLATES.shadowrun_6e.recompute, sr6Sheet, 6); + +describe('a few CWN characters, by hand', () => { + const { system } = compileSystem(CITIES_WITHOUT_NUMBER); + + it('works out a level 3 street samurai', () => { + const v = system.evaluate({ str: 16, dex: 14, con: 12, int: 9, wis: 7, cha: 10, level: 3 }); + expect(v).toMatchObject({ + str_mod: 1, dex_mod: 1, con_mod: 0, int_mod: 0, wis_mod: -1, cha_mod: 0, + save_physical: 12, save_evasion: 12, save_mental: 13, save_luck: 13, + system_strain_max: 12, trauma_target: 6, move: 10, spells_prepared_max: 2, + }); + }); + + it('counts Coordination Augment II toward Move, and a heavy suit toward Trauma Target', () => { + const v = system.evaluate({ + armor_trauma_mod: 3, + armor_mods: JSON.stringify(['active_response']), + cyberware: [{ equipped: true, placed: true, mods: [{ target: 'Move (meters)', value: 10 }] }], + }); + expect(v.move).toBe(20); + expect(v.trauma_target).toBe(10); + }); +}); diff --git a/backend/systemBuilder/definitions.js b/backend/systemBuilder/definitions.js new file mode 100644 index 00000000..1fc4fc16 --- /dev/null +++ b/backend/systemBuilder/definitions.js @@ -0,0 +1,63 @@ +// Built-in systems' derived values, written as data. +// +// Each is a word-for-word restatement of a hand-written recompute function in +// sheets/templates.js - cwnRecompute and sr6Recompute - and is held to it by +// __tests__/system_builder_parity.test.js, which runs both over thousands of sheets and +// requires the same values and the same list of changed fields. +// +// NOT used by the app. Every sheet is still worked out by the hand-written functions. These +// exist to prove the engine can carry a real system before anything is moved onto it, and to +// be the first examples a GM sees in the builder. Entries are listed in the order the +// hand-written functions write them, because the list of changed fields follows that order. + +/** Cities Without Number (CWN QRD v2.2, CC BY-NC 4.0), as cwnRecompute has it. */ +const CITIES_WITHOUT_NUMBER = { + lookups: { + // The attribute modifier table: 3 -> -2, 4-7 -> -1, 8-13 -> 0, 14-17 -> +1, 18+ -> +2. + // The first band keeps an unset stat (read as 0) neutral rather than "a 3", so a + // half-filled sheet does not roll at -2 everywhere. + attribute_mod: { + bands: [ + { upTo: 0, value: 0 }, + { upTo: 3, value: -2 }, + { upTo: 7, value: -1 }, + { upTo: 13, value: 0 }, + { upTo: 17, value: 1 }, + { value: 2 }, + ], + }, + }, + derived: [ + { id: 'str_mod', formula: 'attribute_mod(@str)' }, + { id: 'dex_mod', formula: 'attribute_mod(@dex)' }, + { id: 'con_mod', formula: 'attribute_mod(@con)' }, + { id: 'int_mod', formula: 'attribute_mod(@int)' }, + { id: 'wis_mod', formula: 'attribute_mod(@wis)' }, + { id: 'cha_mod', formula: 'attribute_mod(@cha)' }, + { id: 'save_physical', formula: '16 - (@level + max(@str_mod, @con_mod))' }, + { id: 'save_evasion', formula: '16 - (@level + max(@dex_mod, @int_mod))' }, + { id: 'save_mental', formula: '16 - (@level + max(@wis_mod, @cha_mod))' }, + { id: 'save_luck', formula: '16 - @level' }, + { id: 'system_strain_max', formula: 'max(0, @con + @strain_mod)' }, + { id: 'trauma_target', formula: '6 + @armor_trauma_mod + $cwn_armor_trauma' }, + { id: 'armor_soak_total', formula: 'max(0, @armor_soak + $cwn_armor_soak)' }, + { id: 'move', formula: 'max(0, 10 + @move_mod + $cwn_move_bonus)' }, + { id: 'mage_effort_max', formula: 'max(1, max(@int_mod, @wis_mod) + @cast_skill)' }, + { id: 'spells_prepared_max', formula: 'ceil(@level / 2) + @cast_skill' }, + { id: 'summoner_effort_max', formula: 'max(1, max(@con_mod, @cha_mod) + @summon_skill)' }, + ], +}; + +/** Shadowrun 6E, as sr6Recompute has it. */ +const SHADOWRUN_6E = { + derived: [ + { id: 'physical_monitor', formula: '8 + ceil(@body / 2)' }, + { id: 'stun_monitor', formula: '8 + ceil(@willpower / 2)' }, + { id: 'initiative_score', formula: '@reaction + @intuition' }, + { id: 'composure', formula: '@willpower + @charisma' }, + { id: 'power_points_spent', formula: '$sr6_power_points_spent' }, + { id: 'power_points_remaining', formula: 'round((@magic - @power_points_spent) * 100) / 100' }, + ], +}; + +module.exports = { CITIES_WITHOUT_NUMBER, SHADOWRUN_6E }; diff --git a/backend/systemBuilder/derived.js b/backend/systemBuilder/derived.js new file mode 100644 index 00000000..168673b9 --- /dev/null +++ b/backend/systemBuilder/derived.js @@ -0,0 +1,227 @@ +// A system's derived values, from a definition instead of code. +// +// Today each game system's derived values - CWN's attribute modifiers and saves, Shadowrun's +// condition monitors - are a hand-written function in sheets/templates.js. This works the same +// kind of values out from a description a GM could write: +// +// { +// lookups: { +// attribute_mod: { bands: [{ upTo: 3, value: -2 }, { upTo: 7, value: -1 }, { value: 0 }] }, +// }, +// derived: [ +// { id: 'str_mod', formula: 'attribute_mod(@str)' }, +// { id: 'save_physical', formula: '16 - (@level + max(@str_mod, @con_mod))' }, +// { id: 'burdened', kind: 'condition', when: '@load > @str', then: '1', else: '0' }, +// ], +// } +// +// Formula: an expression (expression.js). Lookup: a table of bands, read top to bottom - the +// first band whose `upTo` the value does not exceed gives the answer, and the last band, with +// no `upTo`, catches everything above. Condition: `when` decides between `then` and `else`. +// +// Nothing live calls this yet. It is proven against the hand-written functions it would +// replace (see __tests__/system_builder_parity.test.js) before any system is moved onto it. +// +// Checking is separate from running, and reports every problem at once rather than the first, +// because the builder will show them as a list beside the graph. + +const { parse, evaluate, references, NAME, BUILTINS } = require('./expression'); +const { ruleValue, hasRule } = require('./rules'); + +const LIMITS = { + /** Derived values in one system. */ + derived: 500, + /** Lookup tables in one system. */ + lookups: 100, + /** Bands in one lookup table. */ + bands: 100, +}; + +const num = (v) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +}; + +const isFiniteNumber = (v) => typeof v === 'number' && Number.isFinite(v); + +/** Read a band table: the value for `x`. Bands are already checked. */ +const lookupIn = (bands, x) => { + for (const b of bands) { + if (b.upTo === undefined || x <= b.upTo) return b.value; + } + return 0; +}; + +const checkLookups = (lookups, problems) => { + const tables = {}; + if (lookups === undefined) return tables; + if (!lookups || typeof lookups !== 'object' || Array.isArray(lookups)) { + problems.push({ where: 'lookups', message: 'Lookups must be a set of named tables' }); + return tables; + } + const names = Object.keys(lookups); + if (names.length > LIMITS.lookups) problems.push({ where: 'lookups', message: `More than ${LIMITS.lookups} lookup tables` }); + for (const name of names.slice(0, LIMITS.lookups)) { + const where = `lookup ${name}`; + if (!NAME.test(name)) { problems.push({ where, message: 'Names use lowercase letters, digits and _, starting with a letter' }); continue; } + if (BUILTINS[name]) { problems.push({ where, message: `"${name}" is a built-in function; pick another name` }); continue; } + const bands = lookups[name] && lookups[name].bands; + if (!Array.isArray(bands) || bands.length === 0) { problems.push({ where, message: 'A table needs at least one band' }); continue; } + if (bands.length > LIMITS.bands) { problems.push({ where, message: `More than ${LIMITS.bands} bands` }); continue; } + let ok = true; + let last = -Infinity; + bands.forEach((b, i) => { + const at = `${where}, band ${i + 1}`; + if (!b || typeof b !== 'object' || !isFiniteNumber(b.value)) { problems.push({ where: at, message: 'Each band needs a number value' }); ok = false; return; } + const final = i === bands.length - 1; + if (b.upTo === undefined) { + if (!final) { problems.push({ where: at, message: 'Only the last band may leave "up to" open' }); ok = false; } + return; + } + if (!isFiniteNumber(b.upTo)) { problems.push({ where: at, message: '"Up to" must be a number' }); ok = false; return; } + if (b.upTo <= last) { problems.push({ where: at, message: '"Up to" must rise from band to band' }); ok = false; } + last = b.upTo; + }); + if (ok) tables[name] = bands.map((b) => ({ upTo: b.upTo, value: b.value })); + } + return tables; +}; + +/** The expressions an entry is made of, by the part of the entry they came from. */ +const partsOf = (entry) => (entry.kind === 'condition' + ? { when: entry.when, then: entry.then, else: entry.else } + : { formula: entry.formula }); + +/** + * Check a definition and prepare it to run. + * + * Returns `{ ok: false, problems: [{ where, message }] }`, or `{ ok: true, system }` where + * `system.evaluate(data)` gives every derived value and `system.apply(data)` writes them the + * way the hand-written recompute functions do. + */ +const compileSystem = (definition) => { + const problems = []; + const def = definition && typeof definition === 'object' ? definition : {}; + const tables = checkLookups(def.lookups, problems); + const isTable = (name) => Object.prototype.hasOwnProperty.call(tables, name) + || Object.prototype.hasOwnProperty.call(def.lookups || {}, name); + + const entries = Array.isArray(def.derived) ? def.derived : []; + if (!Array.isArray(def.derived)) problems.push({ where: 'derived', message: 'Derived values must be a list' }); + if (entries.length > LIMITS.derived) problems.push({ where: 'derived', message: `More than ${LIMITS.derived} derived values` }); + + const compiled = []; + const seen = new Set(); + for (const [i, entry] of entries.slice(0, LIMITS.derived).entries()) { + const id = entry && entry.id; + const where = typeof id === 'string' && id ? `derived ${id}` : `derived value ${i + 1}`; + if (!entry || typeof entry !== 'object') { problems.push({ where, message: 'Not a derived value' }); continue; } + if (typeof id !== 'string' || !NAME.test(id)) { problems.push({ where, message: 'Ids use lowercase letters, digits and _, starting with a letter' }); continue; } + if (seen.has(id)) { problems.push({ where, message: 'Defined twice' }); continue; } + seen.add(id); + if (entry.kind !== undefined && entry.kind !== 'formula' && entry.kind !== 'condition') { + problems.push({ where, message: `Unknown kind "${entry.kind}"` }); + continue; + } + + const trees = {}; + let ok = true; + for (const [part, source] of Object.entries(partsOf(entry))) { + try { + trees[part] = parse(source, isTable); + } catch (err) { + problems.push({ where: `${where}, ${part}`, message: err.message }); + ok = false; + } + } + if (!ok) continue; + + const refs = { fields: new Set(), rules: new Set() }; + for (const tree of Object.values(trees)) { + const r = references(tree); + r.fields.forEach((f) => refs.fields.add(f)); + r.rules.forEach((x) => refs.rules.add(x)); + } + for (const r of refs.rules) { + if (!hasRule(r)) { problems.push({ where, message: `No rule called $${r}` }); ok = false; } + } + if (!ok) continue; + compiled.push({ id, kind: entry.kind === 'condition' ? 'condition' : 'formula', trees, fields: refs.fields }); + } + + // Order: a value is worked out after every derived value it reads. Anything else @named is a + // plain sheet field. A loop is reported with its path, since "A needs B needs A" is the + // mistake a GM can actually fix. + const byId = new Map(compiled.map((c) => [c.id, c])); + const order = []; + const state = new Map(); // id -> 'visiting' | 'done' + const loops = []; + const visit = (id, path) => { + const s = state.get(id); + if (s === 'done') return; + if (s === 'visiting') { + const from = path.indexOf(id); + loops.push([...path.slice(from), id]); + return; + } + state.set(id, 'visiting'); + for (const dep of byId.get(id).fields) { + if (byId.has(dep)) visit(dep, [...path, id]); + } + state.set(id, 'done'); + order.push(id); + }; + // Iterative over entries, recursive over dependencies: depth is bounded by LIMITS.derived. + for (const c of compiled) visit(c.id, []); + for (const loop of loops) { + problems.push({ where: `derived ${loop[0]}`, message: `Depends on itself: ${loop.join(' → ')}` }); + } + + if (problems.length) return { ok: false, problems }; + + const evaluateAll = (data) => { + const sheet = data || {}; + const values = {}; + const ruleCache = new Map(); + const env = { + field: (name) => (Object.prototype.hasOwnProperty.call(values, name) ? values[name] : num(sheet[name])), + rule: (name) => { + if (!ruleCache.has(name)) ruleCache.set(name, ruleValue(name, sheet)); + return ruleCache.get(name); + }, + table: (name, x) => lookupIn(tables[name], x), + }; + for (const id of order) { + const c = byId.get(id); + values[id] = c.kind === 'condition' + ? (evaluate(c.trees.when, env) !== 0 ? evaluate(c.trees.then, env) : evaluate(c.trees.else, env)) + : evaluate(c.trees.formula, env); + } + return values; + }; + + /** + * Write every derived value onto `data`, in the order the definition lists them, and return + * the ids that changed - the contract of the recompute functions in sheets/templates.js, so + * one can stand in for the other. + */ + const apply = (data) => { + const values = evaluateAll(data); + const changed = []; + for (const c of compiled) { + const value = values[c.id]; + if (num(data[c.id]) !== value || data[c.id] === undefined) { + data[c.id] = value; + changed.push(c.id); + } + } + return changed; + }; + + return { + ok: true, + system: { order: [...order], ids: compiled.map((c) => c.id), evaluate: evaluateAll, apply }, + }; +}; + +module.exports = { compileSystem, LIMITS }; diff --git a/backend/systemBuilder/expression.js b/backend/systemBuilder/expression.js new file mode 100644 index 00000000..4c5e04e8 --- /dev/null +++ b/backend/systemBuilder/expression.js @@ -0,0 +1,275 @@ +// The expression language a GM's derived values are written in. Pure, no I/O, no `eval`. +// +// A GM's formulas run on the server whenever a sheet changes, so the language is a closed +// set: numbers, sheet fields, a few operators and a fixed list of functions. There is no way +// to name anything outside it - no property access, no strings, no loops - so a formula can +// compute a wrong number but cannot do anything else. Size and nesting are capped, so a +// pasted wall of text is refused at compile time rather than chewed on at run time. +// +// Grammar, loosest binding first: +// expr := or +// or := and ('or' and)* +// and := not ('and' not)* +// not := 'not' not | compare +// compare := sum (('<' | '<=' | '>' | '>=' | '==' | '!=') sum)? +// sum := product (('+' | '-') product)* +// product := unary (('*' | '/' | '%') unary)* +// unary := '-' unary | '+' unary | primary +// primary := NUMBER | '@' NAME | '$' NAME | NAME '(' args? ')' | '(' expr ')' +// +// @name a sheet field, or another derived value of the same system +// $name a code-backed rule value (see rules.js) - what a formula cannot say, like +// "the soak of whatever armor mods are fitted" +// name() a built-in function below, or one of the system's lookup tables +// +// Everything is a number. A comparison is 1 or 0; `and`, `or`, `not` and `if` treat any +// nonzero value as true. Anything that would come out infinite or NaN - dividing by zero, +// say - comes out 0: a GM's slip shows as a blank value, never as a broken sheet. + +const LIMITS = { + /** Characters in one expression. */ + source: 1000, + /** Operators, values and calls in one expression. */ + nodes: 256, + /** How deeply parentheses and calls may nest. */ + depth: 32, +}; + +const NAME = /^[a-z][a-z0-9_]{0,63}$/; + +/** Built-in functions, by name: how many arguments each takes, and what it does. */ +const BUILTINS = { + min: { min: 1, max: 32, fn: (...a) => Math.min(...a) }, + max: { min: 1, max: 32, fn: (...a) => Math.max(...a) }, + floor: { min: 1, max: 1, fn: (x) => Math.floor(x) }, + ceil: { min: 1, max: 1, fn: (x) => Math.ceil(x) }, + /** Halves round up, as Math.round does: round(2.5) is 3, round(-2.5) is -2. */ + round: { min: 1, max: 1, fn: (x) => Math.round(x) }, + abs: { min: 1, max: 1, fn: (x) => Math.abs(x) }, + clamp: { min: 3, max: 3, fn: (x, lo, hi) => Math.min(Math.max(x, lo), hi) }, + /** if(condition, then, else). Only the branch taken is evaluated. */ + if: { min: 3, max: 3, lazy: true }, +}; + +const KEYWORDS = new Set(['and', 'or', 'not']); + +class ExpressionError extends Error { + constructor(message, at) { + super(at === undefined ? message : `${message} (at character ${at + 1})`); + this.name = 'ExpressionError'; + this.at = at; + } +} + +const tokenize = (src) => { + const tokens = []; + let i = 0; + while (i < src.length) { + const c = src[i]; + if (/\s/.test(c)) { i += 1; continue; } + const rest = src.slice(i); + let m; + if ((m = rest.match(/^(\d+(\.\d+)?|\.\d+)/))) { + tokens.push({ t: 'num', v: Number(m[0]), at: i }); + i += m[0].length; + } else if ((m = rest.match(/^([@$])([a-zA-Z_][a-zA-Z0-9_]*)/))) { + const name = m[2]; + if (!NAME.test(name)) throw new ExpressionError(`"${m[0]}" is not a valid name: lowercase letters, digits and _ only, starting with a letter`, i); + tokens.push({ t: m[1] === '@' ? 'field' : 'rule', v: name, at: i }); + i += m[0].length; + } else if ((m = rest.match(/^[a-zA-Z_][a-zA-Z0-9_]*/))) { + const word = m[0]; + if (KEYWORDS.has(word)) tokens.push({ t: 'op', v: word, at: i }); + else tokens.push({ t: 'name', v: word, at: i }); + i += word.length; + } else if ((m = rest.match(/^(<=|>=|==|!=|[-+*/%<>(),])/))) { + tokens.push({ t: 'op', v: m[0], at: i }); + i += m[0].length; + } else { + throw new ExpressionError(`Unexpected "${c}"`, i); + } + } + tokens.push({ t: 'end', at: src.length }); + return tokens; +}; + +/** + * Parse an expression into a tree. Throws ExpressionError with the position of the problem. + * `isFunction(name)` says whether a bare name may be called: the built-ins always can, and a + * system adds its lookup tables. + */ +const parse = (source, isFunction = () => false) => { + const src = String(source ?? ''); + if (src.length > LIMITS.source) throw new ExpressionError(`Longer than ${LIMITS.source} characters`); + if (!src.trim()) throw new ExpressionError('Empty expression'); + const tokens = tokenize(src); + let pos = 0; + let nodes = 0; + + const peek = () => tokens[pos]; + const take = () => tokens[pos++]; + const isOp = (v) => peek().t === 'op' && peek().v === v; + const node = (n) => { + nodes += 1; + if (nodes > LIMITS.nodes) throw new ExpressionError(`More than ${LIMITS.nodes} parts in one expression`); + return n; + }; + const expect = (v) => { + const tok = take(); + if (tok.t !== 'op' || tok.v !== v) { + throw new ExpressionError(`Expected "${v}"${tok.t === 'end' ? ' before the end' : ''}`, tok.at); + } + return tok; + }; + + const binaryLevel = (ops, next) => (depth) => { + let left = next(depth); + while (peek().t === 'op' && ops.includes(peek().v)) { + const op = take().v; + left = node({ k: 'bin', op, left, right: next(depth) }); + } + return left; + }; + + const primary = (depth) => { + if (depth > LIMITS.depth) throw new ExpressionError(`Nested more than ${LIMITS.depth} deep`, peek().at); + const tok = take(); + if (tok.t === 'num') return node({ k: 'num', v: tok.v }); + if (tok.t === 'field') return node({ k: 'field', name: tok.v }); + if (tok.t === 'rule') return node({ k: 'rule', name: tok.v }); + if (tok.t === 'name') { + const name = tok.v; + if (!isOp('(')) { + throw new ExpressionError(`"${name}" on its own means nothing: a sheet field is written @${name}`, tok.at); + } + if (!BUILTINS[name] && !isFunction(name)) throw new ExpressionError(`No function or table called "${name}"`, tok.at); + take(); + const args = []; + if (!isOp(')')) { + args.push(expr(depth + 1)); + while (isOp(',')) { take(); args.push(expr(depth + 1)); } + } + expect(')'); + const b = BUILTINS[name]; + if (b && (args.length < b.min || args.length > b.max)) { + const want = b.min === b.max ? `${b.min}` : `${b.min} to ${b.max}`; + throw new ExpressionError(`${name}() takes ${want} value${b.max === 1 ? '' : 's'}, not ${args.length}`, tok.at); + } + if (!b && args.length !== 1) throw new ExpressionError(`The table ${name}() takes 1 value, not ${args.length}`, tok.at); + return node({ k: 'call', name, args }); + } + if (tok.t === 'op' && tok.v === '(') { + const inner = expr(depth + 1); + expect(')'); + return inner; + } + if (tok.t === 'end') throw new ExpressionError('Ends too soon', tok.at); + throw new ExpressionError(`Unexpected "${tok.v}"`, tok.at); + }; + + const unary = (depth) => { + if (isOp('-') || isOp('+')) { + const op = take().v; + if (depth > LIMITS.depth) throw new ExpressionError(`Nested more than ${LIMITS.depth} deep`, peek().at); + const arg = unary(depth + 1); + return op === '-' ? node({ k: 'neg', arg }) : arg; + } + return primary(depth); + }; + const product = binaryLevel(['*', '/', '%'], unary); + const sum = binaryLevel(['+', '-'], product); + const compare = (depth) => { + const left = sum(depth); + if (peek().t === 'op' && ['<', '<=', '>', '>=', '==', '!='].includes(peek().v)) { + const op = take().v; + const right = sum(depth); + if (peek().t === 'op' && ['<', '<=', '>', '>=', '==', '!='].includes(peek().v)) { + throw new ExpressionError('Comparisons do not chain: write "a < b and b < c"', peek().at); + } + return node({ k: 'bin', op, left, right }); + } + return left; + }; + const not = (depth) => { + if (isOp('not')) { + take(); + if (depth > LIMITS.depth) throw new ExpressionError(`Nested more than ${LIMITS.depth} deep`, peek().at); + return node({ k: 'not', arg: not(depth + 1) }); + } + return compare(depth); + }; + const and = binaryLevel(['and'], not); + const or = binaryLevel(['or'], and); + function expr(depth) { return or(depth); } + + const tree = expr(0); + const tok = peek(); + if (tok.t !== 'end') throw new ExpressionError(`Unexpected "${tok.v}" after a complete expression`, tok.at); + return tree; +}; + +/** Every @field, $rule and table a tree refers to. */ +const references = (tree) => { + const out = { fields: new Set(), rules: new Set(), tables: new Set() }; + const walk = (n) => { + switch (n.k) { + case 'field': out.fields.add(n.name); break; + case 'rule': out.rules.add(n.name); break; + case 'call': if (!BUILTINS[n.name]) out.tables.add(n.name); n.args.forEach(walk); break; + case 'bin': walk(n.left); walk(n.right); break; + case 'neg': case 'not': walk(n.arg); break; + default: break; + } + }; + walk(tree); + return out; +}; + +/** A finite number, or 0. */ +const finite = (x) => (Number.isFinite(x) ? x : 0); + +/** + * Work a tree out. `env` supplies `field(name)`, `rule(name)` and `table(name, x)`, each + * returning a number. + */ +const evaluate = (tree, env) => { + const ev = (n) => { + switch (n.k) { + case 'num': return n.v; + case 'field': return finite(env.field(n.name)); + case 'rule': return finite(env.rule(n.name)); + case 'neg': return finite(-ev(n.arg)); + case 'not': return ev(n.arg) === 0 ? 1 : 0; + case 'call': { + if (n.name === 'if') return ev(n.args[0]) !== 0 ? ev(n.args[1]) : ev(n.args[2]); + const b = BUILTINS[n.name]; + if (b) return finite(b.fn(...n.args.map(ev))); + return finite(env.table(n.name, ev(n.args[0]))); + } + case 'bin': { + if (n.op === 'and') return ev(n.left) !== 0 && ev(n.right) !== 0 ? 1 : 0; + if (n.op === 'or') return ev(n.left) !== 0 || ev(n.right) !== 0 ? 1 : 0; + const a = ev(n.left); + const b = ev(n.right); + switch (n.op) { + case '+': return finite(a + b); + case '-': return finite(a - b); + case '*': return finite(a * b); + case '/': return finite(a / b); + case '%': return finite(a % b); + case '<': return a < b ? 1 : 0; + case '<=': return a <= b ? 1 : 0; + case '>': return a > b ? 1 : 0; + case '>=': return a >= b ? 1 : 0; + case '==': return a === b ? 1 : 0; + case '!=': return a !== b ? 1 : 0; + default: throw new Error(`unknown operator ${n.op}`); + } + } + default: throw new Error(`unknown node ${n.k}`); + } + }; + return ev(tree); +}; + +module.exports = { parse, evaluate, references, ExpressionError, LIMITS, BUILTINS, NAME }; diff --git a/backend/systemBuilder/rules.js b/backend/systemBuilder/rules.js new file mode 100644 index 00000000..d6fd5c9c --- /dev/null +++ b/backend/systemBuilder/rules.js @@ -0,0 +1,61 @@ +// Code-backed rule values: what a formula cannot say, given a name a formula can use. +// +// Most of a system is arithmetic on sheet fields, and that is data. A few values need to read +// something no formula can - a list of installed armor mods, the modifiers on every fitted +// implant, a JSON list of adept powers. Those are written once, here, in code that is reviewed +// like any other, and a system definition names the ones it uses: `$cwn_move_bonus` in a +// formula is the value of that entry. +// +// This is the start of the plan's Rule library. A GM picks from it; they never add to it. The +// list grows when a GM asks for something new and it gets written here. + +const gearMods = require('../sheets/cwnGearMods'); +const { cwnMoveBonus } = require('../sheets/templates'); + +const num = (v) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +}; + +const RULES = { + /** CWN: what the suit's installed mods add to its Trauma Target (p58). */ + cwn_armor_trauma: { + describe: "Trauma Target added by the armor's installed mods", + value: (data) => gearMods.armorModEffects(data.armor_mods).traumaTarget, + }, + /** CWN: what the suit's installed mods add to its Damage Soak (p58). */ + cwn_armor_soak: { + describe: "Damage Soak added by the armor's installed mods", + value: (data) => gearMods.armorModEffects(data.armor_mods).soak, + }, + /** CWN: meters of Move granted by fitted, equipped chrome (Coordination Augment II). */ + cwn_move_bonus: { + describe: 'Move added by installed cyberware', + value: (data) => cwnMoveBonus(data), + }, + /** SR6: Power Points spent on adept powers, to two decimal places. */ + sr6_power_points_spent: { + describe: 'Power Points spent on adept powers', + value: (data) => { + let spent = 0; + try { + const powers = JSON.parse(data.adept_powers || '[]'); + if (Array.isArray(powers)) { + spent = powers.reduce((sum, p) => sum + (parseFloat(p.cost) || 0), 0); + spent = Math.round(spent * 100) / 100; + } + } catch { /* an unreadable list spends nothing */ } + return spent; + }, + }, +}; + +/** The value of rule `name` for this sheet, as a finite number. Unknown names are 0. */ +const ruleValue = (name, data) => { + const rule = Object.prototype.hasOwnProperty.call(RULES, name) ? RULES[name] : null; + return rule ? num(rule.value(data || {})) : 0; +}; + +const hasRule = (name) => Object.prototype.hasOwnProperty.call(RULES, name); + +module.exports = { RULES, ruleValue, hasRule }; From 8cfd83f5c322a133a54446ef513a99c744b3c5ed Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 29 Sep 2026 10:29:31 -0500 Subject: [PATCH 02/26] docs: the system builder note is unreleased, not part of 1.14.4 --- CHANGELOG.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4879f9..d169885e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [Unreleased] + +### Under the hood + +- **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: + every sheet is still worked out exactly as before. It is proven by restating CWN's and + Shadowrun's derived values as data and checking the results match the existing code on + thousands of generated sheets. + +--- + ## [1.14.4] - 2026-09-29 NPC sheets and hidden faces stay with the GM. @@ -31,15 +44,6 @@ NPC sheets and hidden faces stay with the GM. one by hand. Their tests now run against the checkout, so every case they covered is still checked. -### Under the hood - -- **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: - every sheet is still worked out exactly as before. It is proven by restating CWN's and - Shadowrun's derived values as data and checking the results match the existing code on - thousands of generated sheets. - --- ## [1.14.3] - 2026-09-29 From 5a1d5a1734db91185e332777e4556b56d7c3ec99 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 29 Sep 2026 15:08:17 -0500 Subject: [PATCH 03/26] fix: a player's own login no longer opens the GM's routes and admin socket events Every token is signed with the same secret, and the checks only asked whether a token was valid and not temporary, which a player's login token is. It passed authenticate (delete buildings, GM notes, approve accounts, reset passwords) and every admin socket check (socket admin at identify, grant editor rights, set any bank balance, NPC users, purge dice history). The checks now say what they mean: authenticate admits the GM or a granted editor; authenticatePlayer adds a player's own login for their own sheet and portrait; optionalAuthenticate treats players as anonymous; admin socket events require the GM's own login. Three tests signed a GM token without the role the real login always carries, and now sign it the real way. Tests walk a player token against every route behind the GM check, and hold GM login, granted editors, player sheet and portrait, and chat. Verified end to end on a real server in secure mode. --- CHANGELOG.md | 10 + README.md | 3 +- backend/__tests__/gm_route_auth.test.js | 313 ++++++++++++++++++ .../__tests__/shop_catalogue_sockets.test.js | 2 +- backend/__tests__/sockets.deathsave.test.js | 2 +- .../__tests__/sockets.identify.secure.test.js | 2 +- backend/middleware/auth.js | 83 +++-- backend/routes/sheets.js | 6 +- backend/sockets/index.js | 25 +- 9 files changed, 407 insertions(+), 39 deletions(-) create mode 100644 backend/__tests__/gm_route_auth.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d169885e..2bca77c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Security + +- **Players can no longer use the GM's tools.** A player's own login worked as a key to the GM's + side of the server: with the right request, a signed-in player could delete buildings, edit the + map, read GM notes, approve accounts, reset passwords, set anyone's bank balance, give themselves + editor rights, or post in chat as anyone. Only the GM, and players the GM has granted editing + rights, can now do those things. Nothing a player normally does changes: their sheet, portrait, + chat, shops and bank all work as before, and granting, revoking and giving back editor rights + work as before. + ### Under the hood - **The first piece of the system builder.** An engine that works out a sheet's derived diff --git a/README.md b/README.md index 70e66948..ddfd8246 100644 --- a/README.md +++ b/README.md @@ -334,7 +334,7 @@ CITY_NET/ │ ├── net/ │ │ └── outbound.js # Every request to a host we do not own goes through here. A named destination (exact hostname, never a suffix test), HTTPS, a deadline covering the body as well as the connection, a byte cap, and no redirect following — none of which a caller can opt out of. Two callers, one auditable surface │ ├── middleware/ -│ │ ├── auth.js # JWT verify middleware (admin + elevated users) +│ │ ├── auth.js # Who a token belongs to, in one place. Every token is signed with the same secret, so a valid signature is not enough: `authenticate` admits the GM (role admin) or a granted editor, `authenticatePlayer` also admits a player's own login for their own sheet and portrait, `optionalAuthenticate` treats players as anonymous; `isMainAdmin` is what every admin socket event checks │ │ ├── uploadConstraints.js # What an upload may be and how to say so when it is not. One message shape naming the file, what was wrong and what would have worked — plus a handler for multer's own failures, since an oversized file previously reached Express's HTML error page and the client reported a JSON syntax error to the user │ │ ├── uploadHeaders.js # What a browser may do with a file somebody uploaded. `/uploads` is served with no auth, so a sandbox CSP puts anything opened from it in an opaque origin and nosniff stops it being re-read as HTML — which is what lets the upload allowlists stay as wide as the file pickers │ │ └── rateLimit.js # A sliding per-caller ceiling, for the one open route that spends our outbound requests on an anonymous caller's say-so. Bounded in memory, since the key is whoever is asking; evicts the least recently seen, so it forgives rather than blocks @@ -415,6 +415,7 @@ CITY_NET/ │ │ └── testDb.js # In-memory SQLite factory for isolated test DBs │ ├── admin.test.js # Admin endpoints (auth, settings, undo access); update routes — 409 with a reason rather than a false success, unauthenticated status, boot id on /version; check-update against a stubbed registry — upgrades only, dev tags per channel, and a prerelease not hiding a stable release │ ├── large_deletes.test.js # A map-sized city (40,000 buildings, past SQLite's bound-value limit) deleted, purged and undone; a failed delete rolling back whole; the history capped, oversized entries marked too large to undo, and a failed history write logged rather than crashing +│ ├── gm_route_auth.test.js # Walks a player's real login token against every route behind the GM check (all refused), and holds what must keep working: GM login, granted editors (grant, use, revoke, surrender), a player's own sheet and portrait, chat, and a player unable to become a socket admin, grant rights or set a bank balance │ ├── cpr_stats.test.js # CP:R stat rolls — BODY rollable, MOVE and LUCK not, and every roll button in the template backed by a server-side roll │ ├── shop_checkout_sockets.test.js # The cart's checkout over the socket: totals in each direction, every way a line fails taking the whole checkout down with it, a changed total, overdraft asked once, and the payer from the socket │ ├── nginx_config.test.js # The assumptions the app makes about the proxy every request arrives through, which no other test here touches — body ceiling at least the largest upload limit, X-Forwarded-For present, the socket able to upgrade, and every mounted path actually proxied. Two faults in one release lived exactly in that gap diff --git a/backend/__tests__/gm_route_auth.test.js b/backend/__tests__/gm_route_auth.test.js new file mode 100644 index 00000000..6acdcbb3 --- /dev/null +++ b/backend/__tests__/gm_route_auth.test.js @@ -0,0 +1,313 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'module'; +import { makeTestDb, get, run } from './helpers/testDb.js'; +import { drain } from './helpers/until.js'; + +/** + * Who may use the GM's doors. + * + * Every token the server issues is signed with one secret: the GM's login (role 'admin'), a + * player's login (role 'player'), and a granted editor's (isTemporary). A valid signature only + * says the server issued it. The checks used to stop there, so a player's own login token got + * past `authenticate` and every "not temporary" test: it deleted buildings, read GM notes, + * approved accounts, and at socket sign-in it made the player a full socket admin (grant editor + * rights, set anyone's bank balance, speak as anyone in chat). + * + * What must keep working, and is held here too: the GM's login, granted editors (grant, use, + * revoke, surrender), players' own sheet and portrait, and chat. + */ + +process.env.JWT_SECRET = 'test-secret'; +process.env.DICE_ANIM_MS = '0'; +const SECRET = 'test-secret'; + +const require_ = createRequire(import.meta.url); +// The same module instances the routes and sockets hold, so a grant made here is seen there. +const auth = require_('../middleware/auth'); +const { authenticate, authenticatePlayer, optionalAuthenticate, elevatedUsers } = auth; +const socketsFactory = require_('../sockets/index.js'); + +/** Exactly what each login signs. */ +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, SECRET); // routes/admin.js +const PLAYER = jwt.sign({ username: 'vex', role: 'player', tempPassword: false }, SECRET, { expiresIn: '7d' }); // routes/player.js +const RESET = jwt.sign({ username: 'vex', role: 'player_reset' }, SECRET, { expiresIn: '15m' }); // routes/player.js +const EDITOR = jwt.sign({ username: 'ghost', isTemporary: true }, SECRET, { expiresIn: '12h' }); // sockets grantElevatedAccess +const FORGED = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'not-the-secret'); +const bearer = (t) => ({ Authorization: `Bearer ${t}` }); + +afterEach(() => { elevatedUsers.clear(); vi.restoreAllMocks(); }); + +describe('the checks themselves', () => { + const app = express(); + app.get('/gm', authenticate, (req, res) => res.json({ user: req.user.username })); + app.get('/player', authenticatePlayer, (req, res) => res.json({ user: req.user.username })); + app.get('/public', optionalAuthenticate, (req, res) => res.json({ user: req.user ? req.user.username : null })); + const hit = (url, token) => (token ? request(app).get(url).set(bearer(token)) : request(app).get(url)); + + it('GM routes let in the GM', async () => { + const res = await hit('/gm', GM); + expect(res.status).toBe(200); + expect(res.body.user).toBe('gm'); + }); + + it('GM routes let in a granted editor while the grant stands, and not after', async () => { + elevatedUsers.add('ghost'); + expect((await hit('/gm', EDITOR)).status).toBe(200); + elevatedUsers.delete('ghost'); + expect((await hit('/gm', EDITOR)).status).toBe(401); + }); + + it('GM routes refuse a player, a reset token, a forged token and no token', async () => { + expect((await hit('/gm', PLAYER)).status).toBe(403); + expect((await hit('/gm', RESET)).status).toBe(403); + expect((await hit('/gm', FORGED)).status).toBe(400); + expect((await hit('/gm')).status).toBe(401); + }); + + it("a player's own routes let in the player, the GM and an editor, but not a reset token", async () => { + elevatedUsers.add('ghost'); + expect((await hit('/player', PLAYER)).body.user).toBe('vex'); + expect((await hit('/player', GM)).status).toBe(200); + expect((await hit('/player', EDITOR)).status).toBe(200); + expect((await hit('/player', RESET)).status).toBe(403); + expect((await hit('/player')).status).toBe(401); + }); + + it('public routes treat a player as anyone else, and only know the GM or an editor', async () => { + elevatedUsers.add('ghost'); + expect((await hit('/public', PLAYER)).body.user).toBeNull(); + expect((await hit('/public', FORGED)).body.user).toBeNull(); + expect((await hit('/public')).body.user).toBeNull(); + expect((await hit('/public', GM)).body.user).toBe('gm'); + expect((await hit('/public', EDITOR)).body.user).toBe('ghost'); + }); + + it('names what each token is', () => { + const d = (t) => jwt.verify(t, SECRET); + expect(auth.isMainAdmin(d(GM))).toBe(true); + for (const t of [PLAYER, RESET, EDITOR]) expect(auth.isMainAdmin(d(t))).toBe(false); + expect(auth.isPlayer(d(PLAYER))).toBe(true); + expect(auth.isPlayer(d(RESET))).toBe(false); + expect(auth.isMainAdmin(null)).toBe(false); + }); +}); + +/** Every router, mounted where server.js mounts it. */ +const MOUNTS = [ + ['/api/locations', '../routes/locations.js', 'full'], + ['/api/locations/:id/battle_maps', '../routes/battle_maps.js', 'full'], + ['/api/locations/:id', '../routes/buildingDetails.js', 'full'], + ['/api/battle_maps', '../routes/battle_maps.js', 'full'], + ['/api/maps', '../routes/maps.js', 'full'], + ['/api/roads', '../routes/roads.js', 'full'], + ['/api/overpasses', '../routes/overpasses.js', 'full'], + ['/api/signs', '../routes/signs.js', 'full'], + ['/api/custom_dice', '../routes/custom_dice.js', 'full'], + ['/api/fonts', '../routes/fonts.js', 'io'], + ['/api/player', '../routes/player.js', 'io'], + ['/api', '../routes/admin.js', 'full'], + ['/api/music', '../routes/music.js', 'io'], + ['/api/sheets', '../routes/sheets.js', 'io'], +]; + +const helpers = { emitUpdate: () => {}, recordAction: () => {} }; +const io = { emit: () => {}, to: () => ({ emit: () => {} }) }; + +const mountAll = (db) => { + const app = express(); + app.use(express.json()); + const routes = []; + for (const [prefix, file, kind] of MOUNTS) { + const factory = require_(file); + const router = kind === 'full' ? factory(db, io, helpers) : factory(db, io); + app.use(prefix, router); + for (const layer of router.stack) { + if (!layer.route) continue; + const handles = layer.route.stack.map((s) => s.handle); + if (!handles.includes(authenticate)) continue; + for (const method of Object.keys(layer.route.methods)) { + routes.push({ method, url: `${prefix}${layer.route.path}`.replace(/:\w+/g, '1') }); + } + } + } + return { app, routes }; +}; + +describe('every GM route, walked with a player token', () => { + it('refuses a player everywhere the GM check stands', async () => { + const db = await makeTestDb(); + await run(db, `INSERT INTO locations (name, x, y, z) VALUES ('CITY HALL', 0, 0, 0)`); + const { app, routes } = mountAll(db); + // The walk proves nothing if it found nothing to walk. + expect(routes.length).toBeGreaterThan(60); + + const let_in = []; + for (const { method, url } of routes) { + const res = await request(app)[method](url).set(bearer(PLAYER)).send({}); + if (res.status !== 403) let_in.push(`${method.toUpperCase()} ${url} -> ${res.status}`); + } + expect(let_in).toEqual([]); + // And nothing was changed on the way. + expect((await get(db, 'SELECT COUNT(*) AS n FROM locations')).n).toBe(1); + }); + + it('still lets the GM through the same routes', async () => { + const db = await makeTestDb(); + const { app } = mountAll(db); + const res = await request(app).get('/api/player/admin/players').set(bearer(GM)); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); +}); + +describe("a player's own routes", () => { + const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000' + + '1f15c4890000000d4944415478da6364f8ffbf1e000501020149a2b8f90000000049454e44ae426082', 'hex'); + let db; + let app; + const written = []; + + beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'generic')`); + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('vex', 'generic', '{"name":"VEX"}', 0)`); + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('rook', 'generic', '{"name":"ROOK"}', 0)`); + app = express(); + app.use(express.json()); + app.use('/api/sheets', require_('../routes/sheets.js')(db, io)); + }); + + afterEach(() => { + for (const f of written.splice(0)) { try { fs.unlinkSync(f); } catch { /* already gone */ } } + }); + + it('loads their own sheet', async () => { + const res = await request(app).get('/api/sheets/own').set(bearer(PLAYER)); + expect(res.status).toBe(200); + expect(res.body.name).toBe('VEX'); + }); + + it("uploads their portrait to their own sheet, and cannot aim it at someone else's", async () => { + const res = await request(app).post('/api/sheets/portrait?username=rook').set(bearer(PLAYER)) + .attach('portrait', PNG, 'me.png'); + expect(res.status).toBe(200); + written.push(path.join(path.dirname(require_.resolve('../routes/sheets.js')), '..', res.body.portrait_url)); + expect((await get(db, `SELECT portrait_url FROM character_sheets WHERE username = 'vex'`)).portrait_url).toBe(res.body.portrait_url); + expect((await get(db, `SELECT portrait_url FROM character_sheets WHERE username = 'rook'`)).portrait_url).toBeNull(); + }); + + it('are not a way into the GM sheet routes', async () => { + expect((await request(app).get('/api/sheets/user/rook').set(bearer(PLAYER))).status).toBe(403); + }); +}); + +describe('sockets: sign-in, chat and granting editor rights', () => { + let db; + + /** One socket on the real handlers, sharing the auth module's grant list as server.js does. */ + const boot = () => { + const emitted = []; + let connectionCb; + const ioFake = { + on: (event, cb) => { if (event === 'connection') connectionCb = cb; }, + emit: (event, data) => emitted.push({ event, data }), + to: () => ({ emit: (event, data) => emitted.push({ event, data }) }), + }; + socketsFactory(ioFake, db, { elevatedUsers, emitUpdate: vi.fn(), recordAction: vi.fn() }); + const handlers = {}; + const socket = { + id: `auth-${Math.random().toString(36).slice(2)}`, + on: (event, fn) => { handlers[event] = fn; }, + emit: (event, data) => emitted.push({ event, data, direct: true }), + broadcast: { emit: () => {} }, + use: () => {}, join: () => {}, disconnect: vi.fn(), + }; + connectionCb(socket); + return { handlers, emitted }; + }; + const signIn = async (payload) => { + const s = boot(); + s.handlers.identify(payload); + await drain(db); + return s; + }; + const events = (emitted, name) => emitted.filter((e) => e.event === name); + + beforeEach(async () => { + db = await makeTestDb(); + await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( + username TEXT PRIMARY KEY, balance REAL, debt REAL, + first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('a player chats as themselves', async () => { + const { handlers, emitted } = await signIn('vex'); + handlers.sendMessage({ sender: 'vex', text: 'on my way' }); + await drain(db); + expect(events(emitted, 'receiveMessage').at(-1).data).toMatchObject({ sender: 'vex', text: 'on my way' }); + }); + + it('the GM can speak as someone else in chat', async () => { + const { handlers, emitted } = await signIn({ userName: 'gm', isAdmin: true, token: GM }); + handlers.sendMessage({ sender: 'FIXER', text: 'job is on' }); + await drain(db); + expect(events(emitted, 'receiveMessage').at(-1).data.sender).toBe('FIXER'); + }); + + it("a player who claims to be the GM with their own login is not the GM: chat keeps their name", async () => { + const { handlers, emitted } = await signIn({ userName: 'vex', isAdmin: true, token: PLAYER }); + handlers.sendMessage({ sender: 'FIXER', text: 'free money' }); + await drain(db); + expect(events(emitted, 'receiveMessage').at(-1).data.sender).toBe('vex'); + }); + + it('the GM grants editor rights; the editor then passes the GM check; revoking ends it', async () => { + const { handlers, emitted } = await signIn({ userName: 'gm', isAdmin: true, token: GM }); + handlers.grantElevatedAccess({ adminToken: GM, targetUser: 'ghost' }); + const granted = events(emitted, 'accessGranted').at(-1); + expect(granted.data.targetUser).toBe('ghost'); + expect(elevatedUsers.has('ghost')).toBe(true); + + const app = express(); + app.get('/gm', authenticate, (req, res) => res.json({ ok: true })); + expect((await request(app).get('/gm').set(bearer(granted.data.token))).status).toBe(200); + + handlers.revokeElevatedAccess({ adminToken: GM, targetUser: 'ghost' }); + expect(elevatedUsers.has('ghost')).toBe(false); + expect((await request(app).get('/gm').set(bearer(granted.data.token))).status).toBe(401); + }); + + it('an editor can give their rights back', async () => { + elevatedUsers.add('ghost'); + const { handlers } = await signIn('ghost'); + handlers.surrenderAccess({ token: EDITOR }); + expect(elevatedUsers.has('ghost')).toBe(false); + }); + + it('a player cannot grant editor rights with their own login, to themselves or anyone', async () => { + const { handlers, emitted } = await signIn({ userName: 'vex', isAdmin: true, token: PLAYER }); + handlers.grantElevatedAccess({ adminToken: PLAYER, targetUser: 'vex' }); + expect(elevatedUsers.has('vex')).toBe(false); + expect(events(emitted, 'accessGranted')).toEqual([]); + }); + + it("a player cannot set anyone's bank balance with their own login; the GM still can", async () => { + await run(db, `INSERT INTO player_banks (username, balance, debt) VALUES ('rook', 100, 0)`); + const player = await signIn('vex'); + player.handlers.adminUpdateBank({ token: PLAYER, username: 'rook', balance: 999999, debt: 0 }); + await drain(db); + expect((await get(db, `SELECT balance FROM player_banks WHERE username = 'rook'`)).balance).toBe(100); + + const gm = await signIn({ userName: 'gm', isAdmin: true, token: GM }); + gm.handlers.adminUpdateBank({ token: GM, username: 'rook', balance: 250, debt: 0 }); + await drain(db); + expect((await get(db, `SELECT balance FROM player_banks WHERE username = 'rook'`)).balance).toBe(250); + }); +}); diff --git a/backend/__tests__/shop_catalogue_sockets.test.js b/backend/__tests__/shop_catalogue_sockets.test.js index 4a3cd818..a6b2a262 100644 --- a/backend/__tests__/shop_catalogue_sockets.test.js +++ b/backend/__tests__/shop_catalogue_sockets.test.js @@ -75,7 +75,7 @@ const admin = async (name = 'GM') => { booted.handlers['identify']({ userName: name, isAdmin: true, - token: jwt.sign({ username: name, isTemporary: false }, 'test-secret'), + token: jwt.sign({ id: 1, username: name, role: 'admin', isTemporary: false }, 'test-secret'), }); await drain(db); return booted; diff --git a/backend/__tests__/sockets.deathsave.test.js b/backend/__tests__/sockets.deathsave.test.js index b111ab81..0580b42a 100644 --- a/backend/__tests__/sockets.deathsave.test.js +++ b/backend/__tests__/sockets.deathsave.test.js @@ -198,7 +198,7 @@ describe('generateNpcSheet with tier', () => { `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('Guy', 0, 0, 0, 'enemy_rhombus', 'SYSTEM', 5, 5)`); const loc = await get(db, `SELECT id FROM locations WHERE name = 'Guy'`); const { handlers, emitted } = boot(db); - handlers['identify']({ userName: 'admin', isAdmin: true, token: jwt.sign({ username: 'admin', isTemporary: false }, 'test-secret') }); + handlers['identify']({ userName: 'admin', isAdmin: true, token: jwt.sign({ id: 1, username: 'admin', role: 'admin', isTemporary: false }, 'test-secret') }); await flush(50); handlers['generateNpcSheet']({ location_id: loc.id, tier: 'elite' }); diff --git a/backend/__tests__/sockets.identify.secure.test.js b/backend/__tests__/sockets.identify.secure.test.js index ba35667a..31656e43 100644 --- a/backend/__tests__/sockets.identify.secure.test.js +++ b/backend/__tests__/sockets.identify.secure.test.js @@ -32,7 +32,7 @@ const socketsFactory = (await import('../sockets/index.js')).default; */ const flush = () => drain(db); -const ADMIN_TOKEN = jwt.sign({ username: 'admin', isTemporary: false }, SECRET); +const ADMIN_TOKEN = jwt.sign({ id: 1, username: 'admin', role: 'admin', isTemporary: false }, SECRET); // as routes/admin.js signs it const TEMP_ADMIN_TOKEN = jwt.sign({ username: 'helper', isTemporary: true }, SECRET); const PLAYER_TOKEN = jwt.sign({ username: 'realplayer', role: 'player' }, SECRET); const HELPER_PLAYER_TOKEN = jwt.sign({ username: 'helper', role: 'player' }, SECRET); diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index e708e471..6aeb236b 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -3,34 +3,75 @@ const jwt = require('jsonwebtoken'); const SECRET = process.env.JWT_SECRET; const elevatedUsers = new Set(); +// Who a token belongs to, decided in one place. +// +// Three kinds of token are signed with the same secret: the GM's (routes/admin.js, role +// 'admin'), a player's (routes/player.js, role 'player'), and a granted editor's (sockets, +// isTemporary). A valid signature therefore says only that the server issued the token, not +// that its holder is the GM - and the checks used to stop there. A player's own login token +// passed `authenticate` and every "not temporary" test, which opened the GM's routes and admin +// socket events to any signed-in player. These say what each check actually means. + +/** The GM's own login. Only admin login signs role 'admin'. */ +const isMainAdmin = (v) => !!v && v.role === 'admin' && !v.isTemporary; + +/** A player the GM has granted editing rights, while the grant still stands. */ +const isGrantedEditor = (v) => !!v && !!v.isTemporary && elevatedUsers.has(v.username); + +/** The GM or a granted editor: who the GM-facing routes are for. */ +const canEdit = (v) => isMainAdmin(v) || isGrantedEditor(v); + +/** A player's own login (not a password-reset token). */ +const isPlayer = (v) => !!v && v.role === 'player' && !v.isTemporary; + +/** The verified payload of an `Authorization: Bearer` header, or null. */ +const verifyHeader = (header) => { + try { return jwt.verify(String(header).split(' ')[1], SECRET); } catch { return null; } +}; + +/** GM-facing routes: the GM or a granted editor. */ const authenticate = (req, res, next) => { - const token = req.headers['authorization']; - if (!token) return res.status(401).json({ error: 'Access denied' }); - try { - const verified = jwt.verify(token.split(' ')[1], SECRET); - if (verified.isTemporary && !elevatedUsers.has(verified.username)) { - return res.status(401).json({ error: 'Temporary access revoked' }); - } + const header = req.headers['authorization']; + if (!header) return res.status(401).json({ error: 'Access denied' }); + const verified = verifyHeader(header); + if (!verified) return res.status(400).json({ error: 'Invalid token' }); + if (canEdit(verified)) { req.user = verified; - next(); - } catch (err) { - res.status(400).json({ error: 'Invalid token' }); + return next(); } + if (verified.isTemporary) return res.status(401).json({ error: 'Temporary access revoked' }); + return res.status(403).json({ error: 'GM only' }); }; -const optionalAuthenticate = (req, res, next) => { - const token = req.headers['authorization']; - if (!token) { - req.user = null; +/** + * The few routes a player calls about themselves (their own sheet, their portrait): a + * player's login, or anyone `authenticate` accepts. + */ +const authenticatePlayer = (req, res, next) => { + const header = req.headers['authorization']; + if (!header) return res.status(401).json({ error: 'Access denied' }); + const verified = verifyHeader(header); + if (!verified) return res.status(400).json({ error: 'Invalid token' }); + if (canEdit(verified) || isPlayer(verified)) { + req.user = verified; return next(); } - try { - const verified = jwt.verify(token.split(' ')[1], SECRET); - req.user = (verified.isTemporary && !elevatedUsers.has(verified.username)) ? null : verified; - } catch (err) { - req.user = null; - } + if (verified.isTemporary) return res.status(401).json({ error: 'Temporary access revoked' }); + return res.status(403).json({ error: 'Not allowed' }); +}; + +/** + * 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. + */ +const optionalAuthenticate = (req, res, next) => { + const header = req.headers['authorization']; + const verified = header ? verifyHeader(header) : null; + req.user = canEdit(verified) ? verified : null; next(); }; -module.exports = { authenticate, optionalAuthenticate, elevatedUsers }; +module.exports = { + authenticate, authenticatePlayer, optionalAuthenticate, elevatedUsers, + isMainAdmin, isGrantedEditor, canEdit, isPlayer, +}; diff --git a/backend/routes/sheets.js b/backend/routes/sheets.js index c777970c..835558ec 100644 --- a/backend/routes/sheets.js +++ b/backend/routes/sheets.js @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); -const { authenticate, optionalAuthenticate } = require('../middleware/auth'); +const { authenticate, authenticatePlayer, optionalAuthenticate } = require('../middleware/auth'); const { canReadNpcSheets, redactTokenCard } = require('../sheets/npcPrivacy'); const { TEMPLATES, DEFAULT_SYSTEM, isValidSystem, getLinkedFields, applyDerived, cwnEffectiveAc, @@ -104,7 +104,7 @@ module.exports = (db, io) => { }); // Player's own sheet — used by non-admin players to fetch stats (e.g. SR6 initiative roll). - router.get('/own', authenticate, (req, res) => { + router.get('/own', authenticatePlayer, (req, res) => { getGameSystem((err, system) => { if (err) return res.status(500).json({ error: err.message }); db.get( @@ -621,7 +621,7 @@ module.exports = (db, io) => { // Portrait upload — player uploads their own portrait; admin can upload // for any username via ?username= query param. - router.post('/portrait', authenticate, upload.single('portrait'), (req, res) => { + router.post('/portrait', authenticatePlayer, upload.single('portrait'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'portrait file required' }); const ext = path.extname(req.file.originalname).toLowerCase() || '.jpg'; const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.gif']; diff --git a/backend/sockets/index.js b/backend/sockets/index.js index 029ce456..0559ef63 100644 --- a/backend/sockets/index.js +++ b/backend/sockets/index.js @@ -1,4 +1,5 @@ const jwt = require('jsonwebtoken'); +const { isMainAdmin } = require('../middleware/auth'); const { cryptoRng } = require('../utils/random'); const { registerInitiativeHandlers } = require('./initiative'); const sheetTemplates = require('../sheets/templates'); @@ -218,7 +219,9 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (info.isAdmin && info.token) { try { const verified = jwt.verify(info.token, SECRET); - if (verified.isTemporary) info.isAdmin = false; + // The GM's own login only. A player's login token verifies too, and used to pass + // here as "not temporary", which made any player a socket admin. + if (!isMainAdmin(verified)) info.isAdmin = false; } catch (err) { console.warn(`User ${info.userName} claimed admin but provided invalid token.`); info.isAdmin = false; @@ -320,7 +323,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('grantElevatedAccess', (data) => { try { const verified = jwt.verify(data.adminToken, SECRET); - if (verified && !verified.isTemporary) { + if (isMainAdmin(verified)) { elevatedUsers.add(data.targetUser); const tempToken = jwt.sign({ username: data.targetUser, isTemporary: true }, SECRET, { expiresIn: '12h' }); console.log(`Admin ${verified.username} granted temporary access to ${data.targetUser}`); @@ -333,7 +336,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('revokeElevatedAccess', (data) => { try { const verified = jwt.verify(data.adminToken, SECRET); - if (verified && !verified.isTemporary) { + if (isMainAdmin(verified)) { elevatedUsers.delete(data.targetUser); console.log(`Admin ${verified.username} revoked temporary access from ${data.targetUser}`); io.emit('accessRevoked', { targetUser: data.targetUser }); @@ -357,7 +360,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('createNPC', (data) => { try { const verified = jwt.verify(data.adminToken, SECRET); - if (verified && !verified.isTemporary) { + if (isMainAdmin(verified)) { db.run('INSERT INTO fake_users (username, isActive) VALUES (?, 1)', [data.npcName], function(err) { if (!err) { activeNPCs.push({ userName: data.npcName, isActive: true }); @@ -371,7 +374,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('toggleNPCStatus', (data) => { try { const verified = jwt.verify(data.adminToken, SECRET); - if (verified && !verified.isTemporary) { + if (isMainAdmin(verified)) { db.run('UPDATE fake_users SET isActive = ? WHERE username = ?', [data.isActive ? 1 : 0, data.npcName], function(err) { if (!err) { const npc = activeNPCs.find(n => n.userName === data.npcName); @@ -385,7 +388,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('deleteNPC', (data) => { try { const verified = jwt.verify(data.adminToken, SECRET); - if (verified && !verified.isTemporary) { + if (isMainAdmin(verified)) { db.run('DELETE FROM fake_users WHERE username = ?', [data.npcName], function(err) { if (!err) { activeNPCs = activeNPCs.filter(n => n.userName !== data.npcName); @@ -779,7 +782,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('purgeDiceHistory', (data) => { if (!data.token) return; jwt.verify(data.token, SECRET, (err, decoded) => { - if (err || decoded.isTemporary) return; + if (err || !isMainAdmin(decoded)) return; db.run('DELETE FROM dice_rolls', (err) => { if (err) console.error('Error purging dice rolls:', err); io.emit('diceRollHistory', []); @@ -2069,7 +2072,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!data || !data.token || !Array.isArray(data.usernames) || data.totalAmount === undefined) return; jwt.verify(data.token, SECRET, (err, decoded) => { if (err) return; - if (decoded.isTemporary || (decoded.role && decoded.role !== 'admin')) return; + if (!isMainAdmin(decoded)) return; const count = data.usernames.length; if (count === 0) return; const amountPerPlayer = Math.ceil((parseFloat(data.totalAmount) / count) * 100) / 100; @@ -2098,7 +2101,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!data || !data.token || !Array.isArray(data.usernames)) return; jwt.verify(data.token, SECRET, (err, decoded) => { if (err) return; - if (decoded.isTemporary || (decoded.role && decoded.role !== 'admin')) return; + if (!isMainAdmin(decoded)) return; getGameSystem((sysErr, system) => { if (sysErr) return; // Which column the table advances on, so the award can carry the level with it. @@ -2130,7 +2133,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!data || !data.token || !Array.isArray(data.usernames)) return; jwt.verify(data.token, SECRET, (err, decoded) => { if (err) return; - if (decoded.isTemporary || (decoded.role && decoded.role !== 'admin')) return; + if (!isMainAdmin(decoded)) return; getGameSystem((sysErr, system) => { if (sysErr) return; awardXpModule.adjustLevel(db, { system, usernames: data.usernames, delta: data.delta }, (reason, results) => { @@ -2147,7 +2150,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('adminUpdateBank', (data) => { if (!data || !data.token || !data.username) return; jwt.verify(data.token, SECRET, (err, decoded) => { - if (err || decoded.isTemporary) return; + if (err || !isMainAdmin(decoded)) return; const balance = parseFloat(data.balance); const debt = parseFloat(data.debt); if (isNaN(balance) || isNaN(debt)) return; From b193d4f15bfb4572553d9e94d325df3b48cf79f2 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 29 Sep 2026 15:25:09 -0500 Subject: [PATCH 04/26] fix: editor grants reach only the player promoted; edit approvals need the GM grantElevatedAccess and approveEditing sent the new editor's token with io.emit, so every connected client received a working key. They now send it only to the target's own connections (the client already ignored grants for anyone else, so the promoted player sees no difference). approveEditing, denyEditing and revokeEditing had no check at all: a player could approve their own edit request. They now require the GM or a granted editor, the same people who see those buttons. Tests run several connections on one server; mutation-checked. Verified on a real secure-mode server: grant, use, revoke, surrender, approve and kick all work, a bystander receives nothing, a player cannot approve themselves. --- CHANGELOG.md | 6 + README.md | 2 +- backend/__tests__/gm_route_auth.test.js | 233 +++++++++++++++++------- backend/sockets/index.js | 26 ++- 4 files changed, 192 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bca77c5..7010578d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). chat, shops and bank all work as before, and granting, revoking and giving back editor rights work as before. +- **Editor rights go only to the player receiving them.** Granting editor rights, or approving an + edit request, used to send the new editor's key to every connected player, and any of them + could copy it. It now reaches only the player being promoted. Approving, denying and ending an + edit request were also open to anyone: a player could approve their own request. Those buttons + now work only for the GM and granted editors, as they appear in the admin panel. + ### Under the hood - **The first piece of the system builder.** An engine that works out a sheet's derived diff --git a/README.md b/README.md index ddfd8246..a3e77efd 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,7 @@ CITY_NET/ │ │ └── testDb.js # In-memory SQLite factory for isolated test DBs │ ├── admin.test.js # Admin endpoints (auth, settings, undo access); update routes — 409 with a reason rather than a false success, unauthenticated status, boot id on /version; check-update against a stubbed registry — upgrades only, dev tags per channel, and a prerelease not hiding a stable release │ ├── large_deletes.test.js # A map-sized city (40,000 buildings, past SQLite's bound-value limit) deleted, purged and undone; a failed delete rolling back whole; the history capped, oversized entries marked too large to undo, and a failed history write logged rather than crashing -│ ├── gm_route_auth.test.js # Walks a player's real login token against every route behind the GM check (all refused), and holds what must keep working: GM login, granted editors (grant, use, revoke, surrender), a player's own sheet and portrait, chat, and a player unable to become a socket admin, grant rights or set a bank balance +│ ├── gm_route_auth.test.js # Walks a player's real login token against every route behind the GM check (all refused), and holds what must keep working: GM login, granted editors (grant, use, revoke, surrender), a player's own sheet and portrait, chat, and a player unable to become a socket admin, grant rights, approve their own edit request or set a bank balance; editor grants reach only the player promoted │ ├── cpr_stats.test.js # CP:R stat rolls — BODY rollable, MOVE and LUCK not, and every roll button in the template backed by a server-side roll │ ├── shop_checkout_sockets.test.js # The cart's checkout over the socket: totals in each direction, every way a line fails taking the whole checkout down with it, a changed total, overdraft asked once, and the payer from the socket │ ├── nginx_config.test.js # The assumptions the app makes about the proxy every request arrives through, which no other test here touches — body ceiling at least the largest upload limit, X-Forwarded-For present, the socket able to upgrade, and every mounted path actually proxied. Two faults in one release lived exactly in that gap diff --git a/backend/__tests__/gm_route_auth.test.js b/backend/__tests__/gm_route_auth.test.js index 6acdcbb3..80e7858e 100644 --- a/backend/__tests__/gm_route_auth.test.js +++ b/backend/__tests__/gm_route_auth.test.js @@ -206,37 +206,40 @@ describe("a player's own routes", () => { }); }); -describe('sockets: sign-in, chat and granting editor rights', () => { +describe('sockets: sign-in, chat and editor rights', () => { let db; - /** One socket on the real handlers, sharing the auth module's grant list as server.js does. */ - const boot = () => { - const emitted = []; + /** + * One server on the real handlers, several connections to it, sharing the auth module's + * grant list as server.js does. `sent` records every emit with where it went: 'all' for a + * broadcast, the socket id for io.to(id), 'self' for a reply down one socket. + */ + const server = () => { + const sent = []; let connectionCb; const ioFake = { on: (event, cb) => { if (event === 'connection') connectionCb = cb; }, - emit: (event, data) => emitted.push({ event, data }), - to: () => ({ emit: (event, data) => emitted.push({ event, data }) }), + emit: (event, data) => sent.push({ event, data, to: 'all' }), + to: (id) => ({ emit: (event, data) => sent.push({ event, data, to: id }) }), }; socketsFactory(ioFake, db, { elevatedUsers, emitUpdate: vi.fn(), recordAction: vi.fn() }); - const handlers = {}; - const socket = { - id: `auth-${Math.random().toString(36).slice(2)}`, - on: (event, fn) => { handlers[event] = fn; }, - emit: (event, data) => emitted.push({ event, data, direct: true }), - broadcast: { emit: () => {} }, - use: () => {}, join: () => {}, disconnect: vi.fn(), + const connect = async (identify) => { + const handlers = {}; + const socket = { + id: `auth-${Math.random().toString(36).slice(2)}`, + on: (event, fn) => { handlers[event] = fn; }, + emit: (event, data) => sent.push({ event, data, to: 'self' }), + broadcast: { emit: () => {} }, + use: () => {}, join: () => {}, disconnect: vi.fn(), + }; + connectionCb(socket); + handlers.identify(identify); + await drain(db); + return { id: socket.id, handlers }; }; - connectionCb(socket); - return { handlers, emitted }; + return { sent, connect }; }; - const signIn = async (payload) => { - const s = boot(); - s.handlers.identify(payload); - await drain(db); - return s; - }; - const events = (emitted, name) => emitted.filter((e) => e.event === name); + const events = (sent, name) => sent.filter((e) => e.event === name); beforeEach(async () => { db = await makeTestDb(); @@ -247,65 +250,155 @@ describe('sockets: sign-in, chat and granting editor rights', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); }); - it('a player chats as themselves', async () => { - const { handlers, emitted } = await signIn('vex'); - handlers.sendMessage({ sender: 'vex', text: 'on my way' }); - await drain(db); - expect(events(emitted, 'receiveMessage').at(-1).data).toMatchObject({ sender: 'vex', text: 'on my way' }); - }); - - it('the GM can speak as someone else in chat', async () => { - const { handlers, emitted } = await signIn({ userName: 'gm', isAdmin: true, token: GM }); - handlers.sendMessage({ sender: 'FIXER', text: 'job is on' }); - await drain(db); - expect(events(emitted, 'receiveMessage').at(-1).data.sender).toBe('FIXER'); - }); - - it("a player who claims to be the GM with their own login is not the GM: chat keeps their name", async () => { - const { handlers, emitted } = await signIn({ userName: 'vex', isAdmin: true, token: PLAYER }); - handlers.sendMessage({ sender: 'FIXER', text: 'free money' }); - await drain(db); - expect(events(emitted, 'receiveMessage').at(-1).data.sender).toBe('vex'); + describe('chat', () => { + it('a player chats as themselves', async () => { + const s = server(); + const vex = await s.connect('vex'); + vex.handlers.sendMessage({ sender: 'vex', text: 'on my way' }); + await drain(db); + expect(events(s.sent, 'receiveMessage').at(-1).data).toMatchObject({ sender: 'vex', text: 'on my way' }); + }); + + it('the GM can speak as someone else', async () => { + const s = server(); + const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); + gm.handlers.sendMessage({ sender: 'FIXER', text: 'job is on' }); + await drain(db); + expect(events(s.sent, 'receiveMessage').at(-1).data.sender).toBe('FIXER'); + }); + + it('a player claiming to be the GM with their own login still chats as themselves', async () => { + const s = server(); + const vex = await s.connect({ userName: 'vex', isAdmin: true, token: PLAYER }); + vex.handlers.sendMessage({ sender: 'FIXER', text: 'free money' }); + await drain(db); + expect(events(s.sent, 'receiveMessage').at(-1).data.sender).toBe('vex'); + }); }); - it('the GM grants editor rights; the editor then passes the GM check; revoking ends it', async () => { - const { handlers, emitted } = await signIn({ userName: 'gm', isAdmin: true, token: GM }); - handlers.grantElevatedAccess({ adminToken: GM, targetUser: 'ghost' }); - const granted = events(emitted, 'accessGranted').at(-1); - expect(granted.data.targetUser).toBe('ghost'); - expect(elevatedUsers.has('ghost')).toBe(true); - - const app = express(); - app.get('/gm', authenticate, (req, res) => res.json({ ok: true })); - expect((await request(app).get('/gm').set(bearer(granted.data.token))).status).toBe(200); - - handlers.revokeElevatedAccess({ adminToken: GM, targetUser: 'ghost' }); - expect(elevatedUsers.has('ghost')).toBe(false); - expect((await request(app).get('/gm').set(bearer(granted.data.token))).status).toBe(401); - }); - - it('an editor can give their rights back', async () => { - elevatedUsers.add('ghost'); - const { handlers } = await signIn('ghost'); - handlers.surrenderAccess({ token: EDITOR }); - expect(elevatedUsers.has('ghost')).toBe(false); + describe('temporary admin, granted by the GM', () => { + it('reaches the player it is for, on every connection they have, and nobody else', async () => { + const s = server(); + const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); + const ghostGame = await s.connect('ghost'); + const ghostSheetTab = await s.connect('ghost'); + const bystander = await s.connect('rook'); + + gm.handlers.grantElevatedAccess({ adminToken: GM, targetUser: 'ghost' }); + // The socket module keeps its list of connections for the life of the process, so + // earlier tests' connections are still in it: assert on this test's own. + const grants = events(s.sent, 'accessGranted'); + const to = grants.map((g) => g.to); + expect(to).toContain(ghostGame.id); + expect(to).toContain(ghostSheetTab.id); + expect(to).not.toContain(bystander.id); + expect(to).not.toContain(gm.id); + expect(to).not.toContain('all'); + expect(grants.every((g) => g.data.targetUser === 'ghost' && g.data.token)).toBe(true); + expect(elevatedUsers.has('ghost')).toBe(true); + }); + + it('works as a key to the GM routes until it is revoked', async () => { + const s = server(); + const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); + await s.connect('ghost'); + gm.handlers.grantElevatedAccess({ adminToken: GM, targetUser: 'ghost' }); + const token = events(s.sent, 'accessGranted').at(-1).data.token; + + const app = express(); + app.get('/gm', authenticate, (req, res) => res.json({ ok: true })); + expect((await request(app).get('/gm').set(bearer(token))).status).toBe(200); + + gm.handlers.revokeElevatedAccess({ adminToken: GM, targetUser: 'ghost' }); + expect(elevatedUsers.has('ghost')).toBe(false); + // Revoking still tells everyone: it carries no token, and the client only acts on its own. + expect(events(s.sent, 'accessRevoked').at(-1)).toMatchObject({ to: 'all', data: { targetUser: 'ghost' } }); + expect((await request(app).get('/gm').set(bearer(token))).status).toBe(401); + }); + + it('can be given back by the editor', async () => { + elevatedUsers.add('ghost'); + const s = server(); + const ghost = await s.connect('ghost'); + ghost.handlers.surrenderAccess({ token: EDITOR }); + expect(elevatedUsers.has('ghost')).toBe(false); + }); + + it('cannot be granted by a player with their own login, to themselves or anyone', async () => { + const s = server(); + const vex = await s.connect({ userName: 'vex', isAdmin: true, token: PLAYER }); + vex.handlers.grantElevatedAccess({ adminToken: PLAYER, targetUser: 'vex' }); + expect(elevatedUsers.has('vex')).toBe(false); + expect(events(s.sent, 'accessGranted')).toEqual([]); + }); }); - it('a player cannot grant editor rights with their own login, to themselves or anyone', async () => { - const { handlers, emitted } = await signIn({ userName: 'vex', isAdmin: true, token: PLAYER }); - handlers.grantElevatedAccess({ adminToken: PLAYER, targetUser: 'vex' }); - expect(elevatedUsers.has('vex')).toBe(false); - expect(events(emitted, 'accessGranted')).toEqual([]); + describe('editing requests (REQUEST EDIT on a building)', () => { + it('the GM approves: the player becomes an editor, and only they get the token', async () => { + const s = server(); + const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); + const vex = await s.connect('vex'); + const rook = await s.connect('rook'); + vex.handlers.requestEditing({ userId: 'vex', userName: 'vex', locationId: 1, locationName: 'BAR' }); + expect(events(s.sent, 'editingRequested')).toHaveLength(1); + + gm.handlers.approveEditing({ userId: 'vex', location: { id: 1 } }); + expect(elevatedUsers.has('vex')).toBe(true); + const grants = events(s.sent, 'accessGranted'); + const to = grants.map((g) => g.to); + expect(to).toContain(vex.id); + expect(to).not.toContain(rook.id); + expect(to).not.toContain(gm.id); + expect(to).not.toContain('all'); + expect(grants.every((g) => g.data.targetUser === 'vex' && g.data.forEditing === true)).toBe(true); + expect(events(s.sent, 'editingApproved')).toHaveLength(1); + }); + + it('a granted editor can still approve, as before', async () => { + elevatedUsers.add('ghost'); + const s = server(); + const ghost = await s.connect('ghost'); + await s.connect('vex'); + ghost.handlers.approveEditing({ userId: 'vex' }); + expect(elevatedUsers.has('vex')).toBe(true); + }); + + it('a player cannot approve their own request', async () => { + const s = server(); + const vex = await s.connect('vex'); + vex.handlers.approveEditing({ userId: 'vex' }); + expect(elevatedUsers.has('vex')).toBe(false); + expect(events(s.sent, 'accessGranted')).toEqual([]); + }); + + it('the GM can deny a request and kick an editor; a player can do neither', async () => { + elevatedUsers.add('ghost'); + const s = server(); + const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); + const vex = await s.connect('vex'); + + vex.handlers.revokeEditing({ userId: 'ghost' }); + vex.handlers.denyEditing({ userId: 'ghost' }); + expect(elevatedUsers.has('ghost')).toBe(true); + expect(events(s.sent, 'editingRevoked')).toEqual([]); + expect(events(s.sent, 'editingDenied')).toEqual([]); + + gm.handlers.denyEditing({ userId: 'vex' }); + gm.handlers.revokeEditing({ userId: 'ghost' }); + expect(events(s.sent, 'editingDenied')).toHaveLength(1); + expect(elevatedUsers.has('ghost')).toBe(false); + }); }); it("a player cannot set anyone's bank balance with their own login; the GM still can", async () => { await run(db, `INSERT INTO player_banks (username, balance, debt) VALUES ('rook', 100, 0)`); - const player = await signIn('vex'); - player.handlers.adminUpdateBank({ token: PLAYER, username: 'rook', balance: 999999, debt: 0 }); + const s = server(); + const vex = await s.connect('vex'); + vex.handlers.adminUpdateBank({ token: PLAYER, username: 'rook', balance: 999999, debt: 0 }); await drain(db); expect((await get(db, `SELECT balance FROM player_banks WHERE username = 'rook'`)).balance).toBe(100); - const gm = await signIn({ userName: 'gm', isAdmin: true, token: GM }); + const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); gm.handlers.adminUpdateBank({ token: GM, username: 'rook', balance: 250, debt: 0 }); await drain(db); expect((await get(db, `SELECT balance FROM player_banks WHERE username = 'rook'`)).balance).toBe(250); diff --git a/backend/sockets/index.js b/backend/sockets/index.js index 0559ef63..27e20f0a 100644 --- a/backend/sockets/index.js +++ b/backend/sockets/index.js @@ -129,6 +129,20 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { return !!info && (info.isAdmin || elevatedUsers.has(info.userName)); }; + /** + * Send to every connection signed in as `username`, and to nobody else. + * + * For what only that person may see. A grant of editor rights carries a working token, and + * it used to go out with io.emit: every connected client received it, and any of them could + * copy it and act as the editor. The client only ever used the one addressed to itself, so + * sending it there alone changes nothing for the person being granted. + */ + const emitToUser = (username, event, data) => { + userSockets.forEach((info, id) => { + if (info && info.userName === username) io.to(id).emit(event, data); + }); + }; + const buildActiveUsers = () => { const userMap = new Map(); userSockets.forEach((info) => { @@ -327,7 +341,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { elevatedUsers.add(data.targetUser); const tempToken = jwt.sign({ username: data.targetUser, isTemporary: true }, SECRET, { expiresIn: '12h' }); console.log(`Admin ${verified.username} granted temporary access to ${data.targetUser}`); - io.emit('accessGranted', { targetUser: data.targetUser, token: tempToken }); + emitToUser(data.targetUser, 'accessGranted', { targetUser: data.targetUser, token: tempToken }); broadcastActiveUsers(); } } catch (err) { console.warn('Unauthorized attempt to grant access:', err.message); } @@ -444,16 +458,20 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('requestEditing', (data) => { io.emit('editingRequested', data); }); + // Approving, denying and ending an edit are the GM's (or a granted editor's) buttons in the + // admin panel. They had no check, so any player could send approveEditing for themselves and + // become an editor; the connection's verified sign-in now decides. socket.on('approveEditing', (data) => { + if (!isAdminSocket(socket) || !data || !data.userId) return; elevatedUsers.add(data.userId); const tempToken = jwt.sign({ username: data.userId, isTemporary: true }, SECRET, { expiresIn: '12h' }); - io.emit('accessGranted', { targetUser: data.userId, token: tempToken, forEditing: true }); + emitToUser(data.userId, 'accessGranted', { targetUser: data.userId, token: tempToken, forEditing: true }); io.emit('editingStarted', data); io.emit('editingApproved', data); }); - socket.on('denyEditing', (data) => { io.emit('editingDenied', data); }); - socket.on('revokeEditing', (data) => { elevatedUsers.delete(data.userId); io.emit('editingStopped'); io.emit('editingRevoked', data); broadcastActiveUsers(); }); + socket.on('denyEditing', (data) => { if (!isAdminSocket(socket)) return; io.emit('editingDenied', data); }); + socket.on('revokeEditing', (data) => { if (!isAdminSocket(socket) || !data) return; elevatedUsers.delete(data.userId); io.emit('editingStopped'); io.emit('editingRevoked', data); broadcastActiveUsers(); }); socket.on('editingFinished', (data) => { if (data?.userId) elevatedUsers.delete(data.userId); io.emit('editingStopped'); }); socket.on('requestRhombusPurge', (data) => { From 6991264877f23f2cca362b68c1a10758e6990abd Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 29 Sep 2026 15:32:12 -0500 Subject: [PATCH 05/26] ci: run the test suites on every pull request, whatever its base The trigger listed only main and dev, so PRs into a feature branch (feature/system-builder and its sb/* pieces) were never tested until the final merge to main. --- .github/workflows/ci.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23124034..b8dd9cb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,13 @@ name: CI Tests on: - # Runs tests whenever someone opens or updates a Pull Request targeting 'main' or - # 'dev' — the integration branch that publishes development images. + # Runs tests whenever someone opens or updates a Pull Request, whatever branch it targets. + # + # This used to be limited to 'main' and 'dev', so a long-running feature branch that takes + # its pieces as PRs of its own (feature/system-builder, with sb/* pieces merged into it) + # was never tested until the final merge to main. Every PR is a change about to land + # somewhere, so every PR gets the suites. pull_request: - branches: - - main - - dev # Runs tests when code is merged or pushed directly to 'main'. # From f52a68765ad9b274cd66b77b31b3c4d81d8d526c Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 29 Sep 2026 15:46:14 -0500 Subject: [PATCH 06/26] 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, +}; From 3b65e66660e1709fe85d25880f398904cf78189f Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 29 Sep 2026 16:11:33 -0500 Subject: [PATCH 07/26] feat(system builder 2b): one bank account per player per game system, with a database copy first bank_accounts replaces the one-bank-per-player player_banks, which is kept untouched as the record. Every bank read and write goes through bank/accounts.js, scoped to the running system (a sheet's cash field shows its own system's account), and waits for the one-time move to finish - browsers reconnect within milliseconds of a restart, and an empty account opened first would block a real balance from being copied in. The move (startup/bankAccounts.js) copies the database first when the disk has room (startup/backup.js, VACUUM INTO), then copies each balance, debt and flag into every system the player has a sheet in plus the running one, in one transaction, with a marker so it runs once. Tests: accounts kept apart, the move's rules (mutation-checked: marker, wait, systems, transaction), the copy and its space check, switching systems in play, and db.js opening a 1.14.4-shaped file. The 15 test files that seeded player_banks now use the running system's account. Verified on a read-only copy of the real database: 21 banks into 29 accounts, every value identical. --- CHANGELOG.md | 10 + README.md | 5 + backend/__tests__/bank_accounts.test.js | 311 ++++++++++++++++++ backend/__tests__/bank_own_account.test.js | 11 +- backend/__tests__/cpr_cyberware_roll.test.js | 1 - backend/__tests__/cwn_seating.test.js | 1 - .../__tests__/cwn_skillplugs_sockets.test.js | 1 - backend/__tests__/cwn_sockets.test.js | 1 - backend/__tests__/cwn_vehicle_combat.test.js | 1 - backend/__tests__/gm_route_auth.test.js | 9 +- backend/__tests__/helpers/testDb.js | 11 + backend/__tests__/shop_buy_sockets.test.js | 9 +- .../__tests__/shop_catalogue_sockets.test.js | 7 +- .../__tests__/shop_checkout_sockets.test.js | 7 +- backend/__tests__/shop_sell_sockets.test.js | 7 +- backend/__tests__/sockets.awardxp.test.js | 1 - backend/__tests__/sockets.deathsave.test.js | 1 - .../__tests__/sockets.tokencontrol.test.js | 1 - backend/__tests__/sr6_sockets.test.js | 1 - backend/bank/accounts.js | 96 ++++++ backend/db.js | 25 ++ backend/routes/sheets.js | 5 +- backend/sockets/index.js | 82 ++--- backend/startup/backup.js | 50 +++ backend/startup/bankAccounts.js | 93 ++++++ 25 files changed, 655 insertions(+), 92 deletions(-) create mode 100644 backend/__tests__/bank_accounts.test.js create mode 100644 backend/bank/accounts.js create mode 100644 backend/startup/backup.js create mode 100644 backend/startup/bankAccounts.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 04500110..f89f8d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Changed + +- **Each game system has its own bank.** A character's money now belongs to the game it was + earned in: starting a new campaign on another system opens fresh accounts, and switching + back finds the old money exactly where it was. On the first start after updating, every + player's current balance, debt and bonuses are copied into each system they have a + character in, so nothing looks different in any existing game. A full copy of the + database is saved beside it first (when the disk has room), and the old bank records are + kept untouched. + ### Security - **Players can no longer use the GM's tools.** A player's own login worked as a key to the GM's diff --git a/README.md b/README.md index fbc5b1d2..19cbb6d3 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,8 @@ CITY_NET/ │ ├── bulk.js # Reads and deletes by id in pieces of 500, one transaction per delete so it still happens entirely or not at all. A map-sized city is more ids than SQLite takes in one statement │ ├── updater.js # In-app self-update — paginated registry tag listing so a run of dev builds cannot hide a stable release; release channels selected by IMAGE_TAG alone, the same variable compose pulls with (X.Y.Z-dev tags with an optional counter, ordered so a release supersedes its own dev builds); preflight (compose file mounted, docker socket, compose project labels) so a stack that cannot update says why instead of hanging, and offers updating from the host as an equal option since running without the socket is a supported posture; one update at a time, refused rather than queued, with a stale-run release so a hung pull does not deaden the button; the helper command passed as argv rather than through `sh -c`, so a compose label containing a command substitution is data and not code; upgrade-only semver check; update log on the data volume; boot id so a restart is detectable without a version change; the registry read goes through net/outbound, and the docker probe behind GET /api/version is asked once per process rather than once per request — execSync holds the event loop, so a probe on an open route was a way to stall the server │ ├── buildingTypes.js # What a building is for, which catalogues it sells, and which of those a shelf can actually show. Distinct from `classification`, which is the mesh a custom structure is drawn from - a ripperdoc and a noodle bar can share a shape +│ ├── bank/ +│ │ └── accounts.js # Bank accounts, one per player per game system (`bank_accounts`): a character's money belongs to the game it was earned in. Every read and write goes through here and waits for the one-time move from the old per-player table to finish │ ├── buildings/ │ │ ├── gmNotes.js # The GM's notes, in their own table rather than a column every player downloads. Kept through a single delete so undo brings them back; pruned on a map clear and replaced on a map load, the two places location ids are reused │ │ ├── locationRows.js # Putting whole location rows back - a saved map loading, a delete being undone - with every column the table has, read from the table. The hand-kept lists it replaced had fallen behind and dropped building types, buy-back rates, AC and more @@ -410,6 +412,8 @@ CITY_NET/ │ │ ├── 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/ +│ │ ├── 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 +│ │ ├── bankAccounts.js # The one-time move from one bank per player (`player_banks`, kept untouched) to one per player per system: the database copied first, each balance copied into every system the player has a sheet in plus the running one, in one transaction, with a marker so it never runs twice │ │ └── sanity_checks.js # In-memory DB checks on boot │ ├── utils/ │ │ └── random.js # cryptoRng — uniform [0,1) from OS entropy (crypto.randomInt); default rng for every roll that decides an outcome @@ -442,6 +446,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 +│ ├── 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 │ ├── 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 diff --git a/backend/__tests__/bank_accounts.test.js b/backend/__tests__/bank_accounts.test.js new file mode 100644 index 00000000..a3ba4e06 --- /dev/null +++ b/backend/__tests__/bank_accounts.test.js @@ -0,0 +1,311 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import { createRequire } from 'module'; +import { makeTestDb, get, all, run } from './helpers/testDb.js'; +import { until, untilValue, drain } from './helpers/until.js'; + +/** + * One bank account per player per game system. + * + * A character's money belongs to the game it was earned in, so a CWN character's credits + * must not turn up in a D&D campaign. The move from the old one-bank-per-player table copies + * each balance into every system the player has a sheet in, plus the running one, once, after + * copying the whole database - and never touches the old table, so nothing can be lost. + */ + +process.env.JWT_SECRET = 'test-secret'; +process.env.DICE_ANIM_MS = '0'; +const require_ = createRequire(import.meta.url); +const accounts = require_('../bank/accounts'); +const { migrateBankAccounts, MARKER } = require_('../startup/bankAccounts'); +const { backupDatabase } = require_('../startup/backup'); +const socketsFactory = require_('../sockets/index.js'); +const sqlite3 = require_('sqlite3'); + +const CWN = 'cities_without_number'; +const CPR = 'cyberpunk_red'; +const cb2p = (fn, ...args) => new Promise((resolve, reject) => fn(...args, (err, v) => (err ? reject(err) : resolve(v)))); +const quiet = { log: () => {}, warn: () => {} }; + +let db; +beforeEach(async () => { db = await makeTestDb(); }); +afterEach(() => { accounts.setReady(Promise.resolve()); vi.restoreAllMocks(); }); + +describe('accounts', () => { + it('keeps each system\'s money apart', async () => { + await cb2p(accounts.put, db, 'GHOST', CWN, 500, 20); + await cb2p(accounts.put, db, 'GHOST', CPR, 7, 0); + expect(await cb2p(accounts.get, db, 'GHOST', CWN)).toMatchObject({ balance: 500, debt: 20 }); + expect(await cb2p(accounts.get, db, 'GHOST', CPR)).toMatchObject({ balance: 7, debt: 0 }); + expect(await cb2p(accounts.get, db, 'GHOST', 'shadowrun_6e')).toBeNull(); + }); + + it('opens an account at zero when asked to make sure of one', async () => { + expect(await cb2p(accounts.ensure, db, 'GHOST', CWN)).toMatchObject({ balance: 0, debt: 0, first_pay_done: 0 }); + await cb2p(accounts.put, db, 'GHOST', CWN, 50, 0); + expect((await cb2p(accounts.ensure, db, 'GHOST', CWN)).balance).toBe(50); + }); + + it('adds to a balance, opening the account if needed', async () => { + await cb2p(accounts.addToBalance, db, 'GHOST', CWN, 100); + await cb2p(accounts.addToBalance, db, 'GHOST', CWN, 25.5); + expect((await cb2p(accounts.get, db, 'GHOST', CWN)).balance).toBe(125.5); + }); + + it('moves an existing account, and leaves a missing one alone, as withdrawals always did', async () => { + await cb2p(accounts.put, db, 'GHOST', CWN, 100, 10); + expect(await cb2p(accounts.adjust, db, 'GHOST', CWN, { balance: -30, debt: 5 })).toBe(1); + expect(await cb2p(accounts.get, db, 'GHOST', CWN)).toMatchObject({ balance: 70, debt: 15 }); + expect(await cb2p(accounts.adjust, db, 'NOBODY', CWN, { balance: -30 })).toBe(0); + expect(await cb2p(accounts.get, db, 'NOBODY', CWN)).toBeNull(); + }); + + it('marks one-time events per account, and only the known ones', async () => { + await cb2p(accounts.ensure, db, 'GHOST', CWN); + await cb2p(accounts.markFlag, db, 'GHOST', CWN, 'first_pay_done'); + expect((await cb2p(accounts.get, db, 'GHOST', CWN)).first_pay_done).toBe(1); + await expect(cb2p(accounts.markFlag, db, 'GHOST', CWN, 'balance = 999999, first_pay_done')).rejects.toThrow(/unknown bank flag/); + }); + + it('holds every operation until the move has finished', async () => { + let finish; + accounts.setReady(new Promise((resolve) => { finish = resolve; })); + let seen = 'waiting'; + accounts.ensure(db, 'GHOST', CWN, () => { seen = 'ran'; }); + await drain(db); + expect(seen).toBe('waiting'); + finish(); + await untilValue(() => seen, (s) => s === 'ran', { label: 'the held operation' }); + }); + + it('turns a failed move into an error for each operation, never a crash', async () => { + accounts.setReady(Promise.reject(new Error('move failed'))); + await expect(cb2p(accounts.get, db, 'GHOST', CWN)).rejects.toThrow('move failed'); + }); +}); + +describe('the one-time move from one bank per player', () => { + const legacy = async (rows) => { + await run(db, `CREATE TABLE player_banks (username TEXT PRIMARY KEY, balance REAL DEFAULT 0, debt REAL DEFAULT 0, + first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); + for (const r of rows) { + await run(db, 'INSERT INTO player_banks (username, balance, debt, first_pay_done, high_roller_done) VALUES (?, ?, ?, ?, ?)', + [r.username, r.balance, r.debt ?? 0, r.first ?? 0, r.high ?? 0]); + } + }; + const sheet = (username, system) => run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, '{}', 0)`, [username, system]); + const accountsOf = (username) => all(db, + 'SELECT system, balance, debt, first_pay_done, high_roller_done FROM bank_accounts WHERE username = ? ORDER BY system', [username]); + + beforeEach(async () => { + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [CWN]); + }); + + it('copies each balance into every system the player has a sheet in, and the running one', async () => { + await legacy([ + { username: 'GHOST', balance: 1200.5, debt: 300, first: 1 }, + { username: 'NEWBIE', balance: 40 }, + ]); + await sheet('GHOST', CPR); + await sheet('GHOST', 'shadowrun_6e'); + // An NPC sheet is not a player's game. + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', 'generic', '{}', 1)`); + + const result = await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect(result).toMatchObject({ ran: true, accounts: 4 }); + + const one = { balance: 1200.5, debt: 300, first_pay_done: 1, high_roller_done: 0 }; + expect(await accountsOf('GHOST')).toEqual([ + { system: CWN, ...one }, { system: CPR, ...one }, { system: 'shadowrun_6e', ...one }, + ]); + // No sheets at all: the running game still gets their money. + expect(await accountsOf('NEWBIE')).toEqual([{ system: CWN, balance: 40, debt: 0, first_pay_done: 0, high_roller_done: 0 }]); + }); + + it('never changes the old table', async () => { + await legacy([{ username: 'GHOST', balance: 99, debt: 1 }]); + const before = await all(db, 'SELECT * FROM player_banks'); + await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect(await all(db, 'SELECT * FROM player_banks')).toEqual(before); + }); + + it('runs once: a sheet made later in a new system starts that bank at zero', async () => { + await legacy([{ username: 'GHOST', balance: 500 }]); + await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect(await get(db, 'SELECT value FROM global_settings WHERE key = ?', [MARKER])).toBeTruthy(); + + await sheet('GHOST', CPR); + expect(await migrateBankAccounts(db, ':memory:', { log: quiet })).toEqual({ ran: false }); + expect((await accountsOf('GHOST')).map((a) => a.system)).toEqual([CWN]); + }); + + it('with nothing to move, only records that it ran, and makes no copy', async () => { + const result = await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect(result).toEqual({ ran: true, accounts: 0, backup: null }); + expect(await get(db, 'SELECT value FROM global_settings WHERE key = ?', [MARKER])).toBeTruthy(); + }); + + it('keeps an account that somehow exists already', async () => { + await legacy([{ username: 'GHOST', balance: 500 }]); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('GHOST', ?, 7, 0)`, [CWN]); + await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect((await accountsOf('GHOST'))[0].balance).toBe(7); + }); + + it('lands all or nothing: a failure part way leaves no accounts and no marker, to try again', async () => { + await legacy([{ username: 'AAA', balance: 1 }, { username: 'ZZZ', balance: 2 }]); + // Refuse the second player's account, after the first has been written. + await run(db, `CREATE TRIGGER refuse BEFORE INSERT ON bank_accounts WHEN NEW.username = 'ZZZ' + BEGIN SELECT RAISE(ABORT, 'refused'); END`); + await expect(migrateBankAccounts(db, ':memory:', { log: quiet })).rejects.toThrow('refused'); + expect(await all(db, 'SELECT * FROM bank_accounts')).toEqual([]); + expect(await get(db, 'SELECT value FROM global_settings WHERE key = ?', [MARKER])).toBeUndefined(); + // And the connection is usable afterwards, not stuck in the transaction. + await run(db, 'DROP TRIGGER refuse'); + expect((await migrateBankAccounts(db, ':memory:', { log: quiet })).accounts).toBe(2); + }); +}); + +describe('the database copy before a migration', () => { + let dir; + let fileDb; + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'citynet-backup-')); + fileDb = await new Promise((resolve, reject) => { + const d = new sqlite3.Database(path.join(dir, 'city.db'), (err) => (err ? reject(err) : resolve(d))); + }); + await run(fileDb, 'CREATE TABLE player_banks (username TEXT PRIMARY KEY, balance REAL)'); + await run(fileDb, `INSERT INTO player_banks VALUES ('GHOST', 1234)`); + }); + afterEach(async () => { + await new Promise((resolve) => fileDb.close(resolve)); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('writes a whole, readable copy beside the database', async () => { + const result = await cb2p(backupDatabase, fileDb, path.join(dir, 'city.db'), 'test'); + expect(path.dirname(result.path)).toBe(dir); + expect(path.basename(result.path)).toMatch(/^city\.db\.before-test-\d{4}-\d\d-\d\dT\d\d-\d\d-\d\d\.bak$/); + const copy = await new Promise((resolve, reject) => { + const d = new sqlite3.Database(result.path, sqlite3.OPEN_READONLY, (err) => (err ? reject(err) : resolve(d))); + }); + expect(await get(copy, 'SELECT balance FROM player_banks')).toEqual({ balance: 1234 }); + await new Promise((resolve) => copy.close(resolve)); + }); + + it('makes no copy, and says why, when the disk is too full for one', async () => { + const result = await new Promise((resolve, reject) => backupDatabase(fileDb, path.join(dir, 'city.db'), 'test', + (err, r) => (err ? reject(err) : resolve(r)), { free: () => 1024 })); + expect(result.skipped).toMatch(/not enough disk space/); + expect(fs.readdirSync(dir).filter((f) => f.endsWith('.bak'))).toEqual([]); + }); + + it('has nothing to copy for an in-memory database', async () => { + expect(await cb2p(backupDatabase, db, ':memory:', 'test')).toEqual({ skipped: 'in-memory database' }); + }); +}); + +describe('money in play, with more than one system', () => { + const server = () => { + const sent = []; + let connectionCb; + const io = { + on: (event, cb) => { if (event === 'connection') connectionCb = cb; }, + emit: (event, data) => sent.push({ event, data }), + to: () => ({ emit: (event, data) => sent.push({ event, data }) }), + }; + socketsFactory(io, db, { elevatedUsers: new Set(), emitUpdate: vi.fn(), recordAction: vi.fn() }); + const connect = async (name) => { + const handlers = {}; + connectionCb({ + id: `bank-${Math.random().toString(36).slice(2)}`, + on: (e, fn) => { handlers[e] = fn; }, + emit: (event, data) => sent.push({ event, data, self: true }), + broadcast: { emit: () => {} }, use: () => {}, join: () => {}, disconnect: vi.fn(), + }); + handlers.identify(name); + await drain(db); + return handlers; + }; + return { sent, connect }; + }; + const setSystem = (system) => run(db, `INSERT OR REPLACE INTO global_settings (key, value) VALUES ('game_system', ?)`, [system]); + const latestBalance = (sent, username) => { + const u = sent.filter((e) => e.event === 'bankUpdate' && e.data.username === username).at(-1); + return u ? u.data.balance : undefined; + }; + + beforeEach(() => { vi.spyOn(console, 'log').mockImplementation(() => {}); }); + + it('shows and moves the running system\'s account, and keeps the other one intact', async () => { + await setSystem(CWN); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('GHOST', ?, 1000, 0)`, [CWN]); + const s = server(); + const ghost = await s.connect('GHOST'); + + ghost.requestBankBalance({ username: 'GHOST' }); + expect(await untilValue(() => latestBalance(s.sent, 'GHOST'), (b) => b === 1000, { label: 'CWN balance' })).toBe(1000); + + // A new campaign on another system: a fresh account, not the CWN money. + await setSystem(CPR); + ghost.requestBankBalance({ username: 'GHOST' }); + // until, not untilValue: that one hands back the value, and a balance of 0 reads as "not yet". + await until(() => latestBalance(s.sent, 'GHOST') === 0, { label: 'a fresh CP:R account' }); + ghost.borrowFunds({ amount: 50 }); + await untilValue(() => get(db, 'SELECT debt FROM bank_accounts WHERE username = ? AND system = ?', ['GHOST', CPR]), + (r) => r && r.debt === 50, { label: 'CP:R debt' }); + + // Back to the CWN campaign: its money is exactly where it was. + await setSystem(CWN); + expect(await get(db, 'SELECT balance, debt FROM bank_accounts WHERE username = ? AND system = ?', ['GHOST', CWN])) + .toEqual({ balance: 1000, debt: 0 }); + }); +}); + +describe('the real startup path', () => { + it('moves an existing server\'s banks when db.js opens it, after copying the database', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'citynet-startup-')); + const file = path.join(dir, 'city.db'); + try { + // A database as a server running 1.14.4 left it: one bank per player. + const seed = [ + `CREATE TABLE global_settings (key TEXT PRIMARY KEY, value TEXT)`, + `INSERT INTO global_settings VALUES ('game_system', '${CWN}')`, + `CREATE TABLE character_sheets (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, system TEXT NOT NULL, + data TEXT NOT NULL DEFAULT '{}', portrait_url TEXT, is_npc INTEGER DEFAULT 0, npc_label TEXT, folder TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)`, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', '${CPR}', '{}', 0)`, + `CREATE TABLE player_banks (username TEXT PRIMARY KEY, balance REAL DEFAULT 0.00, debt REAL DEFAULT 0.00, + first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`, + `INSERT INTO player_banks VALUES ('GHOST', 750, 25, 1, 0)`, + ]; + const script = ` + const sqlite3 = require(${JSON.stringify(require_.resolve('sqlite3'))}); + const seedDb = new sqlite3.Database(${JSON.stringify(file)}); + seedDb.serialize(() => { for (const s of ${JSON.stringify(seed)}) seedDb.run(s); }); + seedDb.close(() => { + process.env.DB_PATH = ${JSON.stringify(file)}; + console.log = () => {}; console.warn = () => {}; + const db = require(${JSON.stringify(require_.resolve('../db.js'))}); + const accounts = require(${JSON.stringify(require_.resolve('../bank/accounts.js'))}); + accounts.get(db, 'GHOST', '${CPR}', (err, cpr) => { + accounts.get(db, 'GHOST', '${CWN}', (err2, cwn) => { + // Exit rather than close: db.js's other startup work may still be queued. + process.stdout.write(JSON.stringify({ err: err && err.message, cpr, cwn }), () => process.exit(0)); + }); + }); + });`; + const out = JSON.parse(execFileSync(process.execPath, ['-e', script], { encoding: 'utf8', timeout: 60000 })); + const moved = { balance: 750, debt: 25, first_pay_done: 1, high_roller_done: 0 }; + expect(out).toEqual({ err: null, cpr: moved, cwn: moved }); + const copies = fs.readdirSync(dir).filter((f) => /^city\.db\.before-bank-accounts-.*\.bak$/.test(f)); + expect(copies).toHaveLength(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/backend/__tests__/bank_own_account.test.js b/backend/__tests__/bank_own_account.test.js index b466370d..06ded420 100644 --- a/backend/__tests__/bank_own_account.test.js +++ b/backend/__tests__/bank_own_account.test.js @@ -46,12 +46,9 @@ function boot(db, id = `bank-sock-${(nextSocket += 1)}`) { let db; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); - await run(db, `INSERT INTO player_banks (username, balance, debt) VALUES ('GHOST', 1000, 500)`); - await run(db, `INSERT INTO player_banks (username, balance, debt) VALUES ('VICTIM', 8000, 0)`); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('GHOST', COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), 1000, 500)`); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('VICTIM', COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), 8000, 0)`); }); const identified = async (name = 'GHOST') => { @@ -62,7 +59,7 @@ const identified = async (name = 'GHOST') => { }; const bank = async (username) => - get(db, 'SELECT balance, debt FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance, debt FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); describe('what these handlers still do, unchanged', () => { it('withdraws from the caller\'s own balance', async () => { @@ -124,7 +121,7 @@ describe('whose account it is', () => { }); it('will not spend someone else\'s balance on their debt', async () => { - await run(db, `UPDATE player_banks SET debt = 1000 WHERE username = 'VICTIM'`); + await run(db, `UPDATE bank_accounts SET debt = 1000 WHERE username = 'VICTIM' AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`); const { handlers } = await identified('GHOST'); handlers['payDebt']({ username: 'VICTIM', amount: 500 }); await drain(db); diff --git a/backend/__tests__/cpr_cyberware_roll.test.js b/backend/__tests__/cpr_cyberware_roll.test.js index 6c1f7298..13a31952 100644 --- a/backend/__tests__/cpr_cyberware_roll.test.js +++ b/backend/__tests__/cpr_cyberware_roll.test.js @@ -66,7 +66,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cyberpunk_red')`); }); diff --git a/backend/__tests__/cwn_seating.test.js b/backend/__tests__/cwn_seating.test.js index 12879b7b..1e011480 100644 --- a/backend/__tests__/cwn_seating.test.js +++ b/backend/__tests__/cwn_seating.test.js @@ -59,7 +59,6 @@ beforeEach(async () => { await run(db, `CREATE TABLE dice_rolls ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, total INTEGER, results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); }); diff --git a/backend/__tests__/cwn_skillplugs_sockets.test.js b/backend/__tests__/cwn_skillplugs_sockets.test.js index fc782b3b..edb282d4 100644 --- a/backend/__tests__/cwn_skillplugs_sockets.test.js +++ b/backend/__tests__/cwn_skillplugs_sockets.test.js @@ -45,7 +45,6 @@ beforeEach(async () => { id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, total INTEGER, results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); }); diff --git a/backend/__tests__/cwn_sockets.test.js b/backend/__tests__/cwn_sockets.test.js index e5d1a754..588c7102 100644 --- a/backend/__tests__/cwn_sockets.test.js +++ b/backend/__tests__/cwn_sockets.test.js @@ -65,7 +65,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); }); diff --git a/backend/__tests__/cwn_vehicle_combat.test.js b/backend/__tests__/cwn_vehicle_combat.test.js index fd8fd125..beaaed82 100644 --- a/backend/__tests__/cwn_vehicle_combat.test.js +++ b/backend/__tests__/cwn_vehicle_combat.test.js @@ -61,7 +61,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); // Trauma multiplies damage on a die roll, which would make every damage assertion // below probabilistic. The rule is tested on its own elsewhere. diff --git a/backend/__tests__/gm_route_auth.test.js b/backend/__tests__/gm_route_auth.test.js index 31efe5b8..152ef77f 100644 --- a/backend/__tests__/gm_route_auth.test.js +++ b/backend/__tests__/gm_route_auth.test.js @@ -244,9 +244,6 @@ describe('sockets: sign-in, chat and editor rights', () => { beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -392,16 +389,16 @@ describe('sockets: sign-in, chat and editor rights', () => { }); it("a player cannot set anyone's bank balance with their own login; the GM still can", async () => { - await run(db, `INSERT INTO player_banks (username, balance, debt) VALUES ('rook', 100, 0)`); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('rook', COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), 100, 0)`); const s = server(); const vex = await s.connect('vex'); vex.handlers.adminUpdateBank({ token: PLAYER, username: 'rook', balance: 999999, debt: 0 }); await drain(db); - expect((await get(db, `SELECT balance FROM player_banks WHERE username = 'rook'`)).balance).toBe(100); + expect((await get(db, `SELECT balance FROM bank_accounts WHERE username = 'rook' AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`)).balance).toBe(100); const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); gm.handlers.adminUpdateBank({ token: GM, username: 'rook', balance: 250, debt: 0 }); await drain(db); - expect((await get(db, `SELECT balance FROM player_banks WHERE username = 'rook'`)).balance).toBe(250); + expect((await get(db, `SELECT balance FROM bank_accounts WHERE username = 'rook' AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`)).balance).toBe(250); }); }); diff --git a/backend/__tests__/helpers/testDb.js b/backend/__tests__/helpers/testDb.js index 85856973..56a05f18 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 )`); + // One bank account per player per system (bank/accounts.js). + db.run(`CREATE TABLE bank_accounts ( + username TEXT NOT NULL, + system TEXT NOT NULL, + balance REAL DEFAULT 0, + debt REAL DEFAULT 0, + first_pay_done INTEGER DEFAULT 0, + high_roller_done INTEGER DEFAULT 0, + PRIMARY KEY (username, system) + )`); + db.run(`CREATE TABLE IF NOT EXISTS custom_systems ( id TEXT PRIMARY KEY, name TEXT NOT NULL, diff --git a/backend/__tests__/shop_buy_sockets.test.js b/backend/__tests__/shop_buy_sockets.test.js index bfb3957b..d02a496f 100644 --- a/backend/__tests__/shop_buy_sockets.test.js +++ b/backend/__tests__/shop_buy_sockets.test.js @@ -62,9 +62,6 @@ let gunShop; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); const r = await run(db, `INSERT INTO locations (name, x, y, z, shape, building_type) VALUES ('Vic''s', 0, 0, 0, 'box', 'gun_shop')`); @@ -79,11 +76,11 @@ const identified = async (name = 'GHOST') => { }; const fund = (username, balance, debt = 0) => run(db, - 'INSERT OR REPLACE INTO player_banks (username, balance, debt) VALUES (?, ?, ?)', + `INSERT OR REPLACE INTO bank_accounts (username, system, balance, debt) VALUES (?, COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), ?, ?)`, [username, balance, debt]); const bank = async (username = 'GHOST') => - get(db, 'SELECT balance, debt FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance, debt FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); const receipt = (emitted) => [...emitted].reverse().find((e) => e.event === 'shopCheckout'); @@ -283,7 +280,7 @@ describe('when the money is not there', () => { describe('a player with no account yet', () => { it('cannot buy on credit just by never having banked', async () => { - // No player_banks row at all reads as nothing saved, not as unlimited. + // No bank account at all reads as nothing saved, not as unlimited. const { handlers, emitted } = await identified(); buy(handlers); expect((await waitReceipt(emitted)).data).toMatchObject({ ok: false, reason: 'funds' }); diff --git a/backend/__tests__/shop_catalogue_sockets.test.js b/backend/__tests__/shop_catalogue_sockets.test.js index a6b2a262..ec41092c 100644 --- a/backend/__tests__/shop_catalogue_sockets.test.js +++ b/backend/__tests__/shop_catalogue_sockets.test.js @@ -51,9 +51,6 @@ let gunShop; beforeEach(async () => { store.clear(); db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `CREATE TABLE IF NOT EXISTS shop_catalogues ( system TEXT NOT NULL, catalogue TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, price REAL NOT NULL DEFAULT 0, fields TEXT NOT NULL DEFAULT '{}', @@ -93,11 +90,11 @@ const seed = (data, username = 'GHOST') => run(db, VALUES (?, 'cities_without_number', ?, 0)`, [username, JSON.stringify(data)]); const fund = (username, balance) => run(db, - 'INSERT OR REPLACE INTO player_banks (username, balance, debt) VALUES (?, ?, 0)', + `INSERT OR REPLACE INTO bank_accounts (username, system, balance, debt) VALUES (?, COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), ?, 0)`, [username, balance]); const bank = (username = 'GHOST') => - get(db, 'SELECT balance FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); const last = (emitted, event) => [...emitted].reverse().find((e) => e.event === event); const waitFor = (emitted, event) => diff --git a/backend/__tests__/shop_checkout_sockets.test.js b/backend/__tests__/shop_checkout_sockets.test.js index 3dd9b44c..63bb9f91 100644 --- a/backend/__tests__/shop_checkout_sockets.test.js +++ b/backend/__tests__/shop_checkout_sockets.test.js @@ -50,9 +50,6 @@ let gunShop; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); const r = await run(db, `INSERT INTO locations (name, x, y, z, shape, building_type) VALUES ('Vic''s', 0, 0, 0, 'box', 'gun_shop')`); @@ -65,7 +62,7 @@ const seed = async (data, username = 'GHOST') => run(db, [username, JSON.stringify(data)]); const fund = (username, balance, debt = 0) => run(db, - 'INSERT OR REPLACE INTO player_banks (username, balance, debt) VALUES (?, ?, ?)', + `INSERT OR REPLACE INTO bank_accounts (username, system, balance, debt) VALUES (?, COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), ?, ?)`, [username, balance, debt]); const identified = async (name = 'GHOST') => { @@ -79,7 +76,7 @@ const sheet = async (username = 'GHOST') => JSON.parse((await get(db, `SELECT data FROM character_sheets WHERE username = ?`, [username])).data); const bank = async (username = 'GHOST') => - get(db, 'SELECT balance, debt FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance, debt FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); const result = (emitted) => [...emitted].reverse().find((e) => e.event === 'shopCheckout'); const waitResult = (emitted) => diff --git a/backend/__tests__/shop_sell_sockets.test.js b/backend/__tests__/shop_sell_sockets.test.js index 05e6439b..1f904034 100644 --- a/backend/__tests__/shop_sell_sockets.test.js +++ b/backend/__tests__/shop_sell_sockets.test.js @@ -53,9 +53,6 @@ let gunShop; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); const r = await run(db, `INSERT INTO locations (name, x, y, z, shape, building_type) VALUES ('Vic''s', 0, 0, 0, 'box', 'gun_shop')`); @@ -68,7 +65,7 @@ const seed = async (data, username = 'GHOST') => run(db, [username, JSON.stringify(data)]); const fund = (username, balance) => run(db, - 'INSERT OR REPLACE INTO player_banks (username, balance, debt) VALUES (?, ?, 0)', + `INSERT OR REPLACE INTO bank_accounts (username, system, balance, debt) VALUES (?, COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), ?, 0)`, [username, balance]); const identified = async (name = 'GHOST') => { @@ -82,7 +79,7 @@ const sheet = async (username = 'GHOST') => JSON.parse((await get(db, `SELECT data FROM character_sheets WHERE username = ?`, [username])).data); const bank = async (username = 'GHOST') => - get(db, 'SELECT balance FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); const result = (emitted) => [...emitted].reverse().find((e) => e.event === 'shopCheckout'); const waitResult = (emitted) => diff --git a/backend/__tests__/sockets.awardxp.test.js b/backend/__tests__/sockets.awardxp.test.js index 017bc9ec..9d490679 100644 --- a/backend/__tests__/sockets.awardxp.test.js +++ b/backend/__tests__/sockets.awardxp.test.js @@ -59,7 +59,6 @@ const playerToken = () => jwt.sign({ username: 'bob', role: 'player' }, 'test-se let db; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT OR REPLACE INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('ghost', 'cities_without_number', ?, 0)`, [JSON.stringify({ level: 1, xp: 0 })]); diff --git a/backend/__tests__/sockets.deathsave.test.js b/backend/__tests__/sockets.deathsave.test.js index 0580b42a..30e28516 100644 --- a/backend/__tests__/sockets.deathsave.test.js +++ b/backend/__tests__/sockets.deathsave.test.js @@ -66,7 +66,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cyberpunk_red')`); }); diff --git a/backend/__tests__/sockets.tokencontrol.test.js b/backend/__tests__/sockets.tokencontrol.test.js index b3df40bc..6e4ce6bb 100644 --- a/backend/__tests__/sockets.tokencontrol.test.js +++ b/backend/__tests__/sockets.tokencontrol.test.js @@ -63,7 +63,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); }); const seedToken = async (shape, owner, controllers = null) => { diff --git a/backend/__tests__/sr6_sockets.test.js b/backend/__tests__/sr6_sockets.test.js index c4555b21..b3c5f746 100644 --- a/backend/__tests__/sr6_sockets.test.js +++ b/backend/__tests__/sr6_sockets.test.js @@ -64,7 +64,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'shadowrun_6e')`); }); diff --git a/backend/bank/accounts.js b/backend/bank/accounts.js new file mode 100644 index 00000000..c73af65a --- /dev/null +++ b/backend/bank/accounts.js @@ -0,0 +1,96 @@ +// Bank accounts: one per player per game system. +// +// A character's money belongs to the game it was earned in (decided with the user, +// 2026-09-29): a CWN character's credits must not turn up in a D&D campaign. So an account +// is keyed by player AND system, in `bank_accounts`. The account a handler uses is the +// running system's, which `activeSystem` reads. +// +// Every read and write of an account goes through here, and each one waits for the one-time +// move from the old per-player table (startup/bankAccounts.js) to finish. That wait is not +// decoration: browsers reconnect within milliseconds of a restart, and an empty account +// opened before the move would block the player's real balance from being copied in. + +const { DEFAULT_SYSTEM } = require('../sheets/templates'); + +let ready = Promise.resolve(); + +/** + * Hold every account operation until `promise` settles. Set once at startup by db.js. A + * failure is handled here as well as by each operation, so it can never surface as an + * unhandled rejection, which would stop the whole server. + */ +const setReady = (promise) => { + ready = Promise.resolve(promise); + ready.catch(() => {}); +}; + +/** Run `fn` once accounts are ready, or hand `cb` the reason they never became ready. */ +const whenReady = (cb, fn) => { + ready.then(fn, (err) => cb(err || new Error('Bank accounts are not ready'))); +}; + +/** The system the game is running. */ +const activeSystem = (db, cb) => { + db.get(`SELECT value FROM global_settings WHERE key = 'game_system'`, [], (err, row) => { + cb(err, row && row.value ? row.value : DEFAULT_SYSTEM); + }); +}; + +const COLUMNS = 'balance, debt, first_pay_done, high_roller_done'; + +/** A player's account in `system`, or null when they have none yet. */ +const get = (db, username, system, cb) => whenReady(cb, () => { + db.get(`SELECT ${COLUMNS} FROM bank_accounts WHERE username = ? AND system = ?`, [username, system], + (err, row) => cb(err || null, row || null)); +}); + +/** A player's account in `system`, opened at zero if they have none. */ +const ensure = (db, username, system, cb) => whenReady(cb, () => { + db.run(`INSERT OR IGNORE INTO bank_accounts (username, system, balance, debt) VALUES (?, ?, 0, 0)`, [username, system], (err) => { + if (err) return cb(err); + db.get(`SELECT ${COLUMNS} FROM bank_accounts WHERE username = ? AND system = ?`, [username, system], + (err2, row) => cb(err2 || null, row || null)); + }); +}); + +/** Set an account's balance and debt, opening it if needed. */ +const put = (db, username, system, balance, debt, cb) => whenReady(cb, () => { + db.run( + `INSERT INTO bank_accounts (username, system, balance, debt) VALUES (?, ?, ?, ?) + ON CONFLICT(username, system) DO UPDATE SET balance = excluded.balance, debt = excluded.debt`, + [username, system, balance, debt], + (err) => cb(err || null), + ); +}); + +/** Add to an account's balance, opening it at that amount if needed. */ +const addToBalance = (db, username, system, amount, cb) => whenReady(cb, () => { + db.run( + `INSERT INTO bank_accounts (username, system, balance, debt) VALUES (?, ?, ?, 0) + ON CONFLICT(username, system) DO UPDATE SET balance = COALESCE(balance, 0) + excluded.balance`, + [username, system, amount], + (err) => cb(err || null), + ); +}); + +/** + * Move an existing account's balance and debt by these amounts. An account that does not + * exist is left alone, as the old handlers did (a withdrawal never opens an account). + */ +const adjust = (db, username, system, { balance = 0, debt = 0 }, cb) => whenReady(cb, () => { + db.run( + `UPDATE bank_accounts SET balance = balance + ?, debt = debt + ? WHERE username = ? AND system = ?`, + [balance, debt, username, system], + function (err) { cb(err || null, err ? 0 : this.changes); }, + ); +}); + +const FLAGS = new Set(['first_pay_done', 'high_roller_done']); + +/** Mark a one-time bank event (first payday, high roller) done for this account. */ +const markFlag = (db, username, system, flag, cb) => whenReady(cb, () => { + if (!FLAGS.has(flag)) return cb(new Error(`unknown bank flag ${flag}`)); + db.run(`UPDATE bank_accounts SET ${flag} = 1 WHERE username = ? AND system = ?`, [username, system], (err) => cb(err || null)); +}); + +module.exports = { setReady, activeSystem, get, ensure, put, addToBalance, adjust, markFlag }; diff --git a/backend/db.js b/backend/db.js index 3ee40721..b6310c9c 100644 --- a/backend/db.js +++ b/backend/db.js @@ -335,6 +335,18 @@ db.serialize(() => { // Migrate existing rows that predate the first_pay_done column db.run(`ALTER TABLE player_banks ADD COLUMN first_pay_done INTEGER DEFAULT 0`, () => {}); db.run(`ALTER TABLE player_banks ADD COLUMN high_roller_done INTEGER DEFAULT 0`, () => {}); + // player_banks above is the old one-bank-per-player table. It is kept, never changed, as the + // record of balances before banks became per system; nothing reads it after the one-time move + // (startup/bankAccounts.js). Every account now lives here, one per player per system. + db.run(`CREATE TABLE IF NOT EXISTS bank_accounts ( + username TEXT NOT NULL, + system TEXT NOT NULL, + balance REAL DEFAULT 0, + debt REAL DEFAULT 0, + first_pay_done INTEGER DEFAULT 0, + high_roller_done INTEGER DEFAULT 0, + PRIMARY KEY (username, system) + )`); db.run(`CREATE TABLE IF NOT EXISTS water_bodies ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -532,6 +544,19 @@ db.serialize(() => { db.run(`UPDATE character_sheets SET data = ? WHERE id = ?`, [JSON.stringify(data), row.id]); }); }); + + // Move the old per-player banks into per-system accounts, once. It starts last in the queue, + // so every table above exists, but bank operations are told to wait for it right now, while + // the database is still being opened - before any socket can connect. + const banksMoved = new Promise((resolve, reject) => { + db.get('SELECT 1', () => { + require('./startup/bankAccounts').migrateBankAccounts(db, dbPath).then(resolve, (err) => { + console.error('[bank] Moving banks to per-system accounts failed, so the bank is unavailable until the next start:', err.message); + reject(err); + }); + }); + }); + require('./bank/accounts').setReady(banksMoved); }); module.exports = db; diff --git a/backend/routes/sheets.js b/backend/routes/sheets.js index 835558ec..fa846dc2 100644 --- a/backend/routes/sheets.js +++ b/backend/routes/sheets.js @@ -6,6 +6,7 @@ const crypto = require('crypto'); const multer = require('multer'); const { authenticate, authenticatePlayer, optionalAuthenticate } = require('../middleware/auth'); const { canReadNpcSheets, redactTokenCard } = require('../sheets/npcPrivacy'); +const bankAccounts = require('../bank/accounts'); const { TEMPLATES, DEFAULT_SYSTEM, isValidSystem, getLinkedFields, applyDerived, cwnEffectiveAc, TOKEN_SOURCES, rangedAcOf, acColumns, @@ -136,9 +137,9 @@ module.exports = (db, io) => { const done = () => res.json({ ...row, data }); const overlayCash = () => { if (!Object.values(linked).includes('bank_balance')) return done(); - db.get(`SELECT balance FROM player_banks WHERE username = ?`, [req.params.username], (e3, bank) => { + bankAccounts.get(db, req.params.username, system, (e3, account) => { Object.entries(linked).forEach(([fieldId, source]) => { - if (source === 'bank_balance') data[fieldId] = bank ? bank.balance : 0; + if (source === 'bank_balance') data[fieldId] = account ? account.balance : 0; }); done(); }); diff --git a/backend/sockets/index.js b/backend/sockets/index.js index 27e20f0a..f8f082d0 100644 --- a/backend/sockets/index.js +++ b/backend/sockets/index.js @@ -1,5 +1,6 @@ const jwt = require('jsonwebtoken'); const { isMainAdmin } = require('../middleware/auth'); +const bank = require('../bank/accounts'); const { cryptoRng } = require('../utils/random'); const { registerInitiativeHandlers } = require('./initiative'); const sheetTemplates = require('../sheets/templates'); @@ -158,18 +159,23 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { io.emit('activeUsersUpdated', buildActiveUsers()); }; + /** + * Tell everyone a player's balance, from their account in the running system (bank/accounts.js: + * one per player per system). An account that does not exist yet is opened at zero, as before. + */ const sendBankUpdate = (username) => { - db.get('SELECT balance, debt, first_pay_done, high_roller_done FROM player_banks WHERE username = ?', [username], (err, row) => { - if (!err && row) { + bank.activeSystem(db, (sErr, system) => { + if (sErr) return; + bank.ensure(db, username, system, (err, row) => { + if (err || !row) return; io.emit('bankUpdate', { username, balance: row.balance, debt: row.debt, firstPayDone: !!row.first_pay_done, highRollerDone: !!row.high_roller_done }); - } else if (!err && !row) { - db.run('INSERT INTO player_banks (username, balance, debt) VALUES (?, 0, 0)', [username], () => { - io.emit('bankUpdate', { username, balance: 0, debt: 0, firstPayDone: false, highRollerDone: false }); - }); - } + }); }); }; + /** Run `fn(system)` with the running system, the one whose accounts money moves in. */ + const withBankSystem = (fn) => bank.activeSystem(db, (err, system) => { if (!err) fn(system); }); + // Load NPCs from DB on startup db.all('SELECT username, isActive FROM fake_users', (err, rows) => { if (err) { console.error('Error loading fake_users:', err.message); return; } @@ -843,7 +849,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { }; // Linked fields (declared per-template) live in other systems: token HP - // in locations, cash in player_banks. Overlay their live values onto the + // in locations, cash in bank_accounts. Overlay their live values onto the // sheet data at read time - they are never stored in the sheet's JSON. const overlayLinkedData = (username, system, data, cb) => { const linked = sheetTemplates.getLinkedFields(system); @@ -875,9 +881,10 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (source === 'token_ac_ranged') out[fieldId] = tokenRow ? sheetTemplates.rangedAcOf(tokenRow) : null; }); if (!wantsCash) return done(out); - db.get(`SELECT balance FROM player_banks WHERE username = ?`, [username], (err, bank) => { + // The account in this sheet's own system: a sheet shows the money of its game. + bank.get(db, username, system, (err, account) => { Object.entries(linked).forEach(([fieldId, source]) => { - if (source === 'bank_balance') out[fieldId] = bank ? bank.balance : 0; + if (source === 'bank_balance') out[fieldId] = account ? account.balance : 0; }); done(out); }); @@ -1803,12 +1810,12 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('markFirstPayDone', (data) => { if (!data || !data.username) return; - db.run('UPDATE player_banks SET first_pay_done = 1 WHERE username = ?', [data.username]); + withBankSystem((system) => bank.markFlag(db, data.username, system, 'first_pay_done', () => {})); }); socket.on('markHighRollerDone', (data) => { if (!data || !data.username) return; - db.run('UPDATE player_banks SET high_roller_done = 1 WHERE username = ?', [data.username]); + withBankSystem((system) => bank.markFlag(db, data.username, system, 'high_roller_done', () => {})); }); /** @@ -1837,9 +1844,9 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!username || !data || !data.amount) return; const amount = parseFloat(data.amount); if (isNaN(amount) || amount <= 0) return; - db.run('UPDATE player_banks SET balance = balance - ? WHERE username = ?', [amount, username], (err) => { + withBankSystem((system) => bank.adjust(db, username, system, { balance: -amount }, (err) => { if (!err) sendBankUpdate(username); - }); + })); }); socket.on('borrowFunds', (data) => { @@ -1847,9 +1854,9 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!username || !data || !data.amount) return; const amount = parseFloat(data.amount); if (isNaN(amount) || amount <= 0) return; - db.run('UPDATE player_banks SET debt = debt + ? WHERE username = ?', [amount, username], (err) => { + withBankSystem((system) => bank.adjust(db, username, system, { debt: amount }, (err) => { if (!err) sendBankUpdate(username); - }); + })); }); socket.on('payDebt', (data) => { @@ -1857,15 +1864,15 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!username || !data || !data.amount) return; let amount = parseFloat(data.amount); if (isNaN(amount) || amount <= 0) return; - db.get('SELECT balance, debt FROM player_banks WHERE username = ?', [username], (err, row) => { + withBankSystem((system) => bank.get(db, username, system, (err, row) => { if (err || !row) return; if (amount > row.balance) amount = row.balance; if (amount > row.debt) amount = row.debt; if (amount <= 0) return; - db.run('UPDATE player_banks SET balance = balance - ?, debt = debt - ? WHERE username = ?', [amount, amount, username], (err2) => { + bank.adjust(db, username, system, { balance: -amount, debt: -amount }, (err2) => { if (!err2) sendBankUpdate(username); }); - }); + })); }); /** @@ -1933,18 +1940,16 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!sale.ok) return refuse(sale.reason, { itemId: sale.itemId }); } - db.get( - 'SELECT balance, debt FROM player_banks WHERE username = ?', - [username], - (bErr, bank) => { + bank.get(db, username, system, + (bErr, account) => { if (bErr) return refuse('no_account'); const plan = shopCheckout.planCheckout({ buys: data.buys, priceOf: shopPrices.priceOf, shelved: catalogues, sale, - balance: bank ? Number(bank.balance) || 0 : 0, - debt: bank ? Number(bank.debt) || 0 : 0, + balance: account ? Number(account.balance) || 0 : 0, + debt: account ? Number(account.debt) || 0 : 0, overdraftAllowed, settle: data.settle, expectedNet: data.expectedNet, @@ -1955,12 +1960,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { } const payBank = () => { - const write = bank - ? ['UPDATE player_banks SET balance = ?, debt = ? WHERE username = ?', - [plan.balance, plan.debt, username]] - : ['INSERT INTO player_banks (username, balance, debt) VALUES (?, ?, ?)', - [username, plan.balance, plan.debt]]; - db.run(write[0], write[1], (wErr) => { + bank.put(db, username, system, plan.balance, plan.debt, (wErr) => { if (wErr) return refuse('write'); sendBankUpdate(username); if (sale) io.emit('sheetUpdated', { username, system }); @@ -2095,15 +2095,9 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (count === 0) return; const amountPerPlayer = Math.ceil((parseFloat(data.totalAmount) / count) * 100) / 100; if (isNaN(amountPerPlayer) || amountPerPlayer <= 0) return; - data.usernames.forEach(uname => { - db.get('SELECT username FROM player_banks WHERE username = ?', [uname], (err, row) => { - if (row) { - db.run('UPDATE player_banks SET balance = COALESCE(balance, 0) + ? WHERE username = ?', [amountPerPlayer, uname], () => sendBankUpdate(uname)); - } else { - db.run('INSERT INTO player_banks (username, balance, debt) VALUES (?, ?, 0)', [uname, amountPerPlayer], () => sendBankUpdate(uname)); - } - }); - }); + withBankSystem((system) => data.usernames.forEach((uname) => { + bank.addToBalance(db, uname, system, amountPerPlayer, () => sendBankUpdate(uname)); + })); }); }); @@ -2172,13 +2166,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { const balance = parseFloat(data.balance); const debt = parseFloat(data.debt); if (isNaN(balance) || isNaN(debt)) return; - db.get('SELECT username FROM player_banks WHERE username = ?', [data.username], (err2, row) => { - if (row) { - db.run('UPDATE player_banks SET balance = ?, debt = ? WHERE username = ?', [balance, debt, data.username], () => sendBankUpdate(data.username)); - } else { - db.run('INSERT INTO player_banks (username, balance, debt) VALUES (?, ?, ?)', [data.username, balance, debt], () => sendBankUpdate(data.username)); - } - }); + withBankSystem((system) => bank.put(db, data.username, system, balance, debt, () => sendBankUpdate(data.username))); }); }); diff --git a/backend/startup/backup.js b/backend/startup/backup.js new file mode 100644 index 00000000..3f2ee723 --- /dev/null +++ b/backend/startup/backup.js @@ -0,0 +1,50 @@ +// A copy of the whole database, taken before a migration changes real data. +// +// `VACUUM INTO` writes a complete, consistent copy while the database stays open, which a +// plain file copy of a live database cannot promise. It is only attempted when the disk has +// room: the copy is as large as the database, and running a disk out of space is exactly how +// a delete once took the whole server down (SQLITE_FULL, 1.14.3). With too little room the +// migration still has its own safety net - the tables it moves data out of are left as they +// were - and the log says plainly that no copy was made. + +const fs = require('fs'); +const path = require('path'); + +/** Room to leave free beyond the copy itself, so the copy never fills the disk. */ +const HEADROOM = 50 * 1024 * 1024; + +/** Free bytes on the disk holding `dir`, or null when the platform cannot say. */ +const freeBytes = (dir) => { + try { + const s = fs.statfsSync(dir); + return Number(s.bavail) * Number(s.bsize); + } catch { + return null; + } +}; + +/** `city.db` → `city.db.before-