diff --git a/README.md b/README.md index b269b6a5..d1213fcc 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,7 @@ CITY_NET/ │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 @@ -457,6 +458,7 @@ CITY_NET/ │ ├── 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_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_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.test.js b/backend/__tests__/system_builder_health.test.js new file mode 100644 index 00000000..290a0aa5 --- /dev/null +++ b/backend/__tests__/system_builder_health.test.js @@ -0,0 +1,272 @@ +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'; + +/** + * A custom system's health model in play: what DAMAGE and HEAL do under each model, and the + * HIT_POINTS route using them for a custom system while the built-in systems (and a custom + * one-pool system) go through the route exactly as before. + */ + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const { applyHealthAction } = require_('../systemBuilder/health'); +const runtime = require_('../systemBuilder/runtime'); + +const token = (current, max, temp = 0) => ({ current, max, temp }); +const damage = (amount, extra = {}) => ({ kind: 'damage', amount, ...extra }); +const heal = (amount, extra = {}) => ({ kind: 'heal', amount, ...extra }); + +const TRACKS = { model: 'tracks', tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }], overflow: true }; +const TYPED = { model: 'typed', types: [{ id: 'superficial', label: 'S' }, { id: 'aggravated', label: 'A' }] }; +const TYPED3 = { model: 'typed', types: [{ id: 'light', label: 'L' }, { id: 'heavy', label: 'H' }, { id: 'lethal', label: 'X' }] }; +const HARM = { model: 'harm', levels: [{ id: 'lesser', label: 'L', slots: 2 }, { id: 'moderate', label: 'M', slots: 2 }, { id: 'severe', label: 'S', slots: 1 }] }; +const WOUNDS = { model: 'wounds', count: 3, penalty: -1 }; +const LOCATIONS = { model: 'locations', locations: [{ id: 'head', label: 'HEAD' }, { id: 'body', label: 'BODY' }] }; + +describe('one pool', () => { + it('takes damage through temp HP first, and heals up to the max', () => { + expect(applyHealthAction({ model: 'pool' }, token(10, 20, 3), {}, damage(5))).toMatchObject({ ok: true, token: token(8, 20, 0), out: false }); + expect(applyHealthAction({ model: 'pool' }, token(10, 20, 8), {}, damage(5)).token).toEqual(token(10, 20, 3)); + expect(applyHealthAction({ model: 'pool' }, token(2, 20), {}, damage(9))).toMatchObject({ token: token(0, 20, 0), out: true }); + expect(applyHealthAction({ model: 'pool' }, token(18, 20), {}, heal(9)).token.current).toBe(20); + }); +}); + +describe('two tracks', () => { + it('damages the first track, the token, unless told otherwise', () => { + expect(applyHealthAction(TRACKS, token(10, 10), {}, damage(4))).toMatchObject({ token: token(6, 10), sheetPatch: {} }); + expect(applyHealthAction(TRACKS, token(10, 10), {}, damage(4, { track: 'physical' })).token.current).toBe(6); + }); + + it('fills the second track on the sheet, and spills what is past its maximum into the first', () => { + const sheet = { stun: 7, stun_max: 9 }; + expect(applyHealthAction(TRACKS, token(10, 10), sheet, damage(1, { track: 'stun' }))) + .toMatchObject({ token: token(10, 10), sheetPatch: { stun: 8 } }); + expect(applyHealthAction(TRACKS, token(10, 10, 1), sheet, damage(5, { track: 'stun' }))) + .toMatchObject({ token: token(8, 10, 0), sheetPatch: { stun: 9 }, overflow: 3 }); + }); + + it('stops at the maximum without overflow, counts up with no maximum, and heals the track it is told', () => { + const noSpill = { ...TRACKS, overflow: false }; + expect(applyHealthAction(noSpill, token(10, 10), { stun: 7, stun_max: 9 }, damage(5, { track: 'stun' }))) + .toMatchObject({ token: token(10, 10), sheetPatch: { stun: 9 } }); + expect(applyHealthAction(TRACKS, token(10, 10), {}, damage(12, { track: 'stun' }))) + .toMatchObject({ token: token(10, 10), sheetPatch: { stun: 12 } }); + expect(applyHealthAction(TRACKS, token(10, 10), { stun: 3 }, heal(5, { track: 'stun' })).sheetPatch).toEqual({ stun: 0 }); + expect(applyHealthAction(TRACKS, token(4, 10), { stun: 3 }, heal(5)).token.current).toBe(9); + }); + + it('refuses a track the system does not have', () => { + expect(applyHealthAction(TRACKS, token(10, 10), {}, damage(1, { track: 'mana' }))).toEqual({ ok: false, error: 'Not one of this system\'s tracks' }); + }); +}); + +describe('damage types on one track', () => { + it('marks boxes of the type named, the lightest when none is, and the token shows the boxes left', () => { + expect(applyHealthAction(TYPED, token(5, 5), {}, damage(2))).toMatchObject({ token: token(3, 5), sheetPatch: { superficial: 2, aggravated: 0 }, out: false }); + expect(applyHealthAction(TYPED, token(3, 5), { superficial: 2 }, damage(1, { type: 'aggravated' })).sheetPatch) + .toEqual({ superficial: 2, aggravated: 1 }); + }); + + it('turns a lighter box heavier once the track is full, and is out when every box is the heaviest', () => { + const full = { superficial: 3, aggravated: 2 }; + expect(applyHealthAction(TYPED, token(0, 5), full, damage(2))).toMatchObject({ + token: token(0, 5), sheetPatch: { superficial: 1, aggravated: 4 }, out: false, + }); + expect(applyHealthAction(TYPED, token(0, 5), full, damage(9))).toMatchObject({ sheetPatch: { superficial: 0, aggravated: 5 }, out: true }); + // Three types: a full track moves the lightest box one step, not straight to the heaviest. + expect(applyHealthAction(TYPED3, token(0, 3), { light: 1, heavy: 1, lethal: 1 }, damage(1)).sheetPatch) + .toEqual({ light: 0, heavy: 2, lethal: 1 }); + }); + + it('heals the lightest marks first, or the type named', () => { + const marks = { superficial: 2, aggravated: 2 }; + expect(applyHealthAction(TYPED, token(1, 5), marks, heal(3))).toMatchObject({ token: token(4, 5), sheetPatch: { superficial: 0, aggravated: 1 } }); + expect(applyHealthAction(TYPED, token(1, 5), marks, heal(1, { type: 'aggravated' })).sheetPatch).toEqual({ superficial: 2, aggravated: 1 }); + }); + + it('needs the track to have a size, and knows its own types', () => { + expect(applyHealthAction(TYPED, token(0, 0), {}, damage(1))).toEqual({ ok: false, error: 'Set the size of the track first' }); + expect(applyHealthAction(TYPED, token(5, 5), {}, damage(1, { type: 'fire' }))).toEqual({ ok: false, error: 'Not one of this system\'s damage types' }); + }); +}); + +describe('harm levels', () => { + it('writes the note in the first free slot of the level, the lowest when none is named', () => { + expect(applyHealthAction(HARM, token(0, 0), {}, damage(0, { note: 'Bruised' }))).toMatchObject({ + token: token(4, 5), sheetPatch: { lesser_1: 'Bruised' }, out: false, + }); + expect(applyHealthAction(HARM, token(4, 5), { lesser_1: 'Bruised' }, damage(0, { level: 'moderate', note: 'Cut' })).sheetPatch) + .toEqual({ moderate_1: 'Cut' }); + expect(applyHealthAction(HARM, token(5, 5), {}, damage(0)).sheetPatch).toEqual({ lesser_1: 'Harm' }); + }); + + it('moves harm up a level when its level is full, and is out past the top', () => { + const sheet = { lesser_1: 'a', lesser_2: 'b', moderate_1: 'c', moderate_2: 'd' }; + expect(applyHealthAction(HARM, token(1, 5), sheet, damage(0, { note: 'Shot' }))).toMatchObject({ + token: token(0, 5), sheetPatch: { severe_1: 'Shot' }, out: false, + }); + expect(applyHealthAction(HARM, token(0, 5), { ...sheet, severe_1: 'e' }, damage(0, { level: 'moderate', note: 'Again' }))) + .toMatchObject({ token: token(0, 5), sheetPatch: {}, out: true }); + }); + + it('heals the last harm at a level, or the slot named', () => { + const sheet = { lesser_1: 'a', lesser_2: 'b' }; + expect(applyHealthAction(HARM, token(3, 5), sheet, heal(0, { level: 'lesser' }))).toMatchObject({ token: token(4, 5), sheetPatch: { lesser_2: '' } }); + expect(applyHealthAction(HARM, token(3, 5), sheet, heal(0, { level: 'lesser', slot: 1 })).sheetPatch).toEqual({ lesser_1: '' }); + expect(applyHealthAction(HARM, token(5, 5), {}, heal(0, { level: 'moderate' }))).toEqual({ ok: false, error: 'Nothing to heal at that level' }); + expect(applyHealthAction(HARM, token(5, 5), {}, damage(0, { level: 'mortal' }))).toEqual({ ok: false, error: 'Not one of this system\'s harm levels' }); + }); +}); + +describe('wound count', () => { + it('takes wounds off what is left, and says the penalty for the wounds taken', () => { + expect(applyHealthAction(WOUNDS, token(3, 3), {}, damage(2))).toMatchObject({ token: token(1, 3), penalty: -2, out: false }); + expect(applyHealthAction(WOUNDS, token(1, 3), {}, damage(5))).toMatchObject({ token: token(0, 3), penalty: -3, out: true }); + expect(applyHealthAction(WOUNDS, token(1, 3), {}, heal(9))).toMatchObject({ token: token(3, 3), penalty: -0 }); + }); + + it('takes its size from the setup when the token has none yet', () => { + expect(applyHealthAction(WOUNDS, token(0, 0), {}, heal(3)).token).toEqual(token(3, 3)); + }); +}); + +describe('hit locations', () => { + it('damages the pool, and notes the hit on the location\'s line, adding to what is there', () => { + expect(applyHealthAction(LOCATIONS, token(10, 10), {}, damage(3, { location: 'head', note: 'Graze' }))) + .toMatchObject({ token: token(7, 10), sheetPatch: { head: 'Graze' } }); + expect(applyHealthAction(LOCATIONS, token(7, 10), { head: 'Graze' }, damage(1, { location: 'head' })).sheetPatch).toEqual({ head: 'Graze; Hit' }); + expect(applyHealthAction(LOCATIONS, token(7, 10), {}, damage(1)).sheetPatch).toEqual({}); + }); + + it('clears the location it heals, and knows its own locations', () => { + expect(applyHealthAction(LOCATIONS, token(7, 10), { head: 'Graze' }, heal(2, { location: 'head' }))) + .toMatchObject({ token: token(9, 10), sheetPatch: { head: '' } }); + expect(applyHealthAction(LOCATIONS, token(7, 10), {}, damage(1, { location: 'tail' }))).toEqual({ ok: false, error: 'Not one of this system\'s hit locations' }); + }); +}); + +describe('any model', () => { + it('refuses what it cannot do', () => { + expect(applyHealthAction({ model: 'none' }, token(0, 0), {}, damage(1))).toEqual({ ok: false, error: 'This system tracks harm as conditions, not health' }); + expect(applyHealthAction(TRACKS, token(5, 5), {}, damage(0))).toEqual({ ok: false, error: 'An amount above 0' }); + expect(applyHealthAction(TRACKS, token(5, 5), {}, { kind: 'set', amount: 1 })).toEqual({ ok: false, error: 'Damage or heal' }); + expect(applyHealthAction(null, token(5, 5), {}, damage(1))).toEqual({ ok: false, error: 'This system has no health model' }); + expect(applyHealthAction({ model: 'tracks', tracks: [] }, token(5, 5), {}, damage(1))).toEqual({ ok: false, error: 'This system\'s tracks are not set up' }); + }); +}); + +describe('the HIT_POINTS route', () => { + const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); + let db; + let app; + let emitted; + + /** Publish a system with this health model and make it the running one. */ + 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; + }; + const tokenOf = (owner, current, max, temp = 0) => run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max, hp_temp) VALUES (?, 0, 0, 0, 'rhombus', ?, ?, ?, ?)`, + [owner, owner, current, max, temp]).then((r) => r.lastID); + const sheetOf = (owner, system, data) => run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)`, [owner, system, JSON.stringify(data)]); + const readSheet = async (owner, system) => JSON.parse((await get(db, + 'SELECT data FROM character_sheets WHERE username = ? AND system = ?', [owner, system])).data); + const readToken = (id) => get(db, 'SELECT hp_current, hp_max, hp_temp FROM locations WHERE id = ?', [id]); + const hit = (id, body) => request(app).put(`/api/locations/${id}/health`).send(body); + + beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); + emitted = []; + const io = { emit: (event, data) => emitted.push({ event, data }), to: () => ({ emit: () => {} }) }; + app = express(); + app.use(express.json()); + app.use('/api/systems', require_('../routes/systems.js')(db)); + app.use('/api/locations', require_('../routes/locations.js')(db, io, { emitUpdate: vi.fn(), recordAction: vi.fn() })); + await new Promise((resolve) => runtime.load(db, resolve)); + }); + + it('fills a second track on the sheet and spills the rest onto the token', async () => { + const system = await running(TRACKS); + const id = await tokenOf('GHOST', 10, 10, 1); + await sheetOf('GHOST', system, { stun: 7, stun_max: 9 }); + const res = await hit(id, { action: 'damage', amount: 5, track: 'stun' }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ hp_current: 8, hp_max: 10, hp_temp: 0, out: false, overflow: 3 }); + expect(await readToken(id)).toEqual({ hp_current: 8, hp_max: 10, hp_temp: 0 }); + expect(await readSheet('GHOST', system)).toMatchObject({ stun: 9, stun_max: 9 }); + expect(emitted).toContainEqual({ event: 'sheetUpdated', data: { username: 'GHOST' } }); + }); + + it('marks damage types on the sheet of an NPC linked to the token', async () => { + const system = await running(TYPED); + const loc = 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', ?, '{}', 1, 'Ghoul')`, [system]); + await run(db, 'INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (?, ?)', [loc.lastID, sheet.lastID]); + const res = await request(app).put(`/api/locations/${loc.lastID}/health`).set('Authorization', `Bearer ${GM}`) + .send({ action: 'damage', amount: 3, type: 'aggravated' }); + expect(res.body).toMatchObject({ hp_current: 1, hp_max: 4 }); + expect(JSON.parse((await get(db, 'SELECT data FROM character_sheets WHERE id = ?', [sheet.lastID])).data)) + .toEqual({ superficial: 0, aggravated: 3 }); + }); + + it('writes harm notes, and says the wound penalty', async () => { + const harmSystem = await running(HARM); + const id = await tokenOf('GHOST', 5, 5); + await sheetOf('GHOST', harmSystem, {}); + expect((await hit(id, { action: 'damage', level: 'moderate', note: 'Broken arm' })).body).toMatchObject({ hp_current: 4, hp_max: 5 }); + expect(await readSheet('GHOST', harmSystem)).toEqual({ moderate_1: 'Broken arm' }); + + await running(WOUNDS); + const rook = await tokenOf('ROOK', 3, 3); + expect((await hit(rook, { action: 'damage', amount: 2 })).body).toMatchObject({ hp_current: 1, hp_max: 3, penalty: -2 }); + }); + + it('refuses what the model cannot do, and what needs a sheet the token does not have', async () => { + await running({ model: 'none' }); + const id = await tokenOf('GHOST', 5, 5); + const none = await hit(id, { action: 'damage', amount: 1 }); + expect(none.status).toBe(400); + expect(none.body.error).toBe('This system tracks harm as conditions, not health'); + + await running(TRACKS); + const noSheet = await hit(id, { action: 'damage', amount: 1, track: 'stun' }); + expect(noSheet.status).toBe(409); + expect(await readToken(id)).toEqual({ hp_current: 5, hp_max: 5, hp_temp: 0 }); + // The first track needs no sheet. + expect((await hit(id, { action: 'damage', amount: 1 })).body).toMatchObject({ hp_current: 4 }); + }); + + it('keeps a player\'s edit made while the damage lands', async () => { + const system = await running(TYPED); + const id = await tokenOf('GHOST', 5, 5); + await sheetOf('GHOST', system, { notes: 'old' }); + const { mutateSheet } = require_('../sheets/mutate'); + const sheetId = (await get(db, 'SELECT id FROM character_sheets WHERE username = ?', ['GHOST'])).id; + const edit = new Promise((resolve) => mutateSheet(db, sheetId, (d) => ({ ...d, notes: 'new' }), resolve)); + await Promise.all([edit, hit(id, { action: 'damage', amount: 2 })]); + expect(await readSheet('GHOST', system)).toMatchObject({ notes: 'new', superficial: 2 }); + }); + + it('leaves the built-in systems, and a custom one-pool system, on the route as before', async () => { + const id = await tokenOf('GHOST', 10, 20, 3); + // The pool model's route would refuse an amount of 0; the built-in route shrugs it off. + expect((await hit(id, { action: 'damage', amount: 0 })).status).toBe(200); + expect((await hit(id, { action: 'damage', amount: 5 })).body).toEqual({ id: String(id), hp_current: 8, hp_max: 20, hp_temp: 0 }); + expect((await hit(id, { action: 'heal', amount: 50 })).body).toEqual({ id: String(id), hp_current: 20, hp_max: 20, hp_temp: 0 }); + + await running({ model: 'pool', label: 'VIGOR' }); + expect((await hit(id, { action: 'damage', amount: 0 })).status).toBe(200); + expect((await hit(id, { action: 'damage', amount: 4 })).body).toEqual({ id: String(id), hp_current: 16, hp_max: 20, hp_temp: 0 }); + }); +}); diff --git a/backend/routes/locations.js b/backend/routes/locations.js index 65618b5e..1f877da0 100644 --- a/backend/routes/locations.js +++ b/backend/routes/locations.js @@ -4,7 +4,9 @@ const path = require('path'); const { authenticate, optionalAuthenticate } = require('../middleware/auth'); const identity = require('../sheets/identity'); const { mutateSheet, patchSheet } = require('../sheets/mutate'); -const { DEFAULT_SYSTEM } = require('../sheets/templates'); +const { DEFAULT_SYSTEM, applyDerived } = require('../sheets/templates'); +const customSystems = require('../systemBuilder/runtime'); +const { applyHealthAction } = require('../systemBuilder/health'); const { BUILDING_TYPES, isValidType } = require('../buildingTypes'); const { readPct } = require('../shops/buyback'); const sheetSlots = require('../shops/sheetSlots'); @@ -715,9 +717,83 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { } ); }); + } else if (action === 'damage' || action === 'heal') { + // 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. + 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); + runModelHealth(system, health); + }); } else { runHealth(action); } + + /** + * DAMAGE or HEAL under a custom health model. The detail lives on the sheet behind the + * token (a second track, marked boxes, harm notes), so the rule runs inside that sheet's + * write queue: it reads the sheet as it is at write time, and a player typing into it + * at that moment cannot lose their edit or have the damage worked out from stale marks. + */ + function runModelHealth(system, health) { + const act = { + kind: action, amount, + track: req.body.track, type: req.body.type, level: req.body.level, + slot: req.body.slot, note: req.body.note, location: req.body.location, + }; + const token = { current: row.hp_current, max: row.hp_max, temp: row.hp_temp }; + const findSheet = (cb) => (row.shape === 'rhombus' && row.owner + ? db.get(`SELECT id, username, is_npc FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0`, + [row.owner, system], (e, s) => cb(e ? null : s || null)) + : db.get(`SELECT cs.id, cs.username, cs.is_npc FROM npc_sheet_links l + JOIN character_sheets cs ON cs.id = l.sheet_id WHERE l.location_id = ? AND cs.system = ?`, + [id, system], (e, s) => cb(e ? null : s || null))); + + const writeToken = (result) => { + const t = result.token; + const [sql, params] = row.shape === 'rhombus' && row.owner + ? ['UPDATE locations SET hp_current = ?, hp_max = ?, hp_temp = ? WHERE shape = "rhombus" AND owner = ?', [t.current, t.max, t.temp, row.owner]] + : ['UPDATE locations SET hp_current = ?, hp_max = ?, hp_temp = ? WHERE id = ?', [t.current, t.max, t.temp, id]]; + db.run(sql, params, (err2) => { + if (err2) return res.status(500).json({ error: err2.message }); + // As runHealth: the map redraws, and a player's open sheet re-reads. An NPC's sheet + // window re-reads on the map update. + emitUpdate(); + if (row.shape === 'rhombus' && row.owner) io.emit('sheetUpdated', { username: row.owner }); + res.json({ + 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 } : {}), + }); + }); + }; + + findSheet((sheet) => { + if (!sheet) { + const result = applyHealthAction(health, token, {}, act); + if (!result.ok) return res.status(400).json({ error: result.error }); + if (Object.keys(result.sheetPatch).length) { + return res.status(409).json({ error: 'This token has no sheet to keep that on. Give it one first.' }); + } + return writeToken(result); + } + let result = null; + mutateSheet(db, sheet.id, (data) => { + result = applyHealthAction(health, token, data, act); + if (!result.ok || !Object.keys(result.sheetPatch).length) return undefined; + const next = { ...data, ...result.sheetPatch }; + applyDerived(system, next); + return next; + }, (err3) => { + if (err3) return res.status(500).json({ error: err3.message }); + if (!result) return res.status(500).json({ error: 'Could not read the sheet' }); + if (!result.ok) return res.status(400).json({ error: result.error }); + writeToken(result); + }); + }); + } }); }); diff --git a/backend/systemBuilder/health.js b/backend/systemBuilder/health.js new file mode 100644 index 00000000..d7056288 --- /dev/null +++ b/backend/systemBuilder/health.js @@ -0,0 +1,184 @@ +// How a custom system's health model takes damage and healing, as pure rules. +// +// The setup answers (core.js) say which model a system uses. This works out what DAMAGE and +// HEAL do under it, from the token's numbers and the sheet behind the token, and hands back +// what to write. Nothing here touches the database; routes/locations.js reads, calls this, +// and writes. The built-in systems never come here: their health is their own code. +// +// Each model keeps the token's current and max as the "how hurt" the heart monitor reads, so +// every model shows on the map; the detail lives on the sheet, in the fields the starter +// sheet makes for it (core.js healthLayout): +// +// pool the token's pool; temp HP absorbs first (as the built-in route) +// tracks the first track is the token's, the second is `` / `_max` on the sheet; +// with overflow, damage past the second spills into the first (Shadowrun) +// typed boxes = the token's max; each type's marks are `` on the sheet. On a +// full track a box turns into the next heavier type (Vampire) +// harm text in `_`; a full level pushes harm up one; past the top, out (Blades) +// wounds the token's current is the wounds left; each taken costs the penalty (Savage Worlds) +// locations the pool, plus a note on the sheet's line for the location hit (WFRP) +// none no damage or healing: consequences are conditions + +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() === ''; + +/** Pool damage as the built-in route does it: temp HP first, then the pool, never below 0. */ +const poolDamage = (token, amount) => { + let temp = whole(token.temp); + let remaining = amount; + if (temp >= remaining) { temp -= remaining; remaining = 0; } else { remaining -= temp; temp = 0; } + return { ...token, current: Math.max(0, num(token.current) - remaining), temp }; +}; + +/** Pool healing: up to the max, when there is one. */ +const poolHeal = (token, amount) => { + const max = num(token.max); + const raised = num(token.current) + amount; + return { ...token, current: max > 0 ? Math.min(max, raised) : raised }; +}; + +const fail = (error) => ({ ok: false, error }); +const done = (token, sheetPatch = {}, extra = {}) => ({ + ok: true, token, sheetPatch, out: token.current <= 0, ...extra, +}); + +const tracks = (health, token, sheet, action) => { + const [first, second] = list(health.tracks); + 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 (!onSecond) { + return done(action.kind === 'damage' ? poolDamage(token, action.amount) : poolHeal(token, action.amount)); + } + const max = num(sheet[`${second.id}_max`]); + const now = whole(sheet[second.id]); + if (action.kind === 'heal') return done(token, { [second.id]: Math.max(0, now - action.amount) }); + const raised = now + action.amount; + // No maximum set: the track just counts up. + if (max <= 0) return done(token, { [second.id]: raised }); + const excess = Math.max(0, raised - max); + const patch = { [second.id]: Math.min(max, raised) }; + if (excess > 0 && health.overflow === true) return done(poolDamage(token, excess), patch, { overflow: excess }); + return done(token, patch); +}; + +const typed = (health, token, sheet, action) => { + const types = list(health.types); + if (types.length < 2) return fail('This system\'s damage types are not set up'); + const index = action.type === undefined ? 0 : types.findIndex((t) => t.id === action.type); + if (index < 0) return fail('Not one of this system\'s damage types'); + const boxes = whole(token.max); + if (boxes <= 0) return fail('Set the size of the track first'); + const marks = types.map((t) => whole(sheet[t.id])); + const total = () => marks.reduce((a, b) => a + b, 0); + const heaviest = types.length - 1; + + if (action.kind === 'heal') { + // A named type heals its own marks; otherwise the lightest heal first. + let left = action.amount; + const order = action.type === undefined ? types.map((_, i) => i) : [index]; + for (const i of order) { const take = Math.min(left, marks[i]); marks[i] -= take; left -= take; } + } else { + for (let n = 0; n < action.amount; n += 1) { + if (total() < boxes) { marks[index] += 1; continue; } + // Full: the lightest box below the heaviest type becomes one step heavier. + const lighter = marks.findIndex((m, i) => m > 0 && i < heaviest); + if (lighter < 0) break; // every box already the heaviest: nothing left to take + marks[lighter] -= 1; + marks[lighter + 1] += 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 }; +}; + +const harm = (health, token, sheet, action) => { + const levels = list(health.levels); + if (!levels.length) return fail('This system\'s harm levels are not set up'); + const index = action.level === undefined ? 0 : levels.findIndex((l) => l.id === action.level); + if (index < 0) return fail('Not one of this system\'s harm levels'); + const slots = (level) => Array.from({ length: Math.max(1, whole(level.slots)) }, (_, i) => `${level.id}_${i + 1}`); + const all = levels.flatMap(slots); + const patch = {}; + let out = false; + if (action.kind === 'heal') { + // A named slot, or the last one filled at the level. + const own = slots(levels[index]); + const target = action.slot !== undefined ? own[action.slot - 1] : [...own].reverse().find((id) => !blank(sheet[id])); + if (!target) return fail(action.slot !== undefined ? 'No such slot at that level' : 'Nothing to heal at that level'); + patch[target] = ''; + } 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; } + } + 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 }; +}; + +const wounds = (health, token, action) => { + const max = num(token.max) > 0 ? num(token.max) : whole(health.count); + const current = num(token.current); + const next = action.kind === 'damage' ? Math.max(0, current - action.amount) : Math.min(max, current + action.amount); + const taken = Math.max(0, max - next); + return done({ ...token, current: next, max }, {}, { penalty: taken * num(health.penalty) }); +}; + +const locations = (health, token, sheet, action) => { + const location = action.location === undefined ? null : list(health.locations).find((l) => l.id === action.location); + if (action.location !== undefined && !location) return fail('Not one of this system\'s hit locations'); + const patch = {}; + if (location && action.kind === 'damage') { + const note = blank(action.note) ? 'Hit' : String(action.note).trim().slice(0, 60); + const before = blank(sheet[location.id]) ? '' : `${String(sheet[location.id]).trim()}; `; + patch[location.id] = `${before}${note}`.slice(-300); + } + if (location && action.kind === 'heal') patch[location.id] = ''; + return done(action.kind === 'damage' ? poolDamage(token, action.amount) : poolHeal(token, action.amount), patch); +}; + +/** + * DAMAGE or HEAL under a custom system's health model. + * + * 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? } + * + * Returns { ok: true, token, sheetPatch, out, overflow?, penalty? } or { ok: false, error }. + */ +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'); + 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'); + const a = { ...action, amount }; + const t = { current: num(token && token.current), max: num(token && token.max), temp: whole(token && token.temp) }; + const s = sheet && typeof sheet === 'object' ? sheet : {}; + switch (health.model) { + case 'pool': return done(a.kind === 'damage' ? poolDamage(t, amount) : poolHeal(t, amount)); + case 'tracks': return tracks(health, t, s, a); + case 'typed': return typed(health, t, s, a); + case 'harm': return harm(health, t, s, a); + case 'wounds': return wounds(health, t, a); + case 'locations': return locations(health, t, s, a); + case 'none': return fail('This system tracks harm as conditions, not health'); + default: return fail('This system has no health model'); + } +}; + +module.exports = { applyHealthAction, poolDamage, poolHeal }; diff --git a/backend/systemBuilder/runtime.js b/backend/systemBuilder/runtime.js index 15e0994b..6de3079a 100644 --- a/backend/systemBuilder/runtime.js +++ b/backend/systemBuilder/runtime.js @@ -123,7 +123,14 @@ const list = () => [...loaded.entries()].map(([id, s]) => ({ id, name: s.name, c const tiers = (id) => (loaded.has(id) ? loaded.get(id).tiers : null); +/** A published system's health model (its core.health), or null: built-in systems have none here. */ +const health = (id) => { + const definition = loaded.has(id) ? loaded.get(id).definition : null; + const core = definition && definition.core && typeof definition.core === 'object' ? definition.core : null; + return core && core.health && typeof core.health === 'object' ? core.health : null; +}; + templates.setCustomMeta(meta); npcTiers.setCustomTiers(tiers); -module.exports = { load, refresh, meta, render, list, tiers, metaOf, renderOf }; +module.exports = { load, refresh, meta, render, list, tiers, health, metaOf, renderOf };