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(