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() { :