diff --git a/CHANGELOG.md b/CHANGELOG.md index 04500110..f89f8d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Changed + +- **Each game system has its own bank.** A character's money now belongs to the game it was + earned in: starting a new campaign on another system opens fresh accounts, and switching + back finds the old money exactly where it was. On the first start after updating, every + player's current balance, debt and bonuses are copied into each system they have a + character in, so nothing looks different in any existing game. A full copy of the + database is saved beside it first (when the disk has room), and the old bank records are + kept untouched. + ### Security - **Players can no longer use the GM's tools.** A player's own login worked as a key to the GM's diff --git a/README.md b/README.md index fbc5b1d2..19cbb6d3 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,8 @@ CITY_NET/ │ ├── bulk.js # Reads and deletes by id in pieces of 500, one transaction per delete so it still happens entirely or not at all. A map-sized city is more ids than SQLite takes in one statement │ ├── updater.js # In-app self-update — paginated registry tag listing so a run of dev builds cannot hide a stable release; release channels selected by IMAGE_TAG alone, the same variable compose pulls with (X.Y.Z-dev tags with an optional counter, ordered so a release supersedes its own dev builds); preflight (compose file mounted, docker socket, compose project labels) so a stack that cannot update says why instead of hanging, and offers updating from the host as an equal option since running without the socket is a supported posture; one update at a time, refused rather than queued, with a stale-run release so a hung pull does not deaden the button; the helper command passed as argv rather than through `sh -c`, so a compose label containing a command substitution is data and not code; upgrade-only semver check; update log on the data volume; boot id so a restart is detectable without a version change; the registry read goes through net/outbound, and the docker probe behind GET /api/version is asked once per process rather than once per request — execSync holds the event loop, so a probe on an open route was a way to stall the server │ ├── buildingTypes.js # What a building is for, which catalogues it sells, and which of those a shelf can actually show. Distinct from `classification`, which is the mesh a custom structure is drawn from - a ripperdoc and a noodle bar can share a shape +│ ├── bank/ +│ │ └── accounts.js # Bank accounts, one per player per game system (`bank_accounts`): a character's money belongs to the game it was earned in. Every read and write goes through here and waits for the one-time move from the old per-player table to finish │ ├── buildings/ │ │ ├── gmNotes.js # The GM's notes, in their own table rather than a column every player downloads. Kept through a single delete so undo brings them back; pruned on a map clear and replaced on a map load, the two places location ids are reused │ │ ├── locationRows.js # Putting whole location rows back - a saved map loading, a delete being undone - with every column the table has, read from the table. The hand-kept lists it replaced had fallen behind and dropped building types, buy-back rates, AC and more @@ -410,6 +412,8 @@ CITY_NET/ │ │ ├── definition.js # The system definition format (1: name, description, words, parts, lookups, derived) and its server-side checks. Fatal (cannot be stored: not an object, too large, not JSON) vs ordinary problems (saved in a draft, block publishing), all reported with where they are. Also the app's renamable terms and switchable parts, with wordFor / partOn │ │ └── store.js # `custom_systems`: a draft the builder edits and the published copy a game runs. Ids are sys_ + hex, never a built-in id; publishing refuses a draft with problems; the running system cannot be deleted │ ├── startup/ +│ │ ├── backup.js # A whole copy of the database (VACUUM INTO, beside it) before a migration changes real data; skipped, and logged, when the disk lacks room +│ │ ├── bankAccounts.js # The one-time move from one bank per player (`player_banks`, kept untouched) to one per player per system: the database copied first, each balance copied into every system the player has a sheet in plus the running one, in one transaction, with a marker so it never runs twice │ │ └── sanity_checks.js # In-memory DB checks on boot │ ├── utils/ │ │ └── random.js # cryptoRng — uniform [0,1) from OS entropy (crypto.randomInt); default rng for every roll that decides an outcome @@ -442,6 +446,7 @@ CITY_NET/ │ ├── signs.test.js # Sign API (GET / POST / PATCH / DELETE, auth, image-only, filter_intensity clamping, XSS) │ ├── sheets.test.js # Sheet routes (system switch, admin access, portraits, derived fields, GET /own player self-fetch) │ ├── system_builder_parity.test.js # CWN and Shadowrun as data against cwnRecompute and sr6Recompute over 3,000 seeded sheets each (blank, text, decimal, huge and stale values, broken JSON): same sheet, same changed fields, same order +│ ├── bank_accounts.test.js # Per-system accounts kept apart; the one-time move (every sheet's system plus the running one, the old table untouched, once only, all or nothing); the database copy and its disk-space check; switching systems in play; and db.js opening a 1.14.4-shaped database file in a child process │ ├── system_builder_store.test.js # The definition checks (every problem at once, fatal vs ordinary, words and parts), and the routes: main admin only, drafts saved with problems but not published, the published copy untouched while the draft moves on, the running system not deletable │ ├── system_builder_engine.test.js # The formula language (precedence, functions, 0 for NaN, and a list of script-shaped inputs it refuses), limits, and definitions: dependency order, lookups, conditions, rules, and every mistake reported at once │ ├── npc_privacy.test.js # The map list and token card as anonymous, player and revoked-editor callers see them: no NPC sheet, no silhouetted face, even in the raw response text; the GM and a granted editor still get both diff --git a/backend/__tests__/bank_accounts.test.js b/backend/__tests__/bank_accounts.test.js new file mode 100644 index 00000000..a3ba4e06 --- /dev/null +++ b/backend/__tests__/bank_accounts.test.js @@ -0,0 +1,311 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import { createRequire } from 'module'; +import { makeTestDb, get, all, run } from './helpers/testDb.js'; +import { until, untilValue, drain } from './helpers/until.js'; + +/** + * One bank account per player per game system. + * + * A character's money belongs to the game it was earned in, so a CWN character's credits + * must not turn up in a D&D campaign. The move from the old one-bank-per-player table copies + * each balance into every system the player has a sheet in, plus the running one, once, after + * copying the whole database - and never touches the old table, so nothing can be lost. + */ + +process.env.JWT_SECRET = 'test-secret'; +process.env.DICE_ANIM_MS = '0'; +const require_ = createRequire(import.meta.url); +const accounts = require_('../bank/accounts'); +const { migrateBankAccounts, MARKER } = require_('../startup/bankAccounts'); +const { backupDatabase } = require_('../startup/backup'); +const socketsFactory = require_('../sockets/index.js'); +const sqlite3 = require_('sqlite3'); + +const CWN = 'cities_without_number'; +const CPR = 'cyberpunk_red'; +const cb2p = (fn, ...args) => new Promise((resolve, reject) => fn(...args, (err, v) => (err ? reject(err) : resolve(v)))); +const quiet = { log: () => {}, warn: () => {} }; + +let db; +beforeEach(async () => { db = await makeTestDb(); }); +afterEach(() => { accounts.setReady(Promise.resolve()); vi.restoreAllMocks(); }); + +describe('accounts', () => { + it('keeps each system\'s money apart', async () => { + await cb2p(accounts.put, db, 'GHOST', CWN, 500, 20); + await cb2p(accounts.put, db, 'GHOST', CPR, 7, 0); + expect(await cb2p(accounts.get, db, 'GHOST', CWN)).toMatchObject({ balance: 500, debt: 20 }); + expect(await cb2p(accounts.get, db, 'GHOST', CPR)).toMatchObject({ balance: 7, debt: 0 }); + expect(await cb2p(accounts.get, db, 'GHOST', 'shadowrun_6e')).toBeNull(); + }); + + it('opens an account at zero when asked to make sure of one', async () => { + expect(await cb2p(accounts.ensure, db, 'GHOST', CWN)).toMatchObject({ balance: 0, debt: 0, first_pay_done: 0 }); + await cb2p(accounts.put, db, 'GHOST', CWN, 50, 0); + expect((await cb2p(accounts.ensure, db, 'GHOST', CWN)).balance).toBe(50); + }); + + it('adds to a balance, opening the account if needed', async () => { + await cb2p(accounts.addToBalance, db, 'GHOST', CWN, 100); + await cb2p(accounts.addToBalance, db, 'GHOST', CWN, 25.5); + expect((await cb2p(accounts.get, db, 'GHOST', CWN)).balance).toBe(125.5); + }); + + it('moves an existing account, and leaves a missing one alone, as withdrawals always did', async () => { + await cb2p(accounts.put, db, 'GHOST', CWN, 100, 10); + expect(await cb2p(accounts.adjust, db, 'GHOST', CWN, { balance: -30, debt: 5 })).toBe(1); + expect(await cb2p(accounts.get, db, 'GHOST', CWN)).toMatchObject({ balance: 70, debt: 15 }); + expect(await cb2p(accounts.adjust, db, 'NOBODY', CWN, { balance: -30 })).toBe(0); + expect(await cb2p(accounts.get, db, 'NOBODY', CWN)).toBeNull(); + }); + + it('marks one-time events per account, and only the known ones', async () => { + await cb2p(accounts.ensure, db, 'GHOST', CWN); + await cb2p(accounts.markFlag, db, 'GHOST', CWN, 'first_pay_done'); + expect((await cb2p(accounts.get, db, 'GHOST', CWN)).first_pay_done).toBe(1); + await expect(cb2p(accounts.markFlag, db, 'GHOST', CWN, 'balance = 999999, first_pay_done')).rejects.toThrow(/unknown bank flag/); + }); + + it('holds every operation until the move has finished', async () => { + let finish; + accounts.setReady(new Promise((resolve) => { finish = resolve; })); + let seen = 'waiting'; + accounts.ensure(db, 'GHOST', CWN, () => { seen = 'ran'; }); + await drain(db); + expect(seen).toBe('waiting'); + finish(); + await untilValue(() => seen, (s) => s === 'ran', { label: 'the held operation' }); + }); + + it('turns a failed move into an error for each operation, never a crash', async () => { + accounts.setReady(Promise.reject(new Error('move failed'))); + await expect(cb2p(accounts.get, db, 'GHOST', CWN)).rejects.toThrow('move failed'); + }); +}); + +describe('the one-time move from one bank per player', () => { + const legacy = async (rows) => { + await run(db, `CREATE TABLE player_banks (username TEXT PRIMARY KEY, balance REAL DEFAULT 0, debt REAL DEFAULT 0, + first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); + for (const r of rows) { + await run(db, 'INSERT INTO player_banks (username, balance, debt, first_pay_done, high_roller_done) VALUES (?, ?, ?, ?, ?)', + [r.username, r.balance, r.debt ?? 0, r.first ?? 0, r.high ?? 0]); + } + }; + const sheet = (username, system) => run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, '{}', 0)`, [username, system]); + const accountsOf = (username) => all(db, + 'SELECT system, balance, debt, first_pay_done, high_roller_done FROM bank_accounts WHERE username = ? ORDER BY system', [username]); + + beforeEach(async () => { + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [CWN]); + }); + + it('copies each balance into every system the player has a sheet in, and the running one', async () => { + await legacy([ + { username: 'GHOST', balance: 1200.5, debt: 300, first: 1 }, + { username: 'NEWBIE', balance: 40 }, + ]); + await sheet('GHOST', CPR); + await sheet('GHOST', 'shadowrun_6e'); + // An NPC sheet is not a player's game. + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', 'generic', '{}', 1)`); + + const result = await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect(result).toMatchObject({ ran: true, accounts: 4 }); + + const one = { balance: 1200.5, debt: 300, first_pay_done: 1, high_roller_done: 0 }; + expect(await accountsOf('GHOST')).toEqual([ + { system: CWN, ...one }, { system: CPR, ...one }, { system: 'shadowrun_6e', ...one }, + ]); + // No sheets at all: the running game still gets their money. + expect(await accountsOf('NEWBIE')).toEqual([{ system: CWN, balance: 40, debt: 0, first_pay_done: 0, high_roller_done: 0 }]); + }); + + it('never changes the old table', async () => { + await legacy([{ username: 'GHOST', balance: 99, debt: 1 }]); + const before = await all(db, 'SELECT * FROM player_banks'); + await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect(await all(db, 'SELECT * FROM player_banks')).toEqual(before); + }); + + it('runs once: a sheet made later in a new system starts that bank at zero', async () => { + await legacy([{ username: 'GHOST', balance: 500 }]); + await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect(await get(db, 'SELECT value FROM global_settings WHERE key = ?', [MARKER])).toBeTruthy(); + + await sheet('GHOST', CPR); + expect(await migrateBankAccounts(db, ':memory:', { log: quiet })).toEqual({ ran: false }); + expect((await accountsOf('GHOST')).map((a) => a.system)).toEqual([CWN]); + }); + + it('with nothing to move, only records that it ran, and makes no copy', async () => { + const result = await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect(result).toEqual({ ran: true, accounts: 0, backup: null }); + expect(await get(db, 'SELECT value FROM global_settings WHERE key = ?', [MARKER])).toBeTruthy(); + }); + + it('keeps an account that somehow exists already', async () => { + await legacy([{ username: 'GHOST', balance: 500 }]); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('GHOST', ?, 7, 0)`, [CWN]); + await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect((await accountsOf('GHOST'))[0].balance).toBe(7); + }); + + it('lands all or nothing: a failure part way leaves no accounts and no marker, to try again', async () => { + await legacy([{ username: 'AAA', balance: 1 }, { username: 'ZZZ', balance: 2 }]); + // Refuse the second player's account, after the first has been written. + await run(db, `CREATE TRIGGER refuse BEFORE INSERT ON bank_accounts WHEN NEW.username = 'ZZZ' + BEGIN SELECT RAISE(ABORT, 'refused'); END`); + await expect(migrateBankAccounts(db, ':memory:', { log: quiet })).rejects.toThrow('refused'); + expect(await all(db, 'SELECT * FROM bank_accounts')).toEqual([]); + expect(await get(db, 'SELECT value FROM global_settings WHERE key = ?', [MARKER])).toBeUndefined(); + // And the connection is usable afterwards, not stuck in the transaction. + await run(db, 'DROP TRIGGER refuse'); + expect((await migrateBankAccounts(db, ':memory:', { log: quiet })).accounts).toBe(2); + }); +}); + +describe('the database copy before a migration', () => { + let dir; + let fileDb; + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'citynet-backup-')); + fileDb = await new Promise((resolve, reject) => { + const d = new sqlite3.Database(path.join(dir, 'city.db'), (err) => (err ? reject(err) : resolve(d))); + }); + await run(fileDb, 'CREATE TABLE player_banks (username TEXT PRIMARY KEY, balance REAL)'); + await run(fileDb, `INSERT INTO player_banks VALUES ('GHOST', 1234)`); + }); + afterEach(async () => { + await new Promise((resolve) => fileDb.close(resolve)); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('writes a whole, readable copy beside the database', async () => { + const result = await cb2p(backupDatabase, fileDb, path.join(dir, 'city.db'), 'test'); + expect(path.dirname(result.path)).toBe(dir); + expect(path.basename(result.path)).toMatch(/^city\.db\.before-test-\d{4}-\d\d-\d\dT\d\d-\d\d-\d\d\.bak$/); + const copy = await new Promise((resolve, reject) => { + const d = new sqlite3.Database(result.path, sqlite3.OPEN_READONLY, (err) => (err ? reject(err) : resolve(d))); + }); + expect(await get(copy, 'SELECT balance FROM player_banks')).toEqual({ balance: 1234 }); + await new Promise((resolve) => copy.close(resolve)); + }); + + it('makes no copy, and says why, when the disk is too full for one', async () => { + const result = await new Promise((resolve, reject) => backupDatabase(fileDb, path.join(dir, 'city.db'), 'test', + (err, r) => (err ? reject(err) : resolve(r)), { free: () => 1024 })); + expect(result.skipped).toMatch(/not enough disk space/); + expect(fs.readdirSync(dir).filter((f) => f.endsWith('.bak'))).toEqual([]); + }); + + it('has nothing to copy for an in-memory database', async () => { + expect(await cb2p(backupDatabase, db, ':memory:', 'test')).toEqual({ skipped: 'in-memory database' }); + }); +}); + +describe('money in play, with more than one system', () => { + const server = () => { + const sent = []; + let connectionCb; + const io = { + on: (event, cb) => { if (event === 'connection') connectionCb = cb; }, + emit: (event, data) => sent.push({ event, data }), + to: () => ({ emit: (event, data) => sent.push({ event, data }) }), + }; + socketsFactory(io, db, { elevatedUsers: new Set(), emitUpdate: vi.fn(), recordAction: vi.fn() }); + const connect = async (name) => { + const handlers = {}; + connectionCb({ + id: `bank-${Math.random().toString(36).slice(2)}`, + on: (e, fn) => { handlers[e] = fn; }, + emit: (event, data) => sent.push({ event, data, self: true }), + broadcast: { emit: () => {} }, use: () => {}, join: () => {}, disconnect: vi.fn(), + }); + handlers.identify(name); + await drain(db); + return handlers; + }; + return { sent, connect }; + }; + const setSystem = (system) => run(db, `INSERT OR REPLACE INTO global_settings (key, value) VALUES ('game_system', ?)`, [system]); + const latestBalance = (sent, username) => { + const u = sent.filter((e) => e.event === 'bankUpdate' && e.data.username === username).at(-1); + return u ? u.data.balance : undefined; + }; + + beforeEach(() => { vi.spyOn(console, 'log').mockImplementation(() => {}); }); + + it('shows and moves the running system\'s account, and keeps the other one intact', async () => { + await setSystem(CWN); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('GHOST', ?, 1000, 0)`, [CWN]); + const s = server(); + const ghost = await s.connect('GHOST'); + + ghost.requestBankBalance({ username: 'GHOST' }); + expect(await untilValue(() => latestBalance(s.sent, 'GHOST'), (b) => b === 1000, { label: 'CWN balance' })).toBe(1000); + + // A new campaign on another system: a fresh account, not the CWN money. + await setSystem(CPR); + ghost.requestBankBalance({ username: 'GHOST' }); + // until, not untilValue: that one hands back the value, and a balance of 0 reads as "not yet". + await until(() => latestBalance(s.sent, 'GHOST') === 0, { label: 'a fresh CP:R account' }); + ghost.borrowFunds({ amount: 50 }); + await untilValue(() => get(db, 'SELECT debt FROM bank_accounts WHERE username = ? AND system = ?', ['GHOST', CPR]), + (r) => r && r.debt === 50, { label: 'CP:R debt' }); + + // Back to the CWN campaign: its money is exactly where it was. + await setSystem(CWN); + expect(await get(db, 'SELECT balance, debt FROM bank_accounts WHERE username = ? AND system = ?', ['GHOST', CWN])) + .toEqual({ balance: 1000, debt: 0 }); + }); +}); + +describe('the real startup path', () => { + it('moves an existing server\'s banks when db.js opens it, after copying the database', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'citynet-startup-')); + const file = path.join(dir, 'city.db'); + try { + // A database as a server running 1.14.4 left it: one bank per player. + const seed = [ + `CREATE TABLE global_settings (key TEXT PRIMARY KEY, value TEXT)`, + `INSERT INTO global_settings VALUES ('game_system', '${CWN}')`, + `CREATE TABLE character_sheets (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, system TEXT NOT NULL, + data TEXT NOT NULL DEFAULT '{}', portrait_url TEXT, is_npc INTEGER DEFAULT 0, npc_label TEXT, folder TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)`, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', '${CPR}', '{}', 0)`, + `CREATE TABLE player_banks (username TEXT PRIMARY KEY, balance REAL DEFAULT 0.00, debt REAL DEFAULT 0.00, + first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`, + `INSERT INTO player_banks VALUES ('GHOST', 750, 25, 1, 0)`, + ]; + const script = ` + const sqlite3 = require(${JSON.stringify(require_.resolve('sqlite3'))}); + const seedDb = new sqlite3.Database(${JSON.stringify(file)}); + seedDb.serialize(() => { for (const s of ${JSON.stringify(seed)}) seedDb.run(s); }); + seedDb.close(() => { + process.env.DB_PATH = ${JSON.stringify(file)}; + console.log = () => {}; console.warn = () => {}; + const db = require(${JSON.stringify(require_.resolve('../db.js'))}); + const accounts = require(${JSON.stringify(require_.resolve('../bank/accounts.js'))}); + accounts.get(db, 'GHOST', '${CPR}', (err, cpr) => { + accounts.get(db, 'GHOST', '${CWN}', (err2, cwn) => { + // Exit rather than close: db.js's other startup work may still be queued. + process.stdout.write(JSON.stringify({ err: err && err.message, cpr, cwn }), () => process.exit(0)); + }); + }); + });`; + const out = JSON.parse(execFileSync(process.execPath, ['-e', script], { encoding: 'utf8', timeout: 60000 })); + const moved = { balance: 750, debt: 25, first_pay_done: 1, high_roller_done: 0 }; + expect(out).toEqual({ err: null, cpr: moved, cwn: moved }); + const copies = fs.readdirSync(dir).filter((f) => /^city\.db\.before-bank-accounts-.*\.bak$/.test(f)); + expect(copies).toHaveLength(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/backend/__tests__/bank_own_account.test.js b/backend/__tests__/bank_own_account.test.js index b466370d..06ded420 100644 --- a/backend/__tests__/bank_own_account.test.js +++ b/backend/__tests__/bank_own_account.test.js @@ -46,12 +46,9 @@ function boot(db, id = `bank-sock-${(nextSocket += 1)}`) { let db; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); - await run(db, `INSERT INTO player_banks (username, balance, debt) VALUES ('GHOST', 1000, 500)`); - await run(db, `INSERT INTO player_banks (username, balance, debt) VALUES ('VICTIM', 8000, 0)`); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('GHOST', COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), 1000, 500)`); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('VICTIM', COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), 8000, 0)`); }); const identified = async (name = 'GHOST') => { @@ -62,7 +59,7 @@ const identified = async (name = 'GHOST') => { }; const bank = async (username) => - get(db, 'SELECT balance, debt FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance, debt FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); describe('what these handlers still do, unchanged', () => { it('withdraws from the caller\'s own balance', async () => { @@ -124,7 +121,7 @@ describe('whose account it is', () => { }); it('will not spend someone else\'s balance on their debt', async () => { - await run(db, `UPDATE player_banks SET debt = 1000 WHERE username = 'VICTIM'`); + await run(db, `UPDATE bank_accounts SET debt = 1000 WHERE username = 'VICTIM' AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`); const { handlers } = await identified('GHOST'); handlers['payDebt']({ username: 'VICTIM', amount: 500 }); await drain(db); diff --git a/backend/__tests__/cpr_cyberware_roll.test.js b/backend/__tests__/cpr_cyberware_roll.test.js index 6c1f7298..13a31952 100644 --- a/backend/__tests__/cpr_cyberware_roll.test.js +++ b/backend/__tests__/cpr_cyberware_roll.test.js @@ -66,7 +66,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cyberpunk_red')`); }); diff --git a/backend/__tests__/cwn_seating.test.js b/backend/__tests__/cwn_seating.test.js index 12879b7b..1e011480 100644 --- a/backend/__tests__/cwn_seating.test.js +++ b/backend/__tests__/cwn_seating.test.js @@ -59,7 +59,6 @@ beforeEach(async () => { await run(db, `CREATE TABLE dice_rolls ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, total INTEGER, results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); }); diff --git a/backend/__tests__/cwn_skillplugs_sockets.test.js b/backend/__tests__/cwn_skillplugs_sockets.test.js index fc782b3b..edb282d4 100644 --- a/backend/__tests__/cwn_skillplugs_sockets.test.js +++ b/backend/__tests__/cwn_skillplugs_sockets.test.js @@ -45,7 +45,6 @@ beforeEach(async () => { id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, total INTEGER, results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); }); diff --git a/backend/__tests__/cwn_sockets.test.js b/backend/__tests__/cwn_sockets.test.js index e5d1a754..588c7102 100644 --- a/backend/__tests__/cwn_sockets.test.js +++ b/backend/__tests__/cwn_sockets.test.js @@ -65,7 +65,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); }); diff --git a/backend/__tests__/cwn_vehicle_combat.test.js b/backend/__tests__/cwn_vehicle_combat.test.js index fd8fd125..beaaed82 100644 --- a/backend/__tests__/cwn_vehicle_combat.test.js +++ b/backend/__tests__/cwn_vehicle_combat.test.js @@ -61,7 +61,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); // Trauma multiplies damage on a die roll, which would make every damage assertion // below probabilistic. The rule is tested on its own elsewhere. diff --git a/backend/__tests__/gm_route_auth.test.js b/backend/__tests__/gm_route_auth.test.js index 31efe5b8..152ef77f 100644 --- a/backend/__tests__/gm_route_auth.test.js +++ b/backend/__tests__/gm_route_auth.test.js @@ -244,9 +244,6 @@ describe('sockets: sign-in, chat and editor rights', () => { beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -392,16 +389,16 @@ describe('sockets: sign-in, chat and editor rights', () => { }); it("a player cannot set anyone's bank balance with their own login; the GM still can", async () => { - await run(db, `INSERT INTO player_banks (username, balance, debt) VALUES ('rook', 100, 0)`); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('rook', COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), 100, 0)`); const s = server(); const vex = await s.connect('vex'); vex.handlers.adminUpdateBank({ token: PLAYER, username: 'rook', balance: 999999, debt: 0 }); await drain(db); - expect((await get(db, `SELECT balance FROM player_banks WHERE username = 'rook'`)).balance).toBe(100); + expect((await get(db, `SELECT balance FROM bank_accounts WHERE username = 'rook' AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`)).balance).toBe(100); const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); gm.handlers.adminUpdateBank({ token: GM, username: 'rook', balance: 250, debt: 0 }); await drain(db); - expect((await get(db, `SELECT balance FROM player_banks WHERE username = 'rook'`)).balance).toBe(250); + expect((await get(db, `SELECT balance FROM bank_accounts WHERE username = 'rook' AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`)).balance).toBe(250); }); }); diff --git a/backend/__tests__/helpers/testDb.js b/backend/__tests__/helpers/testDb.js index 85856973..56a05f18 100644 --- a/backend/__tests__/helpers/testDb.js +++ b/backend/__tests__/helpers/testDb.js @@ -223,6 +223,17 @@ function makeTestDb() { FOREIGN KEY(sheet_id) REFERENCES character_sheets(id) ON DELETE CASCADE )`); + // One bank account per player per system (bank/accounts.js). + db.run(`CREATE TABLE bank_accounts ( + username TEXT NOT NULL, + system TEXT NOT NULL, + balance REAL DEFAULT 0, + debt REAL DEFAULT 0, + first_pay_done INTEGER DEFAULT 0, + high_roller_done INTEGER DEFAULT 0, + PRIMARY KEY (username, system) + )`); + db.run(`CREATE TABLE IF NOT EXISTS custom_systems ( id TEXT PRIMARY KEY, name TEXT NOT NULL, diff --git a/backend/__tests__/shop_buy_sockets.test.js b/backend/__tests__/shop_buy_sockets.test.js index bfb3957b..d02a496f 100644 --- a/backend/__tests__/shop_buy_sockets.test.js +++ b/backend/__tests__/shop_buy_sockets.test.js @@ -62,9 +62,6 @@ let gunShop; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); const r = await run(db, `INSERT INTO locations (name, x, y, z, shape, building_type) VALUES ('Vic''s', 0, 0, 0, 'box', 'gun_shop')`); @@ -79,11 +76,11 @@ const identified = async (name = 'GHOST') => { }; const fund = (username, balance, debt = 0) => run(db, - 'INSERT OR REPLACE INTO player_banks (username, balance, debt) VALUES (?, ?, ?)', + `INSERT OR REPLACE INTO bank_accounts (username, system, balance, debt) VALUES (?, COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), ?, ?)`, [username, balance, debt]); const bank = async (username = 'GHOST') => - get(db, 'SELECT balance, debt FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance, debt FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); const receipt = (emitted) => [...emitted].reverse().find((e) => e.event === 'shopCheckout'); @@ -283,7 +280,7 @@ describe('when the money is not there', () => { describe('a player with no account yet', () => { it('cannot buy on credit just by never having banked', async () => { - // No player_banks row at all reads as nothing saved, not as unlimited. + // No bank account at all reads as nothing saved, not as unlimited. const { handlers, emitted } = await identified(); buy(handlers); expect((await waitReceipt(emitted)).data).toMatchObject({ ok: false, reason: 'funds' }); diff --git a/backend/__tests__/shop_catalogue_sockets.test.js b/backend/__tests__/shop_catalogue_sockets.test.js index a6b2a262..ec41092c 100644 --- a/backend/__tests__/shop_catalogue_sockets.test.js +++ b/backend/__tests__/shop_catalogue_sockets.test.js @@ -51,9 +51,6 @@ let gunShop; beforeEach(async () => { store.clear(); db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `CREATE TABLE IF NOT EXISTS shop_catalogues ( system TEXT NOT NULL, catalogue TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, price REAL NOT NULL DEFAULT 0, fields TEXT NOT NULL DEFAULT '{}', @@ -93,11 +90,11 @@ const seed = (data, username = 'GHOST') => run(db, VALUES (?, 'cities_without_number', ?, 0)`, [username, JSON.stringify(data)]); const fund = (username, balance) => run(db, - 'INSERT OR REPLACE INTO player_banks (username, balance, debt) VALUES (?, ?, 0)', + `INSERT OR REPLACE INTO bank_accounts (username, system, balance, debt) VALUES (?, COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), ?, 0)`, [username, balance]); const bank = (username = 'GHOST') => - get(db, 'SELECT balance FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); const last = (emitted, event) => [...emitted].reverse().find((e) => e.event === event); const waitFor = (emitted, event) => diff --git a/backend/__tests__/shop_checkout_sockets.test.js b/backend/__tests__/shop_checkout_sockets.test.js index 3dd9b44c..63bb9f91 100644 --- a/backend/__tests__/shop_checkout_sockets.test.js +++ b/backend/__tests__/shop_checkout_sockets.test.js @@ -50,9 +50,6 @@ let gunShop; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); const r = await run(db, `INSERT INTO locations (name, x, y, z, shape, building_type) VALUES ('Vic''s', 0, 0, 0, 'box', 'gun_shop')`); @@ -65,7 +62,7 @@ const seed = async (data, username = 'GHOST') => run(db, [username, JSON.stringify(data)]); const fund = (username, balance, debt = 0) => run(db, - 'INSERT OR REPLACE INTO player_banks (username, balance, debt) VALUES (?, ?, ?)', + `INSERT OR REPLACE INTO bank_accounts (username, system, balance, debt) VALUES (?, COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), ?, ?)`, [username, balance, debt]); const identified = async (name = 'GHOST') => { @@ -79,7 +76,7 @@ const sheet = async (username = 'GHOST') => JSON.parse((await get(db, `SELECT data FROM character_sheets WHERE username = ?`, [username])).data); const bank = async (username = 'GHOST') => - get(db, 'SELECT balance, debt FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance, debt FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); const result = (emitted) => [...emitted].reverse().find((e) => e.event === 'shopCheckout'); const waitResult = (emitted) => diff --git a/backend/__tests__/shop_sell_sockets.test.js b/backend/__tests__/shop_sell_sockets.test.js index 05e6439b..1f904034 100644 --- a/backend/__tests__/shop_sell_sockets.test.js +++ b/backend/__tests__/shop_sell_sockets.test.js @@ -53,9 +53,6 @@ let gunShop; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); const r = await run(db, `INSERT INTO locations (name, x, y, z, shape, building_type) VALUES ('Vic''s', 0, 0, 0, 'box', 'gun_shop')`); @@ -68,7 +65,7 @@ const seed = async (data, username = 'GHOST') => run(db, [username, JSON.stringify(data)]); const fund = (username, balance) => run(db, - 'INSERT OR REPLACE INTO player_banks (username, balance, debt) VALUES (?, ?, 0)', + `INSERT OR REPLACE INTO bank_accounts (username, system, balance, debt) VALUES (?, COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), ?, 0)`, [username, balance]); const identified = async (name = 'GHOST') => { @@ -82,7 +79,7 @@ const sheet = async (username = 'GHOST') => JSON.parse((await get(db, `SELECT data FROM character_sheets WHERE username = ?`, [username])).data); const bank = async (username = 'GHOST') => - get(db, 'SELECT balance FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); const result = (emitted) => [...emitted].reverse().find((e) => e.event === 'shopCheckout'); const waitResult = (emitted) => diff --git a/backend/__tests__/sockets.awardxp.test.js b/backend/__tests__/sockets.awardxp.test.js index 017bc9ec..9d490679 100644 --- a/backend/__tests__/sockets.awardxp.test.js +++ b/backend/__tests__/sockets.awardxp.test.js @@ -59,7 +59,6 @@ const playerToken = () => jwt.sign({ username: 'bob', role: 'player' }, 'test-se let db; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT OR REPLACE INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('ghost', 'cities_without_number', ?, 0)`, [JSON.stringify({ level: 1, xp: 0 })]); diff --git a/backend/__tests__/sockets.deathsave.test.js b/backend/__tests__/sockets.deathsave.test.js index 0580b42a..30e28516 100644 --- a/backend/__tests__/sockets.deathsave.test.js +++ b/backend/__tests__/sockets.deathsave.test.js @@ -66,7 +66,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cyberpunk_red')`); }); diff --git a/backend/__tests__/sockets.tokencontrol.test.js b/backend/__tests__/sockets.tokencontrol.test.js index b3df40bc..6e4ce6bb 100644 --- a/backend/__tests__/sockets.tokencontrol.test.js +++ b/backend/__tests__/sockets.tokencontrol.test.js @@ -63,7 +63,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); }); const seedToken = async (shape, owner, controllers = null) => { diff --git a/backend/__tests__/sr6_sockets.test.js b/backend/__tests__/sr6_sockets.test.js index c4555b21..b3c5f746 100644 --- a/backend/__tests__/sr6_sockets.test.js +++ b/backend/__tests__/sr6_sockets.test.js @@ -64,7 +64,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'shadowrun_6e')`); }); diff --git a/backend/bank/accounts.js b/backend/bank/accounts.js new file mode 100644 index 00000000..c73af65a --- /dev/null +++ b/backend/bank/accounts.js @@ -0,0 +1,96 @@ +// Bank accounts: one per player per game system. +// +// A character's money belongs to the game it was earned in (decided with the user, +// 2026-09-29): a CWN character's credits must not turn up in a D&D campaign. So an account +// is keyed by player AND system, in `bank_accounts`. The account a handler uses is the +// running system's, which `activeSystem` reads. +// +// Every read and write of an account goes through here, and each one waits for the one-time +// move from the old per-player table (startup/bankAccounts.js) to finish. That wait is not +// decoration: browsers reconnect within milliseconds of a restart, and an empty account +// opened before the move would block the player's real balance from being copied in. + +const { DEFAULT_SYSTEM } = require('../sheets/templates'); + +let ready = Promise.resolve(); + +/** + * Hold every account operation until `promise` settles. Set once at startup by db.js. A + * failure is handled here as well as by each operation, so it can never surface as an + * unhandled rejection, which would stop the whole server. + */ +const setReady = (promise) => { + ready = Promise.resolve(promise); + ready.catch(() => {}); +}; + +/** Run `fn` once accounts are ready, or hand `cb` the reason they never became ready. */ +const whenReady = (cb, fn) => { + ready.then(fn, (err) => cb(err || new Error('Bank accounts are not ready'))); +}; + +/** The system the game is running. */ +const activeSystem = (db, cb) => { + db.get(`SELECT value FROM global_settings WHERE key = 'game_system'`, [], (err, row) => { + cb(err, row && row.value ? row.value : DEFAULT_SYSTEM); + }); +}; + +const COLUMNS = 'balance, debt, first_pay_done, high_roller_done'; + +/** A player's account in `system`, or null when they have none yet. */ +const get = (db, username, system, cb) => whenReady(cb, () => { + db.get(`SELECT ${COLUMNS} FROM bank_accounts WHERE username = ? AND system = ?`, [username, system], + (err, row) => cb(err || null, row || null)); +}); + +/** A player's account in `system`, opened at zero if they have none. */ +const ensure = (db, username, system, cb) => whenReady(cb, () => { + db.run(`INSERT OR IGNORE INTO bank_accounts (username, system, balance, debt) VALUES (?, ?, 0, 0)`, [username, system], (err) => { + if (err) return cb(err); + db.get(`SELECT ${COLUMNS} FROM bank_accounts WHERE username = ? AND system = ?`, [username, system], + (err2, row) => cb(err2 || null, row || null)); + }); +}); + +/** Set an account's balance and debt, opening it if needed. */ +const put = (db, username, system, balance, debt, cb) => whenReady(cb, () => { + db.run( + `INSERT INTO bank_accounts (username, system, balance, debt) VALUES (?, ?, ?, ?) + ON CONFLICT(username, system) DO UPDATE SET balance = excluded.balance, debt = excluded.debt`, + [username, system, balance, debt], + (err) => cb(err || null), + ); +}); + +/** Add to an account's balance, opening it at that amount if needed. */ +const addToBalance = (db, username, system, amount, cb) => whenReady(cb, () => { + db.run( + `INSERT INTO bank_accounts (username, system, balance, debt) VALUES (?, ?, ?, 0) + ON CONFLICT(username, system) DO UPDATE SET balance = COALESCE(balance, 0) + excluded.balance`, + [username, system, amount], + (err) => cb(err || null), + ); +}); + +/** + * Move an existing account's balance and debt by these amounts. An account that does not + * exist is left alone, as the old handlers did (a withdrawal never opens an account). + */ +const adjust = (db, username, system, { balance = 0, debt = 0 }, cb) => whenReady(cb, () => { + db.run( + `UPDATE bank_accounts SET balance = balance + ?, debt = debt + ? WHERE username = ? AND system = ?`, + [balance, debt, username, system], + function (err) { cb(err || null, err ? 0 : this.changes); }, + ); +}); + +const FLAGS = new Set(['first_pay_done', 'high_roller_done']); + +/** Mark a one-time bank event (first payday, high roller) done for this account. */ +const markFlag = (db, username, system, flag, cb) => whenReady(cb, () => { + if (!FLAGS.has(flag)) return cb(new Error(`unknown bank flag ${flag}`)); + db.run(`UPDATE bank_accounts SET ${flag} = 1 WHERE username = ? AND system = ?`, [username, system], (err) => cb(err || null)); +}); + +module.exports = { setReady, activeSystem, get, ensure, put, addToBalance, adjust, markFlag }; diff --git a/backend/db.js b/backend/db.js index 3ee40721..b6310c9c 100644 --- a/backend/db.js +++ b/backend/db.js @@ -335,6 +335,18 @@ db.serialize(() => { // Migrate existing rows that predate the first_pay_done column db.run(`ALTER TABLE player_banks ADD COLUMN first_pay_done INTEGER DEFAULT 0`, () => {}); db.run(`ALTER TABLE player_banks ADD COLUMN high_roller_done INTEGER DEFAULT 0`, () => {}); + // player_banks above is the old one-bank-per-player table. It is kept, never changed, as the + // record of balances before banks became per system; nothing reads it after the one-time move + // (startup/bankAccounts.js). Every account now lives here, one per player per system. + db.run(`CREATE TABLE IF NOT EXISTS bank_accounts ( + username TEXT NOT NULL, + system TEXT NOT NULL, + balance REAL DEFAULT 0, + debt REAL DEFAULT 0, + first_pay_done INTEGER DEFAULT 0, + high_roller_done INTEGER DEFAULT 0, + PRIMARY KEY (username, system) + )`); db.run(`CREATE TABLE IF NOT EXISTS water_bodies ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -532,6 +544,19 @@ db.serialize(() => { db.run(`UPDATE character_sheets SET data = ? WHERE id = ?`, [JSON.stringify(data), row.id]); }); }); + + // Move the old per-player banks into per-system accounts, once. It starts last in the queue, + // so every table above exists, but bank operations are told to wait for it right now, while + // the database is still being opened - before any socket can connect. + const banksMoved = new Promise((resolve, reject) => { + db.get('SELECT 1', () => { + require('./startup/bankAccounts').migrateBankAccounts(db, dbPath).then(resolve, (err) => { + console.error('[bank] Moving banks to per-system accounts failed, so the bank is unavailable until the next start:', err.message); + reject(err); + }); + }); + }); + require('./bank/accounts').setReady(banksMoved); }); module.exports = db; diff --git a/backend/routes/sheets.js b/backend/routes/sheets.js index 835558ec..fa846dc2 100644 --- a/backend/routes/sheets.js +++ b/backend/routes/sheets.js @@ -6,6 +6,7 @@ const crypto = require('crypto'); const multer = require('multer'); const { authenticate, authenticatePlayer, optionalAuthenticate } = require('../middleware/auth'); const { canReadNpcSheets, redactTokenCard } = require('../sheets/npcPrivacy'); +const bankAccounts = require('../bank/accounts'); const { TEMPLATES, DEFAULT_SYSTEM, isValidSystem, getLinkedFields, applyDerived, cwnEffectiveAc, TOKEN_SOURCES, rangedAcOf, acColumns, @@ -136,9 +137,9 @@ module.exports = (db, io) => { const done = () => res.json({ ...row, data }); const overlayCash = () => { if (!Object.values(linked).includes('bank_balance')) return done(); - db.get(`SELECT balance FROM player_banks WHERE username = ?`, [req.params.username], (e3, bank) => { + bankAccounts.get(db, req.params.username, system, (e3, account) => { Object.entries(linked).forEach(([fieldId, source]) => { - if (source === 'bank_balance') data[fieldId] = bank ? bank.balance : 0; + if (source === 'bank_balance') data[fieldId] = account ? account.balance : 0; }); done(); }); diff --git a/backend/sockets/index.js b/backend/sockets/index.js index 27e20f0a..f8f082d0 100644 --- a/backend/sockets/index.js +++ b/backend/sockets/index.js @@ -1,5 +1,6 @@ const jwt = require('jsonwebtoken'); const { isMainAdmin } = require('../middleware/auth'); +const bank = require('../bank/accounts'); const { cryptoRng } = require('../utils/random'); const { registerInitiativeHandlers } = require('./initiative'); const sheetTemplates = require('../sheets/templates'); @@ -158,18 +159,23 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { io.emit('activeUsersUpdated', buildActiveUsers()); }; + /** + * Tell everyone a player's balance, from their account in the running system (bank/accounts.js: + * one per player per system). An account that does not exist yet is opened at zero, as before. + */ const sendBankUpdate = (username) => { - db.get('SELECT balance, debt, first_pay_done, high_roller_done FROM player_banks WHERE username = ?', [username], (err, row) => { - if (!err && row) { + bank.activeSystem(db, (sErr, system) => { + if (sErr) return; + bank.ensure(db, username, system, (err, row) => { + if (err || !row) return; io.emit('bankUpdate', { username, balance: row.balance, debt: row.debt, firstPayDone: !!row.first_pay_done, highRollerDone: !!row.high_roller_done }); - } else if (!err && !row) { - db.run('INSERT INTO player_banks (username, balance, debt) VALUES (?, 0, 0)', [username], () => { - io.emit('bankUpdate', { username, balance: 0, debt: 0, firstPayDone: false, highRollerDone: false }); - }); - } + }); }); }; + /** Run `fn(system)` with the running system, the one whose accounts money moves in. */ + const withBankSystem = (fn) => bank.activeSystem(db, (err, system) => { if (!err) fn(system); }); + // Load NPCs from DB on startup db.all('SELECT username, isActive FROM fake_users', (err, rows) => { if (err) { console.error('Error loading fake_users:', err.message); return; } @@ -843,7 +849,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { }; // Linked fields (declared per-template) live in other systems: token HP - // in locations, cash in player_banks. Overlay their live values onto the + // in locations, cash in bank_accounts. Overlay their live values onto the // sheet data at read time - they are never stored in the sheet's JSON. const overlayLinkedData = (username, system, data, cb) => { const linked = sheetTemplates.getLinkedFields(system); @@ -875,9 +881,10 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (source === 'token_ac_ranged') out[fieldId] = tokenRow ? sheetTemplates.rangedAcOf(tokenRow) : null; }); if (!wantsCash) return done(out); - db.get(`SELECT balance FROM player_banks WHERE username = ?`, [username], (err, bank) => { + // The account in this sheet's own system: a sheet shows the money of its game. + bank.get(db, username, system, (err, account) => { Object.entries(linked).forEach(([fieldId, source]) => { - if (source === 'bank_balance') out[fieldId] = bank ? bank.balance : 0; + if (source === 'bank_balance') out[fieldId] = account ? account.balance : 0; }); done(out); }); @@ -1803,12 +1810,12 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('markFirstPayDone', (data) => { if (!data || !data.username) return; - db.run('UPDATE player_banks SET first_pay_done = 1 WHERE username = ?', [data.username]); + withBankSystem((system) => bank.markFlag(db, data.username, system, 'first_pay_done', () => {})); }); socket.on('markHighRollerDone', (data) => { if (!data || !data.username) return; - db.run('UPDATE player_banks SET high_roller_done = 1 WHERE username = ?', [data.username]); + withBankSystem((system) => bank.markFlag(db, data.username, system, 'high_roller_done', () => {})); }); /** @@ -1837,9 +1844,9 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!username || !data || !data.amount) return; const amount = parseFloat(data.amount); if (isNaN(amount) || amount <= 0) return; - db.run('UPDATE player_banks SET balance = balance - ? WHERE username = ?', [amount, username], (err) => { + withBankSystem((system) => bank.adjust(db, username, system, { balance: -amount }, (err) => { if (!err) sendBankUpdate(username); - }); + })); }); socket.on('borrowFunds', (data) => { @@ -1847,9 +1854,9 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!username || !data || !data.amount) return; const amount = parseFloat(data.amount); if (isNaN(amount) || amount <= 0) return; - db.run('UPDATE player_banks SET debt = debt + ? WHERE username = ?', [amount, username], (err) => { + withBankSystem((system) => bank.adjust(db, username, system, { debt: amount }, (err) => { if (!err) sendBankUpdate(username); - }); + })); }); socket.on('payDebt', (data) => { @@ -1857,15 +1864,15 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!username || !data || !data.amount) return; let amount = parseFloat(data.amount); if (isNaN(amount) || amount <= 0) return; - db.get('SELECT balance, debt FROM player_banks WHERE username = ?', [username], (err, row) => { + withBankSystem((system) => bank.get(db, username, system, (err, row) => { if (err || !row) return; if (amount > row.balance) amount = row.balance; if (amount > row.debt) amount = row.debt; if (amount <= 0) return; - db.run('UPDATE player_banks SET balance = balance - ?, debt = debt - ? WHERE username = ?', [amount, amount, username], (err2) => { + bank.adjust(db, username, system, { balance: -amount, debt: -amount }, (err2) => { if (!err2) sendBankUpdate(username); }); - }); + })); }); /** @@ -1933,18 +1940,16 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!sale.ok) return refuse(sale.reason, { itemId: sale.itemId }); } - db.get( - 'SELECT balance, debt FROM player_banks WHERE username = ?', - [username], - (bErr, bank) => { + bank.get(db, username, system, + (bErr, account) => { if (bErr) return refuse('no_account'); const plan = shopCheckout.planCheckout({ buys: data.buys, priceOf: shopPrices.priceOf, shelved: catalogues, sale, - balance: bank ? Number(bank.balance) || 0 : 0, - debt: bank ? Number(bank.debt) || 0 : 0, + balance: account ? Number(account.balance) || 0 : 0, + debt: account ? Number(account.debt) || 0 : 0, overdraftAllowed, settle: data.settle, expectedNet: data.expectedNet, @@ -1955,12 +1960,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { } const payBank = () => { - const write = bank - ? ['UPDATE player_banks SET balance = ?, debt = ? WHERE username = ?', - [plan.balance, plan.debt, username]] - : ['INSERT INTO player_banks (username, balance, debt) VALUES (?, ?, ?)', - [username, plan.balance, plan.debt]]; - db.run(write[0], write[1], (wErr) => { + bank.put(db, username, system, plan.balance, plan.debt, (wErr) => { if (wErr) return refuse('write'); sendBankUpdate(username); if (sale) io.emit('sheetUpdated', { username, system }); @@ -2095,15 +2095,9 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (count === 0) return; const amountPerPlayer = Math.ceil((parseFloat(data.totalAmount) / count) * 100) / 100; if (isNaN(amountPerPlayer) || amountPerPlayer <= 0) return; - data.usernames.forEach(uname => { - db.get('SELECT username FROM player_banks WHERE username = ?', [uname], (err, row) => { - if (row) { - db.run('UPDATE player_banks SET balance = COALESCE(balance, 0) + ? WHERE username = ?', [amountPerPlayer, uname], () => sendBankUpdate(uname)); - } else { - db.run('INSERT INTO player_banks (username, balance, debt) VALUES (?, ?, 0)', [uname, amountPerPlayer], () => sendBankUpdate(uname)); - } - }); - }); + withBankSystem((system) => data.usernames.forEach((uname) => { + bank.addToBalance(db, uname, system, amountPerPlayer, () => sendBankUpdate(uname)); + })); }); }); @@ -2172,13 +2166,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { const balance = parseFloat(data.balance); const debt = parseFloat(data.debt); if (isNaN(balance) || isNaN(debt)) return; - db.get('SELECT username FROM player_banks WHERE username = ?', [data.username], (err2, row) => { - if (row) { - db.run('UPDATE player_banks SET balance = ?, debt = ? WHERE username = ?', [balance, debt, data.username], () => sendBankUpdate(data.username)); - } else { - db.run('INSERT INTO player_banks (username, balance, debt) VALUES (?, ?, ?)', [data.username, balance, debt], () => sendBankUpdate(data.username)); - } - }); + withBankSystem((system) => bank.put(db, data.username, system, balance, debt, () => sendBankUpdate(data.username))); }); }); diff --git a/backend/startup/backup.js b/backend/startup/backup.js new file mode 100644 index 00000000..3f2ee723 --- /dev/null +++ b/backend/startup/backup.js @@ -0,0 +1,50 @@ +// A copy of the whole database, taken before a migration changes real data. +// +// `VACUUM INTO` writes a complete, consistent copy while the database stays open, which a +// plain file copy of a live database cannot promise. It is only attempted when the disk has +// room: the copy is as large as the database, and running a disk out of space is exactly how +// a delete once took the whole server down (SQLITE_FULL, 1.14.3). With too little room the +// migration still has its own safety net - the tables it moves data out of are left as they +// were - and the log says plainly that no copy was made. + +const fs = require('fs'); +const path = require('path'); + +/** Room to leave free beyond the copy itself, so the copy never fills the disk. */ +const HEADROOM = 50 * 1024 * 1024; + +/** Free bytes on the disk holding `dir`, or null when the platform cannot say. */ +const freeBytes = (dir) => { + try { + const s = fs.statfsSync(dir); + return Number(s.bavail) * Number(s.bsize); + } catch { + return null; + } +}; + +/** `city.db` → `city.db.before-