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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions backend/__tests__/system_builder_words.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
12 changes: 10 additions & 2 deletions backend/systemBuilder/definition.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,21 @@ 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;
return !(isPlainObject(setting) && setting.on === false);
};

module.exports = {
FORMAT, LIMITS, TERMS, PARTS,
parseDefinition, checkDefinition, blankDefinition, wordFor, partOn,
FORMAT, LIMITS, TERMS, PARTS, WORD_FORMS,
parseDefinition, checkDefinition, blankDefinition, wordFor, resolveWords, partOn,
};
17 changes: 15 additions & 2 deletions backend/systemBuilder/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand All @@ -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 };
72 changes: 72 additions & 0 deletions frontend/src/sheets/__tests__/words.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <span>{word('hp', 'plural', 'HP')}</span>;
};

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(<Label system={ID} />);
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(<Label system="cities_without_number" />);
expect(screen.getByText('HP')).toBeTruthy();
expect(fetchMock).not.toHaveBeenCalled();
});
});
4 changes: 3 additions & 1 deletion frontend/src/sheets/customTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export interface CustomRenderSheet {
export interface CustomRender {
id: string;
name: string;
words: Record<string, { singular?: string; plural?: string; short?: string }>;
/** Every term in every form, resolved by the server (the system's word or the neutral default). */
words: Record<string, { singular: string; plural: string; short: string }>;
parts: Record<string, { on: boolean }>;
derived: string[];
sheet: CustomRenderSheet;
Expand Down Expand Up @@ -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 } } : {}),
};
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/sheets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { singular: string; plural: string; short: string }>;
/** 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;
Expand Down
Loading
Loading