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, };