Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions backend/__tests__/system_builder_parts.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
16 changes: 14 additions & 2 deletions backend/systemBuilder/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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;
Expand All @@ -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 };
90 changes: 90 additions & 0 deletions frontend/src/sheets/__tests__/parts.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div data-testid="parts">{PARTS.filter((p) => !on(p)).join(',') || 'all on'}</div>;
}
function WordsProbe({ id }: { id: string }) {
const word = useWords(id);
return <div data-testid="words">{word('bank', 'singular', 'BANK')}</div>;
}
const arrive = () => act(() => {
registerCustomTemplate({ ...system(LATE, { cyberware: { on: false }, xp: { on: false } }),
words: { bank: { singular: 'COFFER', plural: 'COFFERS', short: 'COFFER' } } });
});

it('redraws when a custom system arrives', () => {
render(<PartsProbe id={LATE} />);
expect(screen.getByTestId('parts').textContent).toBe('all on');
arrive();
expect(screen.getByTestId('parts').textContent).toBe('cyberware,xp');
});

it('leaves the words redrawing as before', () => {
render(<WordsProbe id={LATE} />);
expect(screen.getByTestId('words').textContent).toBe('BANK');
arrive();
expect(screen.getByTestId('words').textContent).toBe('COFFER');
});

it('asks the server only about a custom system', () => {
render(<><PartsProbe id="cyberpunk_red" /><WordsProbe id="cyberpunk_red" /></>);
expect(screen.getByTestId('parts').textContent).toBe('all on');
expect(screen.getByTestId('words').textContent).toBe('BANK');
expect(fetch).not.toHaveBeenCalled();
});
});
16 changes: 16 additions & 0 deletions frontend/src/sheets/customTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// it does for a built-in one. A template not loaded yet is fetched the first time it is asked for;
// until it arrives the generic one stands in, and a window event tells the app to redraw.

import { useEffect, useReducer } from 'react';
import type { SheetTemplate, SheetSection, SheetField, SheetFieldType, SheetLinkSource, SectionLayout } from './types';

export interface CustomRenderField {
Expand Down Expand Up @@ -95,6 +96,7 @@ export const templateFromRender = (render: CustomRender): SheetTemplate => {
return {
...layoutTemplate(render.id, render.name, render.sheet, derived),
...(render.words ? { words: render.words } : {}),
...(render.parts ? { parts: render.parts } : {}),
...tiers,
...(npcSheet ? { npcLayout: { ...layoutTemplate(render.id, render.name, npcSheet, derived), ...tiers } } : {}),
};
Expand Down Expand Up @@ -132,3 +134,17 @@ export const loadCustomTemplate = (id: string, fetcher: typeof fetch = fetch): P

/** For tests: forget every loaded template. */
export const clearCustomTemplates = () => { cache.clear(); pending.clear(); };

/**
* Redraw when `system`'s template arrives, fetching it if nobody has yet. For the lookups that
* read a custom system's render copy (useWords, useParts); a built-in id is never fetched.
*/
export function useCustomTemplate(system: string | null | undefined) {
const [, redraw] = useReducer((n: number) => n + 1, 0);
useEffect(() => {
const onLoaded = (e: Event) => { if ((e as CustomEvent).detail?.id === system) redraw(); };
window.addEventListener(CUSTOM_TEMPLATE_EVENT, onLoaded);
if (system) void loadCustomTemplate(system);
return () => window.removeEventListener(CUSTOM_TEMPLATE_EVENT, onLoaded);
}, [system]);
}
Loading
Loading