diff --git a/CHANGELOG.md b/CHANGELOG.md index 035f6a0..94ae5e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **The app can ask which parts a custom system uses.** A custom system's choice to turn off the + bank, shops, vehicles, cyberware and its other parts now reaches the server and every window, ready + for the pieces that hide them. Nothing is hidden yet, and the built-in systems have every part on. + - **A custom system's words reach the building GM notes and the admin GAME tab.** The GM NOTES folder, its header and editor, BANK SOUNDS and the INITIATIVE FOLLOWS BUILDING house rule use the words a custom system's GM chose for the GM, the bank and initiative. The built-in systems keep diff --git a/README.md b/README.md index 9cf5c36..0ca6d07 100644 --- a/README.md +++ b/README.md @@ -418,7 +418,7 @@ CITY_NET/ │ │ ├── health.js # A custom system's health model in play, as pure rules: what DAMAGE and HEAL do under each model (second tracks with overflow, damage types turning heavier on a full track, harm moving up a level, wound penalties, hit-location notes), worked out from the token and the sheet behind it; the HIT_POINTS route (routes/locations.js) uses it for a custom system whose health is not one pool │ │ ├── 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; the render copy the browser draws from, with no formulas and every word resolved; and wordIn(system, term, form, today's text) for text the server writes +│ │ ├── 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; the render copy the browser draws from, with no formulas and every word resolved; and wordIn(system, term, form, today's text) for text the server writes; partIn(system, part) for whether a part of the app is on (always, under a built-in system) │ │ ├── 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/ @@ -463,6 +463,7 @@ CITY_NET/ │ ├── system_builder_core.test.js # The setup answers: every health model's starter sheet (and that it passes the sheet checks), the unanswered starter unchanged to the byte, and every mistake reported with where it is │ ├── 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_initiative_words.test.js # The dice log's initiative lines, written by the server: today's text under every built-in system and an unrenamed custom one, a custom system's own word (in capitals) where it renamed initiative +│ ├── system_builder_parts.test.js # partIn: every part on under the built-in systems and unknown ids, off only where a published custom system turned it off (never a draft, never a deleted system), back on with a new version; a misspelled part throws; the browser names the same parts │ ├── 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 @@ -732,6 +733,7 @@ CITY_NET/ │ │ │ ├── index.ts # Template registry, getMaxPairs, GATED_TABS/hiddenTabsFor (house-rule-gated sheet tabs). getTemplate also answers for published custom systems │ │ │ ├── customTemplates.ts # Custom systems' sheets: the server's render copy turned into a SheetTemplate for the ordinary SheetRenderer (derived values read-only, only armor writing through to the token, GM-only fields marked), with the system's NPC layout and tiers and its words; fetched once and cached, with an event the app redraws on │ │ │ ├── words.ts # The glossary in the browser: word(term, form, today's text). A built-in system always gets today's text back, so its wording never changes; a custom system gets its own word for a term it renamed, and today's text for the rest. asLabel puts a word in the terminal-label style (HIT_POINTS). useWords redraws when the words arrive +│ │ │ ├── parts.ts # Which parts of the app the running system uses: partOn(system, part) and useParts. A built-in system has every part on, so each place keeps its own rule; a custom system has off only what it turned off, and everything on until it loads. Tested in sheets/__tests__/parts.test.tsx │ │ │ ├── SheetPage.tsx # Standalone browser-tab sheet (?sheet=true); reads theme from auth handshake or localStorage; shares logic via usePlayerSheet │ │ │ ├── vehiclePresets.ts # The CWN vehicle table (p.82) — picking a TYPE fills the stat block. Armour left unset on the * and ** vehicles: those are immunities the GM rules on, not numbers │ │ │ ├── vehicleWeapons.ts # The ten weapons a hardpoint can carry (p.81). Damage stored as clean dice; the book's ! rides on the trauma value, since only marked weapons can traumatise a vehicle diff --git a/backend/__tests__/system_builder_parts.test.js b/backend/__tests__/system_builder_parts.test.js new file mode 100644 index 0000000..cf62ebb --- /dev/null +++ b/backend/__tests__/system_builder_parts.test.js @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import sqlite3 from 'sqlite3'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +/** + * Which parts of the app the running system uses (3b1). Every built-in system, and any id the + * server does not know, has every part on, so each place keeps today's own rule. A published + * custom system has a part off only where its definition says so; a draft never counts, and + * a deleted system is not running. Nothing is hidden by this yet. + */ + +const require_ = createRequire(import.meta.url); +const runtime = require_('../systemBuilder/runtime'); +const { PARTS } = require_('../systemBuilder/definition'); + +const OFF = 'sys_aaaaaaaaaaaaaaaa'; +const PLAIN = 'sys_bbbbbbbbbbbbbbbb'; +const DRAFTED = 'sys_cccccccccccccccc'; +const DELETED = 'sys_dddddddddddddddd'; +const BUILT_INS = ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic']; + +const run = (db, sql, p = []) => new Promise((res, rej) => db.run(sql, p, function (e) { e ? rej(e) : res(this); })); +const def = (name, parts) => JSON.stringify({ format: 1, name, ...(parts ? { parts } : {}) }); +const offWith = (bankOn) => def('Hearth', { bank: { on: bankOn }, vehicles: { on: false }, shops: { on: true } }); + +let db; +beforeEach(async () => { + db = new sqlite3.Database(':memory:'); + await run(db, `CREATE TABLE custom_systems (id TEXT PRIMARY KEY, name TEXT, draft TEXT, published TEXT, version INTEGER, deleted_at DATETIME)`); + const rows = [ + [OFF, offWith(false), offWith(false), null], + [PLAIN, def('Plain'), def('Plain'), null], + // Its draft turns the bank off; what runs is the published version, which does not. + [DRAFTED, def('Drafted', { bank: { on: false } }), def('Drafted'), null], + [DELETED, def('Gone', { bank: { on: false } }), def('Gone', { bank: { on: false } }), '2026-10-01'], + ]; + for (const [id, draft, published, deleted] of rows) { + await run(db, 'INSERT INTO custom_systems (id, name, draft, published, version, deleted_at) VALUES (?, ?, ?, ?, 1, ?)', + [id, id, draft, published, deleted]); + } + await new Promise((resolve) => runtime.load(db, resolve)); +}); + +const offIn = (system) => PARTS.filter((p) => !runtime.partIn(system, p)); + +describe('partIn', () => { + it('has every part on under every built-in system, and for an id it does not know', () => { + for (const system of [...BUILT_INS, undefined, null, '', 'sys_eeeeeeeeeeeeeeee']) { + expect(offIn(system), String(system)).toEqual([]); + } + }); + + it('has every part on in a custom system that turned none off', () => { + expect(offIn(PLAIN)).toEqual([]); + }); + + it('has off exactly the parts a custom system turned off', () => { + expect(offIn(OFF)).toEqual(['bank', 'vehicles']); + }); + + it('reads the published version, never a draft, and nothing of a deleted system', () => { + expect(offIn(DRAFTED)).toEqual([]); + expect(offIn(DELETED)).toEqual([]); + }); + + it('follows a new version: a part turned back on is on again', async () => { + await run(db, 'UPDATE custom_systems SET published = ?, version = 2 WHERE id = ?', [offWith(true), OFF]); + await new Promise((resolve) => runtime.refresh(db, OFF, resolve)); + expect(offIn(OFF)).toEqual(['vehicles']); + }); + + it('refuses a part the app does not have, so a misspelling fails rather than reading as on', () => { + expect(() => runtime.partIn(OFF, 'bnak')).toThrow(/Not a part of the app/); + expect(() => runtime.partIn('generic', 'vehicle')).toThrow(/Not a part of the app/); + }); +}); + +describe('the render copy the browser reads', () => { + it('carries the parts as the system set them', () => { + expect(runtime.render(OFF).parts).toEqual({ bank: { on: false }, vehicles: { on: false }, shops: { on: true } }); + expect(runtime.render(PLAIN).parts).toEqual({}); + }); + + it('names the same parts as the browser does (frontend/src/sheets/parts.ts)', () => { + const here = path.dirname(fileURLToPath(import.meta.url)); + const source = fs.readFileSync(path.join(here, '../../frontend/src/sheets/parts.ts'), 'utf8'); + const list = source.match(/export const PARTS = \[([\s\S]*?)\] as const;/); + expect(list).not.toBeNull(); + expect([...list[1].matchAll(/'([a-z_]+)'/g)].map((m) => m[1])).toEqual(PARTS); + }); +}); diff --git a/backend/systemBuilder/runtime.js b/backend/systemBuilder/runtime.js index 759776d..44a5201 100644 --- a/backend/systemBuilder/runtime.js +++ b/backend/systemBuilder/runtime.js @@ -12,7 +12,7 @@ const { compileSystem } = require('./derived'); const { effectiveSheet, fieldsOf } = require('./sheet'); const { npcSheetOf, tiersOf } = require('./npc'); -const { ownWords } = require('./definition'); +const { ownWords, partOn, PARTS } = require('./definition'); const templates = require('../sheets/templates'); const npcTiers = require('../sheets/npcTiers'); @@ -137,6 +137,18 @@ const wordIn = (system, term, form, builtIn) => { return typeof word === 'string' && word ? word : builtIn; }; +/** + * Whether `part` of the app (definition.js PARTS: the bank, shops, vehicles...) is on while + * `system` runs. Only a published custom system can turn one off; a built-in system, a draft and + * an unknown id always answer on, so every place keeps today's own rule for whether it shows (CWN + * alone has cyberware, and so on). Off only ever hides: nothing a part holds is deleted, so + * turning it back on brings all of it back. + */ +const partIn = (system, part) => { + if (!PARTS.includes(part)) throw new Error(`Not a part of the app: ${part}`); + return loaded.has(system) ? partOn(loaded.get(system).definition, part) : true; +}; + /** A published system's health model (its core.health), or null: built-in systems have none here. */ const health = (id) => { const definition = loaded.has(id) ? loaded.get(id).definition : null; @@ -147,4 +159,4 @@ const health = (id) => { templates.setCustomMeta(meta); npcTiers.setCustomTiers(tiers); -module.exports = { load, refresh, meta, render, list, tiers, health, wordIn, metaOf, renderOf }; +module.exports = { load, refresh, meta, render, list, tiers, health, wordIn, partIn, metaOf, renderOf }; diff --git a/frontend/src/sheets/__tests__/parts.test.tsx b/frontend/src/sheets/__tests__/parts.test.tsx new file mode 100644 index 0000000..1efc7f4 --- /dev/null +++ b/frontend/src/sheets/__tests__/parts.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act, cleanup } from '@testing-library/react'; +import { PARTS, partOn, useParts, allPartsOn } from '../parts'; +import { useWords } from '../words'; +import { registerCustomTemplate, clearCustomTemplates, type CustomRender } from '../customTemplates'; + +/** + * Which parts of the app the running system uses, in the browser (3b1). Every built-in system + * has every part on, so each place keeps today's own rule; a custom system has off only the + * parts it turned off, and every part counts as on until its definition has loaded. + */ + +const BUILT_INS = ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic']; +const OFF = 'sys_aaaaaaaaaaaaaaaa'; +const PLAIN = 'sys_bbbbbbbbbbbbbbbb'; +const LATE = 'sys_cccccccccccccccc'; + +const system = (id: string, parts: CustomRender['parts']): CustomRender => + ({ id, name: id, parts, derived: [], sheet: { sections: [] }, words: {} }); + +beforeEach(() => { + clearCustomTemplates(); + registerCustomTemplate(system(OFF, { bank: { on: false }, vehicles: { on: false }, shops: { on: true } })); + registerCustomTemplate(system(PLAIN, {})); + // A custom system nobody has loaded yet: the fetch never answers during the test. + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))); +}); +afterEach(() => { cleanup(); clearCustomTemplates(); vi.unstubAllGlobals(); }); + +const offIn = (id: string | null | undefined) => PARTS.filter((p) => !partOn(id, p)); + +describe('partOn', () => { + it('has every part on under every built-in system, and with no system', () => { + for (const id of [...BUILT_INS, undefined, null, '']) expect(offIn(id), String(id)).toEqual([]); + }); + + it('has every part on in a custom system that turned none off', () => { + expect(offIn(PLAIN)).toEqual([]); + }); + + it('has off exactly the parts a custom system turned off', () => { + expect(offIn(OFF)).toEqual(['bank', 'vehicles']); + }); + + it('has every part on while a custom system has not loaded', () => { + expect(offIn(LATE)).toEqual([]); + }); + + it('has every part on for a component drawn without a running system', () => { + expect(PARTS.filter((p) => !allPartsOn(p))).toEqual([]); + }); +}); + +describe('useParts', () => { + // One probe per hook: each must redraw by itself, now they share the loading code. + function PartsProbe({ id }: { id: string }) { + const on = useParts(id); + return