From 72cfc76b2a9f121dc642121b03c7c39fb523aba4 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 29 Sep 2026 16:26:38 -0500 Subject: [PATCH 1/2] feat(system builder 2c): each game system keeps its own token health, defense and injuries The token's columns keep the running system's values, so combat, damage, the health monitor and linked sheet fields are unchanged; the other systems' values wait in token_vitals. tokens/vitals.js switchSystem swaps them and writes game_system in one transaction, reading the system being left inside it, so a failure or two quick switches can never split tokens from the setting. The system picker route uses it and has every screen redraw; the generic settings route can no longer change game_system or the migration markers. A one-time start (startup/tokenVitals.js, adds rows only, run once) saves each token's current values under every system it could be shown in. Map clears and loads drop saved values for tokens that are gone, since they wind the id sequence back. Tests mutation-checked (restore, transaction, systems, settings guard, prune). Verified on a read-only copy of the real database: 18 tokens switched away and back came back identical. --- CHANGELOG.md | 6 + README.md | 4 + backend/__tests__/helpers/testDb.js | 13 + backend/__tests__/token_vitals.test.js | 252 +++++++++++++++ backend/db.js | 23 ++ backend/routes/admin.js | 6 + backend/routes/maps.js | 426 +++++++++++++------------ backend/routes/sheets.js | 37 ++- backend/startup/tokenVitals.js | 62 ++++ backend/tokens/vitals.js | 95 ++++++ 10 files changed, 697 insertions(+), 227 deletions(-) create mode 100644 backend/__tests__/token_vitals.test.js create mode 100644 backend/startup/tokenVitals.js create mode 100644 backend/tokens/vitals.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f89f8d9e..519b06aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 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 diff --git a/README.md b/README.md index 19cbb6d3..0b290b6e 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,8 @@ 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 @@ -414,6 +416,7 @@ CITY_NET/ │ ├── 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 @@ -447,6 +450,7 @@ CITY_NET/ │ ├── 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 diff --git a/backend/__tests__/helpers/testDb.js b/backend/__tests__/helpers/testDb.js index 56a05f18..dfc619a6 100644 --- a/backend/__tests__/helpers/testDb.js +++ b/backend/__tests__/helpers/testDb.js @@ -234,6 +234,19 @@ function makeTestDb() { 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, diff --git a/backend/__tests__/token_vitals.test.js b/backend/__tests__/token_vitals.test.js new file mode 100644 index 00000000..d3155472 --- /dev/null +++ b/backend/__tests__/token_vitals.test.js @@ -0,0 +1,252 @@ +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 { + const sqliteMain = require_.resolve('sqlite3'); + const script = ` + const sqlite3 = require(${JSON.stringify(sqliteMain)}); + 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'))}); + 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. + for (const k of Object.keys(require.cache)) if (k.includes('MapSystem') && !k.includes('node_modules')) 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/db.js b/backend/db.js index b6310c9c..3509af62 100644 --- a/backend/db.js +++ b/backend/db.js @@ -348,6 +348,20 @@ db.serialize(() => { 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, points_json TEXT NOT NULL, @@ -557,6 +571,15 @@ db.serialize(() => { }); }); 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/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 fa846dc2..8558dcc3 100644 --- a/backend/routes/sheets.js +++ b/backend/routes/sheets.js @@ -7,6 +7,7 @@ const multer = require('multer'); 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, @@ -71,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 --- diff --git a/backend/startup/tokenVitals.js b/backend/startup/tokenVitals.js new file mode 100644 index 00000000..eacdb927 --- /dev/null +++ b/backend/startup/tokenVitals.js @@ -0,0 +1,62 @@ +// The one-time start of per-system token health (tokens/vitals.js). +// +// Before this, a token had one set of health, defense and injuries, shown whatever system ran. +// Now each system keeps its own, swapped in and out when the system changes. So that nothing +// looks different the day this ships, each token's current values are saved under every +// system it could be shown in: +// - a player's token: every system that player has a sheet in, +// - an enemy or friendly token: every system any sheet exists in, +// - and, for both, the system running now. +// Switching to any of those then shows exactly what switching showed before. +// +// It only adds rows to `token_vitals`; no token is changed, so there is nothing to lose and no +// database copy is needed for it. One transaction, and a marker so it runs once. + +const { DEFAULT_SYSTEM } = require('../sheets/templates'); +const { FIELDS, SHAPES_SQL } = require('../tokens/vitals'); + +const MARKER = 'migration_token_vitals'; + +const q = (db, method, sql, params = []) => new Promise((resolve, reject) => { + db[method](sql, params, function (err, rows) { if (err) reject(err); else resolve(method === 'run' ? this : rows); }); +}); + +const migrateTokenVitals = async (db, { log = console } = {}) => { + if (await q(db, 'get', 'SELECT value FROM global_settings WHERE key = ?', [MARKER])) return { ran: false }; + + const active = await q(db, 'get', `SELECT value FROM global_settings WHERE key = 'game_system'`); + const activeSystem = active && active.value ? active.value : DEFAULT_SYSTEM; + const sheets = await q(db, 'all', 'SELECT DISTINCT username, system, is_npc FROM character_sheets'); + const everySystem = new Set(sheets.map((s) => s.system)); + const playerSystems = new Map(); + for (const s of sheets) { + if (s.is_npc) continue; + if (!playerSystems.has(s.username)) playerSystems.set(s.username, new Set()); + playerSystems.get(s.username).add(s.system); + } + const tokens = await q(db, 'all', `SELECT id, shape, owner, ${FIELDS.join(', ')} FROM locations WHERE shape IN (${SHAPES_SQL})`); + + let saved = 0; + await q(db, 'run', 'BEGIN IMMEDIATE'); + try { + for (const t of tokens) { + const systems = new Set(t.shape === 'rhombus' && t.owner ? (playerSystems.get(t.owner) || []) : everySystem); + systems.add(activeSystem); + for (const system of systems) { + const res = await q(db, 'run', + `INSERT OR IGNORE INTO token_vitals (location_id, system, ${FIELDS.join(', ')}) VALUES (?, ?, ${FIELDS.map(() => '?').join(', ')})`, + [t.id, system, ...FIELDS.map((f) => t[f])]); + saved += res.changes; + } + } + await q(db, 'run', 'INSERT OR REPLACE INTO global_settings (key, value) VALUES (?, ?)', [MARKER, new Date().toISOString()]); + await q(db, 'run', 'COMMIT'); + } catch (err) { + await q(db, 'run', 'ROLLBACK').catch(() => {}); + throw err; + } + if (tokens.length) log.log(`[tokens] Saved ${tokens.length} token(s) health under ${saved} system entries.`); + return { ran: true, tokens: tokens.length, saved }; +}; + +module.exports = { migrateTokenVitals, MARKER }; diff --git a/backend/tokens/vitals.js b/backend/tokens/vitals.js new file mode 100644 index 00000000..6089c7ce --- /dev/null +++ b/backend/tokens/vitals.js @@ -0,0 +1,95 @@ +// A token's health, defense and injuries, kept per game system. +// +// A character's state belongs to the game it is in (decided with the user, 2026-09-29), but a +// token lives on the map, which every system shares. So the token's own columns on `locations` +// hold the RUNNING system's values - every reader and writer in the app (combat, the health +// monitor, damage, the sheet's linked HP and AC) keeps working on them unchanged - and the +// other systems' values wait in `token_vitals`, one row per token per system. +// +// Switching systems swaps them: each token's live values are put away under the system being +// left, and the incoming system's are brought back, or blanks where that system has never seen +// the token. One transaction, so a switch happens entirely or not at all. + +const FIELDS = ['hp_current', 'hp_max', 'hp_temp', 'melee_ac', 'ranged_ac', 'injuries']; +const TOKEN_SHAPES = ['rhombus', 'enemy_rhombus', 'friendly_rhombus']; +const SHAPES_SQL = TOKEN_SHAPES.map((s) => `'${s}'`).join(', '); + +let ready = Promise.resolve(); + +/** + * Hold switches until the one-time start (startup/tokenVitals.js) has finished. Set once by + * db.js; a failure is handled here too, so it can never surface as an unhandled rejection. + */ +const setReady = (promise) => { + ready = Promise.resolve(promise); + ready.catch(() => {}); +}; + +const q = (db, method, sql, params = []) => new Promise((resolve, reject) => { + db[method](sql, params, function (err, rows) { if (err) reject(err); else resolve(method === 'run' ? this : rows); }); +}); + +/** + * Change the running system to `to`, carrying every token's health with it: the live values + * are put away under the system being left, and `to`'s are brought back (blanks where `to` + * has never seen the token). + * + * The swap and the `game_system` setting change in ONE transaction, and the system being left + * is read inside it. Were they separate, a failure between them would leave tokens showing one + * system's health while the game runs another, and two quick switches could interleave. + * Resolves to `{ from, to, switched, tokens }`. + */ +const switchSystem = async (db, to, { defaultSystem = 'generic' } = {}) => { + await ready; + await q(db, 'run', 'BEGIN IMMEDIATE'); + try { + const row = await q(db, 'get', `SELECT value FROM global_settings WHERE key = 'game_system'`); + const from = row && row.value ? row.value : defaultSystem; + let tokens = 0; + if (from !== to) { + await q(db, 'run', + `INSERT OR REPLACE INTO token_vitals (location_id, system, ${FIELDS.join(', ')}) + SELECT id, ?, ${FIELDS.join(', ')} FROM locations WHERE shape IN (${SHAPES_SQL})`, + [from]); + // A subselect that finds no row gives NULL: a blank, which is what a system that has + // never seen this token should show. Injuries fall back to none rather than NULL. + const sets = FIELDS.map((col) => (col === 'injuries' + ? `injuries = COALESCE((SELECT injuries FROM token_vitals v WHERE v.location_id = locations.id AND v.system = ?), '{}')` + : `${col} = (SELECT ${col} FROM token_vitals v WHERE v.location_id = locations.id AND v.system = ?)`)); + const res = await q(db, 'run', + `UPDATE locations SET ${sets.join(', ')} WHERE shape IN (${SHAPES_SQL})`, + FIELDS.map(() => to)); + tokens = res.changes; + } + await q(db, 'run', + `INSERT INTO global_settings (key, value) VALUES ('game_system', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [to]); + await q(db, 'run', 'COMMIT'); + return { from, to, switched: from !== to, tokens }; + } catch (err) { + await q(db, 'run', 'ROLLBACK').catch(() => {}); + throw err; + } +}; + +/** + * After a map is cleared or loaded: keep saved values for player tokens only. + * + * Both keep the players' tokens and replace everything else, and both wind the id sequence + * back, so a new enemy can get the id a cleared one had. It must not inherit that token's + * health from another system. Not run when a token is simply deleted: undo brings it back + * under the same id, and its saved values should come back with it. + */ +const pruneAfterMapChange = (db, cb = () => {}) => { + db.run( + `DELETE FROM token_vitals WHERE location_id NOT IN (SELECT id FROM locations WHERE shape = 'rhombus')`, + [], + (err) => { + if (err) console.error('[tokens] Could not tidy saved token health:', err.message); + cb(err || null); + }, + ); +}; + +module.exports = { FIELDS, TOKEN_SHAPES, SHAPES_SQL, setReady, switchSystem, pruneAfterMapChange }; From 06d5311800cc6e55409be2aec9c2ac4539efef5f Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 29 Sep 2026 18:28:30 -0500 Subject: [PATCH 2/2] test: the startup-path test clears the module cache by the backend's own folder It matched the folder name MapSystem, which is only in a Windows checkout's path: on the CI runner it cleared nothing, the second open of db.js returned the first, closed connection, and the test failed with "Database is closed". It now matches the backend directory as resolved, says so plainly if db.js was not cleared, and waits for the first open's startup work (tokens/vitals whenReady) before closing it. --- backend/__tests__/token_vitals.test.js | 20 +++++++++++++++++--- backend/tokens/vitals.js | 5 ++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/token_vitals.test.js b/backend/__tests__/token_vitals.test.js index d3155472..b69b9b1f 100644 --- a/backend/__tests__/token_vitals.test.js +++ b/backend/__tests__/token_vitals.test.js @@ -218,13 +218,21 @@ describe('the real startup path', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'citynet-tokens-')); const file = path.join(dir, 'city.db'); try { - const sqliteMain = require_.resolve('sqlite3'); + // 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 sqlite3 = require(${JSON.stringify(sqliteMain)}); + 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}')"); @@ -234,7 +242,12 @@ describe('the real startup path', () => { }); first.close(() => { // Second open, as after updating: the start runs, then a switch keeps the health. - for (const k of Object.keys(require.cache)) if (k.includes('MapSystem') && !k.includes('node_modules')) delete require.cache[k]; + 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(() => { @@ -242,6 +255,7 @@ describe('the real startup path', () => { 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 } }); diff --git a/backend/tokens/vitals.js b/backend/tokens/vitals.js index 6089c7ce..bae4a207 100644 --- a/backend/tokens/vitals.js +++ b/backend/tokens/vitals.js @@ -92,4 +92,7 @@ const pruneAfterMapChange = (db, cb = () => {}) => { ); }; -module.exports = { FIELDS, TOKEN_SHAPES, SHAPES_SQL, setReady, switchSystem, pruneAfterMapChange }; +/** Resolves once the one-time start has finished (never rejects). For startup code and tests. */ +const whenReady = () => ready.then(() => undefined, () => undefined); + +module.exports = { FIELDS, TOKEN_SHAPES, SHAPES_SQL, setReady, whenReady, switchSystem, pruneAfterMapChange };