diff --git a/README.md b/README.md index 34e0c9f9..a4ce9dd4 100644 --- a/README.md +++ b/README.md @@ -388,7 +388,7 @@ CITY_NET/ │ │ ├── cwnSkillplugs.js # Skillplugs (p64). A plug grants a skill while loaded, overlaid on read like everything else here. Also the one place the app overrides a roll it has already computed: the worst the dice can do is an automatic failure no reroll can save, and the jack then locks for the scene. The intellectual/physical split is OURS and says so - the book gives a principle and six examples rather than a list │ │ ├── charwn.js # Characters Without Number exports. An ADAPTER, not a second importer: their file is flattened into the same candidates a filled-in PDF produces and handed to the ordinary mapper, so the alias table, the skill normaliser, the inventory parser and the cyberware gather all run unchanged. The rule for extending it is therefore to emit a label the FORM already prints │ │ ├── importers.js # Modular sheet import — PDF form extraction + data-driven per-system field mappers (makeMapFields) -│ │ └── npcTiers.js # Per-system NPC power tiers for GENERATE_SHEET (CP:R: Mook→Elite; CWN: +Spirits; SR6: Ganger→Prime Runner) +│ │ └── npcTiers.js # Per-system NPC power tiers for GENERATE_SHEET (CP:R: Mook→Elite; CWN: +Spirits; SR6: Ganger→Prime Runner); a published custom system's tiers through a hook, built-ins looked up first │ ├── shops/ # Buying and selling. The server decides every price and every payout; the window only prints them │ │ ├── prices.js # The built-in CWN catalogues as id -> [label, price], mirrored from the frontend tables and cross-checked line by line. Labels are here because a sheet records what you own by name │ │ ├── purchase.js # What a purchase does to an account, pure. Short of funds with the overdraft house rule on, it refuses with needs_choice rather than picking between debt and a negative balance for the player @@ -412,8 +412,9 @@ CITY_NET/ │ │ ├── rules.js # Code-backed rule values a formula can name as $name, for what arithmetic cannot read (installed armor mods, fitted chrome, adept powers). A GM picks from this list, never adds to it │ │ ├── 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, max pairs and token/bank links) and its checks; a starter sheet for a system that has none -│ │ ├── runtime.js # Published systems in memory for the running game: compiled once into the meta the built-in templates carry (public/combat/linked fields, max pairs, derived recompute), reached by sheets/templates.js through a hook; and the render copy the browser draws from, with no formulas +│ │ ├── 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 +│ │ ├── npc.js # A custom system's NPCs as data: an optional stat-block layout (checked like a sheet, and linking shared fields the same way) and GENERATE_SHEET tiers (label, token HP and defense, starting values) +│ │ ├── runtime.js # Published systems in memory for the running game: compiled once into the meta the built-in templates carry (public/combat/linked/GM-only fields, max pairs, derived recompute), reached by sheets/templates.js through a hook; the NPC tiers, reached by sheets/npcTiers.js the same way; and the render copy the browser draws from, with no formulas │ │ └── store.js # `custom_systems`: a draft the builder edits and the published copy a game runs. Ids are sys_ + hex, never a built-in id; publishing refuses a draft with problems; the running system cannot be deleted │ ├── startup/ │ │ ├── backup.js # A whole copy of the database (VACUUM INTO, beside it) before a migration changes real data; skipped, and logged, when the disk lacks room @@ -454,6 +455,7 @@ CITY_NET/ │ ├── bank_accounts.test.js # Per-system accounts kept apart; the one-time move (every sheet's system plus the running one, the old table untouched, once only, all or nothing); the database copy and its disk-space check; switching systems in play; and db.js opening a 1.14.4-shaped database file in a child process │ ├── token_vitals.test.js # Switching swaps and restores every token's health (enemies too, buildings untouched), entirely or not at all, and waits for the one-time start; the start's systems and run-once; map clears and loads; the system picker route and the settings route's guard; db.js on a real file │ ├── system_builder_runtime.test.js # The sheet format's checks and starter sheet; a published system known to the game (never a draft), answering the same helpers as the built-ins without changing them, its render copy free of formulas, switched to from the picker, and a player's edit recomputing its derived values +│ ├── 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_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 @@ -708,7 +710,7 @@ CITY_NET/ │ │ ├── sheets/ │ │ │ ├── types.ts # Sheet template type system (fields, sections, header, death saves, NPC tiers) │ │ │ ├── index.ts # Template registry, getMaxPairs, GATED_TABS/hiddenTabsFor (house-rule-gated sheet tabs). getTemplate also answers for published custom systems -│ │ │ ├── customTemplates.ts # Custom systems' sheets: the server's render copy turned into a SheetTemplate for the ordinary SheetRenderer (derived values read-only, only armor writing through to the token), fetched once and cached, with an event the app redraws on +│ │ │ ├── customTemplates.ts # Custom systems' sheets: the server's render copy turned into a SheetTemplate for the ordinary SheetRenderer (derived values read-only, only armor writing through to the token, GM-only fields marked), with the system's NPC layout and tiers; fetched once and cached, with an event the app redraws on │ │ │ ├── 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_npc_privacy.test.js b/backend/__tests__/system_builder_npc_privacy.test.js new file mode 100644 index 00000000..d68772bb --- /dev/null +++ b/backend/__tests__/system_builder_npc_privacy.test.js @@ -0,0 +1,297 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { createRequire } from 'module'; +import { makeTestDb, get, run } from './helpers/testDb.js'; +import { untilValue, drain } from './helpers/until.js'; + +/** + * A custom system's privacy and its NPCs, as data. + * + * A field can be the GM's to set (XP, awarded items): the owner sees it and cannot change it, + * by a single edit, a batch edit or an upload, while the GM still can. NPCs get a layout of + * their own and power tiers that GENERATE_SHEET uses. The built-in systems are untouched. + */ + +process.env.JWT_SECRET = 'test-secret'; +process.env.DICE_ANIM_MS = '0'; +const require_ = createRequire(import.meta.url); +const { checkDefinition } = require_('../systemBuilder/definition'); +const runtime = require_('../systemBuilder/runtime'); +const templates = require_('../sheets/templates'); +const npcTiers = require_('../sheets/npcTiers'); +const socketsFactory = require_('../sockets/index.js'); + +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); +const gm = { Authorization: `Bearer ${GM}` }; + +const SHEET = { + tabs: ['KNIGHT'], + header: { nameField: 'name', hpField: 'wounds', hpMaxField: 'wounds_max' }, + sections: [ + { id: 'who', label: 'WHO', layout: 'list', tab: 'KNIGHT', fields: [ + { id: 'name', label: 'Name', type: 'text', visibility: 'public' }, + { id: 'renown', label: 'Renown', type: 'number', edit: 'gm' }, + { id: 'boon', label: 'Boon', type: 'text', edit: 'gm' }, + { id: 'order', label: 'Order', type: 'select', edit: 'player', options: [{ value: 'dawn', label: 'Dawn' }, { value: 'dusk', label: 'Dusk' }] }, + ] }, + { id: 'stats', label: 'STATS', layout: 'grid', tab: 'KNIGHT', fields: [ + { id: 'might', label: 'MIGHT', type: 'number' }, + { id: 'might_mod', label: 'MOD', type: 'number' }, + { id: 'wounds', label: 'WOUNDS', type: 'number', source: 'token_hp', maxField: 'wounds_max' }, + { id: 'wounds_max', label: 'MAX', type: 'number', source: 'token_hp_max' }, + ] }, + ], +}; + +const NPC_SHEET = { + header: { nameField: 'name', hpField: 'wounds' }, + sections: [ + { id: 'block', label: 'STAT BLOCK', layout: 'grid', fields: [ + { id: 'name', label: 'Name', type: 'text' }, + { id: 'might', label: 'MIGHT', type: 'number' }, + { id: 'might_mod', label: 'MOD', type: 'number' }, + { id: 'threat', label: 'THREAT', type: 'number', sensitivity: 'combat' }, + { id: 'wounds', label: 'WOUNDS', type: 'number', source: 'token_hp' }, + // Only NPCs show their armor, and it still lives on the token. + { id: 'ward', label: 'WARD', type: 'number', source: 'token_ac' }, + // Public on the NPC layout, which says nothing: NPC sheets are never shown to players. + { id: 'tactics', label: 'Tactics', type: 'text', visibility: 'public' }, + ] }, + ], +}; + +const VAULT = { + format: 1, + name: 'Vault Knights', + lookups: { mod: { bands: [{ upTo: 9, value: -1 }, { upTo: 13, value: 0 }, { value: 1 }] } }, + derived: [{ id: 'might_mod', formula: 'mod(@might)' }], + sheet: SHEET, + npc: { + sheet: NPC_SHEET, + tiers: [ + { id: 'squire', label: 'SQUIRE', hp: 6, defense: 11, values: { might: 9, threat: 1, tactics: 'Runs' } }, + { id: 'champion', label: 'CHAMPION', hp: 30, defense: 17, values: { might: 16, threat: 4 } }, + { id: 'ghost', label: 'GHOST', values: { tactics: 'Haunts' } }, + ], + }, +}; + +const problems = (definition) => checkDefinition(definition).problems.map((p) => `${p.where}: ${p.message}`); + +describe('the format', () => { + it('accepts a system with GM-only fields, an NPC layout and tiers', () => { + expect(problems(VAULT)).toEqual([]); + }); + + it('knows who may edit a field, and that nobody edits a derived value', () => { + const sheet = JSON.parse(JSON.stringify(SHEET)); + sheet.sections[0].fields[1].edit = 'owner'; + sheet.sections[1].fields[1].edit = 'gm'; + expect(problems({ ...VAULT, sheet })).toEqual([ + 'sheet field renown, edit: player or gm', + 'sheet field might_mod, edit: A derived value is worked out, so nobody edits it', + ]); + }); + + it('checks the NPC layout as a sheet, and holds it to linking fields as the character sheet does', () => { + const npcSheet = JSON.parse(JSON.stringify(NPC_SHEET)); + npcSheet.sections[0].layout = 'cards'; + npcSheet.sections[0].fields.push({ id: 'might', label: 'Again', type: 'number' }); + npcSheet.sections[0].fields[1].source = 'bank_balance'; + expect(problems({ ...VAULT, npc: { sheet: npcSheet } })).toEqual([ + 'npc sheet section block, layout: One of list, grid, notes, inventory', + 'npc sheet field might: Defined twice', + 'npc sheet field might: Linked differently on the character sheet', + ]); + }); + + it('reports every mistake in the tiers, with where it is', () => { + expect(problems({ ...VAULT, npc: { sheet: NPC_SHEET, extra: 1, tiers: [ + { id: 'Squire', label: '', hp: -1, defense: 100, colour: 'red' }, + { id: 'b', label: 'B', values: { might_mod: 3, wounds: 5, nope: 1, might: 'lots', tactics: 7 } }, + { id: 'b', label: 'B' }, + 'x', + ] } })).toEqual([ + 'npc extra: Not part of the NPC settings', + 'npc tier Squire, colour: Not part of a tier', + 'npc tier Squire: Ids use lowercase letters, digits and _, starting with a letter', + 'npc tier Squire, label: Required', + 'npc tier Squire, hp: A whole number from 0 to 9999', + 'npc tier Squire, defense: A whole number from 0 to 99', + 'npc tier b, might_mod: A derived value is worked out, not set', + 'npc tier b, wounds: Lives on the token or in the bank; use the tier\'s hp and defense', + 'npc tier b, nope: Not a field on the NPC sheet', + 'npc tier b, might: Must be a number', + 'npc tier b, tactics: Must be text', + 'npc tier b: Defined twice', + 'npc tier 4: Must be a tier', + ]); + }); + + it('checks tier values against the character sheet when NPCs have no layout of their own', () => { + expect(problems({ ...VAULT, npc: { tiers: [{ id: 'a', label: 'A', values: { order: 'noon', tactics: 'x' } }] } })).toEqual([ + 'npc tier a, order: Not one of the field\'s options', + 'npc tier a, tactics: Not a field on the NPC sheet', + ]); + }); + + it('refuses npc settings that are not settings', () => { + expect(problems({ ...VAULT, npc: [] })).toEqual(['npc: Must be a set of NPC settings']); + expect(problems({ ...VAULT, npc: { tiers: 'many' } })).toEqual(['npc tiers: Must be a list of tiers']); + }); +}); + +describe('a published system with them', () => { + let db; + let app; + let id; + let elevatedUsers; + const emitted = []; + + beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); + emitted.length = 0; + const io = { emit: (event, data) => emitted.push({ event, data }), to: () => ({ emit: () => {} }) }; + app = express(); + app.use(express.json({ limit: '2mb' })); + app.use('/api/systems', require_('../routes/systems.js')(db)); + app.use('/api/sheets', require_('../routes/sheets.js')(db, io)); + id = (await request(app).post('/api/systems').set(gm).send({ definition: VAULT })).body.id; + await new Promise((resolve) => runtime.load(db, resolve)); + await request(app).post(`/api/systems/${id}/publish`).set(gm); + elevatedUsers = new Set(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => vi.restoreAllMocks()); + + /** A connected socket for `userName`, the GM when `admin`. */ + const connectAs = async (userName, { admin = false } = {}) => { + let connect; + socketsFactory({ on: (e, cb) => { if (e === 'connection') connect = cb; }, emit: () => {}, to: () => ({ emit: () => {} }) }, + db, { elevatedUsers, emitUpdate: vi.fn(), recordAction: vi.fn() }); + const handlers = {}; + const sent = []; + connect({ id: `np-${Math.random()}`, on: (e, fn) => { handlers[e] = fn; }, emit: (e, d) => sent.push({ e, d }), + broadcast: { emit: () => {} }, use: () => {}, join: () => {}, disconnect: () => {} }); + handlers.identify(admin ? { userName, isAdmin: true, token: GM } : userName); + await drain(db); + return { handlers, sent }; + }; + + const sheetOf = async (username) => JSON.parse((await get(db, + 'SELECT data FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0', [username, id])).data); + + const playing = async (username, data) => { + await request(app).put('/api/sheets/system').set(gm).send({ system: id }); + await run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)', [username, id, JSON.stringify(data)]); + }; + + describe('GM-only fields', () => { + it('are listed for the server, and every built-in sheet has none', () => { + expect(templates.metaFor(id).gmFields).toEqual(['renown', 'boon']); + expect(templates.playerMayEdit(id, 'renown')).toBe(false); + expect(templates.playerMayEdit(id, 'might')).toBe(true); + for (const system of ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic']) { + expect(templates.metaFor(system).gmFields, system).toBeUndefined(); + expect(templates.playerMayEdit(system, 'xp'), system).toBe(true); + } + }); + + it("cannot be changed by the character's owner, who can still change everything else", async () => { + await playing('GHOST', { renown: 2, might: 10 }); + const { handlers } = await connectAs('GHOST'); + handlers.updateSheetField({ fieldId: 'renown', value: 99 }); + handlers.updateSheetField({ fieldId: 'might', value: 14 }); + const saved = await untilValue(() => sheetOf('GHOST'), (d) => d.might === 14, { label: 'the edit' }); + await drain(db); + expect(await sheetOf('GHOST')).toMatchObject({ renown: 2, might: 14, might_mod: 1 }); + expect(saved.renown).toBe(2); + }); + + it('are left out of an upload or a batch edit, and the rest goes in', async () => { + await playing('GHOST', { renown: 2, boon: 'Blessed blade' }); + const { handlers } = await connectAs('GHOST'); + handlers.importSheetFields({ fields: { renown: 50, boon: 'Crown', name: 'Sir Ash', might: 16 } }); + const saved = await untilValue(() => sheetOf('GHOST'), (d) => d.name === 'Sir Ash', { label: 'the import' }); + expect(saved).toMatchObject({ renown: 2, boon: 'Blessed blade', name: 'Sir Ash', might: 16, might_mod: 1 }); + }); + + it('are the GM\'s to change, on their own sheet or by a grant', async () => { + await playing('gm', { renown: 1 }); + const { handlers } = await connectAs('gm', { admin: true }); + handlers.updateSheetField({ fieldId: 'renown', value: 5 }); + expect((await untilValue(() => sheetOf('gm'), (d) => d.renown === 5, { label: 'the GM edit' })).renown).toBe(5); + + await run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)', ['ROOK', id, '{"renown":1}']); + elevatedUsers.add('ROOK'); + const granted = await connectAs('ROOK'); + granted.handlers.importSheetFields({ fields: { renown: 3 } }); + expect((await untilValue(() => sheetOf('ROOK'), (d) => d.renown === 3, { label: 'the granted edit' })).renown).toBe(3); + }); + + it("are the GM's to change from the GM's sheet window", async () => { + await playing('GHOST', { renown: 2 }); + const res = await request(app).put('/api/sheets/user/GHOST').set(gm).send({ fields: { renown: 7 } }); + expect(res.status).toBe(200); + expect((await sheetOf('GHOST')).renown).toBe(7); + }); + }); + + describe('NPCs', () => { + it('reach the browser with their layout and tier names', async () => { + const res = await request(app).get(`/api/systems/render/${id}`); + expect(res.body.npc).toEqual({ + sheet: NPC_SHEET, + tiers: [{ id: 'squire', label: 'SQUIRE' }, { id: 'champion', label: 'CHAMPION' }, { id: 'ghost', label: 'GHOST' }], + }); + }); + + it("fold the NPC layout's links into the system's rules", () => { + expect(templates.getLinkedFields(id)).toEqual({ wounds: 'token_hp', wounds_max: 'token_hp_max', ward: 'token_ac' }); + expect(templates.metaFor(id).combatFields).toEqual(['threat']); + // Never shown to players: only the character sheet says what is public. + expect(templates.filterPublicData(id, { name: 'Sir Ash', tactics: 'Runs', threat: 3 })).toEqual({ name: 'Sir Ash' }); + }); + + it('offer their tiers to GENERATE_SHEET, built with derived values worked out', () => { + expect(npcTiers.getTierOptions(id).map((t) => t.id)).toEqual(['squire', 'champion', 'ghost']); + expect(npcTiers.buildTier(id, 'champion')).toEqual({ + tierId: 'champion', data: { might: 16, threat: 4, might_mod: 1 }, hp: 30, dv: { melee: 17, ranged: 17 }, + }); + // An unknown tier is the first, as with the built-in ones. + expect(npcTiers.buildTier(id, 'dragon').tierId).toBe('squire'); + }); + + it('leave the built-in tiers as they were', () => { + expect(npcTiers.getTierOptions('cyberpunk_red').map((t) => t.id)).toEqual(['mook', 'skilled', 'pro', 'elite']); + expect(npcTiers.buildTier('cities_without_number', 'elite')).toMatchObject({ tierId: 'elite', hp: 50, dv: { melee: 18, ranged: 18 } }); + expect(npcTiers.buildTier('generic', 'mook')).toBeNull(); + }); + + it("generate a sheet and set the token's HP and defense from a tier", async () => { + await request(app).put('/api/sheets/system').set(gm).send({ system: id }); + await run(db, `INSERT INTO locations (id, name, x, y, z, shape, hp_current, hp_max, melee_ac, ranged_ac) VALUES (40, 'Bandit', 0, 0, 0, 'enemy_rhombus', 3, 3, 8, 8)`); + const { handlers, sent } = await connectAs('gm', { admin: true }); + handlers.generateNpcSheet({ location_id: 40, tier: 'champion' }); + const made = await untilValue(() => sent.find((s) => s.e === 'npcSheetGenerated'), Boolean, { label: 'the NPC sheet' }); + expect(made.d).toMatchObject({ tier: 'champion', system: id }); + const sheet = await get(db, 'SELECT data FROM character_sheets WHERE id = ?', [made.d.sheet_id]); + expect(JSON.parse(sheet.data)).toMatchObject({ name: 'Bandit', might: 16, might_mod: 1, threat: 4 }); + expect(await get(db, 'SELECT hp_current, hp_max, melee_ac, ranged_ac FROM locations WHERE id = 40')) + .toEqual({ hp_current: 30, hp_max: 30, melee_ac: 17, ranged_ac: 17 }); + }); + + it("keep the token's own HP and defense when a tier leaves them out", async () => { + await request(app).put('/api/sheets/system').set(gm).send({ system: id }); + await run(db, `INSERT INTO locations (id, name, x, y, z, shape, hp_current, hp_max, melee_ac, ranged_ac) VALUES (41, 'Wisp', 0, 0, 0, 'enemy_rhombus', 4, 9, 12, 13)`); + const { handlers, sent } = await connectAs('gm', { admin: true }); + handlers.generateNpcSheet({ location_id: 41, tier: 'ghost' }); + await untilValue(() => sent.find((s) => s.e === 'npcSheetGenerated'), Boolean, { label: 'the NPC sheet' }); + expect(await get(db, 'SELECT hp_current, hp_max, melee_ac, ranged_ac FROM locations WHERE id = 41')) + .toEqual({ hp_current: 4, hp_max: 9, melee_ac: 12, ranged_ac: 13 }); + }); + }); +}); diff --git a/backend/sheets/npcTiers.js b/backend/sheets/npcTiers.js index 46cc10e6..17e43700 100644 --- a/backend/sheets/npcTiers.js +++ b/backend/sheets/npcTiers.js @@ -280,14 +280,26 @@ const TIERS = { }, }; -const getTierOptions = (system) => TIERS[system]?.options ?? []; +/** + * Published custom systems' tiers (systemBuilder/runtime.js), through a hook it sets, as + * templates.js does for sheet meta. Built-in systems are looked up first, so a custom + * system can never change what a built-in one generates. A custom tier may leave out HP + * or defense (null), which leaves the token's own value alone. + */ +let customTiers = () => null; +const setCustomTiers = (fn) => { customTiers = typeof fn === 'function' ? fn : () => null; }; + +const getTierOptions = (system) => TIERS[system]?.options ?? customTiers(system)?.options ?? []; // Returns { data, hp, dv } or null when the system has no tiers / unknown id. const buildTier = (system, tierId) => { const t = TIERS[system]; - if (!t) return null; + if (!t) { + const custom = customTiers(system); + return custom ? custom.build(tierId) : null; + } const id = t.build[tierId] ? tierId : t.default; return t.build[id] ? { tierId: id, ...t.build[id]() } : null; }; -module.exports = { TIERS, getTierOptions, buildTier }; +module.exports = { TIERS, getTierOptions, buildTier, setCustomTiers }; diff --git a/backend/sheets/templates.js b/backend/sheets/templates.js index 3650b3ab..77b7fbc6 100644 --- a/backend/sheets/templates.js +++ b/backend/sheets/templates.js @@ -406,6 +406,13 @@ const acColumns = (linked, fields) => { const getLinkedFields = (system) => metaFor(system).linkedFields || {}; +/** + * May a character's owner change this field themselves? Everything, except what a custom + * system marks as the GM's to set (XP, awarded items). The built-in sheets mark nothing, so + * this is always true for them. The GM's own edits do not ask. + */ +const playerMayEdit = (system, fieldId) => !(metaFor(system).gmFields || []).includes(fieldId); + // Returns a map of maxFieldId → currentFieldId for the system. const getMaxPairs = (system) => metaFor(system).maxPairs || {}; @@ -431,7 +438,7 @@ const applyDerived = (system, data, changedFieldId) => { }; module.exports = { - TEMPLATES, DEFAULT_SYSTEM, isValidSystem, isBuiltIn, metaFor, setCustomMeta, filterPublicData, getLinkedFields, getMaxPairs, + TEMPLATES, DEFAULT_SYSTEM, isValidSystem, isBuiltIn, metaFor, setCustomMeta, filterPublicData, getLinkedFields, getMaxPairs, playerMayEdit, applyDerived, cwnEffectiveAc, cwnImplantAc, cwnMoveBonus, CWN_BASE_MOVE, TOKEN_SOURCES, rangedAcOf, acColumns, }; diff --git a/backend/sockets/index.js b/backend/sockets/index.js index f8f082d0..6f90fda7 100644 --- a/backend/sockets/index.js +++ b/backend/sockets/index.js @@ -1256,6 +1256,9 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!payload || typeof payload.fieldId !== 'string') return; getGameSystem((err, system) => { if (err) return; + // A value the system gives the GM to set (a custom system's XP, say): the owner + // sees it but cannot change it. The GM editing their own sheet still can. + if (!sheetTemplates.playerMayEdit(system, payload.fieldId) && !isAdminSocket(socket)) return; // Linked fields are owned by other systems (token HP, bank) - never // stored in sheet JSON. The AC links are the writable ones: a sheet edit // routes to the player's token, keeping the token the source of truth. @@ -1364,8 +1367,12 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { getGameSystem((err, system) => { if (err) return; const linked = sheetTemplates.getLinkedFields(system); + // The GM's values are left out of a player's import or batch edit, as from a single + // edit: uploading a sheet does not set your own XP. + const gm = isAdminSocket(socket); const entries = Object.entries(payload.fields) - .filter(([k, v]) => !linked[k] && (typeof v === 'string' || typeof v === 'number')); + .filter(([k, v]) => !linked[k] && (gm || sheetTemplates.playerMayEdit(system, k)) + && (typeof v === 'string' || typeof v === 'number')); if (entries.length === 0 && !cyber) return; // Through the queue: an import replaces the whole sheet, so a concurrent edit // does not merely lose a field, it disappears entirely. The occupancy carried @@ -1795,9 +1802,11 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { }); } else if (tier) { // Other systems (CWN): the tier's own defense values stand - no - // CP:R melee-DV formula, no take-10 house rule. + // CP:R melee-DV formula, no take-10 house rule. A custom system's tier may + // leave HP or defense out (null), which keeps the token's own. db.run( - `UPDATE locations SET hp_current = ?, hp_max = ?, melee_ac = ?, ranged_ac = ? WHERE id = ?`, + `UPDATE locations SET hp_current = COALESCE(?, hp_current), hp_max = COALESCE(?, hp_max), + melee_ac = COALESCE(?, melee_ac), ranged_ac = COALESCE(?, ranged_ac) WHERE id = ?`, [tier.hp, tier.hp, tier.dv.melee, tier.dv.ranged, location_id], () => { emitUpdate({ isRhombusOnly: true }); insertSheet(); } ); diff --git a/backend/systemBuilder/definition.js b/backend/systemBuilder/definition.js index 595f2609..c0d33131 100644 --- a/backend/systemBuilder/definition.js +++ b/backend/systemBuilder/definition.js @@ -14,6 +14,7 @@ // parts: { vehicles: { on: false }, ... }, // Layer 2 // lookups: { ... }, derived: [ ... ], // Layer 3, the Phase 1 engine's format // sheet: { tabs, header, sections }, // the character sheet (sheet.js) +// npc: { sheet, tiers }, // NPC layout and power tiers (npc.js) // } // // Problems come in two weights. A **fatal** one means the document cannot be stored at all: @@ -27,6 +28,7 @@ const { compileSystem } = require('./derived'); const { checkSheet } = require('./sheet'); +const { checkNpc } = require('./npc'); const FORMAT = 1; @@ -66,7 +68,7 @@ const PARTS = [ 'death', 'luck', 'xp', 'npc_tiers', 'sheet_import', ]; -const SECTIONS = new Set(['format', 'name', 'description', 'words', 'parts', 'lookups', 'derived', 'sheet']); +const SECTIONS = new Set(['format', 'name', 'description', 'words', 'parts', 'lookups', 'derived', 'sheet', 'npc']); const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); @@ -153,6 +155,7 @@ const checkDefinition = (definition) => { const derivedIds = new Set(Array.isArray(definition.derived) ? definition.derived.filter((d) => d && typeof d.id === 'string').map((d) => d.id) : []); checkSheet(definition.sheet, derivedIds, problems); + checkNpc(definition, derivedIds, problems); return { problems }; }; diff --git a/backend/systemBuilder/npc.js b/backend/systemBuilder/npc.js new file mode 100644 index 00000000..b3e023b9 --- /dev/null +++ b/backend/systemBuilder/npc.js @@ -0,0 +1,103 @@ +// A custom system's NPCs: their own sheet layout and their power tiers, as data. +// +// npc: { +// sheet: { tabs, header, sections }, // optional; NPCs use the character sheet without it +// tiers: [ // optional; what GENERATE_SHEET offers +// { id: 'mook', label: 'MOOK', hp: 5, defense: 10, values: { level: 1, attack: 1 } }, +// ], +// } +// +// The built-in systems' NPCs are code (sheets/npcTiers.js) and stay that way. A stat block is +// usually far shorter than a player's sheet, so a system may give NPCs a layout of their own. +// Both layouts belong to one system and share its derived values, and the server keeps one +// set of rules per system (which fields live on the token or in the bank), so a field on +// both layouts must be linked the same way on each. +// +// A tier is a package, as the built-in ones are: a label, the token's HP and defense, and +// the sheet values a generated NPC starts with. The first tier is the default. + +const { checkSheet, fieldsOf, effectiveSheet } = require('./sheet'); + +const NAME = /^[a-z][a-z0-9_]{0,63}$/; +const LIMITS = { tiers: 20, label: 30, values: 200, text: 300, hp: 9999, defense: 99 }; + +const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); + +/** The layout NPCs are drawn with: their own, or the character sheet. */ +const npcSheetOf = (definition) => (definition && isPlainObject(definition.npc) && isPlainObject(definition.npc.sheet) + ? definition.npc.sheet : effectiveSheet(definition)); + +/** The tiers as defined, or none. Only called on a checked definition. */ +const tiersOf = (definition) => (definition && isPlainObject(definition.npc) && Array.isArray(definition.npc.tiers) + ? definition.npc.tiers.filter(isPlainObject) : []); + +const wholeNumber = (value, max) => Number.isInteger(value) && value >= 0 && value <= max; + +const checkTiers = (tiers, npcFields, derivedIds, problems) => { + if (tiers === undefined) return; + if (!Array.isArray(tiers)) { problems.push({ where: 'npc tiers', message: 'Must be a list of tiers' }); return; } + if (tiers.length > LIMITS.tiers) problems.push({ where: 'npc tiers', message: `More than ${LIMITS.tiers} tiers` }); + const byId = new Map(npcFields.map((f) => [f.id, f])); + const ids = new Set(); + tiers.slice(0, LIMITS.tiers).forEach((tier, ti) => { + const tw = isPlainObject(tier) && typeof tier.id === 'string' ? `npc tier ${tier.id}` : `npc tier ${ti + 1}`; + if (!isPlainObject(tier)) { problems.push({ where: tw, message: 'Must be a tier' }); return; } + for (const key of Object.keys(tier)) { + if (!['id', 'label', 'hp', 'defense', 'values'].includes(key)) problems.push({ where: `${tw}, ${key}`, message: 'Not part of a tier' }); + } + if (typeof tier.id !== 'string' || !NAME.test(tier.id)) problems.push({ where: tw, message: 'Ids use lowercase letters, digits and _, starting with a letter' }); + else if (ids.has(tier.id)) problems.push({ where: tw, message: 'Defined twice' }); + else ids.add(tier.id); + if (typeof tier.label !== 'string' || !tier.label.trim()) problems.push({ where: `${tw}, label`, message: 'Required' }); + else if (tier.label.length > LIMITS.label) problems.push({ where: `${tw}, label`, message: `Longer than ${LIMITS.label} characters` }); + if (tier.hp !== undefined && !wholeNumber(tier.hp, LIMITS.hp)) problems.push({ where: `${tw}, hp`, message: `A whole number from 0 to ${LIMITS.hp}` }); + if (tier.defense !== undefined && !wholeNumber(tier.defense, LIMITS.defense)) problems.push({ where: `${tw}, defense`, message: `A whole number from 0 to ${LIMITS.defense}` }); + if (tier.values === undefined) return; + if (!isPlainObject(tier.values)) { problems.push({ where: `${tw}, values`, message: 'Must be a set of field values' }); return; } + const entries = Object.entries(tier.values); + if (entries.length > LIMITS.values) problems.push({ where: `${tw}, values`, message: `More than ${LIMITS.values} values` }); + for (const [fieldId, value] of entries.slice(0, LIMITS.values)) { + const vw = `${tw}, ${fieldId}`; + const field = byId.get(fieldId); + if (derivedIds.has(fieldId)) { problems.push({ where: vw, message: 'A derived value is worked out, not set' }); continue; } + if (!field) { problems.push({ where: vw, message: 'Not a field on the NPC sheet' }); continue; } + // HP and defense come from the tier's own hp and defense, onto the token. + if (field.source) { problems.push({ where: vw, message: 'Lives on the token or in the bank; use the tier\'s hp and defense' }); continue; } + if (field.type === 'number') { + if (typeof value !== 'number' || !Number.isFinite(value)) problems.push({ where: vw, message: 'Must be a number' }); + } else if (typeof value !== 'string') { + problems.push({ where: vw, message: 'Must be text' }); + } else if (value.length > LIMITS.text) { + problems.push({ where: vw, message: `Longer than ${LIMITS.text} characters` }); + } else if (field.type === 'select' && Array.isArray(field.options) && !field.options.some((o) => o && o.value === value)) { + problems.push({ where: vw, message: 'Not one of the field\'s options' }); + } + } + }); +}; + +/** Check the npc section. `definition` supplies the character sheet it is compared with. */ +const checkNpc = (definition, derivedIds, problems) => { + const npc = definition.npc; + if (npc === undefined) return; + if (!isPlainObject(npc)) { problems.push({ where: 'npc', message: 'Must be a set of NPC settings' }); return; } + for (const key of Object.keys(npc)) { + if (!['sheet', 'tiers'].includes(key)) problems.push({ where: `npc ${key}`, message: 'Not part of the NPC settings' }); + } + if (npc.sheet !== undefined) { + // The same checks as the character sheet, reported as the NPC sheet's. + const own = []; + checkSheet(npc.sheet, derivedIds, own); + problems.push(...own.map((p) => ({ ...p, where: `npc ${p.where}` }))); + const character = new Map(fieldsOf(effectiveSheet(definition)).filter(isPlainObject).map((f) => [f.id, f])); + for (const f of fieldsOf(npc.sheet).filter(isPlainObject)) { + const other = character.get(f.id); + if (other && (other.source || null) !== (f.source || null)) { + problems.push({ where: `npc sheet field ${f.id}`, message: 'Linked differently on the character sheet' }); + } + } + } + checkTiers(npc.tiers, fieldsOf(npcSheetOf(definition)).filter(isPlainObject), derivedIds, problems); +}; + +module.exports = { checkNpc, npcSheetOf, tiersOf, LIMITS }; diff --git a/backend/systemBuilder/runtime.js b/backend/systemBuilder/runtime.js index 7b3315f2..15e0994b 100644 --- a/backend/systemBuilder/runtime.js +++ b/backend/systemBuilder/runtime.js @@ -11,7 +11,9 @@ const { compileSystem } = require('./derived'); const { effectiveSheet, fieldsOf } = require('./sheet'); +const { npcSheetOf, tiersOf } = require('./npc'); const templates = require('../sheets/templates'); +const npcTiers = require('../sheets/npcTiers'); /** id -> { name, definition, meta, render } */ const loaded = new Map(); @@ -20,20 +22,25 @@ const parse = (text) => { try { return JSON.parse(text); } catch { return null; /** The server-side meta the built-in templates carry, worked out from a definition. */ const metaOf = (definition) => { - const sheet = effectiveSheet(definition); - const fields = fieldsOf(sheet); + const fields = fieldsOf(effectiveSheet(definition)); + // The NPC layout's fields too: the server keeps one set of rules per system, and an NPC's + // HP lives on its token just as a player's does (npc.js holds the two layouts to agreeing). + const both = [...fields, ...fieldsOf(npcSheetOf(definition))]; const compiled = compileSystem({ lookups: definition.lookups, derived: definition.derived ?? [] }); const linkedFields = {}; const maxPairs = {}; - for (const f of fields) { + for (const f of both) { if (f.source) linkedFields[f.id] = f.source; if (f.maxField) maxPairs[f.maxField] = f.id; } return { name: definition.name, custom: true, + // Only the character sheet's: an NPC's sheet is never shown to players (sheets/npcPrivacy.js). publicFields: fields.filter((f) => f.visibility === 'public' && f.sensitivity !== 'combat').map((f) => f.id), - combatFields: fields.filter((f) => f.sensitivity === 'combat').map((f) => f.id), + combatFields: [...new Set(both.filter((f) => f.sensitivity === 'combat').map((f) => f.id))], + // Values the owner sees and only the GM changes (sheet.js EDIT). + gmFields: fields.filter((f) => f.edit === 'gm').map((f) => f.id), linkedFields, maxPairs, // Derived values on every save, as the built-in recompute functions do. A published @@ -43,6 +50,27 @@ const metaOf = (definition) => { }; }; +/** + * GENERATE_SHEET's tiers for a system, in the shape sheets/npcTiers.js gives the built-in + * ones: the options to offer, and a builder for the sheet values, token HP and defense. The + * values are run through the system's derived values, so a generated NPC's sheet is + * consistent before anybody edits it. Null when the system defines no tiers. + */ +const tiersFor = (definition, recompute) => { + const tiers = tiersOf(definition); + if (!tiers.length) return null; + return { + options: tiers.map((t) => ({ id: t.id, label: t.label })), + build: (tierId) => { + const tier = tiers.find((t) => t.id === tierId) || tiers[0]; + const data = { ...(tier.values || {}) }; + recompute(data); + const defense = tier.defense ?? null; + return { tierId: tier.id, data, hp: tier.hp ?? null, dv: { melee: defense, ranged: defense } }; + }, + }; +}; + /** What the browser draws a sheet from: no formulas, lookups or rule names. */ const renderOf = (id, definition) => ({ id, @@ -51,12 +79,21 @@ const renderOf = (id, definition) => ({ parts: definition.parts || {}, derived: (Array.isArray(definition.derived) ? definition.derived : []).map((d) => d.id), sheet: effectiveSheet(definition), + npc: { + // Null when NPCs use the character sheet. + sheet: definition.npc && definition.npc.sheet ? definition.npc.sheet : null, + tiers: tiersOf(definition).map((t) => ({ id: t.id, label: t.label })), + }, }); const put = (id, publishedText, version) => { const definition = parse(publishedText); if (!definition) { loaded.delete(id); return; } - loaded.set(id, { name: definition.name, version: version || 0, definition, meta: metaOf(definition), render: renderOf(id, definition) }); + const meta = metaOf(definition); + loaded.set(id, { + name: definition.name, version: version || 0, definition, meta, + render: renderOf(id, definition), tiers: tiersFor(definition, meta.recompute), + }); }; /** Load every published system. cb(err, count). */ @@ -84,6 +121,9 @@ const render = (id) => (loaded.has(id) ? loaded.get(id).render : null); const list = () => [...loaded.entries()].map(([id, s]) => ({ id, name: s.name, custom: true, version: s.version })) .sort((a, b) => a.name.localeCompare(b.name)); +const tiers = (id) => (loaded.has(id) ? loaded.get(id).tiers : null); + templates.setCustomMeta(meta); +npcTiers.setCustomTiers(tiers); -module.exports = { load, refresh, meta, render, list, metaOf, renderOf }; +module.exports = { load, refresh, meta, render, list, tiers, metaOf, renderOf }; diff --git a/backend/systemBuilder/sheet.js b/backend/systemBuilder/sheet.js index b5f34fe1..e833d543 100644 --- a/backend/systemBuilder/sheet.js +++ b/backend/systemBuilder/sheet.js @@ -27,7 +27,14 @@ const text = (value, where, max, problems, { required = false } = {}) => { if (value.length > max) problems.push({ where, message: `Longer than ${max} characters` }); }; -const FIELD_KEYS = new Set(['id', 'label', 'type', 'visibility', 'sensitivity', 'maxField', 'hint', 'placeholder', 'unit', 'options', 'source']); +/** + * Who changes a field. 'player' (the default) is the character's owner, as on every built-in + * sheet; 'gm' is a value the owner sees but only the GM sets - XP, awarded items. A derived + * value is neither: it is worked out, so nobody edits it. + */ +const EDIT = ['player', 'gm']; + +const FIELD_KEYS = new Set(['id', 'label', 'type', 'visibility', 'sensitivity', 'maxField', 'hint', 'placeholder', 'unit', 'options', 'source', 'edit']); const SECTION_KEYS = new Set(['id', 'label', 'layout', 'tab', 'columns', 'fields']); const HEADER_KEYS = new Set(['nameField', 'subtitleFields', 'hpField', 'hpMaxField', 'chips']); @@ -93,6 +100,8 @@ const checkSheet = (sheet, derivedIds, problems) => { text(field.unit, `${fw}, unit`, 20, problems); if (field.source !== undefined && !SOURCES.includes(field.source)) problems.push({ where: `${fw}, source`, message: `One of ${SOURCES.join(', ')}` }); if (field.source !== undefined && derivedIds.has(field.id)) problems.push({ where: fw, message: 'Cannot be both a derived value and a linked one' }); + if (field.edit !== undefined && !EDIT.includes(field.edit)) problems.push({ where: `${fw}, edit`, message: 'player or gm' }); + if (field.edit !== undefined && derivedIds.has(field.id)) problems.push({ where: `${fw}, edit`, message: 'A derived value is worked out, so nobody edits it' }); if (field.maxField !== undefined) maxRefs.push({ fw, ref: field.maxField }); if (field.options !== undefined || field.type === 'select') { if (field.type !== 'select') problems.push({ where: `${fw}, options`, message: 'Only a select field has options' }); @@ -171,4 +180,4 @@ const effectiveSheet = (definition) => { const fieldsOf = (sheet) => (Array.isArray(sheet && sheet.sections) ? sheet.sections : []) .flatMap((s) => (s && Array.isArray(s.fields) ? s.fields : [])); -module.exports = { checkSheet, effectiveSheet, fieldsOf, LAYOUTS, TYPES, SOURCES, LIMITS }; +module.exports = { checkSheet, effectiveSheet, fieldsOf, LAYOUTS, TYPES, SOURCES, EDIT, LIMITS }; diff --git a/frontend/src/SheetPage.tsx b/frontend/src/SheetPage.tsx index e1490f43..63d65aaa 100644 --- a/frontend/src/SheetPage.tsx +++ b/frontend/src/SheetPage.tsx @@ -132,6 +132,7 @@ export default function SheetPage() { portraitUrl={sheet.portrait_url} onFieldChange={handleFieldChange} onFieldsChange={handleFieldsChange} + gm={!!adminToken} onPortraitUpload={(adminToken || playerToken) ? handlePortraitUpload : undefined} onRoll={actions.onRoll} onDeathSave={actions.onDeathSave} diff --git a/frontend/src/components/CharacterSheetWindow.tsx b/frontend/src/components/CharacterSheetWindow.tsx index b320d9c6..6eceefbe 100644 --- a/frontend/src/components/CharacterSheetWindow.tsx +++ b/frontend/src/components/CharacterSheetWindow.tsx @@ -109,6 +109,7 @@ export function CharacterSheetWindow({ pos, setPos, onClose, socket, userName, p portraitUrl={sheet.portrait_url} onFieldChange={handleFieldChange} onFieldsChange={handleFieldsChange} + gm={!!adminToken} onSectionAction={(id) => { if (id === 'vehicles') onOpenVehicles?.(); }} onPortraitUpload={(adminToken || playerToken) ? handlePortraitUpload : undefined} onOpenLink={onOpenLink} diff --git a/frontend/src/components/NpcSheetWindow.tsx b/frontend/src/components/NpcSheetWindow.tsx index 004a3197..023b8bb9 100644 --- a/frontend/src/components/NpcSheetWindow.tsx +++ b/frontend/src/components/NpcSheetWindow.tsx @@ -4,7 +4,7 @@ import ReactDOM from 'react-dom'; import { DraggableWindow } from './DraggableWindow'; import { SheetRenderer } from './SheetRenderer'; import { ImportSheetDialog } from './ImportSheetDialog'; -import { getTemplate, getMaxPairs, hiddenTabsFor, type CharacterSheet } from '../sheets'; +import { getTemplate, getMaxPairs, hiddenTabsFor, npcTemplateOf, type CharacterSheet, type SheetTemplate } from '../sheets'; import type { SheetFieldValue } from '../sheets/types'; import { npcInitiativePortrait } from '../modules/initiative/npcPortrait'; @@ -36,6 +36,12 @@ export function NpcSheetWindow({ token, npcId, npcLabel, playerUsername, headsho const apiPath = playerUsername ? `/api/sheets/user/${encodeURIComponent(playerUsername)}` : `/api/sheets/npcs/${npcId}`; + // A player's sheet in their system's character layout; an NPC in the system's NPC layout, + // when it has one of its own (a custom system's stat block). + const layoutFor = useCallback((system: string): SheetTemplate => { + const template = getTemplate(system); + return playerUsername ? template : npcTemplateOf(template); + }, [playerUsername]); const [sheet, setSheet] = useState(null); const [error, setError] = useState(null); const [isImportOpen, setIsImportOpen] = useState(false); @@ -115,7 +121,7 @@ export function NpcSheetWindow({ token, npcId, npcLabel, playerUsername, headsho let clampedCur: { fieldId: string; value: number } | null = null; setSheet(prev => { if (!prev) return prev; - const template = getTemplate(prev.system); + const template = layoutFor(prev.system); const pairs = getMaxPairs(template); const curField = pairs[fieldId]; // non-null when fieldId is a max field const data = { ...prev.data, [fieldId]: value }; @@ -137,7 +143,7 @@ export function NpcSheetWindow({ token, npcId, npcLabel, playerUsername, headsho if (clampedCur) fields[clampedCur.fieldId] = clampedCur.value; saveFields(fields); }, 400)); - }, [saveFields]); + }, [saveFields, layoutFor]); const handlePortraitUpload = useCallback(async (file: File) => { const form = new FormData(); @@ -171,7 +177,7 @@ export function NpcSheetWindow({ token, npcId, npcLabel, playerUsername, headsho handleFieldChange('portrait_shadow_filter', shadowFilter ? 0 : 1); }, [handleFieldChange, shadowFilter]); - const template = sheet ? getTemplate(sheet.system) : null; + const template = sheet ? layoutFor(sheet.system) : null; return ( <> @@ -267,6 +273,8 @@ export function NpcSheetWindow({ token, npcId, npcLabel, playerUsername, headsho data={sheet.data} portraitUrl={sheet.portrait_url} onFieldChange={handleFieldChange} + // GM-only in both modes: the GM sets the values a system keeps for the GM. + gm onPortraitUpload={handlePortraitUpload} portraitShadow={shadowFilter} onTogglePortraitShadow={handleTogglePortraitShadow} diff --git a/frontend/src/components/SheetRenderer.tsx b/frontend/src/components/SheetRenderer.tsx index 4fffe0d8..7a0f9444 100644 --- a/frontend/src/components/SheetRenderer.tsx +++ b/frontend/src/components/SheetRenderer.tsx @@ -104,8 +104,15 @@ interface SheetRendererProps { portraitShadow?: boolean; /** Called when the admin clicks the FX toggle button on the portrait. */ onTogglePortraitShadow?: () => void; + /** The viewer is the GM, who may change the fields a system keeps for the GM + * (SheetField.gmOnly). Everyone else sees those read-only. */ + gm?: boolean; } +/** Whether the viewer is the GM, for FieldInput's GM-only lock, without threading a prop + * through every section layout. */ +const GmContext = React.createContext(false); + const num = (v: unknown): number => { const n = Number(v); return Number.isFinite(n) ? n : 0; @@ -200,13 +207,16 @@ function CustomTagEntry({ placeholder, label, onAdd }: { ); } -function FieldInput({ field, data, readOnly, onFieldChange, onFieldsChange, style, onOpenLink }: { +function FieldInput({ field, data, readOnly: sheetReadOnly, onFieldChange, onFieldsChange, style, onOpenLink }: { field: SheetField; data: SheetData; readOnly: boolean; onFieldChange: (fieldId: string, value: SheetFieldValue) => void; onFieldsChange?: (fields: Record) => void; style?: React.CSSProperties; onOpenLink?: (source: NonNullable) => void; }) { + // A value the system keeps for the GM (XP, awarded items) shows, but only the GM edits it. + const gm = React.useContext(GmContext); + const readOnly = sheetReadOnly || (!!field.gmOnly && !gm); const value = data[field.id] ?? ''; if (field.source && field.sourceWritable && !readOnly) { // Writable linked field (token AC): edits go through the normal save @@ -1789,7 +1799,7 @@ function dropEmptyRetired(section: SheetSection, data: SheetData): SheetSection return fields.length === 0 ? null : { ...section, fields }; } -export function SheetRenderer({ template, data, readOnly = false, onFieldChange, portraitUrl, onPortraitUpload, portraitShadow, onTogglePortraitShadow, onOpenLink, onRoll, onDeathSave, onStabilize, allowFumbleShield = false, xpRate, encumbranceEnforced = false, hiddenTabs, onCastSpell, onRollAbility, onResistDrain, onFieldsChange, onSectionAction }: SheetRendererProps) { +export function SheetRenderer({ template, data, readOnly = false, onFieldChange, portraitUrl, onPortraitUpload, portraitShadow, onTogglePortraitShadow, onOpenLink, onRoll, onDeathSave, onStabilize, allowFumbleShield = false, xpRate, encumbranceEnforced = false, hiddenTabs, onCastSpell, onRollAbility, onResistDrain, onFieldsChange, onSectionAction, gm = false }: SheetRendererProps) { const tabs = (template.tabs ?? ['SHEET']).filter(t => !hiddenTabs?.includes(t)); const [activeTab, setActiveTab] = useState(tabs[0]); // What the character's chrome is doing to their numbers. Computed once for the whole @@ -1923,6 +1933,7 @@ export function SheetRenderer({ template, data, readOnly = false, onFieldChange, () => template.sections.flatMap(s => s.fields ?? []), [template]); return ( +