Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions backend/__tests__/system_builder_health_view.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
48 changes: 43 additions & 5 deletions backend/__tests__/system_builder_words.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
});
});

Expand Down Expand Up @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion backend/sockets/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
8 changes: 4 additions & 4 deletions backend/systemBuilder/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => [
Expand All @@ -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([
Expand Down Expand Up @@ -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' })) },
],
Expand All @@ -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' } };
}
};

Expand Down
41 changes: 2 additions & 39 deletions backend/systemBuilder/definition.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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',
Expand Down Expand Up @@ -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;
Expand All @@ -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,
};
Loading
Loading