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'. # diff --git a/CHANGELOG.md b/CHANGELOG.md index a43ac48a..519b06aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ 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. + +- **Each game system keeps its own token health.** A token's HP, armor and injuries now belong + to the game being played: switching systems puts one game's values away and brings the + other's back, and a character who has never been in that game starts fresh. On the first + start after updating, every token's current values are saved for each system it could be + shown in, so switching looks exactly as it does today for anyone with a character there. + +### 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. + +- **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 + +- **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: + 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. diff --git a/README.md b/README.md index 854e1828..0b290b6e 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 @@ -334,7 +336,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 @@ -352,6 +354,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 @@ -397,11 +400,23 @@ CITY_NET/ │ │ ├── catalogueParse.js # Reads a catalogue a GM pasted or uploaded: CSV, TSV or JSON, real RFC-4180 quoting, per-line problems rather than exceptions. The only reader - the preview is a round trip to it │ │ ├── catalogueStore.js # Uploaded catalogues in memory, added on top of the built-in ones, never replacing them. Holds one system at a time, and consults the built-in book only when that system is CWN - every other game's shops carry only what their GM uploaded. Requires nothing, so priceOf stays synchronous and the lot stays importable from a frontend test │ │ └── catalogueDb.js # The only piece that knows uploaded catalogues live in SQLite. A save replaces one catalogue wholesale, in a transaction +│ ├── tokens/ +│ │ └── vitals.js # A token's health, defense and injuries per game system. The token's own columns hold the running system's (so combat and damage are untouched); the others wait in `token_vitals`. switchSystem swaps them and changes `game_system` in one transaction; map clears and loads drop saved values for tokens that are gone │ ├── sockets/ │ │ ├── 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 +│ │ ├── 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 +│ │ ├── tokenVitals.js # The one-time start of per-system token health: each token's current values saved under every system it could be shown in (a player's sheet systems, or every system for enemies and friendlies, plus the running one), so switching shows what it showed before. Adds rows only; a marker so it runs once │ │ └── 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 @@ -410,6 +425,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, 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 @@ -432,6 +448,11 @@ 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 +│ ├── bank_accounts.test.js # Per-system accounts kept apart; the one-time move (every sheet's system plus the running one, the old table untouched, once only, all or nothing); the database copy and its disk-space check; switching systems in play; and db.js opening a 1.14.4-shaped database file in a child process +│ ├── token_vitals.test.js # Switching swaps and restores every token's health (enemies too, buildings untouched), entirely or not at all, and waits for the one-time start; the start's systems and run-once; map clears and loads; the system picker route and the settings route's guard; db.js on a real file +│ ├── system_builder_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) │ ├── cpr_attack.test.js # CP:R attack module (to-hit, armor, shield, crits, death saves) 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 new file mode 100644 index 00000000..152ef77f --- /dev/null +++ b/backend/__tests__/gm_route_auth.test.js @@ -0,0 +1,404 @@ +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'], + ['/api/systems', '../routes/systems.js', 'db'], +]; + +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) : kind === 'db' ? factory(db) : 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 editor rights', () => { + let db; + + /** + * 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) => 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 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 }; + }; + return { sent, connect }; + }; + const events = (sent, name) => sent.filter((e) => e.event === name); + + beforeEach(async () => { + db = await makeTestDb(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + 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'); + }); + }); + + 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([]); + }); + }); + + 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 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 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 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 f1656715..dfc619a6 100644 --- a/backend/__tests__/helpers/testDb.js +++ b/backend/__tests__/helpers/testDb.js @@ -223,6 +223,41 @@ 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) + )`); + + // A token's health in the systems that are not running (tokens/vitals.js). + db.run(`CREATE TABLE token_vitals ( + location_id INTEGER NOT NULL, + system TEXT NOT NULL, + hp_current INTEGER, + hp_max INTEGER, + hp_temp INTEGER, + melee_ac INTEGER, + ranged_ac INTEGER, + injuries TEXT DEFAULT '{}', + PRIMARY KEY (location_id, system) + )`); + + 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__/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 4a3cd818..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 '{}', @@ -75,7 +72,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; @@ -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 b111ab81..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')`); }); @@ -198,7 +197,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/__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/__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/__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/__tests__/token_vitals.test.js b/backend/__tests__/token_vitals.test.js new file mode 100644 index 00000000..b69b9b1f --- /dev/null +++ b/backend/__tests__/token_vitals.test.js @@ -0,0 +1,266 @@ +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 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 { drain } from './helpers/until.js'; + +/** + * A token's health, defense and injuries, per game system. + * + * The map is shared by every system, but a character's state belongs to its game. The token's + * own columns hold the running system's values - so combat, damage and the health monitor are + * untouched - and switching systems swaps them with the others', in one transaction together + * with the system setting itself. + */ + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const vitals = require_('../tokens/vitals'); +const { migrateTokenVitals, MARKER } = require_('../startup/tokenVitals'); + +const CWN = 'cities_without_number'; +const CPR = 'cyberpunk_red'; +const quiet = { log: () => {}, warn: () => {} }; +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); + +let db; +beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [CWN]); +}); +afterEach(() => { vitals.setReady(Promise.resolve()); vi.restoreAllMocks(); }); + +const addToken = async (shape, { owner = null, hp = null, max = null, temp = null, ac = null, rac = null, injuries = '{}' } = {}) => (await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max, hp_temp, melee_ac, ranged_ac, injuries) + VALUES (?, 0, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)`, + [shape.toUpperCase(), shape, owner, hp, max, temp, ac, rac, injuries])).lastID; +const tokenOf = (id) => get(db, 'SELECT hp_current, hp_max, hp_temp, melee_ac, ranged_ac, injuries FROM locations WHERE id = ?', [id]); +const running = async () => (await get(db, `SELECT value FROM global_settings WHERE key = 'game_system'`)).value; +const BLANK = { hp_current: null, hp_max: null, hp_temp: null, melee_ac: null, ranged_ac: null, injuries: '{}' }; + +describe('switching systems', () => { + it("puts each token's values away and brings the new system's back, and back again", async () => { + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7, max: 12, temp: 2, ac: 14, rac: 13, injuries: '{"head":true}' }); + const cwnValues = await tokenOf(ghost); + + expect(await vitals.switchSystem(db, CPR)).toMatchObject({ from: CWN, to: CPR, switched: true }); + expect(await running()).toBe(CPR); + expect(await tokenOf(ghost)).toEqual(BLANK); + + // Play in Cyberpunk RED changes that game's values only. + await run(db, 'UPDATE locations SET hp_current = 30, hp_max = 40 WHERE id = ?', [ghost]); + + await vitals.switchSystem(db, CWN); + expect(await tokenOf(ghost)).toEqual(cwnValues); + await vitals.switchSystem(db, CPR); + expect(await tokenOf(ghost)).toMatchObject({ hp_current: 30, hp_max: 40 }); + }); + + it('carries enemy and friendly tokens the same way', async () => { + const enemy = await addToken('enemy_rhombus', { hp: 5, max: 9, ac: 12 }); + const friend = await addToken('friendly_rhombus', { hp: 3, max: 3 }); + await vitals.switchSystem(db, CPR); + expect(await tokenOf(enemy)).toEqual(BLANK); + expect(await tokenOf(friend)).toEqual(BLANK); + await vitals.switchSystem(db, CWN); + expect(await tokenOf(enemy)).toMatchObject({ hp_current: 5, hp_max: 9, melee_ac: 12 }); + expect(await tokenOf(friend)).toMatchObject({ hp_current: 3, hp_max: 3 }); + }); + + it('leaves buildings alone', async () => { + const building = (await run(db, `INSERT INTO locations (name, x, y, z, shape, hp_current, melee_ac) VALUES ('BUNKER', 0, 0, 0, 'box', 50, 18)`)).lastID; + await vitals.switchSystem(db, CPR); + expect(await get(db, 'SELECT hp_current, melee_ac FROM locations WHERE id = ?', [building])).toEqual({ hp_current: 50, melee_ac: 18 }); + expect(await all(db, 'SELECT * FROM token_vitals WHERE location_id = ?', [building])).toEqual([]); + }); + + it('switching to the system already running changes nothing', async () => { + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7, max: 12 }); + expect(await vitals.switchSystem(db, CWN)).toMatchObject({ switched: false, tokens: 0 }); + expect(await tokenOf(ghost)).toMatchObject({ hp_current: 7, hp_max: 12 }); + expect(await all(db, 'SELECT * FROM token_vitals')).toEqual([]); + }); + + it('happens entirely or not at all', async () => { + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7, max: 12 }); + await run(db, `CREATE TRIGGER refuse BEFORE UPDATE ON locations BEGIN SELECT RAISE(ABORT, 'refused'); END`); + await expect(vitals.switchSystem(db, CPR)).rejects.toThrow('refused'); + expect(await running()).toBe(CWN); + expect(await tokenOf(ghost)).toMatchObject({ hp_current: 7, hp_max: 12 }); + expect(await all(db, 'SELECT * FROM token_vitals')).toEqual([]); + await run(db, 'DROP TRIGGER refuse'); + expect((await vitals.switchSystem(db, CPR)).switched).toBe(true); + }); + + it('waits for the one-time start to finish', async () => { + let finish; + vitals.setReady(new Promise((resolve) => { finish = resolve; })); + let done = false; + const pending = vitals.switchSystem(db, CPR).then(() => { done = true; }); + await drain(db); + expect(done).toBe(false); + expect(await running()).toBe(CWN); + finish(); + await pending; + expect(await running()).toBe(CPR); + }); +}); + +describe('the one-time start', () => { + const sheet = (username, system, isNpc = 0) => run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, '{}', ?)`, [username, system, isNpc]); + const savedFor = async (id) => (await all(db, 'SELECT system FROM token_vitals WHERE location_id = ? ORDER BY system', [id])).map((r) => r.system); + + it("saves a player's token under every system they have a sheet in, and the running one", async () => { + await sheet('GHOST', CPR); + await sheet('GHOST', 'shadowrun_6e'); + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7, max: 12, ac: 14 }); + await migrateTokenVitals(db, { log: quiet }); + expect(await savedFor(ghost)).toEqual([CWN, CPR, 'shadowrun_6e'].sort()); + expect(await get(db, 'SELECT hp_current, hp_max, melee_ac FROM token_vitals WHERE location_id = ? AND system = ?', [ghost, CPR])) + .toEqual({ hp_current: 7, hp_max: 12, melee_ac: 14 }); + }); + + it('saves an enemy or friendly token under every system in use, NPC sheets included', async () => { + await sheet('GHOST', CPR); + await sheet('gm', 'shadowrun_6e', 1); + const enemy = await addToken('enemy_rhombus', { hp: 5 }); + await migrateTokenVitals(db, { log: quiet }); + expect(await savedFor(enemy)).toEqual([CWN, CPR, 'shadowrun_6e'].sort()); + }); + + it('so that switching afterwards shows exactly what it showed before', async () => { + await sheet('GHOST', CPR); + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7, max: 12, injuries: '{"arm":true}' }); + const before = await tokenOf(ghost); + await migrateTokenVitals(db, { log: quiet }); + await vitals.switchSystem(db, CPR); + expect(await tokenOf(ghost)).toEqual(before); + }); + + it('changes no token, and runs once', async () => { + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7 }); + const before = await tokenOf(ghost); + expect((await migrateTokenVitals(db, { log: quiet })).ran).toBe(true); + expect(await tokenOf(ghost)).toEqual(before); + expect(await get(db, 'SELECT value FROM global_settings WHERE key = ?', [MARKER])).toBeTruthy(); + expect(await migrateTokenVitals(db, { log: quiet })).toEqual({ ran: false }); + }); +}); + +describe('map clears and loads', () => { + it("keep the players' saved values and drop every other token's", async () => { + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7 }); + const enemy = await addToken('enemy_rhombus', { hp: 5 }); + await vitals.switchSystem(db, CPR); + await run(db, 'DELETE FROM locations WHERE id = ?', [enemy]); + await new Promise((resolve) => vitals.pruneAfterMapChange(db, resolve)); + expect((await all(db, 'SELECT location_id FROM token_vitals')).map((r) => r.location_id)).toEqual([ghost]); + }); + + it('through the real clear route', async () => { + const mapsRoute = require_('../routes/maps.js'); + const app = express(); + app.use(express.json()); + app.use('/api/maps', mapsRoute(db, { emit: () => {} }, { emitUpdate: () => {}, recordAction: () => {} })); + await addToken('rhombus', { owner: 'GHOST', hp: 7 }); + const enemy = await addToken('enemy_rhombus', { hp: 5 }); + await vitals.switchSystem(db, CPR); + expect(await all(db, 'SELECT * FROM token_vitals WHERE location_id = ?', [enemy])).toHaveLength(1); + expect((await request(app).post('/api/maps/clear').set('Authorization', `Bearer ${GM}`)).status).toBe(200); + await drain(db); + expect(await all(db, 'SELECT * FROM token_vitals WHERE location_id = ?', [enemy])).toEqual([]); + }); +}); + +describe('the routes', () => { + const makeApp = (emitted) => { + const app = express(); + app.use(express.json()); + const io = { emit: (event, data) => emitted.push({ event, data }), to: () => ({ emit: () => {} }) }; + app.use('/api/sheets', require_('../routes/sheets.js')(db, io)); + app.use('/api', require_('../routes/admin.js')(db, io, { emitUpdate: () => {}, recordAction: () => {} })); + return app; + }; + + it('the system picker switches tokens with the system and has every screen redraw', async () => { + const emitted = []; + const app = makeApp(emitted); + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7 }); + const res = await request(app).put('/api/sheets/system').set('Authorization', `Bearer ${GM}`).send({ system: CPR }); + expect(res.status).toBe(200); + expect(await running()).toBe(CPR); + expect((await tokenOf(ghost)).hp_current).toBeNull(); + expect(emitted.map((e) => e.event)).toEqual(expect.arrayContaining(['gameSystemChanged', 'dataUpdated'])); + expect(emitted.find((e) => e.event === 'dataUpdated').data).toEqual({ isRhombusOnly: true }); + }); + + it('the generic settings route cannot change the system or the migration markers', async () => { + const app = makeApp([]); + for (const key of ['game_system', 'migration_bank_accounts', 'migration_token_vitals']) { + const res = await request(app).post('/api/settings').set('Authorization', `Bearer ${GM}`).send({ key, value: 'x' }); + expect(res.status, key).toBe(400); + } + expect(await running()).toBe(CWN); + // Everything else still goes through as before. + expect((await request(app).post('/api/settings').set('Authorization', `Bearer ${GM}`).send({ key: 'buyback_pct', value: '50' })).status).toBe(200); + }); +}); + +describe('the real startup path', () => { + it("saves an existing server's token health when db.js opens it", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'citynet-tokens-')); + const file = path.join(dir, 'city.db'); + try { + // The backend's own folder, however the checkout is named. Matching a folder name instead + // passed on a Windows checkout under F:\MapSystem and cleared nothing on the CI runner, + // whose path has no such name - so the second open got the first, closed connection. + const backendDir = path.dirname(require_.resolve('../db.js')); + const script = ` + const path = require('path'); + const backendDir = ${JSON.stringify(backendDir)}; + const ours = (k) => k.startsWith(backendDir + path.sep) && !k.includes(path.sep + 'node_modules' + path.sep); + process.env.DB_PATH = ${JSON.stringify(file)}; + console.log = () => {}; console.warn = () => {}; + // First open: a 1.14.4-era database is made, with a token carrying health. + const first = require(${JSON.stringify(require_.resolve('../db.js'))}); + // Its own startup work first (the one-time moves run after the tables exist), so + // nothing of it is still running when this connection closes. + require(${JSON.stringify(require_.resolve('../tokens/vitals.js'))}).whenReady().then(() => { + first.serialize(() => { + first.run("DELETE FROM global_settings WHERE key = 'migration_token_vitals'"); + first.run("INSERT OR REPLACE INTO global_settings (key, value) VALUES ('game_system', '${CWN}')"); + first.run("INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', '${CPR}', '{}', 0)"); + first.run("INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max, melee_ac) VALUES ('GHOST', 0, 0, 0, 'rhombus', 'GHOST', 7, 12, 14)"); + first.run("DELETE FROM token_vitals"); + }); + first.close(() => { + // Second open, as after updating: the start runs, then a switch keeps the health. + const cleared = Object.keys(require.cache).filter(ours); + if (!cleared.some((k) => k.endsWith(path.sep + 'db.js'))) { + process.stdout.write(JSON.stringify({ err: 'db.js was not cleared from the module cache' }), () => process.exit(0)); + return; + } + for (const k of cleared) delete require.cache[k]; + const db = require(${JSON.stringify(require_.resolve('../db.js'))}); + const vitals = require(${JSON.stringify(require_.resolve('../tokens/vitals.js'))}); + vitals.switchSystem(db, '${CPR}').then(() => { + db.get("SELECT hp_current, hp_max, melee_ac FROM locations WHERE owner = 'GHOST'", (err, row) => { + process.stdout.write(JSON.stringify({ err: err && err.message, row }), () => process.exit(0)); + }); + }, (e) => { process.stdout.write(JSON.stringify({ err: e.message }), () => process.exit(0)); }); + }); + });`; + const out = JSON.parse(execFileSync(process.execPath, ['-e', script], { encoding: 'utf8', timeout: 60000 })); + expect(out).toEqual({ err: null, row: { hp_current: 7, hp_max: 12, melee_ac: 14 } }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); 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 f68733a4..3509af62 100644 --- a/backend/db.js +++ b/backend/db.js @@ -335,6 +335,32 @@ 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) + )`); + + // A token's health, defense and injuries in the systems that are NOT running. The running + // system's live on the token itself; switching swaps them (tokens/vitals.js). + db.run(`CREATE TABLE IF NOT EXISTS token_vitals ( + location_id INTEGER NOT NULL, + system TEXT NOT NULL, + hp_current INTEGER, + hp_max INTEGER, + hp_temp INTEGER, + melee_ac INTEGER, + ranged_ac INTEGER, + injuries TEXT DEFAULT '{}', + PRIMARY KEY (location_id, system) + )`); db.run(`CREATE TABLE IF NOT EXISTS water_bodies ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -474,6 +500,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. @@ -519,6 +558,28 @@ 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); + + // Save each token's current health under every system it could be shown in, once; a system + // switch waits for it. After the bank move, so the two never share a transaction. + const tokensSaved = banksMoved.catch(() => {}).then(() => require('./startup/tokenVitals').migrateTokenVitals(db)) + .catch((err) => { + console.error('[tokens] Saving token health per system failed, so switching systems is unavailable until the next start:', err.message); + throw err; + }); + require('./tokens/vitals').setReady(tokensSaved); }); module.exports = db; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index e708e471..b1814424 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -3,34 +3,84 @@ 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' }); +}; + +/** + * 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. + */ +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, requireMainAdmin, elevatedUsers, + isMainAdmin, isGrantedEditor, canEdit, isPlayer, +}; diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 6334b5db..fdb2483f 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -34,6 +34,12 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { router.post('/settings', authenticate, (req, res) => { const { key, value } = req.body; + // Not through here: the game system changes with every token's health in one transaction + // (PUT /api/sheets/system), and the migration markers record one-time moves that must not + // be undone by hand. + if (key === 'game_system' || String(key || '').startsWith('migration_')) { + return res.status(400).json({ error: 'That setting is not changed here' }); + } db.run('INSERT INTO global_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=?', [key, value, value], (err) => { if (err) return res.status(500).json({ error: err.message }); io.emit('settingsUpdated'); diff --git a/backend/routes/maps.js b/backend/routes/maps.js index de8ff8dc..c82e2c79 100644 --- a/backend/routes/maps.js +++ b/backend/routes/maps.js @@ -1,210 +1,216 @@ -const express = require('express'); -const { authenticate } = require('../middleware/auth'); -const gmNotes = require('../buildings/gmNotes'); -const { columnsOf, queueInserts } = require('../buildings/locationRows'); - -/** - * Remember which saved map is live, so exports can name their files after it. - * - * Nothing tracked this before: loading a map replaced the world and forgot where it - * came from. Kept in global_settings rather than client state so every admin agrees - * and it survives a restart. - */ -const setActiveMapName = (db, name) => { - if (name) { - db.run( - `INSERT INTO global_settings (key, value) VALUES ('active_map_name', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, - [String(name).slice(0, 120)], - () => {}, - ); - } else { - db.run(`DELETE FROM global_settings WHERE key = 'active_map_name'`, () => {}); - } -}; - -module.exports = (db, io, { emitUpdate, recordAction }) => { - const router = express.Router(); - - // --- Saved Maps --- - router.get('/', (req, res) => { - db.all('SELECT id, name, timestamp FROM saved_maps ORDER BY timestamp DESC', [], (err, rows) => { - if (err) return res.status(500).json({ error: err.message }); - res.json(rows); - }); - }); - - router.post('/save', authenticate, (req, res) => { - const { name } = req.body; - if (!name) return res.status(400).json({ error: 'Map name required' }); - // Saving under a name makes that the live map. - setActiveMapName(db, name); - - db.serialize(() => { - db.all("SELECT * FROM locations WHERE shape != 'rhombus' OR shape IS NULL", (err1, locations) => { - if (err1) return res.status(500).json({ error: err1.message }); - db.all('SELECT * FROM districts', (err2, districts) => { - if (err2) return res.status(500).json({ error: err2.message }); - db.all('SELECT * FROM roads', (err3, roads) => { - if (err3) return res.status(500).json({ error: err3.message }); - db.all('SELECT * FROM overpasses', (err4, overpasses) => { - if (err4) return res.status(500).json({ error: err4.message }); - db.all('SELECT * FROM water_bodies', (err5, waterBodies) => { - if (err5) return res.status(500).json({ error: err5.message }); - db.all('SELECT * FROM signs', (err6, signs) => { - if (err6) return res.status(500).json({ error: err6.message }); - // The GM's notes on these buildings travel with the map: location ids are - // reused on load, so notes left behind would land on the next map's - // buildings. Only ever read back through the authenticated load below - - // the public listing returns names and timestamps, nothing else. - gmNotes.all(db, (err7, notes) => { - if (err7) return res.status(500).json({ error: err7.message }); - - const sql = `INSERT INTO saved_maps (name, locations_data, districts_data, roads_data, overpasses_data, water_bodies_data, signs_data, gm_notes_data) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(name) DO UPDATE SET - locations_data=excluded.locations_data, - districts_data=excluded.districts_data, - roads_data=excluded.roads_data, - overpasses_data=excluded.overpasses_data, - water_bodies_data=excluded.water_bodies_data, - signs_data=excluded.signs_data, - gm_notes_data=excluded.gm_notes_data, - timestamp=CURRENT_TIMESTAMP`; - db.run(sql, [name, JSON.stringify(locations), JSON.stringify(districts), JSON.stringify(roads), JSON.stringify(overpasses), JSON.stringify(waterBodies), JSON.stringify(signs), JSON.stringify(notes)], function(err) { - if (err) return res.status(500).json({ error: err.message }); - res.json({ message: 'Map saved successfully' }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - - router.post('/load/:name', authenticate, (req, res) => { - db.get('SELECT * FROM saved_maps WHERE name = ?', [req.params.name], (err, row) => { - if (err) return res.status(500).json({ error: err.message }); - if (!row) return res.status(404).json({ error: 'Map not found' }); - setActiveMapName(db, req.params.name); - - const locations = JSON.parse(row.locations_data || '[]'); - const districts = JSON.parse(row.districts_data || '[]'); - const roads = JSON.parse(row.roads_data || '[]'); - const overpasses = JSON.parse(row.overpasses_data || '[]'); - const waterBodies = JSON.parse(row.water_bodies_data || '[]'); - const signs = JSON.parse(row.signs_data || '[]'); - // Null for a map saved before notes traveled with it. That map had none to carry, - // so whatever is in the table now belongs to a different map and goes. - const notes = JSON.parse(row.gm_notes_data || '[]'); - - // The table's columns, looked up before anything is queued so the inserts below keep - // their place in the serialized order - the id sequence is reset after them. - columnsOf(db, 'locations', (colErr, columns) => { - if (colErr) return res.status(500).json({ error: colErr.message }); - - db.serialize(() => { - // Delete all locations except live player rhombuses; enemy/friendly tokens are map content and get replaced - db.run(`DELETE FROM locations WHERE shape IS NULL OR shape != 'rhombus'`); - db.run('DELETE FROM districts'); - db.run('DELETE FROM roads'); - db.run('DELETE FROM overpasses'); - db.run('DELETE FROM water_bodies'); - db.run('DELETE FROM signs'); - - // Every column each building was saved with. The hand-kept list this replaced had - // fallen behind the table, so a loaded map lost every building's type, buy-back - // rate, AC, sidewalk and signage settings and hidden flag. A column the snapshot - // predates takes the table's default, as it would for a new building. - queueInserts(db, columns, locations, { orIgnore: true }); - - if (districts.length > 0) { - const stmtD = db.prepare(`INSERT INTO districts (id, name, color) VALUES (?, ?, ?)`); - districts.forEach(d => stmtD.run([d.id, d.name, d.color])); - stmtD.finalize(); - } - - if (roads.length > 0) { - const stmtR = db.prepare(`INSERT INTO roads (id, x1, z1, x2, z2, width) VALUES (?, ?, ?, ?, ?, ?)`); - roads.forEach(r => stmtR.run([r.id, r.x1, r.z1, r.x2, r.z2, r.width])); - stmtR.finalize(); - } - - if (overpasses.length > 0) { - const stmtO = db.prepare(`INSERT INTO overpasses (id, points, height, width, ramp_length, ramp_length_start, ramp_length_end, pillar_spacing) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`); - overpasses.forEach(o => stmtO.run([o.id, o.points, o.height, o.width, o.ramp_length, o.ramp_length_start ?? null, o.ramp_length_end ?? null, o.pillar_spacing])); - stmtO.finalize(); - } - - db.run('UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM locations) WHERE name="locations"'); - db.run('UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM districts) WHERE name="districts"'); - db.run('UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM roads) WHERE name="roads"'); - db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM overpasses), 0) WHERE name="overpasses"'); - - if (waterBodies.length > 0) { - const stmtW = db.prepare(`INSERT INTO water_bodies (id, points_json, map_scale_multiplier) VALUES (?, ?, ?)`); - waterBodies.forEach(w => stmtW.run([w.id, w.points_json, w.map_scale_multiplier])); - stmtW.finalize(); - } - db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM water_bodies), 0) WHERE name="water_bodies"'); - - if (signs.length > 0) { - const stmtS = db.prepare(`INSERT INTO signs (id, text, x, y, z, rotation_y, font_size, font_family, image_url, use_tv_filter, lines, filter_intensity) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); - signs.forEach(s => stmtS.run([s.id, s.text, s.x, s.y, s.z, s.rotation_y, s.font_size, s.font_family, s.image_url ?? null, s.use_tv_filter ?? 0, s.lines ?? null, s.filter_intensity ?? 1.0])); - stmtS.finalize(); - } - db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM signs), 0) WHERE name="signs"'); - - db.run('SELECT 1', () => { - // After the buildings are in, so a note is only restored onto a building the map - // actually has. - gmNotes.replaceAll(db, notes, () => { - emitUpdate(); - res.json({ message: 'Map loaded successfully' }); - }); - }); - }); - }); - }); - }); - - router.post('/clear', authenticate, (req, res) => { - // Wiping the world leaves no map loaded. - setActiveMapName(db, null); - db.serialize(() => { - // Preserve only live player rhombuses; enemy/friendly tokens are map content - db.run(`DELETE FROM locations WHERE shape IS NULL OR shape != 'rhombus'`); - db.run('DELETE FROM districts'); - db.run('DELETE FROM roads'); - db.run('DELETE FROM overpasses'); - db.run('DELETE FROM water_bodies'); - db.run('DELETE FROM signs'); - db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM locations), 0) WHERE name="locations"'); - db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="districts"'); - db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="roads"'); - db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="overpasses"'); - db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="water_bodies"'); - db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="signs"'); - // The id sequence was just wound back, so the next building made gets an id a - // cleared one had. Its notes must not be waiting for it. - gmNotes.pruneOrphans(db); - - db.run('SELECT 1', () => { - emitUpdate(); - res.json({ message: 'Map cleared completely' }); - }); - }); - }); - - router.delete('/:id', authenticate, (req, res) => { - db.run('DELETE FROM saved_maps WHERE id = ?', [req.params.id], function(err) { - if (err) return res.status(500).json({ error: err.message }); - res.json({ message: 'Map deleted' }); - }); - }); - - return router; -}; +const express = require('express'); +const { authenticate } = require('../middleware/auth'); +const gmNotes = require('../buildings/gmNotes'); +const tokenVitals = require('../tokens/vitals'); +const { columnsOf, queueInserts } = require('../buildings/locationRows'); + +/** + * Remember which saved map is live, so exports can name their files after it. + * + * Nothing tracked this before: loading a map replaced the world and forgot where it + * came from. Kept in global_settings rather than client state so every admin agrees + * and it survives a restart. + */ +const setActiveMapName = (db, name) => { + if (name) { + db.run( + `INSERT INTO global_settings (key, value) VALUES ('active_map_name', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [String(name).slice(0, 120)], + () => {}, + ); + } else { + db.run(`DELETE FROM global_settings WHERE key = 'active_map_name'`, () => {}); + } +}; + +module.exports = (db, io, { emitUpdate, recordAction }) => { + const router = express.Router(); + + // --- Saved Maps --- + router.get('/', (req, res) => { + db.all('SELECT id, name, timestamp FROM saved_maps ORDER BY timestamp DESC', [], (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(rows); + }); + }); + + router.post('/save', authenticate, (req, res) => { + const { name } = req.body; + if (!name) return res.status(400).json({ error: 'Map name required' }); + // Saving under a name makes that the live map. + setActiveMapName(db, name); + + db.serialize(() => { + db.all("SELECT * FROM locations WHERE shape != 'rhombus' OR shape IS NULL", (err1, locations) => { + if (err1) return res.status(500).json({ error: err1.message }); + db.all('SELECT * FROM districts', (err2, districts) => { + if (err2) return res.status(500).json({ error: err2.message }); + db.all('SELECT * FROM roads', (err3, roads) => { + if (err3) return res.status(500).json({ error: err3.message }); + db.all('SELECT * FROM overpasses', (err4, overpasses) => { + if (err4) return res.status(500).json({ error: err4.message }); + db.all('SELECT * FROM water_bodies', (err5, waterBodies) => { + if (err5) return res.status(500).json({ error: err5.message }); + db.all('SELECT * FROM signs', (err6, signs) => { + if (err6) return res.status(500).json({ error: err6.message }); + // The GM's notes on these buildings travel with the map: location ids are + // reused on load, so notes left behind would land on the next map's + // buildings. Only ever read back through the authenticated load below - + // the public listing returns names and timestamps, nothing else. + gmNotes.all(db, (err7, notes) => { + if (err7) return res.status(500).json({ error: err7.message }); + + const sql = `INSERT INTO saved_maps (name, locations_data, districts_data, roads_data, overpasses_data, water_bodies_data, signs_data, gm_notes_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + locations_data=excluded.locations_data, + districts_data=excluded.districts_data, + roads_data=excluded.roads_data, + overpasses_data=excluded.overpasses_data, + water_bodies_data=excluded.water_bodies_data, + signs_data=excluded.signs_data, + gm_notes_data=excluded.gm_notes_data, + timestamp=CURRENT_TIMESTAMP`; + db.run(sql, [name, JSON.stringify(locations), JSON.stringify(districts), JSON.stringify(roads), JSON.stringify(overpasses), JSON.stringify(waterBodies), JSON.stringify(signs), JSON.stringify(notes)], function(err) { + if (err) return res.status(500).json({ error: err.message }); + res.json({ message: 'Map saved successfully' }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + + router.post('/load/:name', authenticate, (req, res) => { + db.get('SELECT * FROM saved_maps WHERE name = ?', [req.params.name], (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + if (!row) return res.status(404).json({ error: 'Map not found' }); + setActiveMapName(db, req.params.name); + + const locations = JSON.parse(row.locations_data || '[]'); + const districts = JSON.parse(row.districts_data || '[]'); + const roads = JSON.parse(row.roads_data || '[]'); + const overpasses = JSON.parse(row.overpasses_data || '[]'); + const waterBodies = JSON.parse(row.water_bodies_data || '[]'); + const signs = JSON.parse(row.signs_data || '[]'); + // Null for a map saved before notes traveled with it. That map had none to carry, + // so whatever is in the table now belongs to a different map and goes. + const notes = JSON.parse(row.gm_notes_data || '[]'); + + // The table's columns, looked up before anything is queued so the inserts below keep + // their place in the serialized order - the id sequence is reset after them. + columnsOf(db, 'locations', (colErr, columns) => { + if (colErr) return res.status(500).json({ error: colErr.message }); + + db.serialize(() => { + // Delete all locations except live player rhombuses; enemy/friendly tokens are map content and get replaced + db.run(`DELETE FROM locations WHERE shape IS NULL OR shape != 'rhombus'`); + db.run('DELETE FROM districts'); + db.run('DELETE FROM roads'); + db.run('DELETE FROM overpasses'); + db.run('DELETE FROM water_bodies'); + db.run('DELETE FROM signs'); + + // Every column each building was saved with. The hand-kept list this replaced had + // fallen behind the table, so a loaded map lost every building's type, buy-back + // rate, AC, sidewalk and signage settings and hidden flag. A column the snapshot + // predates takes the table's default, as it would for a new building. + queueInserts(db, columns, locations, { orIgnore: true }); + + if (districts.length > 0) { + const stmtD = db.prepare(`INSERT INTO districts (id, name, color) VALUES (?, ?, ?)`); + districts.forEach(d => stmtD.run([d.id, d.name, d.color])); + stmtD.finalize(); + } + + if (roads.length > 0) { + const stmtR = db.prepare(`INSERT INTO roads (id, x1, z1, x2, z2, width) VALUES (?, ?, ?, ?, ?, ?)`); + roads.forEach(r => stmtR.run([r.id, r.x1, r.z1, r.x2, r.z2, r.width])); + stmtR.finalize(); + } + + if (overpasses.length > 0) { + const stmtO = db.prepare(`INSERT INTO overpasses (id, points, height, width, ramp_length, ramp_length_start, ramp_length_end, pillar_spacing) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`); + overpasses.forEach(o => stmtO.run([o.id, o.points, o.height, o.width, o.ramp_length, o.ramp_length_start ?? null, o.ramp_length_end ?? null, o.pillar_spacing])); + stmtO.finalize(); + } + + db.run('UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM locations) WHERE name="locations"'); + db.run('UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM districts) WHERE name="districts"'); + db.run('UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM roads) WHERE name="roads"'); + db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM overpasses), 0) WHERE name="overpasses"'); + + if (waterBodies.length > 0) { + const stmtW = db.prepare(`INSERT INTO water_bodies (id, points_json, map_scale_multiplier) VALUES (?, ?, ?)`); + waterBodies.forEach(w => stmtW.run([w.id, w.points_json, w.map_scale_multiplier])); + stmtW.finalize(); + } + db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM water_bodies), 0) WHERE name="water_bodies"'); + + if (signs.length > 0) { + const stmtS = db.prepare(`INSERT INTO signs (id, text, x, y, z, rotation_y, font_size, font_family, image_url, use_tv_filter, lines, filter_intensity) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + signs.forEach(s => stmtS.run([s.id, s.text, s.x, s.y, s.z, s.rotation_y, s.font_size, s.font_family, s.image_url ?? null, s.use_tv_filter ?? 0, s.lines ?? null, s.filter_intensity ?? 1.0])); + stmtS.finalize(); + } + db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM signs), 0) WHERE name="signs"'); + + db.run('SELECT 1', () => { + // After the buildings are in, so a note is only restored onto a building the map + // actually has. + gmNotes.replaceAll(db, notes, () => { + // The loaded tokens are new here: no other system has seen them. + tokenVitals.pruneAfterMapChange(db, () => { + emitUpdate(); + res.json({ message: 'Map loaded successfully' }); + }); + }); + }); + }); + }); + }); + }); + + router.post('/clear', authenticate, (req, res) => { + // Wiping the world leaves no map loaded. + setActiveMapName(db, null); + db.serialize(() => { + // Preserve only live player rhombuses; enemy/friendly tokens are map content + db.run(`DELETE FROM locations WHERE shape IS NULL OR shape != 'rhombus'`); + db.run('DELETE FROM districts'); + db.run('DELETE FROM roads'); + db.run('DELETE FROM overpasses'); + db.run('DELETE FROM water_bodies'); + db.run('DELETE FROM signs'); + db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM locations), 0) WHERE name="locations"'); + db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="districts"'); + db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="roads"'); + db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="overpasses"'); + db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="water_bodies"'); + db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="signs"'); + // The id sequence was just wound back, so the next building made gets an id a + // cleared one had. Its notes must not be waiting for it. + gmNotes.pruneOrphans(db); + // Likewise a token's health saved under other systems. + tokenVitals.pruneAfterMapChange(db); + + db.run('SELECT 1', () => { + emitUpdate(); + res.json({ message: 'Map cleared completely' }); + }); + }); + }); + + router.delete('/:id', authenticate, (req, res) => { + db.run('DELETE FROM saved_maps WHERE id = ?', [req.params.id], function(err) { + if (err) return res.status(500).json({ error: err.message }); + res.json({ message: 'Map deleted' }); + }); + }); + + return router; +}; diff --git a/backend/routes/sheets.js b/backend/routes/sheets.js index c777970c..8558dcc3 100644 --- a/backend/routes/sheets.js +++ b/backend/routes/sheets.js @@ -4,8 +4,10 @@ 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 bankAccounts = require('../bank/accounts'); +const tokenVitals = require('../tokens/vitals'); const { TEMPLATES, DEFAULT_SYSTEM, isValidSystem, getLinkedFields, applyDerived, cwnEffectiveAc, TOKEN_SOURCES, rangedAcOf, acColumns, @@ -70,23 +72,25 @@ module.exports = (db, io) => { router.put('/system', authenticate, requireAdmin, (req, res) => { const { system } = req.body; if (!isValidSystem(system)) return res.status(400).json({ error: 'Unknown game system' }); - db.run( - `INSERT INTO global_settings (key, value) VALUES ('game_system', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, - [system], - (err) => { - if (err) return res.status(500).json({ error: err.message }); - /** - * The uploaded catalogues in memory belong to the system that was running a - * moment ago. Left alone they would price the new game's shops from the old - * game's list, so they are swapped before anybody is told the system changed. - */ - catalogueDb.refresh(db, system, () => { - io.emit('gameSystemChanged', { system }); - res.json({ message: 'Game system updated', system }); - }); - } - ); + // Tokens carry each system's health, defense and injuries: the swap and the setting change + // are one transaction (tokens/vitals.js). + tokenVitals.switchSystem(db, system, { defaultSystem: DEFAULT_SYSTEM }).then((switched) => { + /** + * The uploaded catalogues in memory belong to the system that was running a + * moment ago. Left alone they would price the new game's shops from the old + * game's list, so they are swapped before anybody is told the system changed. + */ + catalogueDb.refresh(db, system, () => { + io.emit('gameSystemChanged', { system }); + // Every token's health may have changed with it: have every screen redraw the map. + // Rhombus-only, so it does not mark the map as having unsaved changes. + if (switched.switched) io.emit('dataUpdated', { isRhombusOnly: true }); + res.json({ message: 'Game system updated', system }); + }); + }, (err) => { + console.error('[system switch]', err.message); + res.status(500).json({ error: 'Could not switch systems; nothing was changed' }); + }); }); // --- Admin sheet access --- @@ -104,7 +108,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( @@ -136,9 +140,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(); }); @@ -621,7 +625,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/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/sockets/index.js b/backend/sockets/index.js index 029ce456..f8f082d0 100644 --- a/backend/sockets/index.js +++ b/backend/sockets/index.js @@ -1,4 +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'); @@ -128,6 +130,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) => { @@ -143,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; } @@ -218,7 +239,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,11 +343,11 @@ 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}`); - 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); } @@ -333,7 +356,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 +380,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 +394,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 +408,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); @@ -441,16 +464,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) => { @@ -779,7 +806,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', []); @@ -822,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); @@ -854,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); }); @@ -1782,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', () => {})); }); /** @@ -1816,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) => { @@ -1826,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) => { @@ -1836,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); }); - }); + })); }); /** @@ -1912,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, @@ -1934,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 }); @@ -2069,20 +2090,14 @@ 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; 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)); + })); }); }); @@ -2098,7 +2113,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 +2145,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,17 +2162,11 @@ 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; - 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-