From 1b1bc8d8c558c5a8b2f2f9ad68a6dea54f463e30 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 30 Sep 2026 11:55:40 -0500 Subject: [PATCH] feat: share a custom system as a .citysys file, and delete as a hide that a reinstall undoes A published system exports as one readable JSON file with a cover: name, author, version, the CITY_NET version it came from, license, and its origin, who the system is across servers. A file is read on the server as untrusted input: capped in size, checked like the editor's own work, text kept as text, and it never carries characters or sheets. Installing is a preview that changes nothing, then new, update or keep both. An update only replaces a copy nobody has changed here, and never a system made here; keep both gives the copy its own id and origin, so the two never collide. Never a merge. A file with problems installs as a draft to fix, never as something a game can run. Deleting now hides a system instead of removing it: it leaves every list and cannot be run, and reinstalling its file brings it back under its old id, with every character, bank and token health played in it. The preview warns when that would replace changes made here. New custom_systems columns (origin, source_hash, deleted_at) are added on start; a system made before them is its own origin. Checked on a copy of a real database: nothing else changes. --- README.md | 6 +- backend/__tests__/helpers/testDb.js | 5 +- .../__tests__/system_builder_citysys.test.js | 268 ++++++++++++++++++ backend/db.js | 11 +- backend/routes/systems.js | 30 ++ backend/systemBuilder/citysys.js | 111 ++++++++ backend/systemBuilder/definition.js | 8 +- backend/systemBuilder/runtime.js | 4 +- backend/systemBuilder/store.js | 147 +++++++++- 9 files changed, 573 insertions(+), 17 deletions(-) create mode 100644 backend/__tests__/system_builder_citysys.test.js create mode 100644 backend/systemBuilder/citysys.js diff --git a/README.md b/README.md index 6f008581..478ae375 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,7 @@ CITY_NET/ │ │ ├── signs.js # Custom sign CRUD (GET all / POST / PATCH :id / DELETE :id); text optional when image_url set; rotation_x/y/z persisted, non-finite angles rejected │ │ ├── fonts.js # Font file upload/list/delete (.ttf .otf .woff .woff2); served as static under /uploads/fonts/ │ │ ├── player.js # Player auth (register, login, forgot, reset, registration status poll) -│ │ ├── systems.js # Custom game systems: list, create (from a name or a whole definition), read, save a draft, publish, delete. Main admin only, reading included; delete refused for the running system +│ │ ├── systems.js # Custom game systems: list, create (from a name or a whole definition), read, save a draft, publish, delete (a hide, refused for the running system); export a published system as a .citysys file, preview a file, install it as new, an update or a second copy. Main admin only, reading included │ │ └── sheets.js # Character sheets — admin sheet access, NPC library, portraits, LUCK/Edge reset & grant, import preview. The table-wide resets scan to decide who is affected and then work out each value as that sheet is written, rather than writing back a scan that has already gone stale │ ├── dice/ │ │ └── systemDice.js # Built-in dice manifest keyed by game system (ids namespaced `builtin:`); lives in code, not the DB, so app updates change definitions with no migration and nothing is mutable through the API @@ -418,7 +418,8 @@ CITY_NET/ │ │ ├── healthView.js # What a token's HEALTH folder is sent under a custom health model: the full detail (a second track's numbers, box marks, harm notes, the wound penalty, location notes) for the GM, a granted editor or the token's owner, and only a description (fills, the worst harm's name, WOUNDED, which locations are hurt) for everyone else; sent by the socket's requestHealthView │ │ ├── npc.js # A custom system's NPCs as data: an optional stat-block layout (checked like a sheet, and linking shared fields the same way) and GENERATE_SHEET tiers (label, token HP and defense, starting values) │ │ ├── runtime.js # Published systems in memory for the running game: compiled once into the meta the built-in templates carry (public/combat/linked/GM-only fields, max pairs, derived recompute), reached by sheets/templates.js through a hook; the NPC tiers, reached by sheets/npcTiers.js the same way; and the render copy the browser draws from, with no formulas -│ │ └── 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 +│ │ ├── citysys.js # A system as a file to share (.citysys): plain JSON with a cover (name, author, version, builder, license, origin); read as untrusted input, capped, and checked like the editor's work; never carries characters +│ │ └── 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, and deleting hides a system so reinstalling its file brings it back with its characters; export, preview and install (new, update when unchanged here, keep both with a new origin; never a merge) │ ├── 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 @@ -462,6 +463,7 @@ CITY_NET/ │ ├── system_builder_health.test.js # Every health model's DAMAGE and HEAL rules; the HIT_POINTS route using them for players and linked NPCs, refusing what a model cannot do, keeping an edit made while damage lands, and leaving the built-in systems and a custom one-pool system on the route as before │ ├── system_builder_health_view.test.js # Every model's full and described view; the socket sending the full one only to the GM, a granted editor or the owner (never through an NPC's owner field) and answering only the asker; a second track's SET MAX; the moved-up and turned-heavier details │ ├── system_builder_npc_privacy.test.js # GM-only fields refused to the owner by edit, batch and upload but not to the GM or a granted admin; the NPC layout and tier checks; tiers generating a sheet and setting (or keeping) the token's HP and defense; built-ins unchanged +│ ├── system_builder_citysys.test.js # Export (published only, readable, never a character), reading a file as untrusted input, a preview that changes nothing, installing as new / update / keep both with their refusals, deleting as a hide, and a deleted system coming back under its old id with its characters │ ├── 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 dfc619a6..0ba5b496 100644 --- a/backend/__tests__/helpers/testDb.js +++ b/backend/__tests__/helpers/testDb.js @@ -255,7 +255,10 @@ function makeTestDb() { version INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - published_at DATETIME + published_at DATETIME, + origin TEXT, + source_hash TEXT, + deleted_at DATETIME )`); db.run(`CREATE TABLE sqlite_sequence (name TEXT, seq INTEGER)`, () => { diff --git a/backend/__tests__/system_builder_citysys.test.js b/backend/__tests__/system_builder_citysys.test.js new file mode 100644 index 00000000..d5c64166 --- /dev/null +++ b/backend/__tests__/system_builder_citysys.test.js @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { createRequire } from 'module'; +import { makeTestDb, get, run } from './helpers/testDb.js'; + +/** + * Sharing a custom system as a .citysys file: export a published system, preview a file + * without changing anything, install it as new, as an update, or as a second copy; and delete + * as a hide, so reinstalling a deleted system's file brings it back with its characters. + */ + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const citysys = require_('../systemBuilder/citysys'); +const { checkDefinition, LIMITS } = require_('../systemBuilder/definition'); +const runtime = require_('../systemBuilder/runtime'); +const templates = require_('../sheets/templates'); + +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); +const PLAYER = jwt.sign({ id: 5, username: 'GHOST', role: 'player' }, 'test-secret'); +const gm = { Authorization: `Bearer ${GM}` }; + +const VAULT = { + format: 1, name: 'Vault Knights', author: 'Cody', license: 'CC BY 4.0', description: 'Knights in vaults.', + words: { hp: { singular: 'WOUND' } }, + derived: [{ id: 'guard', formula: '10 + @might' }], + core: { health: { model: 'wounds', count: 3 } }, +}; + +let db; +let app; +beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); + app = express(); + app.use(express.json({ limit: '2mb' })); + app.use('/api/systems', require_('../routes/systems.js')(db)); + await new Promise((resolve) => runtime.load(db, resolve)); + vi.restoreAllMocks(); +}); + +const create = async (definition = VAULT) => (await request(app).post('/api/systems').set(gm).send({ definition })).body.id; +const publish = (id) => request(app).post(`/api/systems/${id}/publish`).set(gm); +const exported = async (id) => (await request(app).get(`/api/systems/${id}/export`).set(gm)).text; +const preview = (file) => request(app).post('/api/systems/install/preview').set(gm).send({ file }); +const install = (file, mode) => request(app).post('/api/systems/install').set(gm).send({ file, mode }); +const rows = () => new Promise((resolve) => db.all('SELECT id, name, version, origin, deleted_at FROM custom_systems ORDER BY created_at, id', (e, r) => resolve(r))); +const fileOf = (definition, origin = 'org_vault', version = 3) => JSON.stringify(citysys.buildFile({ definition, version, origin })); + +describe('the definition\'s cover fields', () => { + it('takes an author and a license as free text, with limits', () => { + expect(checkDefinition(VAULT)).toEqual({ problems: [] }); + expect(checkDefinition({ ...VAULT, author: 'x'.repeat(LIMITS.author + 1), license: 7 }).problems.map((p) => `${p.where}: ${p.message}`)) + .toEqual([`author: Longer than ${LIMITS.author} characters`, 'license: Must be text']); + }); +}); + +describe('exporting', () => { + it('writes the published system as a readable file with its cover', async () => { + const id = await create(); + await publish(id); + const res = await request(app).get(`/api/systems/${id}/export`).set(gm); + expect(res.status).toBe(200); + expect(res.headers['content-disposition']).toBe('attachment; filename="vault-knights.citysys"'); + expect(res.text).toContain('\n "manifest": {'); + const file = JSON.parse(res.text); + expect(file).toMatchObject({ + citysys: 1, + manifest: { name: 'Vault Knights', author: 'Cody', license: 'CC BY 4.0', version: 1, origin: id }, + definition: VAULT, + }); + expect(typeof file.manifest.builder).toBe('string'); + expect(Number.isNaN(Date.parse(file.manifest.exported))).toBe(false); + }); + + it('shares the published copy, not a draft being worked on, and nothing unpublished', async () => { + const id = await create(); + expect((await request(app).get(`/api/systems/${id}/export`).set(gm)).status).toBe(409); + await publish(id); + await request(app).put(`/api/systems/${id}/draft`).set(gm).send({ definition: { ...VAULT, description: 'Secret draft' } }); + expect(await exported(id)).not.toContain('Secret draft'); + }); + + it('never carries a character or a sheet', async () => { + const id = await create(); + await publish(id); + await run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)', ['GHOST', id, '{"name":"Sir Ghost-in-the-file"}']); + expect(await exported(id)).not.toContain('Ghost-in-the-file'); + }); + + it('names the file from the system\'s name, safely', () => { + expect(citysys.fileNameFor('Vault Knights: Ärmor & Ash!')).toBe('vault-knights-armor-ash.citysys'); + expect(citysys.fileNameFor('../../etc')).toBe('etc.citysys'); + expect(citysys.fileNameFor('!!!')).toBe('system.citysys'); + }); +}); + +describe('reading a file', () => { + it('refuses what cannot be installed at all, saying why', () => { + const fatal = (text) => citysys.readFile(text).fatal; + expect(fatal(undefined)).toBe('Not a file'); + expect(fatal('x'.repeat(citysys.MAX_BYTES + 1))).toMatch(/^Larger than/); + expect(fatal('{nope')).toBe('Not a CITY_NET system file'); + expect(fatal('{"name":"x"}')).toBe('Not a CITY_NET system file'); + expect(fatal(JSON.stringify({ citysys: 2, manifest: {}, definition: {} }))).toBe('Made by a newer CITY_NET (file format 2); update to install it'); + expect(fatal(JSON.stringify({ citysys: 1, manifest: { origin: 'o' } }))).toBe('The file is missing its system'); + expect(fatal(JSON.stringify({ citysys: 1, manifest: { origin: '../x' }, definition: VAULT }))).toBe('The file does not say which system it is'); + expect(fatal(JSON.stringify({ citysys: 1, manifest: {}, definition: VAULT }))).toBe('The file does not say which system it is'); + }); + + it('keeps only the cover fields it knows, cut to length, and never trusts the version', () => { + const text = JSON.stringify({ citysys: 1, manifest: { origin: 'o1', name: 'n'.repeat(200), author: 5, version: -2, script: 'alert(1)' }, definition: VAULT }); + const { file } = citysys.readFile(text); + expect(file.manifest).toEqual({ name: 'n'.repeat(80), author: '', license: '', builder: '', version: 0, origin: 'o1' }); + }); + + it('passes the definition through the editor\'s own checks', () => { + const { problems } = citysys.readFile(fileOf({ ...VAULT, derived: [{ id: 'a', formula: '@b' }, { id: 'b', formula: '@a' }] })); + expect(problems.length).toBeGreaterThan(0); + }); +}); + +describe('previewing an install', () => { + it('says what is inside and changes nothing', async () => { + const before = await rows(); + const res = await preview(fileOf(VAULT)); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + name: 'Vault Knights', + manifest: { author: 'Cody', version: 3, origin: 'org_vault' }, + inside: { words: 1, derived: 1, healthModel: 'wounds' }, + problems: [], installed: [], restores: null, + }); + expect(await rows()).toEqual(before); + }); + + it('refuses a file it cannot read', async () => { + const res = await preview('not json'); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Not a CITY_NET system file'); + }); +}); + +describe('installing', () => { + it('as new: published, runnable, and remembering where it came from', async () => { + const res = await install(fileOf(VAULT), 'new'); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ published: true, problems: [] }); + const row = await get(db, 'SELECT * FROM custom_systems WHERE id = ?', [res.body.id]); + expect(row).toMatchObject({ name: 'Vault Knights', version: 1, origin: 'org_vault' }); + expect(row.source_hash).toBe(citysys.hashOf(row.draft)); + expect(templates.isValidSystem(res.body.id)).toBe(true); + }); + + it('as new with problems: kept as a draft to fix, never runnable', async () => { + const broken = { ...VAULT, derived: [{ id: 'a', formula: '@b' }, { id: 'b', formula: '@a' }] }; + const res = await install(fileOf(broken), 'new'); + expect(res.body.published).toBe(false); + expect(res.body.problems.length).toBeGreaterThan(0); + expect((await get(db, 'SELECT published, version FROM custom_systems WHERE id = ?', [res.body.id]))).toEqual({ published: null, version: 0 }); + expect(templates.isValidSystem(res.body.id)).toBe(false); + }); + + it('as new when it is already here: asks for update or keep both', async () => { + const first = (await install(fileOf(VAULT), 'new')).body.id; + const again = await install(fileOf(VAULT), 'new'); + expect(again.status).toBe(409); + expect(again.body).toMatchObject({ error: 'Already installed. Update it or keep both.', installed: [{ id: first, name: 'Vault Knights' }] }); + expect((await preview(fileOf(VAULT))).body.installed).toEqual([{ id: first, name: 'Vault Knights', version: 1, edited: false }]); + }); + + it('as an update: the same system, its next version, while it has not been changed here', async () => { + const id = (await install(fileOf(VAULT), 'new')).body.id; + const v2 = { ...VAULT, description: 'Now with more vaults.' }; + const res = await install(fileOf(v2, 'org_vault', 4), 'update'); + expect(res.body).toMatchObject({ id, published: true }); + expect(await get(db, 'SELECT version, published FROM custom_systems WHERE id = ?', [id])).toMatchObject({ version: 2, published: JSON.stringify(v2) }); + expect(runtime.render(id).name).toBe('Vault Knights'); + }); + + it('as an update: refused over changes made here, over problems, and when not installed', async () => { + expect((await install(fileOf(VAULT), 'update')).status).toBe(404); + const id = (await install(fileOf(VAULT), 'new')).body.id; + const broken = { ...VAULT, derived: [{ id: 'a', formula: '@a' }] }; + expect((await install(fileOf(broken), 'update')).status).toBe(409); + await request(app).put(`/api/systems/${id}/draft`).set(gm).send({ definition: { ...VAULT, description: 'Mine now' } }); + expect((await preview(fileOf(VAULT))).body.installed[0].edited).toBe(true); + const res = await install(fileOf({ ...VAULT, description: 'Theirs' }), 'update'); + expect(res.status).toBe(409); + expect(res.body.error).toBe('This system has been changed here since it was installed. Keep both instead.'); + expect(JSON.parse((await get(db, 'SELECT draft FROM custom_systems WHERE id = ?', [id])).draft).description).toBe('Mine now'); + }); + + it('never overwrites a system made here, even from its own file', async () => { + const id = await create(); + await publish(id); + const file = await exported(id); + expect((await preview(file)).body.installed).toEqual([{ id, name: 'Vault Knights', version: 1, edited: true }]); + expect((await install(file, 'update')).status).toBe(409); + }); + + it('keeping both: a second copy with its own id and origin, so the two never collide', async () => { + const first = (await install(fileOf(VAULT), 'new')).body.id; + const copy = (await install(fileOf(VAULT), 'keep_both')).body.id; + expect(copy).not.toBe(first); + const [a, b] = await Promise.all([first, copy].map((id) => get(db, 'SELECT origin FROM custom_systems WHERE id = ?', [id]))); + expect(a.origin).toBe('org_vault'); + expect(b.origin).toBe(copy); + // The original is still the one the file matches. + expect((await preview(fileOf(VAULT))).body.installed.map((s) => s.id)).toEqual([first]); + }); + + it('refuses a mode it does not know', async () => { + expect((await install(fileOf(VAULT), 'merge')).status).toBe(400); + }); +}); + +describe('deleting', () => { + it('hides the system everywhere but keeps it', async () => { + const id = (await install(fileOf(VAULT), 'new')).body.id; + expect((await request(app).delete(`/api/systems/${id}`).set(gm)).status).toBe(200); + expect((await request(app).get('/api/systems').set(gm)).body.map((s) => s.id)).not.toContain(id); + expect((await request(app).get(`/api/systems/${id}`).set(gm)).status).toBe(404); + expect((await request(app).put(`/api/systems/${id}/draft`).set(gm).send({ definition: VAULT })).status).toBe(404); + expect((await request(app).get(`/api/systems/${id}/export`).set(gm)).status).toBe(404); + expect((await request(app).delete(`/api/systems/${id}`).set(gm)).status).toBe(404); + expect(templates.isValidSystem(id)).toBe(false); + // Still there, for its characters' sake. + expect((await get(db, 'SELECT deleted_at FROM custom_systems WHERE id = ?', [id])).deleted_at).not.toBeNull(); + // And never loaded again on a restart. + await new Promise((resolve) => runtime.load(db, resolve)); + expect(templates.isValidSystem(id)).toBe(false); + }); + + it('comes back, under its old id with its characters, when its file is installed again', async () => { + const id = (await install(fileOf(VAULT), 'new')).body.id; + await run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)', ['GHOST', id, '{"name":"Sir Ghost"}']); + await request(app).delete(`/api/systems/${id}`).set(gm); + expect((await preview(fileOf(VAULT))).body.restores).toEqual({ id, name: 'Vault Knights', replacesChanges: false }); + const res = await install(fileOf(VAULT), 'new'); + expect(res.body).toMatchObject({ id, restored: true, published: true }); + expect(templates.isValidSystem(id)).toBe(true); + expect((await request(app).get('/api/systems').set(gm)).body.map((s) => s.id)).toContain(id); + expect((await get(db, 'SELECT data FROM character_sheets WHERE system = ?', [id])).data).toContain('Sir Ghost'); + }); + + it('warns when bringing one back would replace changes made here', async () => { + const id = await create(); + await publish(id); + const file = await exported(id); + await request(app).put(`/api/systems/${id}/draft`).set(gm).send({ definition: { ...VAULT, description: 'Unshared work' } }); + await request(app).delete(`/api/systems/${id}`).set(gm); + expect((await preview(file)).body.restores).toEqual({ id, name: 'Vault Knights', replacesChanges: true }); + }); +}); + +describe('who may', () => { + it('only the GM exports, previews or installs', async () => { + const id = (await install(fileOf(VAULT), 'new')).body.id; + const player = { Authorization: `Bearer ${PLAYER}` }; + expect((await request(app).get(`/api/systems/${id}/export`).set(player)).status).toBe(403); + expect((await request(app).post('/api/systems/install/preview').set(player).send({ file: fileOf(VAULT) })).status).toBe(403); + expect((await request(app).post('/api/systems/install').set(player).send({ file: fileOf(VAULT), mode: 'keep_both' })).status).toBe(403); + expect((await request(app).post('/api/systems/install').send({ file: fileOf(VAULT), mode: 'keep_both' })).status).toBe(401); + }); +}); diff --git a/backend/db.js b/backend/db.js index 3509af62..a30e4925 100644 --- a/backend/db.js +++ b/backend/db.js @@ -501,7 +501,7 @@ db.serialize(() => { db.run(`ALTER TABLE initiative_scene ADD COLUMN sides TEXT NOT NULL DEFAULT '[]'`, () => {}); // Game systems a GM built: a draft the builder edits and the published copy a game runs. - // See systemBuilder/store.js. Nothing in the running game reads it yet. + // See systemBuilder/store.js; published ones are loaded into the game by systemBuilder/runtime.js. db.run(`CREATE TABLE IF NOT EXISTS custom_systems ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -512,6 +512,15 @@ db.serialize(() => { updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, published_at DATETIME )`); + // Sharing systems as files (systemBuilder/citysys.js). `origin` is who the system is across + // servers, kept by every copy installed from its file; `source_hash` is what was installed, + // so an update can tell a copy edited since; `deleted_at` hides a deleted system while + // keeping it, so reinstalling its file brings it back with every character played in it. + db.run(`ALTER TABLE custom_systems ADD COLUMN origin TEXT`, () => {}); + db.run(`ALTER TABLE custom_systems ADD COLUMN source_hash TEXT`, () => {}); + db.run(`ALTER TABLE custom_systems ADD COLUMN deleted_at DATETIME`, () => {}); + // A system made before files existed is its own origin. + db.run(`UPDATE custom_systems SET origin = id WHERE origin IS NULL`, () => {}); // Migration: CP:R's name field was stored as 'handle'; it is now 'name' // (uniform across systems — the sheet is the source of truth for player diff --git a/backend/routes/systems.js b/backend/routes/systems.js index 4fdab814..1b34aa87 100644 --- a/backend/routes/systems.js +++ b/backend/routes/systems.js @@ -44,6 +44,36 @@ module.exports = (db) => { }); }); + // ─── Sharing as files (systemBuilder/citysys.js) ──────────────────────────── + + // A published system as a .citysys file to download. + router.get('/:id/export', gm, (req, res) => { + store.exportSystem(db, req.params.id, (err, file) => { + if (err) return answer(res, err); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${file.fileName}"`); + res.send(file.text); + }); + }); + + // What installing a file would do, changing nothing. The file arrives as text so its size is + // checked before it is parsed. + router.post('/install/preview', gm, (req, res) => { + store.previewInstall(db, req.body && req.body.file, (err, preview) => answer(res, err, preview)); + }); + + router.post('/install', gm, (req, res) => { + const { file, mode } = req.body || {}; + store.installSystem(db, file, mode, (err, installed) => { + if (err) return res.status(err.status || 500).json({ + error: err.status ? err.message : 'Could not reach the systems store', + ...(err.problems ? { problems: err.problems } : {}), + ...(err.installed ? { installed: err.installed } : {}), + }); + runtime.refresh(db, installed.id, () => answer(res, null, installed)); + }); + }); + router.delete('/:id', gm, (req, res) => { store.deleteSystem(db, req.params.id, (err) => { if (err) return answer(res, err); diff --git a/backend/systemBuilder/citysys.js b/backend/systemBuilder/citysys.js new file mode 100644 index 00000000..3e18ebfe --- /dev/null +++ b/backend/systemBuilder/citysys.js @@ -0,0 +1,111 @@ +// A custom system as a file to share: `vault-knights.citysys`. +// +// { +// "citysys": 1, +// "manifest": { "name", "author", "version", "builder", "license", "origin", "exported" }, +// "definition": { ...the system, format 1 (definition.js) } +// } +// +// Plain JSON, pretty-printed, so a system can be read, diffed and kept on GitHub. The plan's +// zip form, a folder of images beside the JSON, arrives when a system can hold images; until +// then every system is this one file. +// +// A file is untrusted whoever sent it: it is read here on the server, capped in size, and its +// definition goes through the same checks as the editor's (definition.js). It is data only, +// never code, and it never carries characters or sheets: those belong to the server they were +// played on. `origin` is who the system is across servers. Installing a file whose origin is +// already here offers an update or a second copy; a deleted system with that origin comes back. + +const crypto = require('crypto'); +const { checkDefinition, LIMITS: DEFINITION_LIMITS } = require('./definition'); + +const FILE_FORMAT = 1; +/** The definition's own cap plus room for the cover. */ +const MAX_BYTES = DEFINITION_LIMITS.bytes + 16 * 1024; +const MANIFEST_TEXT = { name: 80, author: 80, license: 200, builder: 40 }; +const ORIGIN = /^[A-Za-z0-9_-]{1,64}$/; + +const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); + +/** The app's version, for the cover: informative only, never trusted on install. */ +const appVersion = () => { + if (process.env.APP_VERSION) return process.env.APP_VERSION; + try { return require('../../package.json').version; } catch { return 'dev'; } +}; + +/** A stable fingerprint of a definition as stored. */ +const hashOf = (text) => crypto.createHash('sha256').update(String(text)).digest('hex'); + +/** A file name from a system's name: lowercase, dashes, never empty. */ +const fileNameFor = (name) => { + // Accents come off their letters (Ä to A) rather than turning into dashes. + const slug = String(name || '').normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase() + .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60); + return `${slug || 'system'}.citysys`; +}; + +/** The file for a system's published definition. */ +const buildFile = ({ definition, version, origin }) => ({ + citysys: FILE_FORMAT, + manifest: { + name: definition.name, + author: typeof definition.author === 'string' ? definition.author : '', + version, + builder: appVersion(), + license: typeof definition.license === 'string' ? definition.license : '', + origin, + exported: new Date().toISOString(), + }, + definition, +}); + +/** + * Read a file's text. Returns { fatal } when it cannot be installed at all (not a system file, + * too large, unreadable), otherwise { file: { manifest, definition }, problems }. Problems are + * the definition's own, as the editor lists them: a file with problems installs as a draft to + * fix, never as something a game can run. + */ +const readFile = (text) => { + if (typeof text !== 'string') return { fatal: 'Not a file' }; + if (Buffer.byteLength(text, 'utf8') > MAX_BYTES) return { fatal: `Larger than ${Math.round(MAX_BYTES / 1024)} KB` }; + let parsed; + try { parsed = JSON.parse(text); } catch { return { fatal: 'Not a CITY_NET system file' }; } + if (!isPlainObject(parsed) || !('citysys' in parsed)) return { fatal: 'Not a CITY_NET system file' }; + if (parsed.citysys !== FILE_FORMAT) return { fatal: `Made by a newer CITY_NET (file format ${parsed.citysys}); update to install it` }; + if (!isPlainObject(parsed.manifest) || !isPlainObject(parsed.definition)) return { fatal: 'The file is missing its system' }; + + const m = parsed.manifest; + const text80 = (v, max) => (typeof v === 'string' ? v.slice(0, max) : ''); + if (typeof m.origin !== 'string' || !ORIGIN.test(m.origin)) return { fatal: 'The file does not say which system it is' }; + const manifest = { + name: text80(m.name, MANIFEST_TEXT.name), + author: text80(m.author, MANIFEST_TEXT.author), + license: text80(m.license, MANIFEST_TEXT.license), + builder: text80(m.builder, MANIFEST_TEXT.builder), + version: Number.isInteger(m.version) && m.version >= 0 ? m.version : 0, + origin: m.origin, + }; + + const checked = checkDefinition(parsed.definition); + if (checked.fatal) return { fatal: checked.fatal }; + return { file: { manifest, definition: parsed.definition }, problems: checked.problems }; +}; + +/** What the preview lists as inside: counts, never the content itself. */ +const summarize = (definition) => { + const sheet = isPlainObject(definition.sheet) ? definition.sheet : null; + const count = (v) => (Array.isArray(v) ? v.length : 0); + const fields = sheet && Array.isArray(sheet.sections) + ? sheet.sections.reduce((n, s) => n + (isPlainObject(s) ? count(s.fields) : 0), 0) : 0; + return { + words: isPlainObject(definition.words) ? Object.keys(definition.words).length : 0, + partsOff: isPlainObject(definition.parts) ? Object.values(definition.parts).filter((p) => isPlainObject(p) && p.on === false).length : 0, + derived: count(definition.derived), + lookups: isPlainObject(definition.lookups) ? Object.keys(definition.lookups).length : 0, + sheetFields: fields, + npcTiers: isPlainObject(definition.npc) ? count(definition.npc.tiers) : 0, + healthModel: isPlainObject(definition.core) && isPlainObject(definition.core.health) ? definition.core.health.model || null : null, + }; +}; + +module.exports = { FILE_FORMAT, MAX_BYTES, buildFile, readFile, summarize, hashOf, fileNameFor, appVersion }; diff --git a/backend/systemBuilder/definition.js b/backend/systemBuilder/definition.js index 741437de..abff025a 100644 --- a/backend/systemBuilder/definition.js +++ b/backend/systemBuilder/definition.js @@ -10,6 +10,7 @@ // format: 1, // name: 'Vault Knights', // what the system picker shows // description: '...', // optional +// author: '...', license: '...', // optional, free text; a shared file's cover // words: { hp: { singular: 'WOUND', plural: 'WOUNDS', short: 'W' }, ... }, // Layer 1 // parts: { vehicles: { on: false }, ... }, // Layer 2 // lookups: { ... }, derived: [ ... ], // Layer 3, the Phase 1 engine's format @@ -39,6 +40,9 @@ const LIMITS = { bytes: 512 * 1024, name: 80, description: 2000, + /** Free text: there are no accounts across servers, so an author is whatever they type. */ + author: 80, + license: 200, /** One glossary word. */ word: 40, }; @@ -70,7 +74,7 @@ const PARTS = [ 'death', 'luck', 'xp', 'npc_tiers', 'sheet_import', ]; -const SECTIONS = new Set(['format', 'name', 'description', 'words', 'parts', 'lookups', 'derived', 'sheet', 'npc', 'core']); +const SECTIONS = new Set(['format', 'name', 'description', 'author', 'license', 'words', 'parts', 'lookups', 'derived', 'sheet', 'npc', 'core']); const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); @@ -147,6 +151,8 @@ const checkDefinition = (definition) => { } checkText(definition.name, 'name', LIMITS.name, problems, { required: true }); checkText(definition.description, 'description', LIMITS.description, problems); + checkText(definition.author, 'author', LIMITS.author, problems); + checkText(definition.license, 'license', LIMITS.license, problems); checkWords(definition.words, problems); checkParts(definition.parts, problems); diff --git a/backend/systemBuilder/runtime.js b/backend/systemBuilder/runtime.js index 6de3079a..8cf43ca6 100644 --- a/backend/systemBuilder/runtime.js +++ b/backend/systemBuilder/runtime.js @@ -98,7 +98,7 @@ const put = (id, publishedText, version) => { /** Load every published system. cb(err, count). */ const load = (db, cb = () => {}) => { - db.all('SELECT id, published, version FROM custom_systems WHERE published IS NOT NULL', [], (err, rows) => { + db.all('SELECT id, published, version FROM custom_systems WHERE published IS NOT NULL AND deleted_at IS NULL', [], (err, rows) => { if (err) { console.error('[systems] Could not load custom systems:', err.message); return cb(err); } loaded.clear(); for (const r of rows) put(r.id, r.published, r.version); @@ -108,7 +108,7 @@ const load = (db, cb = () => {}) => { /** Reload one system after it is published or deleted. cb(err). */ const refresh = (db, id, cb = () => {}) => { - db.get('SELECT published, version FROM custom_systems WHERE id = ?', [id], (err, row) => { + db.get('SELECT published, version FROM custom_systems WHERE id = ? AND deleted_at IS NULL', [id], (err, row) => { if (err) return cb(err); if (row && row.published) put(id, row.published, row.version); else loaded.delete(id); cb(null); diff --git a/backend/systemBuilder/store.js b/backend/systemBuilder/store.js index 6bbfc8c3..3fbc0c26 100644 --- a/backend/systemBuilder/store.js +++ b/backend/systemBuilder/store.js @@ -9,11 +9,14 @@ // (cities_without_number, cyberpunk_red...) never start with `sys_`, so the two can never // collide, and the active system - `game_system` in global_settings - can name either. // -// Nothing in the running game reads these yet: loading a published system into the game is -// a later piece (2d). This is storage and its rules only. +// Deleting hides a system rather than removing it: it leaves every list and can no longer be +// run, but reinstalling its file (citysys.js) brings it back under the same id, and with it +// every character, bank and token health played in it. A definition is a few KB; what was +// played in it was never removed by a delete anyway. const crypto = require('crypto'); const { checkDefinition, blankDefinition } = require('./definition'); +const citysys = require('./citysys'); const PREFIX = 'sys_'; @@ -32,7 +35,7 @@ const fail = (status, message, extra) => Object.assign(new Error(message), { sta const listSystems = (db, cb) => { db.all( `SELECT id, name, version, draft, published, updated_at, published_at - FROM custom_systems ORDER BY updated_at DESC, name`, + FROM custom_systems WHERE deleted_at IS NULL ORDER BY updated_at DESC, name`, [], (err, rows) => { if (err) return cb(err); @@ -53,7 +56,7 @@ const listSystems = (db, cb) => { /** One system: both copies of its definition, and the draft's current problems. */ const getSystem = (db, id, cb) => { if (!isCustomId(id)) return cb(fail(404, 'No such system')); - db.get('SELECT * FROM custom_systems WHERE id = ?', [id], (err, row) => { + db.get('SELECT * FROM custom_systems WHERE id = ? AND deleted_at IS NULL', [id], (err, row) => { if (err) return cb(err); if (!row) return cb(fail(404, 'No such system')); const draft = parse(row.draft); @@ -81,10 +84,11 @@ const createSystem = (db, { name, definition } = {}, cb) => { if (checked.fatal) return cb(fail(400, checked.fatal)); if (typeof def.name !== 'string' || !def.name.trim()) return cb(fail(400, 'A system needs a name')); const id = newId(); + // A system made here is its own origin: shared as a file, every copy keeps it. db.run( - `INSERT INTO custom_systems (id, name, draft, version, created_at, updated_at) - VALUES (?, ?, ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, - [id, def.name.trim(), JSON.stringify(def)], + `INSERT INTO custom_systems (id, name, draft, version, origin, created_at, updated_at) + VALUES (?, ?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, + [id, def.name.trim(), JSON.stringify(def), id], (err) => (err ? cb(err) : cb(null, { id, problems: checked.problems })), ); }; @@ -96,7 +100,7 @@ const saveDraft = (db, id, definition, cb) => { if (checked.fatal) return cb(fail(400, checked.fatal)); const name = typeof definition.name === 'string' && definition.name.trim() ? definition.name.trim() : null; db.run( - `UPDATE custom_systems SET draft = ?, name = COALESCE(?, name), updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + `UPDATE custom_systems SET draft = ?, name = COALESCE(?, name), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL`, [JSON.stringify(definition), name, id], function (err) { if (err) return cb(err); @@ -121,13 +125,16 @@ const publishSystem = (db, id, cb) => { }); }; -/** Delete a system. Refused while it is the one the game runs. */ +/** + * Delete a system: hidden, not removed. Refused while it is the one the game runs. Reinstalling + * its file brings it back (installSystem). + */ const deleteSystem = (db, id, cb) => { if (!isCustomId(id)) return cb(fail(404, 'No such system')); db.get(`SELECT value FROM global_settings WHERE key = 'game_system'`, [], (err, row) => { if (err) return cb(err); if (row && row.value === id) return cb(fail(409, 'This is the system the game is running. Switch to another first.')); - db.run('DELETE FROM custom_systems WHERE id = ?', [id], function (err2) { + db.run('UPDATE custom_systems SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL', [id], function (err2) { if (err2) return cb(err2); if (this.changes === 0) return cb(fail(404, 'No such system')); cb(null); @@ -135,6 +142,126 @@ const deleteSystem = (db, id, cb) => { }); }; +// ─── Sharing as files ─────────────────────────────────────────────────────── + +/** A published system as a file: { fileName, text }. A draft is not shared. */ +const exportSystem = (db, id, cb) => { + if (!isCustomId(id)) return cb(fail(404, 'No such system')); + db.get('SELECT published, version, origin FROM custom_systems WHERE id = ? AND deleted_at IS NULL', [id], (err, row) => { + if (err) return cb(err); + if (!row) return cb(fail(404, 'No such system')); + const definition = parse(row.published); + if (!definition) return cb(fail(409, 'Publish the system before sharing it')); + const file = citysys.buildFile({ definition, version: row.version, origin: row.origin || id }); + cb(null, { fileName: citysys.fileNameFor(definition.name), text: JSON.stringify(file, null, 2) }); + }); +}; + +/** + * Has this copy changed since it was installed? A system made here has no record of what was + * installed, so it never matches and always counts as changed: a file must never overwrite + * someone's own work. + */ +const editedSinceInstall = (row) => citysys.hashOf(row.draft) !== row.source_hash + || (row.published != null && citysys.hashOf(row.published) !== row.source_hash); + +/** The systems here with a file's origin: the ones in use, and the most recently deleted. */ +const matchesFor = (db, origin, cb) => { + db.all( + `SELECT id, name, version, draft, published, source_hash, deleted_at FROM custom_systems + WHERE origin = ? ORDER BY updated_at DESC`, + [origin], + (err, rows) => { + if (err) return cb(err); + cb(null, { + installed: rows.filter((r) => r.deleted_at == null), + deleted: rows.find((r) => r.deleted_at != null) || null, + }); + }, + ); +}; + +/** + * What installing a file would do, changing nothing: its cover, what is inside, its problems, + * the copies already here, and whether it would bring a deleted system back. + */ +const previewInstall = (db, text, cb) => { + const read = citysys.readFile(text); + if (read.fatal) return cb(fail(400, read.fatal)); + const { manifest, definition } = read.file; + matchesFor(db, manifest.origin, (err, found) => { + if (err) return cb(err); + cb(null, { + manifest, + name: definition.name, + inside: citysys.summarize(definition), + problems: read.problems, + installed: found.installed.map((r) => ({ id: r.id, name: r.name, version: r.version, edited: editedSinceInstall(r) })), + // Bringing a deleted system back puts the file's definition in it. `replacesChanges` warns + // when the deleted copy held changes made here that the file does not have. + restores: !found.installed.length && found.deleted ? { + id: found.deleted.id, + name: found.deleted.name, + replacesChanges: found.deleted.draft !== JSON.stringify(definition) && editedSinceInstall(found.deleted), + } : null, + }); + }); +}; + +/** + * Install a file. + * new a system not here yet; one deleted here comes back under its old id + * update replace the copy already here, when it has not been changed since installing + * keep_both a second copy beside it, with a new id and a new origin of its own + * Published straight away when the file has no problems; otherwise kept as a draft to fix. + * Never a merge. Resolves { id, published, restored?, problems }. + */ +const installSystem = (db, text, mode, cb) => { + if (!['new', 'update', 'keep_both'].includes(mode)) return cb(fail(400, 'Install as new, update or keep both')); + const read = citysys.readFile(text); + if (read.fatal) return cb(fail(400, read.fatal)); + const { manifest, definition } = read.file; + const stored = JSON.stringify(definition); + const hash = citysys.hashOf(stored); + const name = definition.name.trim(); + const publishable = read.problems.length === 0; + const done = (id, extra) => (err) => (err ? cb(err) : cb(null, { id, published: publishable, problems: read.problems, ...extra })); + + const insert = (id, origin, extra) => db.run( + `INSERT INTO custom_systems (id, name, draft, published, version, origin, source_hash, created_at, updated_at, published_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ${publishable ? 'CURRENT_TIMESTAMP' : 'NULL'})`, + [id, name, stored, publishable ? stored : null, publishable ? 1 : 0, origin, hash], + done(id, extra), + ); + // The row keeps its id, so everything played in it is still its own. + const replace = (row, extra) => db.run( + `UPDATE custom_systems SET name = ?, draft = ?, source_hash = ?, deleted_at = NULL, updated_at = CURRENT_TIMESTAMP + ${publishable ? ', published = ?, version = version + 1, published_at = CURRENT_TIMESTAMP' : ''} + WHERE id = ?`, + publishable ? [name, stored, hash, stored, row.id] : [name, stored, hash, row.id], + done(row.id, extra), + ); + + matchesFor(db, manifest.origin, (err, found) => { + if (err) return cb(err); + const here = found.installed[0]; + if (mode === 'keep_both') { + const id = newId(); + return insert(id, id); + } + if (mode === 'update') { + if (!here) return cb(fail(404, 'This system is not installed here; install it as new')); + if (editedSinceInstall(here)) return cb(fail(409, 'This system has been changed here since it was installed. Keep both instead.')); + if (!publishable) return cb(fail(409, 'The file has problems; install it as a second copy to fix them', { problems: read.problems })); + return replace(here); + } + if (here) return cb(fail(409, 'Already installed. Update it or keep both.', { installed: found.installed.map((r) => ({ id: r.id, name: r.name })) })); + if (found.deleted) return replace(found.deleted, { restored: true }); + return insert(newId(), manifest.origin); + }); +}; + module.exports = { PREFIX, isCustomId, listSystems, getSystem, createSystem, saveDraft, publishSystem, deleteSystem, + exportSystem, previewInstall, installSystem, };