diff --git a/CHANGELOG.md b/CHANGELOG.md
index 037decb1..0511dbbb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -52,6 +52,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Under the hood
+- **Custom game systems can rename the app's words.** A system's own words for terms such as HP,
+ credits, level and GM now reach every window, ready for the windows to use them. Nothing on
+ screen changes yet, and the built-in systems keep their wording exactly.
+
+- **Custom game systems can be shared as files.** A published system exports as one readable
+ `.citysys` file, and installs on another server after a preview that changes nothing: as a new
+ system, an update, or a second copy beside it. It is never merged, and a file never carries
+ characters. Deleting a custom system now hides it instead, so reinstalling its file brings it
+ back with every character played in it.
+
+- **Custom game systems choose how health works.** Creating one asks how its core works: the
+ health model (one pool, two tracks, damage types, harm levels, a wound count, hit locations, or
+ none), how characters advance, its common dice and its unit of distance. Each health model
+ takes damage by its own rules and has its own HEALTH folder, and other players still see a
+ description, never a number. The built-in systems' health is unchanged.
+
+- **Custom game systems can keep fields for the GM, and give NPCs their own stat blocks.** A
+ player sees a GM-only field, such as XP or an awarded item, but cannot change it, including by
+ uploading a sheet. NPCs can have a shorter layout and power tiers for GENERATE_SHEET.
+
- **A published custom game system can run the game.** It appears in the game system picker
beside the built-in ones, players' sheets are drawn from its own layout by the same sheet
window, and its derived values are worked out on every save. Players' browsers receive the
diff --git a/README.md b/README.md
index 478ae375..cded8ec1 100644
--- a/README.md
+++ b/README.md
@@ -417,7 +417,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; and the render copy the browser draws from, with no formulas
+│ │ ├── 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
│ │ ├── 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/
@@ -464,6 +464,7 @@ CITY_NET/
│ ├── 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_words.test.js # Every term in every form resolved (own words, neutral defaults), sent with the sheet, and the server's own text keeping the built-in systems' wording while a published custom system's words apply
│ ├── 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
@@ -723,7 +724,8 @@ CITY_NET/
│ │ ├── sheets/
│ │ │ ├── types.ts # Sheet template type system (fields, sections, header, death saves, NPC tiers)
│ │ │ ├── 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; fetched once and cached, with an event the app redraws on
+│ │ │ ├── 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 (or the neutral default) once loaded. useWords redraws when they arrive
│ │ │ ├── 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_words.test.js b/backend/__tests__/system_builder_words.test.js
new file mode 100644
index 00000000..53fac4db
--- /dev/null
+++ b/backend/__tests__/system_builder_words.test.js
@@ -0,0 +1,76 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import express from 'express';
+import request from 'supertest';
+import jwt from 'jsonwebtoken';
+import { createRequire } from 'module';
+import { makeTestDb, run } from './helpers/testDb.js';
+
+/**
+ * The glossary's plumbing (Layer 1): a custom system's words for the app's terms, every form
+ * resolved on the server, and a lookup for text the server writes that leaves the built-in
+ * systems' wording exactly as it is.
+ */
+
+process.env.JWT_SECRET = 'test-secret';
+const require_ = createRequire(import.meta.url);
+const { resolveWords, TERMS, WORD_FORMS } = require_('../systemBuilder/definition');
+const runtime = require_('../systemBuilder/runtime');
+
+const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret');
+const gm = { Authorization: `Bearer ${GM}` };
+const FANTASY = { format: 1, name: 'Hearth', words: { hp: { singular: 'WOUND', plural: 'WOUNDS' }, money: { plural: 'GOLD', short: 'GP' }, gm: { singular: 'WARDEN' } } };
+
+describe('resolving a system\'s words', () => {
+ it('gives every term in every form', () => {
+ const words = resolveWords(FANTASY);
+ expect(Object.keys(words).sort()).toEqual(Object.keys(TERMS).sort());
+ for (const term of Object.keys(TERMS)) expect(Object.keys(words[term]).sort(), term).toEqual([...WORD_FORMS].sort());
+ });
+
+ it('uses the system\'s own words, and the neutral defaults for the rest', () => {
+ const words = resolveWords(FANTASY);
+ expect(words.hp).toEqual({ singular: 'WOUND', plural: 'WOUNDS', short: 'HP' });
+ expect(words.money).toEqual({ singular: 'CREDIT', plural: 'GOLD', short: 'GP' });
+ expect(words.gm.singular).toBe('WARDEN');
+ expect(words.level).toEqual({ singular: 'LEVEL', plural: 'LEVELS', short: 'LVL' });
+ // A term with no short form uses its singular.
+ expect(words.character.short).toBe('CHARACTER');
+ expect(resolveWords({ name: 'Bare' })).toEqual(resolveWords({}));
+ });
+
+ it('sends the browser the resolved words with a custom system\'s sheet', () => {
+ expect(runtime.renderOf('sys_0123456789abcdef', FANTASY).words).toEqual(resolveWords(FANTASY));
+ });
+});
+
+describe('the server\'s own text', () => {
+ 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());
+ app.use('/api/systems', require_('../routes/systems.js')(db));
+ await new Promise((resolve) => runtime.load(db, resolve));
+ });
+
+ it('keeps every built-in system\'s wording exactly as it is', () => {
+ for (const system of ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic', null, undefined]) {
+ expect(runtime.wordIn(system, 'money', 'plural', 'EDDIES'), String(system)).toBe('EDDIES');
+ }
+ });
+
+ it('uses a published custom system\'s words, defaults included', async () => {
+ const { id } = (await request(app).post('/api/systems').set(gm).send({ definition: FANTASY })).body;
+ expect(runtime.wordIn(id, 'gm', 'singular', 'GM')).toBe('GM');
+ await request(app).post(`/api/systems/${id}/publish`).set(gm);
+ expect(runtime.wordIn(id, 'gm', 'singular', 'GM')).toBe('WARDEN');
+ expect(runtime.wordIn(id, 'money', 'short', 'CR')).toBe('GP');
+ // A term it did not rename: the neutral default, not the built-in text of that place.
+ expect(runtime.wordIn(id, 'xp', 'short', 'EXP')).toBe('XP');
+ expect(runtime.wordIn(id, 'nonsense', 'singular', 'KEPT')).toBe('KEPT');
+ await request(app).delete(`/api/systems/${id}`).set(gm);
+ expect(runtime.wordIn(id, 'gm', 'singular', 'GM')).toBe('GM');
+ });
+});
diff --git a/backend/systemBuilder/definition.js b/backend/systemBuilder/definition.js
index abff025a..83f9ac9f 100644
--- a/backend/systemBuilder/definition.js
+++ b/backend/systemBuilder/definition.js
@@ -180,6 +180,14 @@ const wordFor = (definition, term, form = 'singular') => {
return fallback ? (fallback[form] || fallback.singular) : term;
};
+/**
+ * Every term the app can rename, in every form, as this system says it: its own word where it
+ * set one, the neutral default otherwise. What the browser and the server's own text use.
+ */
+const resolveWords = (definition) => Object.fromEntries(Object.keys(TERMS).map((term) => [
+ term, Object.fromEntries(WORD_FORMS.map((form) => [form, wordFor(definition, term, form)])),
+]));
+
/** Is `part` on in this system? Everything is, unless the system turns it off. */
const partOn = (definition, part) => {
const setting = definition && isPlainObject(definition.parts) ? definition.parts[part] : undefined;
@@ -187,6 +195,6 @@ const partOn = (definition, part) => {
};
module.exports = {
- FORMAT, LIMITS, TERMS, PARTS,
- parseDefinition, checkDefinition, blankDefinition, wordFor, partOn,
+ FORMAT, LIMITS, TERMS, PARTS, WORD_FORMS,
+ parseDefinition, checkDefinition, blankDefinition, wordFor, resolveWords, partOn,
};
diff --git a/backend/systemBuilder/runtime.js b/backend/systemBuilder/runtime.js
index 8cf43ca6..d7c617d4 100644
--- a/backend/systemBuilder/runtime.js
+++ b/backend/systemBuilder/runtime.js
@@ -12,6 +12,7 @@
const { compileSystem } = require('./derived');
const { effectiveSheet, fieldsOf } = require('./sheet');
const { npcSheetOf, tiersOf } = require('./npc');
+const { resolveWords } = require('./definition');
const templates = require('../sheets/templates');
const npcTiers = require('../sheets/npcTiers');
@@ -75,7 +76,8 @@ const tiersFor = (definition, recompute) => {
const renderOf = (id, definition) => ({
id,
name: definition.name,
- words: definition.words || {},
+ // Every term in every form, resolved here, so the browser holds no table of defaults.
+ words: resolveWords(definition),
parts: definition.parts || {},
derived: (Array.isArray(definition.derived) ? definition.derived : []).map((d) => d.id),
sheet: effectiveSheet(definition),
@@ -123,6 +125,17 @@ const list = () => [...loaded.entries()].map(([id, s]) => ({ id, name: s.name, c
const tiers = (id) => (loaded.has(id) ? loaded.get(id).tiers : null);
+/**
+ * What the app calls `term` in `form` while `system` runs, for text the server writes (chat
+ * lines, the dice log). A published custom system's word; otherwise `builtIn`, the text that
+ * place has always shown, so a built-in system's wording never changes.
+ */
+const wordIn = (system, term, form, builtIn) => {
+ const render = loaded.has(system) ? loaded.get(system).render : null;
+ const word = render && render.words[term] ? render.words[term][form] : undefined;
+ return typeof word === 'string' && word ? word : builtIn;
+};
+
/** 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;
@@ -133,4 +146,4 @@ const health = (id) => {
templates.setCustomMeta(meta);
npcTiers.setCustomTiers(tiers);
-module.exports = { load, refresh, meta, render, list, tiers, health, metaOf, renderOf };
+module.exports = { load, refresh, meta, render, list, tiers, health, wordIn, metaOf, renderOf };
diff --git a/frontend/src/sheets/__tests__/words.test.tsx b/frontend/src/sheets/__tests__/words.test.tsx
new file mode 100644
index 00000000..8cbdf718
--- /dev/null
+++ b/frontend/src/sheets/__tests__/words.test.tsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import { wordFor, useWords } from '../words';
+import { registerCustomTemplate, clearCustomTemplates, type CustomRender } from '../customTemplates';
+
+/**
+ * The glossary in the browser: a place that shows a term asks with the text it shows today.
+ * A built-in system always gets that text back; a custom system gets its own word once its
+ * words have arrived.
+ */
+
+const ID = 'sys_0123456789abcdef';
+const resolved = (singular: string, plural: string, short: string) => ({ singular, plural, short });
+const HEARTH: CustomRender = {
+ id: ID, name: 'Hearth', parts: {}, derived: [], sheet: { sections: [] },
+ words: {
+ hp: resolved('WOUND', 'WOUNDS', 'HP'),
+ money: resolved('CREDIT', 'GOLD', 'GP'),
+ gm: resolved('WARDEN', 'GMS', 'GM'),
+ },
+};
+
+beforeEach(() => clearCustomTemplates());
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
+
+describe('wordFor', () => {
+ it('gives a built-in system the text that place shows today, whatever it is', () => {
+ for (const system of ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic', null, undefined]) {
+ expect(wordFor(system, 'money', 'plural', 'EDDIES')).toBe('EDDIES');
+ }
+ });
+
+ it('never renames a built-in system, even with words cached under its id', () => {
+ registerCustomTemplate({ ...HEARTH, id: 'cyberpunk_red' });
+ expect(wordFor('cyberpunk_red', 'money', 'plural', 'EDDIES')).toBe('EDDIES');
+ });
+
+ it('gives a custom system its own word once loaded, and today\'s text until then', () => {
+ expect(wordFor(ID, 'money', 'plural', 'CREDITS')).toBe('CREDITS');
+ registerCustomTemplate(HEARTH);
+ expect(wordFor(ID, 'money', 'plural', 'CREDITS')).toBe('GOLD');
+ expect(wordFor(ID, 'money', 'short', 'CR')).toBe('GP');
+ expect(wordFor(ID, 'gm', 'singular', 'GM')).toBe('WARDEN');
+ // A term the server sent nothing for keeps today's text.
+ expect(wordFor(ID, 'vehicle', 'singular', 'VEHICLE')).toBe('VEHICLE');
+ });
+});
+
+describe('useWords', () => {
+ const Label = ({ system }: { system: string }) => {
+ const word = useWords(system);
+ return {word('hp', 'plural', 'HP')};
+ };
+
+ it('fetches a custom system\'s words and redraws when they arrive', async () => {
+ const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(HEARTH) } as Response));
+ vi.stubGlobal('fetch', fetchMock);
+ render();
+ expect(screen.getByText('HP')).toBeTruthy();
+ expect(await screen.findByText('WOUNDS')).toBeTruthy();
+ expect(fetchMock).toHaveBeenCalledWith(`/api/systems/render/${ID}`);
+ });
+
+ it('never asks the server about a built-in system', () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ render();
+ expect(screen.getByText('HP')).toBeTruthy();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/sheets/customTemplates.ts b/frontend/src/sheets/customTemplates.ts
index 8a43fe88..914849e4 100644
--- a/frontend/src/sheets/customTemplates.ts
+++ b/frontend/src/sheets/customTemplates.ts
@@ -33,7 +33,8 @@ export interface CustomRenderSheet {
export interface CustomRender {
id: string;
name: string;
- words: Record;
+ /** Every term in every form, resolved by the server (the system's word or the neutral default). */
+ words: Record;
parts: Record;
derived: string[];
sheet: CustomRenderSheet;
@@ -93,6 +94,7 @@ export const templateFromRender = (render: CustomRender): SheetTemplate => {
const npcSheet = render.npc?.sheet;
return {
...layoutTemplate(render.id, render.name, render.sheet, derived),
+ ...(render.words ? { words: render.words } : {}),
...tiers,
...(npcSheet ? { npcLayout: { ...layoutTemplate(render.id, render.name, npcSheet, derived), ...tiers } } : {}),
};
diff --git a/frontend/src/sheets/types.ts b/frontend/src/sheets/types.ts
index 09bafad1..f7f8bd17 100644
--- a/frontend/src/sheets/types.ts
+++ b/frontend/src/sheets/types.ts
@@ -250,6 +250,9 @@ export interface SheetTemplate {
/** NPC power tiers offered by GENERATE_SHEET (must mirror the server's
* npcTiers registry for this system). Absent = untiered generation. */
npcTiers?: { id: string; label: string }[];
+ /** A custom system's words for the app's terms, every form resolved by the server
+ * (sheets/words.ts reads them). Absent on the built-in systems, whose wording is their own. */
+ words?: Record;
/** The layout an NPC's sheet is drawn with, when the system gives NPCs one of their own
* (a custom system's stat block). Absent = NPCs use this template. */
npcLayout?: SheetTemplate;
diff --git a/frontend/src/sheets/words.ts b/frontend/src/sheets/words.ts
new file mode 100644
index 00000000..8bd1aa23
--- /dev/null
+++ b/frontend/src/sheets/words.ts
@@ -0,0 +1,42 @@
+import { useEffect, useReducer } from 'react';
+import { CUSTOM_TEMPLATE_EVENT, customTemplate, isCustomSystem, loadCustomTemplate } from './customTemplates';
+
+// What the app calls its own terms while a game runs: the glossary (Layer 1 of the system
+// builder). A custom system can call HP "WOUNDS", credits "GOLD", the GM "WARDEN".
+//
+// Every place that shows a term asks with the text it shows today:
+//
+// word('money', 'plural', 'CREDITS')
+//
+// Under a built-in system the answer is always that text, so CWN, Cyberpunk RED, Shadowrun and
+// Generic read exactly as they always have, whatever each place's wording is. Under a custom
+// system it is that system's word, or the neutral default where it set none; the server
+// resolves both (systemBuilder/definition.js resolveWords). Until the custom system's words
+// have loaded, the text shown today stands in. Window titles (BANK.EXE) are never asked.
+
+export type Term = 'character' | 'hp' | 'money' | 'level' | 'xp' | 'class' | 'initiative'
+ | 'round' | 'turn' | 'gm' | 'shop' | 'bank' | 'vehicle';
+export type WordForm = 'singular' | 'plural' | 'short';
+
+/** What `system` calls `term` in `form`, or `builtIn` (the text this place shows today). */
+export const wordFor = (system: string | null | undefined, term: Term, form: WordForm, builtIn: string): string => {
+ if (!isCustomSystem(system)) return builtIn;
+ const word = customTemplate(system)?.words?.[term]?.[form];
+ return typeof word === 'string' && word ? word : builtIn;
+};
+
+/**
+ * `word(term, form, builtIn)` for the running system, redrawing when a custom system's words
+ * arrive. Fetches them if nobody has yet.
+ */
+export function useWords(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);
+ // Asks the server only about a custom system (loadCustomTemplate refuses a built-in id).
+ if (system) void loadCustomTemplate(system);
+ return () => window.removeEventListener(CUSTOM_TEMPLATE_EVENT, onLoaded);
+ }, [system]);
+ return (term: Term, form: WordForm, builtIn: string) => wordFor(system, term, form, builtIn);
+}