diff --git a/CHANGELOG.md b/CHANGELOG.md index 393ebdd3..167874fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **Tokens can carry conditions.** The server now keeps a list of conditions on each token, put on + and taken off by whoever may change its health: the GM, a granted editor, the player whose token + it is, or the player given a friendly NPC. Only conditions the running game has are accepted: a + custom system's own, or under a built-in game the standard ten as names and descriptions, with + nothing that changes its rules. Everyone sees which conditions a token has; how many rounds one + has left goes only to the GM. Like a token's health, each game keeps its own conditions when you + switch systems. The windows that show and change them come next. + - **The builder's CONDITIONS page.** A new page between NPCS and TRY IT lists the ten standard conditions with on/off switches and the system's own below them. Each can be renamed (the standard name shows until you type one), given a short label for its chip, an icon (24 drawn for diff --git a/README.md b/README.md index e52a3af3..ab761209 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,7 @@ CITY_NET/ │ │ └── rateLimit.js # A sliding per-caller ceiling, for the one open route that spends our outbound requests on an anonymous caller's say-so. Bounded in memory, since the key is whoever is asking; evicts the least recently seen, so it forgives rather than blocks │ ├── routes/ │ │ ├── admin.js # Admin-only REST endpoints; undo covers locations, roads, signs; POST /update preflights and returns 409 naming what is missing, GET /update/status reports phase and a stable failure code and nothing anyone said to us — it is unauthenticated by necessity, so the compose output that used to ride along on it now stays in the log file, POST /check-update offers only genuine upgrades from the deployment's own channel; POST /water marks generated water so a regenerate can clear its own river without touching a lake the GM drew -│ │ ├── locations.js # Location CRUD; JOIN→CUSTOM classification upserts roots + child parts to custom_structure_library; serves GET /custom-library (CUSTOM-only); GET / includes sheet_data for the GM's NPC initiative rolls, and withholds it (and a silhouetted NPC's portrait) from everyone else via sheets/npcPrivacy.js; POST /purge-region clears one region's generated content in a single transaction, keeping GM-named structures, tokens, battle-map content and hand-drawn water; who may change a token, its health or its injuries is tokens/tokenAccess.js +│ │ ├── locations.js # Location CRUD; JOIN→CUSTOM classification upserts roots + child parts to custom_structure_library; serves GET /custom-library (CUSTOM-only); GET / includes sheet_data for the GM's NPC initiative rolls, and withholds it (and a silhouetted NPC's portrait) from everyone else via sheets/npcPrivacy.js; POST /purge-region clears one region's generated content in a single transaction, keeping GM-named structures, tokens, battle-map content and hand-drawn water; who may change a token, its health or its injuries is tokens/tokenAccess.js; PUT /:id/conditions sets a token's conditions (tokens/conditions.js) by the same rule, and the public list hides their rounds left from everyone but the GM │ │ ├── battle_maps.js # Battle map upload and management. Streams to a temporary file and hashes in chunks rather than buffering, so a 250MB animated map costs disk rather than RAM, then renames to the content hash — the same map on a dozen locations is one file. Sweeps partial uploads left by a process that died mid-transfer, since those are the one case the handler's own cleanup cannot reach. Accepts what the scene can actually draw, stills and loops alike, since a format the renderer cannot decode uploads perfectly and then shows nothing │ │ ├── buildingDetails.js # A building's photo and the GM's notes, mounted under /api/locations/:id. Photo upload and remove, and the notes both ways, are main-admin only - a player granted editing rights holds a token that passes `authenticate` and is refused here. The notes never join the public location list │ │ ├── maps.js # Saved map snapshots (locations, districts, roads, overpasses, water bodies); preserves only rhombus tokens on load/clear; records active_map_name in global_settings so exports can name their files @@ -357,7 +357,7 @@ CITY_NET/ │ │ ├── signs.js # Custom sign CRUD (GET all / POST / PATCH :id / DELETE :id); text optional when image_url set; rotation_x/y/z persisted, non-finite angles rejected │ │ ├── fonts.js # Font file upload/list/delete (.ttf .otf .woff .woff2); served as static under /uploads/fonts/ │ │ ├── player.js # Player auth (register, login, forgot, reset, registration status poll) -│ │ ├── systems.js # Custom game systems: list, create (from a name, a whole definition, or a copy of a built-in example or genre starter), read, save a draft, publish, rename, duplicate, delete (a hide, refused for the running system); export a published system as a .citysys file, preview a file, install it as new, an update or a second copy; upload a currency or condition icon (PNG, WebP or SVG, 0.25MB, stored by content hash, one route for both) and set or clear a currency's icon in the draft and running copy alike; the builder's previews, which change nothing (a draft's values from its sample, its sheet as drawn, an NPC tier rolled for a level, health tried on a pretend token); the examples and starters, listed and opened. Main admin only, reading included +│ │ ├── systems.js # Custom game systems: list, create (from a name, a whole definition, or a copy of a built-in example or genre starter), read, save a draft, publish, rename, duplicate, delete (a hide, refused for the running system); export a published system as a .citysys file, preview a file, install it as new, an update or a second copy; upload a currency or condition icon (PNG, WebP or SVG, 0.25MB, stored by content hash, one route for both) and set or clear a currency's icon in the draft and running copy alike; the builder's previews, which change nothing (a draft's values from its sample, its sheet as drawn, an NPC tier rolled for a level, health tried on a pretend token); the examples and starters, listed and opened. Main admin only, reading included, except the two public reads every player needs: a published system's render copy, and the conditions a system offers (GET /conditions/:system, modifiers left out for anyone but the GM) │ │ └── sheets.js # Character sheets — admin sheet access, NPC library, portraits, LUCK/Edge reset & grant, import preview; a friendly NPC's sheet, read-only, for the player the GM gave it to (GET /npcs/controlled/:location_id). The table-wide resets scan to decide who is affected and then work out each value as that sheet is written, rather than writing back a scan that has already gone stale │ ├── dice/ │ │ └── systemDice.js # Built-in dice manifest keyed by game system (ids namespaced `builtin:`); lives in code, not the DB, so app updates change definitions with no migration and nothing is mutable through the API @@ -405,8 +405,9 @@ CITY_NET/ │ │ ├── catalogueStore.js # Uploaded catalogues in memory, added on top of the built-in ones, never replacing them. Holds one system at a time, and consults the built-in book only when that system is CWN - every other game's shops carry only what their GM uploaded. Requires nothing, so priceOf stays synchronous and the lot stays importable from a frontend test │ │ └── catalogueDb.js # The only piece that knows uploaded catalogues live in SQLite. A save replaces one catalogue wholesale, in a transaction │ ├── tokens/ +│ │ ├── conditions.js # A token's conditions as stored ([{ id, left }]): read safely, checked against what the running system offers (one twice, an unknown one, rounds out of 1 to 99 refused by name; one that ends after rounds starting at its own), and shown to everyone without the rounds left │ │ ├── tokenAccess.js # Who may change a token through the HTTP routes: its health, its injuries, or the token itself. The GM and granted editors any token; a player their own, by their own login, and a friendly NPC the GM gave them (its health only); under Secure Mode nobody unnamed changes anything. Before it, anyone could change any player's token, owner included -│ │ └── vitals.js # A token's health, defense and injuries per game system. The token's own columns hold the running system's (so combat and damage are untouched); the others wait in `token_vitals`. switchSystem swaps them and changes `game_system` in one transaction; map clears and loads drop saved values for tokens that are gone +│ │ └── vitals.js # A token's health, defense, injuries and conditions per game system. The token's own columns hold the running system's (so combat and damage are untouched); the others wait in `token_vitals`. switchSystem swaps them and changes `game_system` in one transaction; map clears and loads drop saved values for tokens that are gone │ ├── sockets/ │ │ ├── tokenControl.js # Who may move a token, in one place because two move handlers ask it. An admin always may; the owner may; a friendly NPC may name players, or open to everyone. Only friendly NPCs can carry a grant, enforced here rather than by the caller, so one that reaches an enemy row through an import or a restore is inert — and anything unreadable in the column means nobody, since a malformed grant must never open a token up │ │ ├── index.js # All Socket.IO event handlers. Every write to a character sheet goes through sheets/mutate.js: rolls, damage, death saves, stabilisation, spell effort and vehicle hulls all touch sheets their owner is very likely looking at, and anything relative is worked out inside the write so two of them landing together both count. Sheet rolls and sheet attacks can come from an NPC's sheet (rollSheetOf): the GM's for any NPC, a player's for a friendly NPC the GM gave them, the log naming the NPC @@ -427,7 +428,7 @@ CITY_NET/ │ │ ├── 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), each box a number, a formula with @level, or dice (rolled by tierRolls.js) -│ │ ├── 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; the render copy the browser draws from, with no formulas and every word resolved; and wordIn(system, term, form, today's text) for text the server writes; partIn(system, part) for whether a part of the app is on (always, under a built-in system) +│ │ ├── 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; the render copy the browser draws from, with no formulas and every word resolved; and wordIn(system, term, form, today's text) for text the server writes; partIn(system, part) for whether a part of the app is on (always, under a built-in system); conditionsIn(system) for the conditions its tokens can have (a custom system's own, or the standard set with no modifiers under a built-in one) │ │ ├── citysys.js # A system as a file to share (.citysys): plain JSON with a cover (name, author, version, builder, license, origin); read as untrusted input, capped, and checked like the editor's work; never carries characters │ │ ├── conditions.js # A system's conditions: the standard set every system starts with (Blinded, Bleeding, Poisoned... ten in all), each renamed, described, given modifiers and an end or turned off, and the system's own, up to 60 in all. A definition stores only its changes; conditionsOf gives each one whole. Icons are one of 24 drawn for CITY_NET or an uploaded picture; modifiers name a stat, a formula or all rolls and are recorded until a system's rolls are built │ │ ├── stats.js # A system's stats: the numbers players fill in, in groups (a skill may be tied to its ability), names for derived values, and the builder's sample character; until a system designs its own sheet, each group is a section of the starter sheet @@ -483,6 +484,7 @@ CITY_NET/ │ ├── system_builder_rename.test.js # RENAME at once in the draft and the running copy, a name another system has refused so another can be picked straight away │ ├── system_builder_duplicate.test.js # DUPLICATE from the draft, unpublished changes included, named " copy", its own origin and unpublished │ ├── system_builder_names.test.js # The same name whatever its case and spaces, and " copy", "copy 02"... fitted inside the name limit +│ ├── token_conditions.test.js # Conditions on tokens: the stored list read safely, checked against the running game's (a custom system's own, the standard set under a built-in one with no modifiers), put on and taken off by whoever may change the token's health, a player's on every token of theirs, rounds left only to the GM, the game's list public without modifiers, and each game keeping its own across a switch │ ├── token_access.test.js # Who may change a token's health, injuries or row over HTTP: the GM and editors any, a player their own and a friendly NPC given to them, nobody else; Secure Mode refusing anyone unnamed; without it, as before │ ├── token_control.test.js # Who may move a token: the admin always, the owner, and players a friendly NPC was granted to; a grant never reaching a token not meant to carry one, and anything unreadable meaning nobody │ ├── sockets.tokencontrol.test.js # The same rule through both move handlers and the database, waiting for each move to land rather than a fixed time diff --git a/backend/__tests__/helpers/testDb.js b/backend/__tests__/helpers/testDb.js index dbe0b64f..1f29589f 100644 --- a/backend/__tests__/helpers/testDb.js +++ b/backend/__tests__/helpers/testDb.js @@ -46,6 +46,7 @@ function makeTestDb() { melee_ac INTEGER, ranged_ac INTEGER, injuries TEXT DEFAULT '{}', + conditions TEXT DEFAULT '[]', controllers TEXT, is_global INTEGER DEFAULT 0, has_sidewalk INTEGER DEFAULT 1, @@ -253,6 +254,7 @@ function makeTestDb() { melee_ac INTEGER, ranged_ac INTEGER, injuries TEXT DEFAULT '{}', + conditions TEXT DEFAULT '[]', PRIMARY KEY (location_id, system) )`); diff --git a/backend/__tests__/token_conditions.test.js b/backend/__tests__/token_conditions.test.js new file mode 100644 index 00000000..ef57caa2 --- /dev/null +++ b/backend/__tests__/token_conditions.test.js @@ -0,0 +1,203 @@ +/** + * Conditions on tokens, on the server (4e2a1). Approved mockup builder-conditions (2026-10-09): + * every system's tokens carry conditions, built-in games the standard set and a custom system its + * own; whoever may change a token's health puts them on and takes them off; everyone sees which, + * and only the GM how many rounds each has left; each game keeps its own, as with health. + */ + +import { describe, it, expect, beforeEach, afterEach } 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 locationsRouteFactory from '../routes/locations.js'; + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const { parseTokenConditions, checkTokenConditions, publicTokenConditions, LIMITS } = require_('../tokens/conditions'); +const { conditionsOf, STANDARD } = require_('../systemBuilder/conditions'); +const runtime = require_('../systemBuilder/runtime'); +const { switchSystem, FIELDS } = require_('../tokens/vitals'); +const { elevatedUsers } = require_('../middleware/auth'); +const systemsRoute = require_('../routes/systems.js'); + +const sign = (payload) => jwt.sign(payload, 'test-secret'); +const GM = sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }); +const EDITOR = sign({ username: 'ghost', isTemporary: true }); +const VEX = sign({ username: 'vex', role: 'player' }); +const ASH = sign({ username: 'ash', role: 'player' }); + +const HEARTH = 'sys_aaaaaaaaaaaaaaaa'; +const HEARTH_DEF = { + format: 1, name: 'Hearth', + stats: [{ id: 'abilities', label: 'ABILITIES', stats: [{ id: 'str', label: 'Strength' }] }], + conditions: { + blinded: { on: false }, + poisoned: { ends: 'rounds', rounds: 3, modifiers: [{ target: 'all_rolls', amount: -1 }] }, + glitching: { name: 'Glitching', icon: 'signal', modifiers: [{ target: 'str', amount: -2 }] }, + }, +}; +const OFFERED = conditionsOf(HEARTH_DEF); + +describe('a token\'s stored conditions', () => { + it('read whatever the column holds as a list of { id, left }, never a throw', () => { + expect(parseTokenConditions('[{"id":"prone"},{"id":"poisoned","left":2}]')).toEqual([{ id: 'prone' }, { id: 'poisoned', left: 2 }]); + for (const junk of ['', 'nope', '{}', null, undefined, '42', '[1,"x",null,{"left":2}]']) expect(parseTokenConditions(junk), String(junk)).toEqual([]); + expect(parseTokenConditions('[{"id":"prone","left":0},{"id":"stunned","left":1.5},{"id":"x","extra":1}]')).toEqual([{ id: 'prone' }, { id: 'stunned' }, { id: 'x' }]); + }); + + it('show everyone which conditions, never the rounds left', () => { + expect(publicTokenConditions('[{"id":"prone"},{"id":"poisoned","left":2}]')).toBe('[{"id":"prone"},{"id":"poisoned"}]'); + expect(publicTokenConditions('broken')).toBe('[]'); + }); +}); + +describe('checking a token\'s conditions against what the game offers', () => { + it('takes the system\'s own, starting one that ends after rounds at its own rounds', () => { + expect(checkTokenConditions([{ id: 'prone' }, { id: 'poisoned' }, { id: 'glitching' }], OFFERED)) + .toEqual({ ok: true, value: [{ id: 'prone' }, { id: 'poisoned', left: 3 }, { id: 'glitching' }] }); + }); + + it('takes rounds set when it is put on, on any condition, and drops nothing else sent', () => { + expect(checkTokenConditions([{ id: 'poisoned', left: 1 }, { id: 'prone', left: 4, note: 'x' }], OFFERED)) + .toEqual({ ok: true, value: [{ id: 'poisoned', left: 1 }, { id: 'prone', left: 4 }] }); + expect(checkTokenConditions([{ id: 'prone', left: null }], OFFERED)).toEqual({ ok: true, value: [{ id: 'prone' }] }); + expect(checkTokenConditions([], OFFERED)).toEqual({ ok: true, value: [] }); + }); + + it('refuses one the game doesn\'t have, turned off or never there, by name', () => { + expect(checkTokenConditions([{ id: 'blinded' }], OFFERED)).toEqual({ ok: false, error: '"blinded" is not a condition this game has' }); + expect(checkTokenConditions([{ id: 'hungry' }], OFFERED)).toEqual({ ok: false, error: '"hungry" is not a condition this game has' }); + expect(checkTokenConditions(['prone'], OFFERED)).toEqual({ ok: false, error: 'That is not a condition this game has' }); + }); + + it('refuses a list that isn\'t one, one twice, rounds out of range, or more than sixty', () => { + expect(checkTokenConditions('prone', OFFERED)).toEqual({ ok: false, error: 'Conditions must be a list' }); + expect(checkTokenConditions([{ id: 'prone' }, { id: 'prone' }], OFFERED)).toEqual({ ok: false, error: 'Prone is on the list twice' }); + for (const left of [0, 100, 2.5, '2']) { + expect(checkTokenConditions([{ id: 'poisoned', left }], OFFERED), String(left)).toEqual({ ok: false, error: 'Rounds left on Poisoned must be a whole number from 1 to 99' }); + } + expect(checkTokenConditions(Array.from({ length: LIMITS.conditions + 1 }, () => ({ id: 'prone' })), OFFERED)) + .toEqual({ ok: false, error: 'At most 60 conditions on a token' }); + }); +}); + +describe('the routes', () => { + let db; + let app; + let ids; + let updates; + const was = process.env.SECURE_MODE; + + beforeEach(async () => { + process.env.SECURE_MODE = 'true'; + elevatedUsers.add('ghost'); + db = await makeTestDb(); + const text = JSON.stringify(HEARTH_DEF); + await run(db, 'INSERT INTO custom_systems (id, name, draft, published, version) VALUES (?, ?, ?, ?, 1)', [HEARTH, 'Hearth', text, text]); + await new Promise((resolve) => runtime.load(db, resolve)); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [HEARTH]); + updates = 0; + app = express(); + app.use(express.json()); + app.use('/api/locations', locationsRouteFactory(db, { emit: () => {} }, { emitUpdate: () => { updates += 1; }, recordAction: () => {} })); + app.use('/api/systems', systemsRoute(db)); + const add = async (name, shape, owner, controllers = null) => (await run(db, + 'INSERT INTO locations (name, x, y, z, shape, owner, controllers) VALUES (?, 0, 0, 0, ?, ?, ?)', [name, shape, owner, controllers])).lastID; + ids = { + vex: await add('VEX', 'rhombus', 'vex'), + vexOnMap: await add('VEX', 'rhombus', 'vex'), + ash: await add('ASH', 'rhombus', 'ash'), + enemy: await add('GANGER', 'enemy_rhombus', 'gm'), + dog: await add('DOG', 'friendly_rhombus', 'gm', JSON.stringify({ all: false, users: ['vex'] })), + }; + }); + afterEach(() => { + if (was === undefined) delete process.env.SECURE_MODE; else process.env.SECURE_MODE = was; + elevatedUsers.delete('ghost'); + }); + + const put = (id, conditions, token) => request(app).put(`/api/locations/${id}/conditions`) + .set(token ? { Authorization: `Bearer ${token}` } : {}).send({ conditions }); + const storedOn = async (id) => JSON.parse((await get(db, 'SELECT conditions FROM locations WHERE id = ?', [id])).conditions); + + it('puts conditions on a token and takes them off, as the running game offers them, telling every screen', async () => { + const res = await put(ids.enemy, [{ id: 'poisoned' }, { id: 'glitching' }], GM); + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: ids.enemy, conditions: [{ id: 'poisoned', left: 3 }, { id: 'glitching' }] }); + expect(await storedOn(ids.enemy)).toEqual([{ id: 'poisoned', left: 3 }, { id: 'glitching' }]); + expect(updates).toBe(1); + expect((await put(ids.enemy, [], GM)).status).toBe(200); + expect(await storedOn(ids.enemy)).toEqual([]); + }); + + it('refuses one the running game doesn\'t have, changing nothing', async () => { + const res = await put(ids.enemy, [{ id: 'blinded' }], GM); + expect(res).toMatchObject({ status: 400, body: { error: '"blinded" is not a condition this game has' } }); + expect(await storedOn(ids.enemy)).toEqual([]); + expect(updates).toBe(0); + }); + + it('is for whoever may change the token\'s health: the GM and editors any, a player their own and a friendly NPC given to them', async () => { + expect((await put(ids.vex, [{ id: 'prone' }], VEX)).status).toBe(200); + expect((await put(ids.ash, [{ id: 'prone' }], VEX)).status).toBe(403); + expect((await put(ids.enemy, [{ id: 'prone' }], VEX)).status).toBe(403); + expect((await put(ids.dog, [{ id: 'prone' }], VEX)).status).toBe(200); + expect((await put(ids.dog, [{ id: 'prone' }], ASH)).status).toBe(403); + expect((await put(ids.ash, [{ id: 'prone' }], EDITOR)).status).toBe(200); + expect((await put(ids.ash, [{ id: 'prone' }])).status).toBe(401); + expect((await put(9999, [], GM)).status).toBe(404); + }); + + it('follows a player onto every token of theirs, as their health does', async () => { + await put(ids.vex, [{ id: 'prone' }], VEX); + expect(await storedOn(ids.vexOnMap)).toEqual([{ id: 'prone' }]); + expect(await storedOn(ids.ash)).toEqual([]); + }); + + it('shows everyone which conditions a token has, and only the GM and editors the rounds left', async () => { + await put(ids.enemy, [{ id: 'poisoned' }, { id: 'prone' }], GM); + const listed = async (token) => { + const res = await request(app).get('/api/locations').set(token ? { Authorization: `Bearer ${token}` } : {}); + return JSON.parse(res.body.find((l) => l.id === ids.enemy).conditions); + }; + expect(await listed(GM)).toEqual([{ id: 'poisoned', left: 3 }, { id: 'prone' }]); + expect(await listed(EDITOR)).toEqual([{ id: 'poisoned', left: 3 }, { id: 'prone' }]); + expect(await listed(VEX)).toEqual([{ id: 'poisoned' }, { id: 'prone' }]); + expect(await listed(null)).toEqual([{ id: 'poisoned' }, { id: 'prone' }]); + }); + + it('offers the standard set under a built-in game, with no modifiers, so its rules don\'t change', async () => { + await run(db, `UPDATE global_settings SET value = 'cities_without_number' WHERE key = 'game_system'`); + expect((await put(ids.enemy, [{ id: 'blinded' }], GM)).status).toBe(200); + expect((await put(ids.enemy, [{ id: 'glitching' }], GM)).status).toBe(400); + expect(runtime.conditionsIn('cities_without_number')).toEqual(conditionsOf(null)); + expect(runtime.conditionsIn('cities_without_number').map((c) => c.id)).toEqual(STANDARD.map((c) => c.id)); + expect(runtime.conditionsIn('cities_without_number').every((c) => c.modifiers.length === 0)).toBe(true); + }); + + it('lists a game\'s conditions for everyone, their modifiers only for the GM and editors', async () => { + const list = async (token) => (await request(app).get(`/api/systems/conditions/${HEARTH}`).set(token ? { Authorization: `Bearer ${token}` } : {})).body; + expect(await list(GM)).toEqual(OFFERED); + expect(await list(EDITOR)).toEqual(OFFERED); + const players = await list(VEX); + expect(players.map((c) => c.id)).toEqual(OFFERED.map((c) => c.id)); + expect(players.every((c) => c.modifiers.length === 0)).toBe(true); + expect(players.find((c) => c.id === 'glitching')).toMatchObject({ name: 'Glitching', icon: 'signal' }); + expect((await list(null)).find((c) => c.id === 'poisoned').modifiers).toEqual([]); + expect((await request(app).get('/api/systems/conditions/generic')).body).toEqual(conditionsOf(null)); + }); + + it('keeps each game\'s own, swapped with the token\'s health when the game changes', async () => { + expect(FIELDS).toContain('conditions'); + await put(ids.enemy, [{ id: 'glitching' }], GM); + await switchSystem(db, 'cities_without_number'); + expect(await storedOn(ids.enemy)).toEqual([]); + await put(ids.enemy, [{ id: 'prone' }], GM); + await switchSystem(db, HEARTH); + expect(await storedOn(ids.enemy)).toEqual([{ id: 'glitching' }]); + await switchSystem(db, 'cities_without_number'); + expect(await storedOn(ids.enemy)).toEqual([{ id: 'prone' }]); + }); +}); diff --git a/backend/db.js b/backend/db.js index 88eef472..b04316d0 100644 --- a/backend/db.js +++ b/backend/db.js @@ -29,6 +29,9 @@ db.serialize(() => { injuries TEXT DEFAULT '{}' )`); db.run(`ALTER TABLE locations ADD COLUMN injuries TEXT DEFAULT '{}'`, () => {}); + // A token's conditions (4e2a): JSON [{"id":"prone"},{"id":"poisoned","left":2}], kept per game + // system like its health (tokens/vitals.js), checked by tokens/conditions.js. + db.run(`ALTER TABLE locations ADD COLUMN conditions TEXT DEFAULT '[]'`, () => {}); // Who besides an admin may move this token. JSON: {"all":bool,"users":[names]}. // Only friendly NPCs honour it - see backend/sockets/tokenControl.js. db.run(`ALTER TABLE locations ADD COLUMN controllers TEXT`, () => {}); @@ -371,6 +374,7 @@ db.serialize(() => { injuries TEXT DEFAULT '{}', PRIMARY KEY (location_id, system) )`); + db.run(`ALTER TABLE token_vitals ADD COLUMN conditions TEXT DEFAULT '[]'`, () => {}); db.run(`CREATE TABLE IF NOT EXISTS water_bodies ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/backend/routes/locations.js b/backend/routes/locations.js index 6b40cb50..23daf7ef 100644 --- a/backend/routes/locations.js +++ b/backend/routes/locations.js @@ -13,6 +13,7 @@ const { shopsOpen } = require('../shops/availability'); const { selectByIds, deleteByIds } = require('../bulk'); const { canReadNpcSheets, redactLocation } = require('../sheets/npcPrivacy'); const tokenAccess = require('../tokens/tokenAccess'); +const { checkTokenConditions, publicTokenConditions } = require('../tokens/conditions'); /** A change to a token refused (tokens/tokenAccess.js): 401 when nobody said who they are. */ const refuse = (res, caller, message = 'You can only change your own token') => @@ -112,7 +113,8 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { [], (err, rows) => { if (err) return res.status(500).json({ error: err.message }); - res.json(rows.map(row => redactLocation(row, canRead))); + // Everyone sees which conditions a token has; how many rounds each has left is the GM's. + res.json(rows.map((row) => redactLocation(canRead || row.conditions === undefined ? row : { ...row, conditions: publicTokenConditions(row.conditions) }, canRead))); } ); }); @@ -844,6 +846,35 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { }); }); + /** + * A token's conditions (4e2a): the whole list, as with injuries, each one the running system + * offers (runtime.conditionsIn). Whoever may change the token's health may change them; a + * player's conditions follow them onto every token of theirs, as their health does. + */ + router.put('/:id/conditions', optionalAuthenticate, (req, res) => { + const { id } = req.params; + db.get('SELECT shape, owner, controllers FROM locations WHERE id = ?', [id], (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + if (!row) return res.status(404).json({ error: 'Not found' }); + const caller = tokenAccess.callerOf(req); + if (!tokenAccess.mayChangeHealth(row, caller)) return refuse(res, caller); + db.get(`SELECT value FROM global_settings WHERE key = 'game_system'`, (gErr, gRow) => { + if (gErr) return res.status(500).json({ error: gErr.message }); + const checked = checkTokenConditions(req.body && req.body.conditions, customSystems.conditionsIn((gRow && gRow.value) || DEFAULT_SYSTEM)); + if (!checked.ok) return res.status(400).json({ error: checked.error }); + const json = JSON.stringify(checked.value); + const query = row.shape === 'rhombus' && row.owner + ? ['UPDATE locations SET conditions = ? WHERE shape = "rhombus" AND owner = ?', [json, row.owner]] + : ['UPDATE locations SET conditions = ? WHERE id = ?', [json, id]]; + db.run(query[0], query[1], (err2) => { + if (err2) return res.status(500).json({ error: err2.message }); + emitUpdate(); + res.json({ id: Number(id), conditions: checked.value }); + }); + }); + }); + }); + router.delete('/:id', authenticate, (req, res) => { db.get('SELECT * FROM locations WHERE id = ?', [req.params.id], (err, row) => { if (err) return res.status(500).json({ error: err.message }); diff --git a/backend/routes/systems.js b/backend/routes/systems.js index 1d7b6c1a..7dc1e345 100644 --- a/backend/routes/systems.js +++ b/backend/routes/systems.js @@ -3,7 +3,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); -const { authenticate, requireMainAdmin } = require('../middleware/auth'); +const { authenticate, optionalAuthenticate, requireMainAdmin } = require('../middleware/auth'); const { LIMITS, rejectFormat, uploadErrors } = require('../middleware/uploadConstraints'); const store = require('../systemBuilder/store'); const runtime = require('../systemBuilder/runtime'); @@ -259,5 +259,16 @@ module.exports = (db, io = null) => { res.json(render); }); + /** + * The conditions a system offers for tokens (4e2a; runtime.conditionsIn): a published custom + * system's own, or the standard set under a built-in one. Public, as the token list is: everyone + * sees a token's conditions by name, icon and description. Their modifiers go only to the GM and + * a granted editor here; a token's owner sees them in its HEALTH folder. + */ + router.get('/conditions/:system', optionalAuthenticate, (req, res) => { + const all = runtime.conditionsIn(req.params.system); + res.json(req.user ? all : all.map(({ modifiers, ...c }) => ({ ...c, modifiers: [] }))); + }); + return router; }; diff --git a/backend/systemBuilder/runtime.js b/backend/systemBuilder/runtime.js index 8a7fef35..1eef8ff5 100644 --- a/backend/systemBuilder/runtime.js +++ b/backend/systemBuilder/runtime.js @@ -17,6 +17,7 @@ const { ownWords } = require('./definition'); const { partOn, PARTS } = require('./parts'); const { buildingOn, buildingName, catalogueCurrency, ownBuildings } = require('./buildings'); const { currenciesOf } = require('./currencies'); +const { conditionsOf } = require('./conditions'); const { bankOf } = require('./bank'); const { distanceOf } = require('./core'); const templates = require('../sheets/templates'); @@ -214,4 +215,11 @@ const health = (id) => { templates.setCustomMeta(meta); npcTiers.setCustomTiers(tiers); -module.exports = { load, refresh, meta, render, list, tiers, health, wordIn, partIn, buildingIn, buildingNameIn, currenciesIn, catalogueCurrencyIn, metaOf, renderOf }; +/** + * The conditions `system` offers for tokens (4e2a; conditions.js): a published custom system's own, + * or, for a built-in system (and anything not loaded), the standard set as it stands, names and + * descriptions with no modifiers, so a built-in game's rules don't change. + */ +const conditionsIn = (system) => conditionsOf(loaded.has(system) ? loaded.get(system).definition : null); + +module.exports = { load, refresh, meta, render, list, tiers, health, wordIn, partIn, buildingIn, buildingNameIn, currenciesIn, catalogueCurrencyIn, conditionsIn, metaOf, renderOf }; diff --git a/backend/tokens/conditions.js b/backend/tokens/conditions.js new file mode 100644 index 00000000..4cd21636 --- /dev/null +++ b/backend/tokens/conditions.js @@ -0,0 +1,60 @@ +// A token's conditions (4e2a; approved mockup docs/mockups/builder-conditions.html, 2026-10-09). +// +// locations.conditions = '[{"id":"prone"},{"id":"poisoned","left":2}]' +// +// Each entry names one of the conditions the running system offers (systemBuilder/runtime.js +// conditionsIn), with the rounds it has left when it ends after rounds. Kept per game system like +// the token's health (tokens/vitals.js), so each game keeps its own. Who may change them is who may +// change the token's health (tokens/tokenAccess.js). Everyone sees which conditions a token has, as +// with injuries; the rounds left go only to the GM and a granted editor in the public token list. +// +// Pure: the routes read and check through it. + +const LIMITS = { conditions: 60, rounds: 99 }; + +const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); + +/** A token's stored conditions, whatever the column holds: a list of { id, left? }, never a throw. */ +const parseTokenConditions = (text) => { + let list; + try { list = typeof text === 'string' ? JSON.parse(text) : text; } catch { return []; } + if (!Array.isArray(list)) return []; + return list + .filter((c) => isPlainObject(c) && typeof c.id === 'string') + .map((c) => (Number.isInteger(c.left) && c.left > 0 ? { id: c.id, left: c.left } : { id: c.id })); +}; + +/** + * A token's conditions as asked for, checked against what the running system `offered` + * (conditionsIn). Answers `{ ok: true, value }`, the list to store, or `{ ok: false, error }` + * naming what is wrong. A condition that ends after rounds starts with its own rounds unless + * `left` says otherwise; one that ends when removed carries no rounds whatever is sent. + */ +const checkTokenConditions = (asked, offered) => { + if (!Array.isArray(asked)) return { ok: false, error: 'Conditions must be a list' }; + if (asked.length > LIMITS.conditions) return { ok: false, error: `At most ${LIMITS.conditions} conditions on a token` }; + const byId = new Map(offered.map((c) => [c.id, c])); + const seen = new Set(); + const value = []; + for (const entry of asked) { + const c = isPlainObject(entry) && typeof entry.id === 'string' ? byId.get(entry.id) : null; + if (!c) { + const named = isPlainObject(entry) && typeof entry.id === 'string' ? `"${entry.id}" is` : 'That is'; + return { ok: false, error: `${named} not a condition this game has` }; + } + if (seen.has(c.id)) return { ok: false, error: `${c.name} is on the list twice` }; + seen.add(c.id); + if (entry.left !== undefined && entry.left !== null && (!Number.isInteger(entry.left) || entry.left < 1 || entry.left > LIMITS.rounds)) { + return { ok: false, error: `Rounds left on ${c.name} must be a whole number from 1 to ${LIMITS.rounds}` }; + } + if (c.ends === 'rounds') value.push({ id: c.id, left: Number.isInteger(entry.left) ? entry.left : c.rounds }); + else if (Number.isInteger(entry.left)) value.push({ id: c.id, left: entry.left }); + else value.push({ id: c.id }); + } + return { ok: true, value }; +}; + +/** What everyone but the GM and a granted editor sees of a token's conditions: which, not for how long. */ +const publicTokenConditions = (text) => JSON.stringify(parseTokenConditions(text).map(({ id }) => ({ id }))); + +module.exports = { LIMITS, parseTokenConditions, checkTokenConditions, publicTokenConditions }; diff --git a/backend/tokens/vitals.js b/backend/tokens/vitals.js index bae4a207..07f62609 100644 --- a/backend/tokens/vitals.js +++ b/backend/tokens/vitals.js @@ -1,4 +1,4 @@ -// A token's health, defense and injuries, kept per game system. +// A token's health, defense, injuries and conditions, kept per game system. // // A character's state belongs to the game it is in (decided with the user, 2026-09-29), but a // token lives on the map, which every system shares. So the token's own columns on `locations` @@ -10,7 +10,9 @@ // left, and the incoming system's are brought back, or blanks where that system has never seen // the token. One transaction, so a switch happens entirely or not at all. -const FIELDS = ['hp_current', 'hp_max', 'hp_temp', 'melee_ac', 'ranged_ac', 'injuries']; +const FIELDS = ['hp_current', 'hp_max', 'hp_temp', 'melee_ac', 'ranged_ac', 'injuries', 'conditions']; +/** What a token shows where a system has never seen it, for the fields that are lists rather than numbers. */ +const EMPTY = { injuries: '{}', conditions: '[]' }; const TOKEN_SHAPES = ['rhombus', 'enemy_rhombus', 'friendly_rhombus']; const SHAPES_SQL = TOKEN_SHAPES.map((s) => `'${s}'`).join(', '); @@ -52,9 +54,9 @@ const switchSystem = async (db, to, { defaultSystem = 'generic' } = {}) => { SELECT id, ?, ${FIELDS.join(', ')} FROM locations WHERE shape IN (${SHAPES_SQL})`, [from]); // A subselect that finds no row gives NULL: a blank, which is what a system that has - // never seen this token should show. Injuries fall back to none rather than NULL. - const sets = FIELDS.map((col) => (col === 'injuries' - ? `injuries = COALESCE((SELECT injuries FROM token_vitals v WHERE v.location_id = locations.id AND v.system = ?), '{}')` + // never seen this token should show. Injuries and conditions fall back to none rather than NULL. + const sets = FIELDS.map((col) => (EMPTY[col] + ? `${col} = COALESCE((SELECT ${col} FROM token_vitals v WHERE v.location_id = locations.id AND v.system = ?), '${EMPTY[col]}')` : `${col} = (SELECT ${col} FROM token_vitals v WHERE v.location_id = locations.id AND v.system = ?)`)); const res = await q(db, 'run', `UPDATE locations SET ${sets.join(', ')} WHERE shape IN (${SHAPES_SQL})`,