diff --git a/README.md b/README.md index c7a028eb..cb3ec6c9 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,7 @@ CITY_NET/ │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 @@ -459,6 +460,7 @@ CITY_NET/ │ ├── 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_core.test.js # The setup answers: every health model's starter sheet (and that it passes the sheet checks), the unanswered starter unchanged to the byte, and every mistake reported with where it is │ ├── system_builder_health.test.js # Every health model's DAMAGE and HEAL rules; the HIT_POINTS route using them for players and linked NPCs, refusing what a model cannot do, keeping an edit made while damage lands, and leaving the built-in systems and a custom one-pool system on the route as before +│ ├── 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_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 diff --git a/backend/__tests__/system_builder_health_view.test.js b/backend/__tests__/system_builder_health_view.test.js new file mode 100644 index 00000000..0d7b663a --- /dev/null +++ b/backend/__tests__/system_builder_health_view.test.js @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach, 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'; + +/** + * What the HEALTH folder is sent under a custom health model: the full detail to whoever may + * change the token's health (the GM, a granted editor, the owner) and a description, never a + * number or a note, to everyone else. Plus the details the window reports after an action. + */ + +process.env.JWT_SECRET = 'test-secret'; +process.env.DICE_ANIM_MS = '0'; +const require_ = createRequire(import.meta.url); +const { healthView } = require_('../systemBuilder/healthView'); +const { applyHealthAction } = require_('../systemBuilder/health'); +const runtime = require_('../systemBuilder/runtime'); +const socketsFactory = require_('../sockets/index.js'); + +const TRACKS = { model: 'tracks', tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }], overflow: true }; +const TYPED = { model: 'typed', types: [{ id: 'superficial', label: 'SUPERFICIAL' }, { id: 'aggravated', label: 'AGGRAVATED' }] }; +const HARM = { model: 'harm', levels: [ + { id: 'lesser', label: 'LESSER', slots: 2, penalty: 'Reduced effect' }, + { id: 'moderate', label: 'MODERATE', slots: 2, penalty: '-1d' }, + { id: 'severe', label: 'SEVERE', slots: 1, penalty: 'Need help' }, +] }; +const WOUNDS = { model: 'wounds', count: 3, penalty: -1 }; +const LOCATIONS = { model: 'locations', locations: [{ id: 'head', label: 'HEAD' }, { id: 'body', label: 'BODY' }] }; +const token = (current, max) => ({ current, max, temp: 0 }); +const both = (health, t, sheet) => [healthView(health, t, sheet, { full: true }), healthView(health, t, sheet)]; + +describe('the view', () => { + it('is nothing for a built-in system, which draws its own health', () => { + expect(healthView(null, token(5, 10), {})).toEqual({ model: null }); + expect(healthView({ model: 'tracks', tracks: [] }, token(5, 10), {})).toEqual({ model: null }); + }); + + it('one pool: its own word for health', () => { + expect(healthView({ model: 'pool', label: 'VIGOR' }, token(5, 10), {})).toEqual({ model: 'pool', full: false, label: 'VIGOR' }); + expect(healthView({ model: 'pool' }, token(5, 10), {}).label).toBe('HP'); + }); + + it('two tracks: the second track\'s numbers in full, only its fill to others', () => { + const [full, others] = both(TRACKS, token(8, 10), { stun: 6, stun_max: 9 }); + expect(full).toEqual({ + model: 'tracks', full: true, overflow: true, + tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }], + second: { id: 'stun', label: 'STUN', current: 6, max: 9 }, + }); + expect(others.second).toEqual({ label: 'STUN', fill: 6 / 9, full: false }); + expect(healthView(TRACKS, token(8, 10), { stun: 9, stun_max: 9 }).second).toMatchObject({ fill: 1, full: true }); + expect(healthView(TRACKS, token(8, 10), { stun: 4 }).second).toMatchObject({ fill: 0, full: false }); + }); + + it('damage types: each type\'s marks in full, light against heavy as shares to others', () => { + const [full, others] = both(TYPED, token(3, 8), { superficial: 3, aggravated: 2 }); + expect(full).toMatchObject({ boxes: 8, types: [{ id: 'superficial', label: 'SUPERFICIAL', marks: 3 }, { id: 'aggravated', label: 'AGGRAVATED', marks: 2 }] }); + expect(others).toEqual({ model: 'typed', full: false, light: 3 / 8, heavy: 2 / 8 }); + }); + + it('harm levels: the notes in full, the worst level\'s name to others, and out only when full and down', () => { + const sheet = { lesser_1: 'Bruised ribs', moderate_1: 'Twisted knee' }; + const [full, others] = both(HARM, token(3, 5), sheet); + expect(full.levels).toEqual([ + { id: 'lesser', label: 'LESSER', penalty: 'Reduced effect', slots: ['Bruised ribs', ''] }, + { id: 'moderate', label: 'MODERATE', penalty: '-1d', slots: ['Twisted knee', ''] }, + { id: 'severe', label: 'SEVERE', penalty: 'Need help', slots: [''] }, + ]); + expect(full).toMatchObject({ worst: 1, out: false }); + expect(others).toEqual({ model: 'harm', full: false, levelCount: 3, worst: 1, worstLabel: 'MODERATE', out: false }); + expect(JSON.stringify(others)).not.toContain('Twisted'); + const all = { lesser_1: 'a', lesser_2: 'b', moderate_1: 'c', moderate_2: 'd', severe_1: 'e' }; + expect(healthView(HARM, token(0, 5), all)).toMatchObject({ worstLabel: 'SEVERE', out: true }); + expect(healthView(HARM, token(1, 5), all).out).toBe(false); + expect(healthView(HARM, token(5, 5), {})).toMatchObject({ worst: -1, worstLabel: null }); + }); + + it('wound count: the penalty in full, a word to others', () => { + const [full, others] = both(WOUNDS, token(1, 3), {}); + expect(full).toEqual({ model: 'wounds', full: true, penalty: -2 }); + expect(others).toEqual({ model: 'wounds', full: false, state: 'wounded' }); + expect(healthView(WOUNDS, token(3, 3), {}).state).toBe('unhurt'); + expect(healthView(WOUNDS, token(0, 3), {}).state).toBe('incapacitated'); + // No size on the token yet: the setup's count. + expect(healthView(WOUNDS, token(2, 0), {}, { full: true }).penalty).toBe(-1); + }); + + it('hit locations: the notes in full, only which are hurt to others', () => { + const [full, others] = both(LOCATIONS, token(9, 15), { body: 'Graze' }); + expect(full.locations).toEqual([{ id: 'head', label: 'HEAD', note: '' }, { id: 'body', label: 'BODY', note: 'Graze' }]); + expect(others.locations).toEqual([{ id: 'head', label: 'HEAD', hit: false }, { id: 'body', label: 'BODY', hit: true }]); + }); + + it('none: only which model it is', () => { + expect(healthView({ model: 'none' }, token(0, 0), {})).toEqual({ model: 'none', full: false }); + }); +}); + +describe('what an action reports', () => { + it('how many boxes turned heavier, and which level harm landed on', () => { + expect(applyHealthAction(TYPED, token(0, 5), { superficial: 3, aggravated: 2 }, { kind: 'damage', amount: 2 })).toMatchObject({ turned: 2 }); + expect(applyHealthAction(TYPED, token(5, 5), {}, { kind: 'damage', amount: 2 }).turned).toBeUndefined(); + const sheet = { lesser_1: 'a', lesser_2: 'b' }; + expect(applyHealthAction(HARM, token(3, 5), sheet, { kind: 'damage', level: 'lesser', note: 'Cut' })).toMatchObject({ placed: 'moderate', sheetPatch: { moderate_1: 'Cut' } }); + expect(applyHealthAction(HARM, token(5, 5), {}, { kind: 'damage', note: 'Cut' }).placed).toBe('lesser'); + const all = { ...sheet, moderate_1: 'c', moderate_2: 'd', severe_1: 'e' }; + expect(applyHealthAction(HARM, token(0, 5), all, { kind: 'damage', note: 'x' }).placed).toBeUndefined(); + }); + + it('sets the second track\'s maximum, bringing its current down to it, and refuses any other', () => { + expect(applyHealthAction(TRACKS, token(8, 10), { stun: 7 }, { kind: 'set_max', track: 'stun', amount: 12 }).sheetPatch).toEqual({ stun_max: 12 }); + expect(applyHealthAction(TRACKS, token(8, 10), { stun: 7 }, { kind: 'set_max', track: 'stun', amount: 5 }).sheetPatch).toEqual({ stun_max: 5, stun: 5 }); + expect(applyHealthAction(TRACKS, token(8, 10), {}, { kind: 'set_max', track: 'physical', amount: 5 })).toEqual({ ok: false, error: 'The first track\'s maximum is set on the token' }); + expect(applyHealthAction(WOUNDS, token(2, 3), {}, { kind: 'set_max', amount: 5 })).toEqual({ ok: false, error: 'This maximum is set on the token' }); + }); +}); + +describe('in the running game', () => { + const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); + let db; + let app; + let elevatedUsers; + + const running = async (health) => { + const definition = { format: 1, name: `Test ${Math.random()}`, core: { health } }; + const { id } = (await request(app).post('/api/systems').set('Authorization', `Bearer ${GM}`).send({ definition })).body; + await request(app).post(`/api/systems/${id}/publish`).set('Authorization', `Bearer ${GM}`); + await run(db, `UPDATE global_settings SET value = ? WHERE key = 'game_system'`, [id]); + return id; + }; + + /** A socket for `userName` (the GM when `admin`), with what it was sent. */ + 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: `hv-${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 viewFor = async (who, locationId) => { + who.handlers.requestHealthView({ location_id: locationId }); + const found = await untilValue(() => who.sent.find((s) => s.e === 'healthView' && s.d.location_id === locationId), Boolean, { label: 'the health view' }); + who.sent.length = 0; + return found.d; + }; + + beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); + elevatedUsers = new Set(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + app = express(); + app.use(express.json()); + app.use('/api/systems', require_('../routes/systems.js')(db)); + app.use('/api/locations', require_('../routes/locations.js')(db, { emit: () => {} }, { emitUpdate: vi.fn(), recordAction: vi.fn() })); + await new Promise((resolve) => runtime.load(db, resolve)); + }); + + const playerToken = async (owner, system, data) => { + await run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)', [owner, system, JSON.stringify(data)]); + return (await run(db, `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES (?, 0, 0, 0, 'rhombus', ?, 8, 10)`, [owner, owner])).lastID; + }; + + it('sends a player\'s own token in full, and another player only the description', async () => { + const system = await running(HARM); + const id = await playerToken('GHOST', system, { moderate_1: 'Twisted knee' }); + const owner = await connectAs('GHOST'); + const other = await connectAs('ROOK'); + expect(await viewFor(owner, id)).toMatchObject({ location_id: id, model: 'harm', full: true, worst: 1 }); + const seen = await viewFor(other, id); + expect(seen).toMatchObject({ model: 'harm', full: false, worstLabel: 'MODERATE' }); + expect(JSON.stringify(seen)).not.toContain('Twisted'); + }); + + it('sends the GM and a granted editor everything, an NPC included', async () => { + const system = await running(TRACKS); + const id = await playerToken('GHOST', system, { stun: 3, stun_max: 9 }); + const npc = await run(db, `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('Ghoul', 0, 0, 0, 'enemy_rhombus', 'gm', 4, 4)`); + const sheet = await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('gm', ?, '{"stun":2,"stun_max":5}', 1, 'Ghoul')`, [system]); + await run(db, 'INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (?, ?)', [npc.lastID, sheet.lastID]); + + const gm = await connectAs('gm', { admin: true }); + expect((await viewFor(gm, id)).second).toEqual({ id: 'stun', label: 'STUN', current: 3, max: 9 }); + expect((await viewFor(gm, npc.lastID)).second).toEqual({ id: 'stun', label: 'STUN', current: 2, max: 5 }); + + elevatedUsers.add('ROOK'); + const granted = await connectAs('ROOK'); + expect((await viewFor(granted, npc.lastID)).full).toBe(true); + // Nobody else sees an NPC's numbers, even with the NPC's owner field naming the GM. + const player = await connectAs('GHOST'); + expect((await viewFor(player, npc.lastID)).second).toEqual({ label: 'STUN', fill: 2 / 5, full: false }); + }); + + it("does not give a player an NPC's numbers because the NPC's owner field names them", async () => { + // An enemy made by a player while the GM had granted them editing carries their name. + // Once the grant is gone, so is the full view. + const system = await running(TRACKS); + const npc = await run(db, `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('Drone', 0, 0, 0, 'enemy_rhombus', 'ROOK', 4, 4)`); + const sheet = await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('ROOK', ?, '{"stun":1,"stun_max":4}', 1, 'Drone')`, [system]); + await run(db, 'INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (?, ?)', [npc.lastID, sheet.lastID]); + const rook = await connectAs('ROOK'); + expect(await viewFor(rook, npc.lastID)).toMatchObject({ full: false, second: { label: 'STUN', fill: 0.25, full: false } }); + }); + + 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'); + const bystander = await connectAs('ROOK'); + expect(await viewFor(asker, id)).toEqual({ location_id: id, model: null }); + expect(bystander.sent.some((s) => s.e === 'healthView')).toBe(false); + // Always an answer, so a window never waits on one: even for a token not on the map. + expect(await viewFor(asker, 9999)).toEqual({ location_id: 9999, model: null }); + }); + + it('sets a second track\'s maximum on the sheet, and any other maximum on the token as before', async () => { + const system = await running(TRACKS); + const id = await playerToken('GHOST', system, { stun: 7, stun_max: 9 }); + const res = await request(app).put(`/api/locations/${id}/health`).send({ action: 'set_max', track: 'stun', amount: 5 }); + expect(res.status).toBe(200); + expect(JSON.parse((await get(db, 'SELECT data FROM character_sheets WHERE username = ?', ['GHOST'])).data)).toMatchObject({ stun: 5, stun_max: 5 }); + expect(await get(db, 'SELECT hp_max FROM locations WHERE id = ?', [id])).toEqual({ hp_max: 10 }); + + await request(app).put(`/api/locations/${id}/health`).send({ action: 'set_max', track: 'physical', hp_max: 14 }); + expect(await get(db, 'SELECT hp_max FROM locations WHERE id = ?', [id])).toEqual({ hp_max: 14 }); + }); + + it('passes the details on to the window', async () => { + const system = await running(HARM); + const id = await playerToken('GHOST', system, { lesser_1: 'a', lesser_2: 'b' }); + const res = await request(app).put(`/api/locations/${id}/health`).send({ action: 'damage', level: 'lesser', note: 'Cut' }); + expect(res.body).toMatchObject({ placed: 'moderate' }); + + await running(TYPED); + const full = await playerToken('ROOK', (await get(db, `SELECT value FROM global_settings WHERE key = 'game_system'`)).value, { superficial: 4, aggravated: 0 }); + await run(db, 'UPDATE locations SET hp_current = 0, hp_max = 4 WHERE id = ?', [full]); + expect((await request(app).put(`/api/locations/${full}/health`).send({ action: 'damage', amount: 1 })).body).toMatchObject({ turned: 1 }); + }); +}); diff --git a/backend/routes/locations.js b/backend/routes/locations.js index 1f877da0..11dedeca 100644 --- a/backend/routes/locations.js +++ b/backend/routes/locations.js @@ -717,14 +717,19 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { } ); }); - } else if (action === 'damage' || action === 'heal') { + } else if (action === 'damage' || action === 'heal' || (action === 'set_max' && req.body.track !== undefined)) { // A custom system whose health is not one pool takes damage by its own model // (systemBuilder/health.js). Everything else, the built-in systems included, goes - // through runHealth exactly as before. + // through runHealth exactly as before. So does every SET MAX but a second track's, + // whose maximum lives on the sheet. db.get(`SELECT value FROM global_settings WHERE key = 'game_system'`, (gErr, gRow) => { const system = !gErr && gRow ? gRow.value : null; const health = system ? customSystems.health(system) : null; if (!health || health.model === 'pool') return runHealth(action); + if (action === 'set_max') { + const second = health.model === 'tracks' && Array.isArray(health.tracks) ? health.tracks[1] : null; + if (!second || req.body.track !== second.id) return runHealth(action); + } runModelHealth(system, health); }); } else { @@ -766,6 +771,8 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { id, hp_current: t.current, hp_max: t.max, hp_temp: t.temp, out: result.out, ...(result.overflow ? { overflow: result.overflow } : {}), ...(result.penalty !== undefined ? { penalty: result.penalty } : {}), + ...(result.turned ? { turned: result.turned } : {}), + ...(result.placed ? { placed: result.placed } : {}), }); }); }; diff --git a/backend/sockets/index.js b/backend/sockets/index.js index 6f90fda7..9766247b 100644 --- a/backend/sockets/index.js +++ b/backend/sockets/index.js @@ -17,6 +17,8 @@ const awardXpModule = require('../sheets/awardXp'); const tokenControl = require('./tokenControl'); const attackSr6 = require('../sheets/attackSr6'); const npcTiers = require('../sheets/npcTiers'); +const customSystems = require('../systemBuilder/runtime'); +const { healthView } = require('../systemBuilder/healthView'); const headshots = require('../sheets/headshots'); const identity = require('../sheets/identity'); const vehicleState = require('../sheets/vehicleState'); @@ -1743,6 +1745,41 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { }); }); + // A token's health under a custom system's model, for its HEALTH folder (systemBuilder/ + // healthView.js). The full view, numbers and notes, goes to whoever may change that + // token's health: the GM, a player granted editing, or the token's owner. Everyone else + // gets the description. Over the socket rather than a route because the socket is where a + // player's identity is known in every mode; a player in non-secure mode has no login to + // send with a request. Answers only the asker. + socket.on('requestHealthView', (data) => { + const info = userSockets.get(socket.id); + if (!info || !data || !Number.isInteger(Number(data.location_id))) return; + const locationId = Number(data.location_id); + getGameSystem((err, system) => { + if (err) return; + const health = customSystems.health(system); + const reply = (view) => socket.emit('healthView', { location_id: locationId, ...view }); + if (!health) return reply({ model: null }); + db.get(`SELECT id, shape, owner, hp_current, hp_max, hp_temp FROM locations WHERE id = ?`, [locationId], (e2, loc) => { + if (e2 || !loc || !RHOMBUS_SHAPES.includes(loc.shape)) return; + const playerToken = loc.shape === 'rhombus' && !!loc.owner; + const full = isAdminSocket(socket) || (playerToken && loc.owner === info.userName); + 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 })); + }; + if (playerToken) { + db.get(`SELECT data FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0`, + [loc.owner, system], (e3, row) => send(e3 ? null : row)); + } else { + db.get(`SELECT cs.data FROM npc_sheet_links l JOIN character_sheets cs ON cs.id = l.sheet_id + WHERE l.location_id = ? AND cs.system = ?`, [loc.id, system], (e3, row) => send(e3 ? null : row)); + } + }); + }); + }); + // Admin: seed an NPC sheet from a token (enemy_rhombus / friendly_rhombus). // Creates a character_sheets row pre-filled with the token's name, description // and current HP, then links it to the location via npc_sheet_links. diff --git a/backend/systemBuilder/health.js b/backend/systemBuilder/health.js index d7056288..06eec5c5 100644 --- a/backend/systemBuilder/health.js +++ b/backend/systemBuilder/health.js @@ -52,6 +52,13 @@ const tracks = (health, token, sheet, action) => { if (!first || !second) return fail('This system\'s tracks are not set up'); const onSecond = action.track === second.id; if (action.track !== undefined && !onSecond && action.track !== first.id) return fail('Not one of this system\'s tracks'); + if (action.kind === 'set_max') { + // The first track's maximum is the token's own, set as for any system. + if (!onSecond) return fail('The first track\'s maximum is set on the token'); + const patch = { [`${second.id}_max`]: action.amount }; + if (whole(sheet[second.id]) > action.amount) patch[second.id] = action.amount; + return done(token, patch); + } if (!onSecond) { return done(action.kind === 'damage' ? poolDamage(token, action.amount) : poolHeal(token, action.amount)); } @@ -78,6 +85,7 @@ const typed = (health, token, sheet, action) => { const total = () => marks.reduce((a, b) => a + b, 0); const heaviest = types.length - 1; + let turned = 0; if (action.kind === 'heal') { // A named type heals its own marks; otherwise the lightest heal first. let left = action.amount; @@ -91,12 +99,14 @@ const typed = (health, token, sheet, action) => { if (lighter < 0) break; // every box already the heaviest: nothing left to take marks[lighter] -= 1; marks[lighter + 1] += 1; + turned += 1; } } // Marks cannot outnumber the boxes, even from a sheet edited by hand. const patch = Object.fromEntries(types.map((t, i) => [t.id, marks[i]])); const current = Math.max(0, boxes - total()); - return { ok: true, token: { ...token, current }, sheetPatch: patch, out: marks[heaviest] >= boxes }; + // How many boxes turned heavier, for the window to say so. + return { ok: true, token: { ...token, current }, sheetPatch: patch, out: marks[heaviest] >= boxes, ...(turned ? { turned } : {}) }; }; const harm = (health, token, sheet, action) => { @@ -108,6 +118,7 @@ const harm = (health, token, sheet, action) => { const all = levels.flatMap(slots); const patch = {}; let out = false; + let placed = null; if (action.kind === 'heal') { // A named slot, or the last one filled at the level. const own = slots(levels[index]); @@ -117,16 +128,16 @@ const harm = (health, token, sheet, action) => { } else { const note = blank(action.note) ? 'Harm' : String(action.note).trim().slice(0, 60); // The first free slot at this level or, when it is full, the next level up. - let placed = false; for (let i = index; i < levels.length && !placed; i += 1) { const free = slots(levels[i]).find((id) => blank(sheet[id])); - if (free) { patch[free] = note; placed = true; } + if (free) { patch[free] = note; placed = levels[i].id; } } out = !placed; } const filled = all.filter((id) => !blank(id in patch ? patch[id] : sheet[id])).length; const current = out ? 0 : all.length - filled; - return { ok: true, token: { ...token, current, max: all.length }, sheetPatch: patch, out }; + // Which level the harm landed on: the window says so when it moved up. + return { ok: true, token: { ...token, current, max: all.length }, sheetPatch: patch, out, ...(placed ? { placed } : {}) }; }; const wounds = (health, token, action) => { @@ -156,13 +167,17 @@ const locations = (health, token, sheet, action) => { * health the system's core.health (checked on publish) * token { current, max, temp } from the token * sheet the data of the sheet behind the token ({} when there is none) - * action { kind: 'damage' | 'heal', amount, track?, type?, level?, slot?, note?, location? } + * action { kind: 'damage' | 'heal' | 'set_max', amount, track?, type?, level?, slot?, note?, location? } + * set_max is the second track's maximum (tracks only) * - * Returns { ok: true, token, sheetPatch, out, overflow?, penalty? } or { ok: false, error }. + * Returns { ok: true, token, sheetPatch, out, overflow?, penalty?, turned?, placed? } or + * { ok: false, error }. turned: boxes that turned heavier; placed: the harm level written to. */ const applyHealthAction = (health, token, sheet, action) => { if (!health || typeof health !== 'object') return fail('This system has no health model'); - if (!action || !['damage', 'heal'].includes(action.kind)) return fail('Damage or heal'); + if (!action || !['damage', 'heal', 'set_max'].includes(action.kind)) return fail('Damage or heal'); + // A second track's maximum lives on the sheet; every other maximum is the token's own. + if (action.kind === 'set_max' && health.model !== 'tracks') return fail('This maximum is set on the token'); const amount = whole(action.amount); // Harm is a note, not a number; every other model needs an amount. if (health.model !== 'harm' && amount <= 0) return fail('An amount above 0'); diff --git a/backend/systemBuilder/healthView.js b/backend/systemBuilder/healthView.js new file mode 100644 index 00000000..e8a65b1b --- /dev/null +++ b/backend/systemBuilder/healthView.js @@ -0,0 +1,109 @@ +// What the HEALTH folder is shown of a token's health under a custom system's model. +// +// Two views. The full one goes to whoever may change the token's health: the GM, a player the +// GM has granted editing, and the token's owner. It has the numbers and the notes. Everyone +// else gets the description only, the same rule the heart monitor keeps: a second track as a +// fill with no numbers, damage split light and heavy, the worst harm's name but never its +// notes, WOUNDED rather than a count, which locations are hurt but not how. +// +// Pure: sockets/index.js reads the token and the sheet behind it and sends what this returns. +// The heart monitor itself reads the token's HP, as it does for every system. + +const num = (v) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +}; +const whole = (v) => Math.max(0, Math.floor(num(v))); +const list = (v) => (Array.isArray(v) ? v.filter((e) => e && typeof e === 'object') : []); +const blank = (v) => v === undefined || v === null || String(v).trim() === ''; +const fraction = (part, of) => (of > 0 ? Math.max(0, Math.min(1, part / of)) : 0); + +const harmLevels = (health, sheet) => list(health.levels).map((l) => ({ + id: l.id, + label: l.label, + penalty: l.penalty || '', + slots: Array.from({ length: Math.max(1, whole(l.slots)) }, (_, i) => { + const v = sheet[`${l.id}_${i + 1}`]; + return blank(v) ? '' : String(v); + }), +})); + +/** + * The view of a token's health. + * + * health the running system's core.health, or null for a built-in system + * 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 + * + * 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 } = {}) => { + 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) }; + const base = { model: health.model, full: !!full }; + + switch (health.model) { + case 'pool': + return { ...base, label: health.label || 'HP' }; + + case 'tracks': { + const [first, second] = list(health.tracks); + if (!first || !second) return { model: null }; + const current = whole(s[second.id]); + const max = whole(s[`${second.id}_max`]); + const tracks = [{ id: first.id, label: first.label }, { id: second.id, label: second.label }]; + const secondView = full + ? { id: second.id, label: second.label, current, max } + : { label: second.label, fill: fraction(current, max), full: max > 0 && current >= max }; + return { ...base, tracks, second: secondView, overflow: health.overflow === true }; + } + + case 'typed': { + const types = list(health.types); + const boxes = whole(t.max); + const marks = types.map((ty) => whole(s[ty.id])); + if (full) return { ...base, boxes, types: types.map((ty, i) => ({ id: ty.id, label: ty.label, marks: marks[i] })) }; + // The heaviest type against everything lighter, as a share of the track. + const heavy = marks.length ? marks[marks.length - 1] : 0; + const light = marks.slice(0, -1).reduce((a, b) => a + b, 0); + return { ...base, light: fraction(light, boxes), heavy: fraction(heavy, boxes) }; + } + + case 'harm': { + const levels = harmLevels(health, s); + const filled = levels.flatMap((l) => l.slots).filter((v) => v !== '').length; + const total = levels.reduce((a, l) => a + l.slots.length, 0); + const out = total > 0 && filled >= total && t.current <= 0; + let worst = -1; + levels.forEach((l, i) => { if (l.slots.some((v) => v !== '')) worst = i; }); + if (full) return { ...base, levels, worst, out }; + return { ...base, levelCount: levels.length, worst, worstLabel: worst >= 0 ? levels[worst].label : null, out }; + } + + case 'wounds': { + const max = t.max > 0 ? t.max : whole(health.count); + const taken = Math.max(0, max - t.current); + if (full) return { ...base, penalty: taken * num(health.penalty) }; + return { ...base, state: t.current <= 0 ? 'incapacitated' : taken > 0 ? 'wounded' : 'unhurt' }; + } + + case 'locations': + return { + ...base, + locations: list(health.locations).map((l) => (full + ? { id: l.id, label: l.label, note: blank(s[l.id]) ? '' : String(s[l.id]) } + : { id: l.id, label: l.label, hit: !blank(s[l.id]) })), + }; + + case 'none': + return base; + + default: + return { model: null }; + } +}; + +module.exports = { healthView };