From 5d64f1fae963d512f22c16091aad2fa2634bfbfb Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 30 Sep 2026 22:28:21 -0500 Subject: [PATCH 1/2] feat: the running system's words reach every window, with built-ins left exactly as they read The glossary's plumbing, with nothing on screen changed yet. Every place that will show one of the app's terms 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. Under a custom system it is that system's word, or the neutral default where it set none. The server resolves every term in every form and sends them with a custom system's sheet, so the browser holds no table of defaults (sheets/words.ts, with a hook that redraws when they arrive). Text the server writes itself gets the same lookup (runtime.wordIn). --- README.md | 6 +- .../__tests__/system_builder_words.test.js | 76 +++++++++++++++++++ backend/systemBuilder/definition.js | 12 ++- backend/systemBuilder/runtime.js | 17 ++++- frontend/src/sheets/__tests__/words.test.tsx | 72 ++++++++++++++++++ frontend/src/sheets/customTemplates.ts | 4 +- frontend/src/sheets/types.ts | 3 + frontend/src/sheets/words.ts | 42 ++++++++++ 8 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 backend/__tests__/system_builder_words.test.js create mode 100644 frontend/src/sheets/__tests__/words.test.tsx create mode 100644 frontend/src/sheets/words.ts 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(