From 7a77e6b1d6a063fe2c5508ee161d9a63e5078c52 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 30 Sep 2026 23:18:03 -0500 Subject: [PATCH 1/2] feat: a custom system's words on the sheet, the token window and the HEALTH folder Every place listed for this piece in the words inventory now asks for its word with the text it shows today: the sidebar's HIT_POINTS and CHARACTER_CONTROLS, the sheet header's HP bar and its tooltip, the HEALTH folder's TEMP_HP and MAX_HP, the token window's GM NOTES, INITIATIVE SCORE and ADD TO INIT, the starter sheet's HP and a one-pool system's HEALTH label. Underscore labels keep their style (HIT POINTS as HIT_POINTS, WOUNDS as TEMP_WOUNDS), and text naming another label changes with it, so the sheet's tooltip always names the sidebar's button. A place changes only when a custom system renamed that term; otherwise it shows today's text, so HIT_POINTS stays HIT_POINTS until a system renames hit points. To keep that, a custom system's sheet now carries only the words it chose, every form filled from its own (systemBuilder/terms.js, the glossary in a module of its own). The built-in systems read exactly as they did, tested place by place. --- CHANGELOG.md | 5 + README.md | 6 +- .../system_builder_health_view.test.js | 9 + .../__tests__/system_builder_words.test.js | 48 ++++- backend/sockets/index.js | 3 +- backend/systemBuilder/core.js | 8 +- backend/systemBuilder/definition.js | 41 +--- backend/systemBuilder/healthView.js | 5 +- backend/systemBuilder/runtime.js | 11 +- backend/systemBuilder/sheet.js | 5 +- backend/systemBuilder/terms.js | 66 +++++++ frontend/src/App.tsx | 1 + frontend/src/components/HealthModelPanels.tsx | 11 +- frontend/src/components/HitPoints.tsx | 14 +- frontend/src/components/SheetRenderer.tsx | 8 +- frontend/src/components/Sidebar.tsx | 12 +- frontend/src/components/TokenWindow.tsx | 21 +- .../__tests__/wordsInWindows.test.tsx | 187 ++++++++++++++++++ frontend/src/sheets/words.ts | 22 ++- 19 files changed, 399 insertions(+), 84 deletions(-) create mode 100644 backend/systemBuilder/terms.js create mode 100644 frontend/src/components/__tests__/wordsInWindows.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0511dbbb..ce9ade16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **A custom system's words show on the sheet, the token window and the HEALTH folder.** Where a + custom system renamed hit points, the character, the GM or initiative, those places use its word: + WOUNDS for HIT_POINTS, WARDEN NOTES for GM NOTES. A term it did not rename keeps today's text. + Nothing changes for the built-in systems, and no custom system can be made yet. + - **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. diff --git a/README.md b/README.md index cded8ec1..000f9edc 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,7 @@ CITY_NET/ │ │ ├── definitions.js # CWN's and Shadowrun's derived values restated as data, entry for entry in the order their functions write them │ │ ├── definition.js # The system definition format (1: name, description, words, parts, lookups, derived) and its server-side checks. Fatal (cannot be stored: not an object, too large, not JSON) vs ordinary problems (saved in a draft, block publishing), all reported with where they are. Also the app's renamable terms and switchable parts, with wordFor / partOn │ │ ├── sheet.js # A custom system's character sheet as data (tabs, header, sections of text/number/textarea/select fields with visibility, combat sensitivity, who edits it (the owner, or only the GM), max pairs and token/bank links) and its checks; a starter sheet for a system that has none +│ │ ├── terms.js # The glossary: the 13 terms a system may rename, their neutral defaults (for the builder), and ownWords, only the terms a system renamed (for the running game) │ │ ├── core.js # A custom system's setup answers: the health model (one pool, two tracks, damage types, harm levels, wound count, hit locations, none) with its settings, advancement styles, common dice and distance unit, and their checks; the health part of the starter sheet each model gives (no answer = the one-pool starter every system had) │ │ ├── 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 @@ -464,7 +465,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_words.test.js # Every term in every form resolved for the builder; only the terms a system renamed sent with its sheet; the server's own text, the starter sheet and a one-pool HEALTH folder using a system's word only where it chose one │ ├── 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 @@ -638,6 +639,7 @@ CITY_NET/ │ │ │ ├── DraggableWindow.test.tsx │ │ │ ├── BootScreen.test.tsx # Types out by itself and finishes, skips on SKIP and nothing else, reports lines and clicks │ │ │ ├── HitPoints.test.tsx +│ │ │ ├── wordsInWindows.test.tsx # The glossary in the sidebar, sheet header, HEALTH folder and token window: exactly today's text under every built-in system and under a custom system that renamed nothing; a custom system's own words, in the label style, where it did │ │ │ ├── HealthModelPanels.test.tsx # Each model's editor sending what the server expects and reporting it (spills, boxes turning heavier, harm moving up, out, refusals), the GM-only SET rows, other players seeing no numbers or notes, and the built-in systems' folder untouched and never asking │ │ │ ├── healthBands.test.tsx # The bands' thresholds and colors, each rhythm's beats (the steady one unchanged, beats inside their stretch, weak ones drawn smaller), reduced motion, and the same band in the editing panel, the review panel and the stream │ │ │ ├── MapElements.test.tsx @@ -725,7 +727,7 @@ CITY_NET/ │ │ │ ├── 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 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 +│ │ │ ├── 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 │ │ │ ├── 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_health_view.test.js b/backend/__tests__/system_builder_health_view.test.js index 0d7b663a..6d39b05f 100644 --- a/backend/__tests__/system_builder_health_view.test.js +++ b/backend/__tests__/system_builder_health_view.test.js @@ -210,6 +210,15 @@ describe('in the running game', () => { expect(await viewFor(rook, npc.lastID)).toMatchObject({ full: false, second: { label: 'STUN', fill: 0.25, full: false } }); }); + it('names a one-pool system\'s health in its own word for hit points', async () => { + const definition = { format: 1, name: 'Hearth', words: { hp: { singular: 'WOUND', short: 'WND' } }, core: { health: { model: 'pool' } } }; + const { id: system } = (await request(app).post('/api/systems').set('Authorization', `Bearer ${GM}`).send({ definition })).body; + await request(app).post(`/api/systems/${system}/publish`).set('Authorization', `Bearer ${GM}`); + await run(db, `UPDATE global_settings SET value = ? WHERE key = 'game_system'`, [system]); + const id = await playerToken('GHOST', system, {}); + expect(await viewFor(await connectAs('GHOST'), id)).toMatchObject({ model: 'pool', label: 'WND' }); + }); + it('says there is nothing to draw for a built-in system, and answers only the one who asked', async () => { const id = await playerToken('GHOST', 'cities_without_number', {}); const asker = await connectAs('GHOST'); diff --git a/backend/__tests__/system_builder_words.test.js b/backend/__tests__/system_builder_words.test.js index 53fac4db..301ce4ff 100644 --- a/backend/__tests__/system_builder_words.test.js +++ b/backend/__tests__/system_builder_words.test.js @@ -13,7 +13,9 @@ import { makeTestDb, run } from './helpers/testDb.js'; process.env.JWT_SECRET = 'test-secret'; const require_ = createRequire(import.meta.url); -const { resolveWords, TERMS, WORD_FORMS } = require_('../systemBuilder/definition'); +const { resolveWords, ownWords, TERMS, WORD_FORMS } = require_('../systemBuilder/definition'); +const { effectiveSheet } = require_('../systemBuilder/sheet'); +const { healthView } = require_('../systemBuilder/healthView'); const runtime = require_('../systemBuilder/runtime'); const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); @@ -38,8 +40,44 @@ describe('resolving a system\'s words', () => { 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)); + it('sends the browser only the terms a system renamed, every form filled from its own', () => { + expect(runtime.renderOf('sys_0123456789abcdef', FANTASY).words).toEqual({ + hp: { singular: 'WOUND', plural: 'WOUNDS', short: 'WOUND' }, + money: { singular: 'GOLD', plural: 'GOLD', short: 'GP' }, + gm: { singular: 'WARDEN', plural: 'WARDEN', short: 'WARDEN' }, + }); + expect(runtime.renderOf('sys_0123456789abcdef', { name: 'Bare' }).words).toEqual({}); + }); +}); + +describe('a system\'s own words', () => { + it('ignore blank forms, a term with no usable form, and terms the app does not have', () => { + expect(ownWords({ words: { + hp: { singular: ' ', plural: 'WOUNDS' }, + xp: { singular: '', short: ' ' }, + mana: { singular: 'MANA' }, + level: 'RANK', + } })).toEqual({ hp: { singular: 'WOUNDS', plural: 'WOUNDS', short: 'WOUNDS' } }); + expect(ownWords({ words: 'none' })).toEqual({}); + expect(ownWords(null)).toEqual({}); + }); + + it('name the starter sheet\'s hit points, and nothing else changes', () => { + const pool = (definition) => effectiveSheet(definition).sections.find((s) => s.id === 'health').fields.map((f) => f.label); + expect(pool({ name: 'A' })).toEqual(['HP', 'HP MAX']); + expect(pool({ name: 'A', words: { gm: { singular: 'WARDEN' } } })).toEqual(['HP', 'HP MAX']); + expect(pool({ name: 'A', words: { hp: { singular: 'WOUND', plural: 'WOUNDS', short: 'WND' } } })).toEqual(['WND', 'WND MAX']); + expect(pool({ name: 'A', words: { hp: { short: 'WND' } }, core: { health: { model: 'locations', locations: [{ id: 'head', label: 'HEAD' }] } } })) + .toEqual(['WND', 'WND MAX']); + // A label the setup gave wins over the word. + expect(pool({ name: 'A', words: { hp: { short: 'WND' } }, core: { health: { model: 'pool', label: 'VIGOR' } } })).toEqual(['VIGOR', 'VIGOR MAX']); + }); + + it('name a one-pool system\'s HEALTH folder, unless the setup gave it a label', () => { + const token = { current: 5, max: 10 }; + expect(healthView({ model: 'pool' }, token, {}).label).toBe('HP'); + expect(healthView({ model: 'pool' }, token, {}, { hpWord: 'WND' }).label).toBe('WND'); + expect(healthView({ model: 'pool', label: 'VIGOR' }, token, {}, { hpWord: 'WND' }).label).toBe('VIGOR'); }); }); @@ -67,8 +105,8 @@ describe('the server\'s own text', () => { 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'); + // A term it did not rename keeps the text that place shows today. + expect(runtime.wordIn(id, 'xp', 'short', 'EXP')).toBe('EXP'); 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/sockets/index.js b/backend/sockets/index.js index 9766247b..54c3db43 100644 --- a/backend/sockets/index.js +++ b/backend/sockets/index.js @@ -1767,7 +1767,8 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { const send = (row) => { let sheet = {}; try { sheet = row ? JSON.parse(row.data || '{}') : {}; } catch { sheet = {}; } - reply(healthView(health, { current: loc.hp_current, max: loc.hp_max, temp: loc.hp_temp }, sheet, { full })); + reply(healthView(health, { current: loc.hp_current, max: loc.hp_max, temp: loc.hp_temp }, sheet, + { full, hpWord: customSystems.wordIn(system, 'hp', 'short', 'HP') })); }; if (playerToken) { db.get(`SELECT data FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0`, diff --git a/backend/systemBuilder/core.js b/backend/systemBuilder/core.js index 1befe28c..351c9ec5 100644 --- a/backend/systemBuilder/core.js +++ b/backend/systemBuilder/core.js @@ -132,7 +132,7 @@ const checkHealth = (health, problems) => { * pair when the model has a pool on the token. A pool with no label is exactly the starter * sheet every system has had, so a system that answers nothing looks as it did. */ -const healthLayout = (health) => { +const healthLayout = (health, hpWord = 'HP') => { const h = isPlainObject(health) ? health : { model: 'pool' }; const grid = (fields) => ({ id: 'health', label: 'HEALTH', layout: 'grid', tab: 'STATS', columns: 2, fields }); const pool = (id, name) => [ @@ -144,7 +144,7 @@ const healthLayout = (health) => { switch (h.model) { case 'tracks': { const [first, second] = list(h.tracks); - if (!first || !second) return healthLayout({ model: 'pool' }); + if (!first || !second) return healthLayout({ model: 'pool' }, hpWord); // The first track is the token's; the second lives on the sheet. return { sections: [grid([ @@ -186,7 +186,7 @@ const healthLayout = (health) => { case 'locations': return { sections: [ - grid(pool('hp', text(h.label, 'HP'))), + grid(pool('hp', text(h.label, hpWord))), { id: 'injuries', label: 'INJURIES', layout: 'list', tab: 'STATS', fields: list(h.locations).map((l) => ({ id: l.id, label: l.label, type: 'text' })) }, ], @@ -195,7 +195,7 @@ const healthLayout = (health) => { case 'none': return { sections: [], header: {} }; default: - return { sections: [grid(pool('hp', text(h.label, 'HP')))], header: { hpField: 'hp', hpMaxField: 'hp_max' } }; + return { sections: [grid(pool('hp', text(h.label, hpWord)))], header: { hpField: 'hp', hpMaxField: 'hp_max' } }; } }; diff --git a/backend/systemBuilder/definition.js b/backend/systemBuilder/definition.js index 83f9ac9f..c6835e38 100644 --- a/backend/systemBuilder/definition.js +++ b/backend/systemBuilder/definition.js @@ -32,6 +32,7 @@ const { compileSystem } = require('./derived'); const { checkSheet } = require('./sheet'); const { checkNpc } = require('./npc'); const { checkCore } = require('./core'); +const { TERMS, WORD_FORMS, wordFor, resolveWords, ownWords } = require('./terms'); const FORMAT = 1; @@ -47,27 +48,6 @@ const LIMITS = { word: 40, }; -/** - * The app's own words a system may rename (Layer 1). The key is the stable id the app looks - * up; the default is what shows when a system says nothing. - */ -const TERMS = { - character: { singular: 'CHARACTER', plural: 'CHARACTERS' }, - hp: { singular: 'HP', plural: 'HP', short: 'HP' }, - money: { singular: 'CREDIT', plural: 'CREDITS', short: 'CR' }, - level: { singular: 'LEVEL', plural: 'LEVELS', short: 'LVL' }, - xp: { singular: 'XP', plural: 'XP', short: 'XP' }, - class: { singular: 'CLASS', plural: 'CLASSES' }, - initiative: { singular: 'INITIATIVE', plural: 'INITIATIVE', short: 'INIT' }, - round: { singular: 'ROUND', plural: 'ROUNDS' }, - turn: { singular: 'TURN', plural: 'TURNS' }, - gm: { singular: 'GM', plural: 'GMS', short: 'GM' }, - shop: { singular: 'SHOP', plural: 'SHOPS' }, - bank: { singular: 'BANK', plural: 'BANKS' }, - vehicle: { singular: 'VEHICLE', plural: 'VEHICLES' }, -}; -const WORD_FORMS = ['singular', 'plural', 'short']; - /** The parts of the app a system can turn off (Layer 2). All on unless a system says. */ const PARTS = [ 'bank', 'shops', 'vehicles', 'cyberware', 'initiative', 'combat', 'token_health', @@ -171,23 +151,6 @@ const checkDefinition = (definition) => { /** A new system's starting point: a name and nothing else. */ const blankDefinition = (name) => ({ format: FORMAT, name: String(name || '').trim() }); -/** What the app calls `term` in this system: the system's word, or the app's own. */ -const wordFor = (definition, term, form = 'singular') => { - const own = definition && isPlainObject(definition.words) && isPlainObject(definition.words[term]) - ? definition.words[term][form] : undefined; - if (typeof own === 'string' && own.trim()) return own; - const fallback = TERMS[term]; - 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; @@ -196,5 +159,5 @@ const partOn = (definition, part) => { module.exports = { FORMAT, LIMITS, TERMS, PARTS, WORD_FORMS, - parseDefinition, checkDefinition, blankDefinition, wordFor, resolveWords, partOn, + parseDefinition, checkDefinition, blankDefinition, wordFor, resolveWords, ownWords, partOn, }; diff --git a/backend/systemBuilder/healthView.js b/backend/systemBuilder/healthView.js index e8a65b1b..cb9422f8 100644 --- a/backend/systemBuilder/healthView.js +++ b/backend/systemBuilder/healthView.js @@ -35,11 +35,12 @@ const harmLevels = (health, sheet) => list(health.levels).map((l) => ({ * token { current, max, temp } * sheet the data of the sheet behind the token ({} when there is none) * full true for the GM, a granted editor, or the token's owner + * hpWord the system's own word for hit points, for a pool with no label of its own * * Returns { model, full, ... } with the model's detail, or { model: null } when the system's * health is not a custom model (the built-in systems draw their own). */ -const healthView = (health, token, sheet, { full = false } = {}) => { +const healthView = (health, token, sheet, { full = false, hpWord = 'HP' } = {}) => { if (!health || typeof health !== 'object' || typeof health.model !== 'string') return { model: null }; const s = sheet && typeof sheet === 'object' ? sheet : {}; const t = { current: num(token && token.current), max: num(token && token.max) }; @@ -47,7 +48,7 @@ const healthView = (health, token, sheet, { full = false } = {}) => { switch (health.model) { case 'pool': - return { ...base, label: health.label || 'HP' }; + return { ...base, label: health.label || hpWord }; case 'tracks': { const [first, second] = list(health.tracks); diff --git a/backend/systemBuilder/runtime.js b/backend/systemBuilder/runtime.js index d7c617d4..759776d5 100644 --- a/backend/systemBuilder/runtime.js +++ b/backend/systemBuilder/runtime.js @@ -12,7 +12,7 @@ const { compileSystem } = require('./derived'); const { effectiveSheet, fieldsOf } = require('./sheet'); const { npcSheetOf, tiersOf } = require('./npc'); -const { resolveWords } = require('./definition'); +const { ownWords } = require('./definition'); const templates = require('../sheets/templates'); const npcTiers = require('../sheets/npcTiers'); @@ -76,8 +76,9 @@ const tiersFor = (definition, recompute) => { const renderOf = (id, definition) => ({ id, name: definition.name, - // Every term in every form, resolved here, so the browser holds no table of defaults. - words: resolveWords(definition), + // The terms this system renamed, every form filled in. A term it left alone is absent, so + // each place keeps the text it shows today. + words: ownWords(definition), parts: definition.parts || {}, derived: (Array.isArray(definition.derived) ? definition.derived : []).map((d) => d.id), sheet: effectiveSheet(definition), @@ -127,8 +128,8 @@ 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. + * lines, the dice log). A published custom system's own word, when it renamed the term; + * otherwise `builtIn`, the text that place has always shown. */ const wordIn = (system, term, form, builtIn) => { const render = loaded.has(system) ? loaded.get(system).render : null; diff --git a/backend/systemBuilder/sheet.js b/backend/systemBuilder/sheet.js index 7bbcda2f..223ebe22 100644 --- a/backend/systemBuilder/sheet.js +++ b/backend/systemBuilder/sheet.js @@ -11,6 +11,7 @@ // generic layout plus its derived values), so a published system can always be drawn. const { healthLayout } = require('./core'); +const { ownWords } = require('./terms'); const NAME = /^[a-z][a-z0-9_]{0,63}$/; @@ -156,7 +157,9 @@ const checkSheet = (sheet, derivedIds, problems) => { const effectiveSheet = (definition) => { if (definition && isPlainObject(definition.sheet)) return definition.sheet; const derived = Array.isArray(definition && definition.derived) ? definition.derived.filter((d) => d && typeof d.id === 'string') : []; - const health = healthLayout(definition && isPlainObject(definition.core) ? definition.core.health : undefined); + // A system that renamed hit points has its own word on its starter sheet's pool. + const hp = ownWords(definition).hp; + const health = healthLayout(definition && isPlainObject(definition.core) ? definition.core.health : undefined, hp ? hp.short : 'HP'); const sections = [ { id: 'identity', label: 'IDENTITY', layout: 'list', tab: 'STATS', fields: [ { id: 'name', label: 'Name', type: 'text', visibility: 'public' }, diff --git a/backend/systemBuilder/terms.js b/backend/systemBuilder/terms.js new file mode 100644 index 00000000..fcd77870 --- /dev/null +++ b/backend/systemBuilder/terms.js @@ -0,0 +1,66 @@ +// The glossary (Layer 1): the app's own words a system may rename, and what a system calls them. +// +// Its own module, with nothing required, so the definition checks (definition.js), the starter +// sheet (sheet.js, core.js) and the running game (runtime.js) can all use it without a circle. + +const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); + +/** + * The app's own words a system may rename. The key is the stable id the app looks up; the + * default is what the builder shows when a system says nothing. + */ +const TERMS = { + character: { singular: 'CHARACTER', plural: 'CHARACTERS' }, + hp: { singular: 'HP', plural: 'HP', short: 'HP' }, + money: { singular: 'CREDIT', plural: 'CREDITS', short: 'CR' }, + level: { singular: 'LEVEL', plural: 'LEVELS', short: 'LVL' }, + xp: { singular: 'XP', plural: 'XP', short: 'XP' }, + class: { singular: 'CLASS', plural: 'CLASSES' }, + initiative: { singular: 'INITIATIVE', plural: 'INITIATIVE', short: 'INIT' }, + round: { singular: 'ROUND', plural: 'ROUNDS' }, + turn: { singular: 'TURN', plural: 'TURNS' }, + gm: { singular: 'GM', plural: 'GMS', short: 'GM' }, + shop: { singular: 'SHOP', plural: 'SHOPS' }, + bank: { singular: 'BANK', plural: 'BANKS' }, + vehicle: { singular: 'VEHICLE', plural: 'VEHICLES' }, +}; +const WORD_FORMS = ['singular', 'plural', 'short']; + +/** What the app calls `term` in this system: the system's word, or the app's own. */ +const wordFor = (definition, term, form = 'singular') => { + const own = definition && isPlainObject(definition.words) && isPlainObject(definition.words[term]) + ? definition.words[term][form] : undefined; + if (typeof own === 'string' && own.trim()) return own; + const fallback = TERMS[term]; + 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. For the builder, to show what a term will read as. + */ +const resolveWords = (definition) => Object.fromEntries(Object.keys(TERMS).map((term) => [ + term, Object.fromEntries(WORD_FORMS.map((form) => [form, wordFor(definition, term, form)])), +])); + +/** + * Only the terms this system renamed, every form filled from its own other forms (a system that + * gave only WOUNDS reads WOUNDS in every form). What the running game's windows use: a term the + * system did not rename keeps the text each place shows today, so nothing changes that nobody + * chose to change (decided with the user, 2026-09-30). + */ +const ownWords = (definition) => { + const words = definition && isPlainObject(definition.words) ? definition.words : {}; + const out = {}; + for (const term of Object.keys(TERMS)) { + const own = isPlainObject(words[term]) ? words[term] : null; + if (!own) continue; + const pick = (form) => (typeof own[form] === 'string' && own[form].trim() ? own[form] : null); + const singular = pick('singular') || pick('plural') || pick('short'); + if (!singular) continue; + out[term] = { singular, plural: pick('plural') || singular, short: pick('short') || singular }; + } + return out; +}; + +module.exports = { TERMS, WORD_FORMS, wordFor, resolveWords, ownWords }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 04ac256a..c46f4e5c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2513,6 +2513,7 @@ function App() { :
NO VITALS · NOT ON THE MAP
); return ( (() => (view.model === 'tracks' ? view.tracks?.[0]?.id @@ -210,7 +215,7 @@ export function ModelHealthEditor({ view, target, send, gm }: { view: HealthView const act = (kind: 'heal' | 'damage') => run(kind, { amount, ...(picked ? { location: picked } : {}), ...(note ? { note } : {}) }, () => { setNote(''); return ''; }); return ( <> -
{cur} / {max}
HP
+
{cur} / {max}
{words('hp', 'short', 'HP').toUpperCase()}
{view.locations.map((l) => (
@@ -230,7 +235,7 @@ export function ModelHealthEditor({ view, target, send, gm }: { view: HealthView {status} {amountInput} {buttons(() => act('heal'), () => act('damage'))} - {gm && run('set_max', { hp_max: n }, () => '')} />} + {gm && run('set_max', { hp_max: n }, () => '')} />} ); } diff --git a/frontend/src/components/HitPoints.tsx b/frontend/src/components/HitPoints.tsx index c92da4d8..2b143400 100644 --- a/frontend/src/components/HitPoints.tsx +++ b/frontend/src/components/HitPoints.tsx @@ -3,6 +3,7 @@ import type { Location } from '../types'; import { bandOf, BAND_COLOR, BAND_WORDS, RHYTHM, beatPoints, type HealthBand } from './healthBands'; import { useHealthView } from '../hooks/useHealthView'; import { isCustomSystem } from '../sheets'; +import { useWords, asLabel } from '../sheets/words'; import { ModelHealthEditor, ModelHealthDescription, NoHealthNotice, monitorBandFor, type SendHealth } from './HealthModelPanels'; // Inline SVGs so we can tint them with CSS `color` (currentColor) export const PersonSVG = ({ color = 'currentColor', style }: { color?: string; style?: React.CSSProperties }) => ( @@ -163,6 +164,9 @@ export function HitPointsPanel({ target, token, refreshLocations, gameSystem, on // A custom system's health model, as the server lets this viewer see it. Null for the // built-in systems, which keep the panel below exactly as it was. const view = useHealthView(socket, target?.id, isCustomSystem(gameSystem)); + // A custom system's own word for hit points (TEMP_WOUNDS); today's text otherwise. + const word = useWords(gameSystem); + const hp = word('hp', 'plural', 'HP'); useEffect(() => { setInjuries(parseInjuries(rawInjuries)); }, [target?.id, rawInjuries]); @@ -228,10 +232,10 @@ export function HitPointsPanel({ target, token, refreshLocations, gameSystem, on ); const tempRow = (
- +
{ let val = parseInt(e.target.value) || 0; if (val > 100) val = 100; setTempAmount(val); }} style={{ flex: 1, minWidth: 0 }} @@ -253,7 +257,7 @@ export function HitPointsPanel({ target, token, refreshLocations, gameSystem, on setInjuriesOpen((o) => !o)} size={18} />
{injuryMap} - + {(view.model === 'tracks' || view.model === 'locations') && (
{tempRow}
)} @@ -308,9 +312,9 @@ export function HitPointsPanel({ target, token, refreshLocations, gameSystem, on
{token !== '' && (
- +
- setMaxAmount(parseInt(e.target.value) || 0)} style={{ flex: 1, minWidth: 0 }} /> + setMaxAmount(parseInt(e.target.value) || 0)} style={{ flex: 1, minWidth: 0 }} />
diff --git a/frontend/src/components/SheetRenderer.tsx b/frontend/src/components/SheetRenderer.tsx index 7a0f9444..eda2e2b3 100644 --- a/frontend/src/components/SheetRenderer.tsx +++ b/frontend/src/components/SheetRenderer.tsx @@ -13,6 +13,7 @@ import { } from '../sheets/cyberwareEffects'; import React, { useState, useEffect, useMemo } from 'react'; import type { SheetTemplate, SheetSection, SheetField, SheetData, SheetFieldValue } from '../sheets'; +import { wordFor, asLabel } from '../sheets/words'; import { TvPortrait } from './TvPortrait'; import { xpProgress, describeXp } from '../sheets/cwnAdvancement'; import { carriedEnc, encState, describeEnc, encumberedMove } from '../sheets/cwnEncumbrance'; @@ -547,14 +548,17 @@ function SheetHeaderBlock({ template, data, portraitUrl, onPortraitUpload, portr const cur = Math.max(0, Math.min(hp ?? 0, max)); const ratio = max > 0 ? cur / max : 0; const hpColor = ratio > 0.5 ? 'var(--green)' : ratio > 0.25 ? 'var(--warning)' : 'var(--danger)'; + // The system's own words for hit points, if it renamed them; today's text otherwise. + // The tooltip names the sidebar's button, so the two always read the same. + const hpButton = asLabel(wordFor(template.id, 'hp', 'plural', 'HIT POINTS')); return (
onOpenLink('token_hp') : undefined} style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', cursor: onOpenLink ? 'pointer' : 'default' }} > - HP + {wordFor(template.id, 'hp', 'short', 'HP').toUpperCase()}
40 ? '1px' : '2px', height: '12px', border: `1px solid ${hpColor}`, background: 'color-mix(in srgb, var(--black) 60%, transparent)', padding: '1px', transition: 'border-color 0.3s' }}> {max > 0 ? Array.from({ length: max }, (_, i) => (
l.shape === 'rhombus' && l.owner === userName); @@ -270,7 +272,7 @@ export function CharacterControlsMenu({ rhombusState, setRhombusState, selectedL
-

CHARACTER_CONTROLS

+

{asLabel(word('character', 'singular', 'CHARACTER'))}_CONTROLS

@@ -1166,6 +1168,10 @@ interface SidebarProps { export function Sidebar({ activeMenu, setActiveMenu, locations, onSelect, onZoom, selectedLocation, userName, token, onLogout, audioEnabled, setAudioEnabled, masterVolume, setMasterVolume, musicVolume, setMusicVolume, rhombusState, setRhombusState, refreshLocations, socketRef, isChatOpen, setIsChatOpen, hasUnreadChat, syncRhombusToDB, view, activeBattleMapData, isHitPointsOpen, setIsHitPointsOpen, activeUsers, setIsDiceTrayOpen, setNotification, measureMode, setMeasureMode, isBankOpen, setIsBankOpen, isSheetOpen, setIsSheetOpen, isVehiclesOpen, setIsVehiclesOpen, gameSystem, attackPending, onCancelAttack, isRadioOpen, onToggleRadio, musicPlaying, currencyIcon, currentTheme, onThemeChange, isInitiativeOpen, onToggleInitiative, initiativeActive, initiativeNeedsRoll, onRollEnemies, onRollFriendlies, activeCombats, onListCombats, onJumpToScene, onEndCombat, customDice, onOpenCustomDieBuilder, onDeleteCustomDie }: SidebarProps) { /** The rail label under the pointer, positioned from the button it belongs to. */ const [railTip, setRailTip] = useState<{ label: string; x: number; y: number } | null>(null); + // A custom system's own words for the rail's labels; today's text otherwise. + const word = useWords(gameSystem); + const characterControls = `${asLabel(word('character', 'singular', 'CHARACTER'))}_CONTROLS`; + const hitPoints = asLabel(word('hp', 'plural', 'HIT POINTS')); const userRhombus = locations.find((l: any) => l.shape === 'rhombus' && l.owner === userName && ( view === 'battle_map' && activeBattleMapData ? (l.battle_map_id == activeBattleMapData.locationId && l.floor_index == activeBattleMapData.currentFloorIndex) @@ -1248,12 +1254,12 @@ export function Sidebar({ activeMenu, setActiveMenu, locations, onSelect, onZoom - - + >ADD TO {word('initiative', 'short', 'INIT').toUpperCase()}
)} diff --git a/frontend/src/components/__tests__/wordsInWindows.test.tsx b/frontend/src/components/__tests__/wordsInWindows.test.tsx new file mode 100644 index 00000000..bf28a745 --- /dev/null +++ b/frontend/src/components/__tests__/wordsInWindows.test.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { Sidebar, CharacterControlsMenu } from '../Sidebar'; +import { SheetRenderer } from '../SheetRenderer'; +import { HitPointsPanel } from '../HitPoints'; +import { ModelHealthEditor } from '../HealthModelPanels'; +import { TokenWindow } from '../TokenWindow'; +import { getTemplate } from '../../sheets'; +import { registerCustomTemplate, clearCustomTemplates, type CustomRender } from '../../sheets/customTemplates'; +import { asLabel, wordFor } from '../../sheets/words'; + +/** + * The glossary in the sheet, token and health windows (3a3). Every place reads exactly as + * today under each built-in system, and under a custom system that did not rename the term; a + * custom system that did rename it shows its own word, in the terminal-label style where the + * place uses one (VIEW_BANK becomes VIEW_COIN_PURSE). + */ + +const BUILT_INS = ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic']; +const RENAMED = 'sys_aaaaaaaaaaaaaaaa'; +const PLAIN = 'sys_bbbbbbbbbbbbbbbb'; +const both = (w: string) => ({ singular: w, plural: w, short: w }); +const custom = (id: string, words: CustomRender['words']): CustomRender => ({ + id, name: id, parts: {}, derived: [], words, + sheet: { + header: { nameField: 'name', hpField: 'hp', hpMaxField: 'hp_max' }, + sections: [{ id: 'h', label: 'H', layout: 'grid', fields: [ + { id: 'hp', label: 'HP', type: 'number', source: 'token_hp', maxField: 'hp_max' }, + { id: 'hp_max', label: 'MAX', type: 'number', source: 'token_hp_max' }, + ] }], + }, +}); + +beforeEach(() => { + clearCustomTemplates(); + registerCustomTemplate(custom(RENAMED, { + hp: { singular: 'WOUND', plural: 'WOUNDS', short: 'WND' }, + character: both('OPERATIVE'), + gm: both('WARDEN'), + initiative: { singular: 'ORDER', plural: 'ORDER', short: 'ORD' }, + })); + registerCustomTemplate(custom(PLAIN, {})); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response))); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +describe('the label style', () => { + it('capitals, with spaces to underscores', () => { + expect(asLabel('HIT POINTS')).toBe('HIT_POINTS'); + expect(asLabel(' coin purse ')).toBe('COIN_PURSE'); + expect(asLabel('WOUNDS')).toBe('WOUNDS'); + }); +}); + +describe('the sidebar', () => { + const rail = (gameSystem: string) => { + const props: any = { + activeMenu: 'none', setActiveMenu: vi.fn(), locations: [], onSelect: vi.fn(), onZoom: vi.fn(), selectedLocation: null, + userName: 'GHOST', token: '', onLogout: vi.fn(), audioEnabled: false, setAudioEnabled: vi.fn(), masterVolume: 1, + setMasterVolume: vi.fn(), musicVolume: 1, setMusicVolume: vi.fn(), rhombusState: { color: '#00ff00' }, setRhombusState: vi.fn(), + refreshLocations: vi.fn(), socketRef: { current: { on: vi.fn(), off: vi.fn(), emit: vi.fn() } }, isChatOpen: false, + setIsChatOpen: vi.fn(), hasUnreadChat: false, syncRhombusToDB: vi.fn(), view: 'list', activeBattleMapData: null, + isHitPointsOpen: false, setIsHitPointsOpen: vi.fn(), activeUsers: [], setIsDiceTrayOpen: vi.fn(), setNotification: vi.fn(), + measureMode: false, setMeasureMode: vi.fn(), isBankOpen: false, setIsBankOpen: vi.fn(), isSheetOpen: false, + setIsSheetOpen: vi.fn(), gameSystem, + }; + render(); + const label = (name: string) => screen.queryByRole('button', { name })?.getAttribute('data-tip'); + const out = { hp: label(gameSystem === RENAMED ? 'WOUNDS' : 'HIT_POINTS'), controls: label(gameSystem === RENAMED ? 'OPERATIVE_CONTROLS' : 'CHARACTER_CONTROLS') }; + cleanup(); + return out; + }; + + it('reads as today under every built-in system, and a custom one that renamed nothing', () => { + for (const system of [...BUILT_INS, PLAIN]) expect(rail(system), system).toEqual({ hp: 'HIT_POINTS', controls: 'CHARACTER_CONTROLS' }); + }); + + it('uses a custom system\'s own words, in the label style', () => { + expect(rail(RENAMED)).toEqual({ hp: 'WOUNDS', controls: 'OPERATIVE_CONTROLS' }); + }); + + it('heads the controls menu the same way', () => { + const menu = (gameSystem: string) => { + render(); + const text = screen.getByRole('heading', { level: 3 }).textContent; + cleanup(); + return text; + }; + for (const system of [...BUILT_INS, PLAIN]) expect(menu(system), system).toBe('CHARACTER_CONTROLS'); + expect(menu(RENAMED)).toBe('OPERATIVE_CONTROLS'); + }); +}); + +describe('the sheet header', () => { + const header = (system: string) => { + const template = getTemplate(system); + const { container } = render(); + const bar = container.querySelector('[title^="Synced with your token"]') as HTMLElement; + const out = { label: bar.querySelector('span')!.textContent, tip: bar.getAttribute('title') }; + cleanup(); + return out; + }; + + it('reads as today under every built-in system, and a custom one that renamed nothing', () => { + for (const system of [...BUILT_INS, PLAIN]) { + expect(header(system), system).toEqual({ label: 'HP', tip: 'Synced with your token — click to open HIT_POINTS' }); + } + }); + + it('names a custom system\'s own hit points, and the same button the sidebar shows', () => { + expect(header(RENAMED)).toEqual({ label: 'WND', tip: 'Synced with your token — click to open WOUNDS' }); + }); +}); + +describe('the HEALTH folder', () => { + const token = { id: 7, name: 'Razor', x: 0, y: 0, z: 0, shape: 'rhombus', owner: 'RAZOR', hp_current: 8, hp_max: 10, hp_temp: 0 } as never; + const labels = (gameSystem: string) => { + render(); + const out = { + temp: screen.getByText(/^TEMP_/).textContent, + max: screen.getByText(/^MAX_/).textContent, + tempAria: screen.getByLabelText(/^Temp /).getAttribute('aria-label'), + maxAria: screen.getByLabelText(/^Max /).getAttribute('aria-label'), + }; + cleanup(); + return out; + }; + + it('reads as today under every built-in system, and a custom one that renamed nothing', () => { + for (const system of [...BUILT_INS, PLAIN]) { + expect(labels(system), system).toEqual({ temp: 'TEMP_HP', max: 'MAX_HP', tempAria: 'Temp HP', maxAria: 'Max HP' }); + } + }); + + it('uses a custom system\'s own word', () => { + expect(labels(RENAMED)).toEqual({ temp: 'TEMP_WOUNDS', max: 'MAX_WOUNDS', tempAria: 'Temp WOUNDS', maxAria: 'Max WOUNDS' }); + }); + + it('names the hit-location model\'s pool the same way', () => { + const view = { location_id: 7, model: 'locations' as const, full: true, locations: [{ id: 'head', label: 'HEAD', note: '' }] }; + render(); + expect(screen.getByText('HP')).toBeTruthy(); + expect(screen.getByLabelText('MAX_HP')).toBeTruthy(); + cleanup(); + render( wordFor(RENAMED, t, f, b)} />); + expect(screen.getByText('WND')).toBeTruthy(); + expect(screen.getByLabelText('MAX_WOUNDS')).toBeTruthy(); + }); +}); + +describe('the token window', () => { + const npc = { id: 9, name: 'Ghoul', shape: 'enemy_rhombus' }; + const open = (gameSystem: string) => { + render( + } + gmHealth={{ defense: { label: 'AC', melee: 10, ranged: null }, onSaveDefense: vi.fn(), onAddToInit: vi.fn() }} + gmNotesToken="gm-token" gameSystem={gameSystem} />, + ); + fireEvent.click(screen.getByRole('tab', { name: 'HEALTH' })); + const out: Record = { + score: screen.getByText(/ SCORE$/).textContent, + add: screen.getByRole('button', { name: /^ADD TO / }).textContent, + aria: screen.getByLabelText(/ score$/).getAttribute('aria-label'), + folder: screen.getAllByRole('tab').map((t) => t.textContent).find((t) => t?.endsWith(' NOTES')) ?? null, + }; + fireEvent.click(screen.getByRole('tab', { name: out.folder! })); + out.header = screen.getByText(/ ONLY · PLAYERS NEVER SEE THIS$/).textContent; + cleanup(); + return out; + }; + + it('reads as today under every built-in system, and a custom one that renamed nothing', () => { + for (const system of [...BUILT_INS, PLAIN, undefined as unknown as string]) { + expect(open(system), String(system)).toEqual({ + score: 'INITIATIVE SCORE', add: 'ADD TO INIT', aria: 'Initiative score', folder: 'GM NOTES', header: 'GM ONLY · PLAYERS NEVER SEE THIS', + }); + } + }); + + it('uses a custom system\'s own words for the GM and initiative', () => { + expect(open(RENAMED)).toEqual({ + score: 'ORDER SCORE', add: 'ADD TO ORD', aria: 'ORDER score', folder: 'WARDEN NOTES', header: 'WARDEN ONLY · PLAYERS NEVER SEE THIS', + }); + }); +}); diff --git a/frontend/src/sheets/words.ts b/frontend/src/sheets/words.ts index 8bd1aa23..cd497111 100644 --- a/frontend/src/sheets/words.ts +++ b/frontend/src/sheets/words.ts @@ -10,14 +10,28 @@ import { CUSTOM_TEMPLATE_EVENT, customTemplate, isCustomSystem, loadCustomTempla // // 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. +// system it is that system's word when it renamed the term, and that text otherwise: nothing +// changes that the system did not choose to change (decided with the user, 2026-09-30). The +// server sends only the renamed terms (systemBuilder/terms.js ownWords). 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'; +/** A lookup bound to the running system, as useWords returns. */ +export type WordLookup = (term: Term, form: WordForm, builtIn: string) => string; + +/** Today's text, unchanged: for components drawn without a running system to ask about. */ +export const todaysWords: WordLookup = (_term, _form, builtIn) => builtIn; + +/** + * A word in the app's terminal-label style: capitals, spaces to underscores. HIT POINTS becomes + * HIT_POINTS, COIN PURSE becomes COIN_PURSE (VIEW_COIN_PURSE). + */ +export const asLabel = (text: string): string => text.trim().toUpperCase().replace(/\s+/g, '_'); + /** 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; @@ -38,5 +52,5 @@ export function useWords(system: string | null | undefined) { 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); + return ((term, form, builtIn) => wordFor(system, term, form, builtIn)) as WordLookup; } From 13175fda4f1029b1feb738edddf9dc8d41c9a635 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 30 Sep 2026 23:22:01 -0500 Subject: [PATCH 2/2] docs: the 3a3 changelog line says the word is the GM's own, not a new standard --- CHANGELOG.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce9ade16..3500f2e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,10 +52,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood -- **A custom system's words show on the sheet, the token window and the HEALTH folder.** Where a - custom system renamed hit points, the character, the GM or initiative, those places use its word: - WOUNDS for HIT_POINTS, WARDEN NOTES for GM NOTES. A term it did not rename keeps today's text. - Nothing changes for the built-in systems, and no custom system can be made yet. +- **A custom system's words show on the sheet, the token window and the HEALTH folder.** When the + GM who built a custom system gives hit points, the character, the GM or initiative a name of + their own, those places use it: a game that calls hit points VIGOR shows VIGOR where HIT_POINTS + was. A term it did not rename keeps today's text. Nothing changes for the built-in systems, and + no custom system can be made yet. - **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