diff --git a/CHANGELOG.md b/CHANGELOG.md index 0511dbb..3500f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ 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.** 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 screen changes yet, and the built-in systems keep their wording exactly. diff --git a/README.md b/README.md index cded8ec..000f9ed 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 0d7b663..6d39b05 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 53fac4d..301ce4f 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 9766247..54c3db4 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 1befe28..351c9ec 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 83f9ac9..c6835e3 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 e8a65b1..cb9422f 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 d7c617d..759776d 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 7bbcda2..223ebe2 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 0000000..fcd7787 --- /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 04ac256..c46f4e5 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 c92da4d..2b14340 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 7a0f944..eda2e2b 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 0000000..bf28a74 --- /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 8bd1aa2..cd49711 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; }