From 2364c15a16f2c774d818ac71f13d30da3030b4a1 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 9 Oct 2026 11:37:10 -0500 Subject: [PATCH] feat(system-builder): conditions count down by the round (4e2a2) Each new round of a combat, the initiative tracker counts its tokens' conditions down: every one with rounds left loses one, and those reaching none come off (tokens/conditions.js tickRound, tickCombat). A new round is when the turn order wraps, when the sides wrap in side mode, or in Shadowrun when every pass is spent rather than at the end of each pass. The combat's tokens come from the tracker's ids: npc: for NPC tokens, player: for every token of that player's, each counted once. Every screen redraws the map when any changed. A new socket request, requestTokenConditions, answers a token's conditions as the viewer may see them, under every system: the GM, the token's owner and a player given a friendly NPC get the rounds left and each condition's modifiers; anyone else gets which conditions, as the token list shows. --- CHANGELOG.md | 5 +- README.md | 7 +- backend/__tests__/condition_rounds.test.js | 204 +++++++++++++++++++++ backend/sockets/index.js | 29 ++- backend/sockets/initiative.js | 22 ++- backend/tokens/conditions.js | 73 +++++++- 6 files changed, 329 insertions(+), 11 deletions(-) create mode 100644 backend/__tests__/condition_rounds.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 167874fa..05ca06e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,7 +101,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 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. + switch systems. A condition put on for a number of rounds counts down each new round of a + combat its token is in (in Shadowrun, once every pass is spent) and comes off when it runs out. + The GM, the token's owner and a player given a friendly NPC can see the rounds left and each + condition's modifiers. 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 diff --git a/README.md b/README.md index ab761209..4099b6c6 100644 --- a/README.md +++ b/README.md @@ -405,13 +405,13 @@ 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 +│ │ ├── 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), shown to everyone without the rounds left, and counted down a round at a time for a combat's tokens (tickCombat), coming off at none │ │ ├── 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, 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 -│ │ └── initiative.js # Initiative tracker socket events (start, roll, next, remove, reorder, end); individual and side-based modes; SR6 pass-decay on wrap; CWN side auto-create, PC-side score derivation, friendly-NPC routing; roll history broadcast +│ │ ├── 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. requestTokenConditions answers a token's conditions with rounds left and modifiers to whoever may change them, and which conditions to anyone else +│ │ └── initiative.js # Initiative tracker socket events (start, roll, next, remove, reorder, end); individual and side-based modes; SR6 pass-decay on wrap; CWN side auto-create, PC-side score derivation, friendly-NPC routing; roll history broadcast; each new round counting its tokens' conditions down (tokens/conditions.js tickCombat), in Shadowrun only once every pass is spent │ ├── systemBuilder/ # The system builder: a GM's own game system as data (words, parts, stats, formulas, sheet, NPCs, health, money), checked, stored, shared as a file, and run by the game once published. The built-in systems never run on it - their code in sheets/templates.js stays the truth, and their data versions are examples held to that code by a parity test │ │ ├── expression.js # The formula language, parsed and evaluated with no eval: numbers, @fields, $rules, a fixed list of functions and a system's lookup tables. Length, size and nesting capped; anything infinite or NaN comes out 0 │ │ ├── derived.js # Checks a definition (every problem at once, with where it is, loops shown as a path), orders values by what they read, and works them out. apply() keeps the contract of the hand-written recompute functions @@ -485,6 +485,7 @@ CITY_NET/ │ ├── 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 +│ ├── condition_rounds.test.js # A round on: rounds left counting down and conditions coming off at none, for a combat's tokens only (a player's on every token of theirs, once); the real tracker counting down when its order wraps, when the sides wrap, and in Shadowrun only when every pass is spent; requestTokenConditions giving the GM, owner and a friendly NPC's controller the rounds left and modifiers, and anyone else which conditions │ ├── 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__/condition_rounds.test.js b/backend/__tests__/condition_rounds.test.js new file mode 100644 index 00000000..6e7b99e3 --- /dev/null +++ b/backend/__tests__/condition_rounds.test.js @@ -0,0 +1,204 @@ +/** + * Conditions counting down, and who sees how long they have left (4e2a2). Approved mockup + * builder-conditions (2026-10-09): a condition that ends after rounds loses one each new round of a + * combat its token is in, and comes off at none; the GM, the token's owner and a player given a + * friendly NPC see the rounds left and the modifiers; everyone else sees which conditions. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import jwt from 'jsonwebtoken'; +import { createRequire } from 'module'; +import { makeTestDb, get, run } from './helpers/testDb.js'; +import { drain, untilValue } from './helpers/until.js'; + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const { tickRound, combatTokens, tickCombat } = require_('../tokens/conditions'); +const runtime = require_('../systemBuilder/runtime'); +const socketsFactory = (await import('../sockets/index.js')).default; +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); + +describe('a round on', () => { + it('takes one from every condition with rounds left, and takes off those at none', () => { + expect(tickRound([{ id: 'prone' }, { id: 'poisoned', left: 3 }, { id: 'stunned', left: 1 }])).toEqual({ + list: [{ id: 'prone' }, { id: 'poisoned', left: 2 }], ended: ['stunned'], changed: true, + }); + }); + + it('changes nothing where nothing has rounds', () => { + expect(tickRound([{ id: 'prone' }])).toEqual({ list: [{ id: 'prone' }], ended: [], changed: false }); + expect(tickRound([])).toEqual({ list: [], ended: [], changed: false }); + }); +}); + +describe('a combat\'s tokens', () => { + it('are its NPCs by token id and its players by name, each once', () => { + expect(combatTokens([ + { id: 'npc:12' }, { id: 'player:vex' }, { id: 'npc:12' }, { id: 'player:vex' }, { id: 'npc:x' }, { id: 'c:A' }, { id: 7 }, null, + ])).toEqual({ npcs: [12], players: ['vex'] }); + expect(combatTokens(undefined)).toEqual({ npcs: [], players: [] }); + }); +}); + +let db; +const conditionsOf = async (id) => JSON.parse((await get(db, 'SELECT conditions FROM locations WHERE id = ?', [id])).conditions); +const token = async (name, shape, owner, conditions, controllers = null) => (await run(db, + 'INSERT INTO locations (name, x, y, z, shape, owner, conditions, controllers) VALUES (?, 0, 0, 0, ?, ?, ?, ?)', + [name, shape, owner, JSON.stringify(conditions), controllers])).lastID; + +describe('counting a combat down', () => { + let ids; + beforeEach(async () => { + db = await makeTestDb(); + ids = { + ganger: await token('GANGER', 'enemy_rhombus', 'gm', [{ id: 'poisoned', left: 2 }, { id: 'prone' }]), + bystander: await token('BYSTANDER', 'enemy_rhombus', 'gm', [{ id: 'poisoned', left: 2 }]), + vex: await token('VEX', 'rhombus', 'vex', [{ id: 'stunned', left: 1 }]), + vexElsewhere: await token('VEX', 'rhombus', 'vex', [{ id: 'stunned', left: 1 }]), + box: await token('CRATE', 'box', null, [{ id: 'poisoned', left: 2 }]), + }; + }); + const tick = (combatants) => new Promise((resolve, reject) => tickCombat(db, combatants, (err, r) => (err ? reject(err) : resolve(r)))); + + it('counts down the combat\'s tokens only, a player\'s on every token of theirs, once each', async () => { + const r = await tick([{ id: `npc:${ids.ganger}` }, { id: 'player:vex' }, { id: `npc:${ids.ganger}` }]); + expect(r).toEqual({ tokens: 3 }); + expect(await conditionsOf(ids.ganger)).toEqual([{ id: 'poisoned', left: 1 }, { id: 'prone' }]); + expect(await conditionsOf(ids.vex)).toEqual([]); + expect(await conditionsOf(ids.vexElsewhere)).toEqual([]); + expect(await conditionsOf(ids.bystander)).toEqual([{ id: 'poisoned', left: 2 }]); + }); + + it('touches nothing that isn\'t an NPC token, and writes nothing when nothing counts down', async () => { + expect(await tick([{ id: `npc:${ids.box}` }, { id: `npc:${ids.vex}` }])).toEqual({ tokens: 0 }); + expect(await conditionsOf(ids.box)).toEqual([{ id: 'poisoned', left: 2 }]); + await run(db, `UPDATE locations SET conditions = '[{"id":"prone"}]' WHERE id = ?`, [ids.ganger]); + expect(await tick([{ id: `npc:${ids.ganger}` }, { id: 'player:nobody' }, { id: 'npc:9999' }])).toEqual({ tokens: 0 }); + }); + + it('takes a condition off after as many rounds as it was given', async () => { + const combat = [{ id: `npc:${ids.ganger}` }]; + await tick(combat); + await tick(combat); + expect(await conditionsOf(ids.ganger)).toEqual([{ id: 'prone' }]); + }); +}); + +/** The tracker's tables, as db.js makes them. */ +const initiativeTables = async () => { + await run(db, `CREATE TABLE IF NOT EXISTS initiative_combat (id INTEGER PRIMARY KEY AUTOINCREMENT, turn_counter INTEGER DEFAULT 1, + pass_counter INTEGER DEFAULT 1, system TEXT DEFAULT 'generic', mode TEXT DEFAULT 'individual', created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`); + await run(db, `CREATE TABLE IF NOT EXISTS initiative_scene (scene_key TEXT PRIMARY KEY, combat_id INTEGER NOT NULL, combatants TEXT NOT NULL DEFAULT '[]', + sides TEXT NOT NULL DEFAULT '[]', turn_index INTEGER DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)`); + await run(db, `CREATE TABLE IF NOT EXISTS dice_rolls (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, total INTEGER, results TEXT, color TEXT, historyString TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)`); +}; + +/** One connection as `who`, through the real socket handlers, with what it was sent. */ +const connect = async (who, { admin = false } = {}) => { + const emitted = []; + const emitUpdate = vi.fn(); + // The sockets module and the initiative tracker each listen for connections: both are called. + const connections = []; + const io = { on: (e, cb) => { if (e === 'connection') connections.push(cb); }, emit: () => {}, to: () => ({ emit: () => {} }) }; + socketsFactory(io, db, { elevatedUsers: new Set(), emitUpdate, recordAction: vi.fn() }); + const handlers = {}; + const socket = { id: `sock-${who}-${Math.random()}`, on: (e, fn) => { handlers[e] = fn; }, emit: (e, d) => emitted.push({ event: e, data: d }), + broadcast: { emit: () => {} }, use: () => {}, join: () => {}, disconnect: () => {} }; + connections.forEach((cb) => cb(socket)); + handlers.identify(admin ? { userName: who, isAdmin: true, token: GM } : who); + await drain(db); + return { handlers, emitted, emitUpdate }; +}; + +describe('the initiative tracker\'s new rounds', () => { + let ganger; + beforeEach(async () => { + db = await makeTestDb(); + await initiativeTables(); + ganger = await token('GANGER', 'enemy_rhombus', 'gm', [{ id: 'poisoned', left: 2 }]); + }); + const scene = async ({ system = 'generic', mode = 'individual', combatants, sides = [], turnIndex }) => { + const combat = (await run(db, 'INSERT INTO initiative_combat (system, mode) VALUES (?, ?)', [system, mode])).lastID; + await run(db, 'INSERT INTO initiative_scene (scene_key, combat_id, combatants, sides, turn_index) VALUES (?, ?, ?, ?, ?)', + ['main', combat, JSON.stringify(combatants), JSON.stringify(sides), turnIndex]); + }; + const leftOn = async () => (await conditionsOf(ganger))[0]?.left; + + it('counts down when the turn order wraps, not on every turn, and redraws the map', async () => { + await scene({ combatants: [{ id: `npc:${ganger}`, score: 12 }, { id: 'player:vex', score: 8 }], turnIndex: 0 }); + const gm = await connect('gm', { admin: true }); + gm.handlers['initiative:next']({ sceneKey: 'main' }); + await drain(db); + expect(await leftOn()).toBe(2); + gm.handlers['initiative:next']({ sceneKey: 'main' }); + await untilValue(leftOn, (v) => v === 1, { label: 'a round counted down' }); + await untilValue(() => gm.emitUpdate.mock.calls.length, (n) => n > 0, { label: 'the map redrawn' }); + }); + + it('counts down when the sides wrap, in side mode', async () => { + await scene({ mode: 'side', combatants: [{ id: `npc:${ganger}`, sideId: 'npc' }], sides: [{ id: 'pc', score: 9 }, { id: 'npc', score: 4 }], turnIndex: 1 }); + const gm = await connect('gm', { admin: true }); + gm.handlers['initiative:next']({ sceneKey: 'main' }); + await untilValue(leftOn, (v) => v === 1, { label: 'a round counted down' }); + }); + + it('counts down in Shadowrun only when every pass is spent, not at the end of a pass', async () => { + await scene({ system: 'shadowrun_6e', combatants: [{ id: `npc:${ganger}`, score: 15 }], turnIndex: 0 }); + const gm = await connect('gm', { admin: true }); + gm.handlers['initiative:next']({ sceneKey: 'main' }); + await drain(db); + // 15 - 10 leaves a pass to go: the same round. + expect(await leftOn()).toBe(2); + gm.handlers['initiative:next']({ sceneKey: 'main' }); + await untilValue(leftOn, (v) => v === 1, { label: 'the round counted down' }); + }); +}); + +describe('a token\'s conditions, asked for over the socket', () => { + const HEARTH = 'sys_aaaaaaaaaaaaaaaa'; + let ids; + beforeEach(async () => { + db = await makeTestDb(); + const def = JSON.stringify({ format: 1, name: 'Hearth', conditions: { poisoned: { ends: 'rounds', rounds: 3, modifiers: [{ target: 'all_rolls', amount: -1 }] } } }); + await run(db, 'INSERT INTO custom_systems (id, name, draft, published, version) VALUES (?, ?, ?, ?, 1)', [HEARTH, 'Hearth', def, def]); + await new Promise((resolve) => runtime.load(db, resolve)); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [HEARTH]); + const list = [{ id: 'poisoned', left: 2 }, { id: 'prone' }]; + ids = { + vex: await token('VEX', 'rhombus', 'vex', list), + dog: await token('DOG', 'friendly_rhombus', 'gm', list, JSON.stringify({ all: false, users: ['vex'] })), + ganger: await token('GANGER', 'enemy_rhombus', 'gm', list), + }; + }); + const ask = async (c, id) => { + c.handlers.requestTokenConditions({ location_id: id }); + return (await untilValue(() => c.emitted.find((e) => e.event === 'tokenConditions' && e.data.location_id === id), Boolean, { label: 'the reply' })).data; + }; + const WHOLE = [{ id: 'poisoned', left: 2, modifiers: [{ target: 'all_rolls', amount: -1 }] }, { id: 'prone', modifiers: [] }]; + + it('gives the GM, the owner and a player given a friendly NPC the rounds left and the modifiers', async () => { + const gm = await connect('gm', { admin: true }); + expect(await ask(gm, ids.ganger)).toEqual({ location_id: ids.ganger, full: true, conditions: WHOLE }); + const vex = await connect('vex'); + expect(await ask(vex, ids.vex)).toEqual({ location_id: ids.vex, full: true, conditions: WHOLE }); + expect(await ask(vex, ids.dog)).toEqual({ location_id: ids.dog, full: true, conditions: WHOLE }); + }); + + it('gives anyone else which conditions only', async () => { + const ash = await connect('ash'); + expect(await ask(ash, ids.vex)).toEqual({ location_id: ids.vex, full: false, conditions: [{ id: 'poisoned' }, { id: 'prone' }] }); + const vex = await connect('vex'); + expect(await ask(vex, ids.ganger)).toEqual({ location_id: ids.ganger, full: false, conditions: [{ id: 'poisoned' }, { id: 'prone' }] }); + }); + + it('answers nothing for what isn\'t a token, or someone who never said who they are', async () => { + const box = (await run(db, `INSERT INTO locations (name, x, y, z, shape) VALUES ('CRATE', 0, 0, 0, 'box')`)).lastID; + const gm = await connect('gm', { admin: true }); + gm.handlers.requestTokenConditions({ location_id: box }); + gm.handlers.requestTokenConditions({ location_id: 'x' }); + gm.handlers.requestTokenConditions(null); + await drain(db); + expect(gm.emitted.filter((e) => e.event === 'tokenConditions')).toEqual([]); + }); +}); diff --git a/backend/sockets/index.js b/backend/sockets/index.js index a4800c02..0a1ffa51 100644 --- a/backend/sockets/index.js +++ b/backend/sockets/index.js @@ -18,6 +18,7 @@ const skillplugs = require('../sheets/cwnSkillplugs'); const awardXpModule = require('../sheets/awardXp'); const tokenControl = require('./tokenControl'); const tokenAccess = require('../tokens/tokenAccess'); +const { parseTokenConditions } = require('../tokens/conditions'); const attackSr6 = require('../sheets/attackSr6'); const npcTiers = require('../sheets/npcTiers'); const customSystems = require('../systemBuilder/runtime'); @@ -74,7 +75,7 @@ const formatMeasurementPayload = (data, userName, socketId) => ({ }); module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { - registerInitiativeHandlers(io, db); + registerInitiativeHandlers(io, db, { emitUpdate }); /** * Bring the running system's uploaded catalogues into memory. @@ -1844,6 +1845,32 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { }); }); + /** + * A token's conditions as this viewer may see them (4e2a2), under every system. Whoever may + * change them (the GM, the token's owner, a player given a friendly NPC) gets the rounds left + * and each condition's modifiers; everyone else gets which conditions, as the token list shows. + */ + socket.on('requestTokenConditions', (data) => { + const info = userSockets.get(socket.id); + if (!info || !data || !Number.isInteger(Number(data.location_id))) return; + const locationId = Number(data.location_id); + db.get(`SELECT id, shape, owner, controllers, conditions FROM locations WHERE id = ?`, [locationId], (e1, loc) => { + if (e1 || !loc || !RHOMBUS_SHAPES.includes(loc.shape)) return; + const full = isAdminSocket(socket) || (loc.shape === 'rhombus' && !!loc.owner && loc.owner === info.userName) || tokenAccess.controls(loc, info.userName); + const on = parseTokenConditions(loc.conditions); + if (!full) return socket.emit('tokenConditions', { location_id: locationId, full: false, conditions: on.map(({ id }) => ({ id })) }); + getGameSystem((err, system) => { + if (err) return; + const offered = new Map(customSystems.conditionsIn(system).map((c) => [c.id, c])); + socket.emit('tokenConditions', { + location_id: locationId, + full: true, + conditions: on.map((c) => ({ ...c, modifiers: offered.has(c.id) ? offered.get(c.id).modifiers : [] })), + }); + }); + }); + }); + // 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/sockets/initiative.js b/backend/sockets/initiative.js index e70828e6..72e7bcfc 100644 --- a/backend/sockets/initiative.js +++ b/backend/sockets/initiative.js @@ -1,5 +1,6 @@ const { cryptoRng } = require('../utils/random'); const customSystems = require('../systemBuilder/runtime'); +const { tickCombat } = require('../tokens/conditions'); /** A custom system's own word for initiative in the dice log; today's text for the rest. */ const initiativeWord = (system) => customSystems.wordIn(system, 'initiative', 'singular', 'INITIATIVE').toUpperCase(); @@ -62,7 +63,16 @@ function sortSides(sides) { }); } -function registerInitiativeHandlers(io, db) { +function registerInitiativeHandlers(io, db, { emitUpdate = () => {} } = {}) { + /** + * A new round: conditions with rounds left on the combat's tokens lose one, and those at none + * come off (tokens/conditions.js, 4e2a2). Every screen redraws the map when any token changed. + */ + const newRound = (combatants) => tickCombat(db, combatants, (err, { tokens }) => { + if (err) console.error('[initiative] Could not count conditions down:', err.message); + if (tokens) emitUpdate(); + }); + io.on('connection', (socket) => { // ── Join scene: send current state if initiative is active ───────────────── @@ -353,6 +363,7 @@ function registerInitiativeHandlers(io, db) { (err) => { if (err) return; if (wrapped) { + newRound(JSON.parse(row.combatants || '[]')); db.run( `UPDATE initiative_combat SET turn_counter = turn_counter + 1 WHERE id = ?`, [row.combat_id], @@ -387,8 +398,10 @@ function registerInitiativeHandlers(io, db) { if (row.system === 'shadowrun_6e') { const decayed = combatants.map((c) => ({ ...c, score: c.score - 10 })); const survivors = decayed.filter((c) => c.score > 0); - const newRound = survivors.length === 0; - const nextCombatants = newRound ? [] : survivors; + const startsRound = survivors.length === 0; + const nextCombatants = startsRound ? [] : survivors; + // Shadowrun's round ends when every pass is spent, not when a pass ends. + if (startsRound) newRound(combatants); db.run( `UPDATE initiative_scene SET combatants = ?, turn_index = 0, updated_at = CURRENT_TIMESTAMP WHERE scene_key = ?`, @@ -398,11 +411,12 @@ function registerInitiativeHandlers(io, db) { db.run( `UPDATE initiative_combat SET pass_counter = pass_counter + 1 WHERE id = ?`, [row.combat_id], - () => broadcastScene(io, db, sceneKey, { newRound }) + () => broadcastScene(io, db, sceneKey, { newRound: startsRound }) ); } ); } else { + newRound(combatants); db.run( `UPDATE initiative_scene SET turn_index = 0, updated_at = CURRENT_TIMESTAMP WHERE scene_key = ?`, [sceneKey], diff --git a/backend/tokens/conditions.js b/backend/tokens/conditions.js index 4cd21636..625becd9 100644 --- a/backend/tokens/conditions.js +++ b/backend/tokens/conditions.js @@ -3,7 +3,9 @@ // 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 +// conditionsIn), with the rounds it has left when it ends after rounds. Each new round of a combat +// takes one off every condition with rounds left on its tokens, and one that reaches none comes off +// (tickCombat, from the initiative tracker). 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. @@ -54,7 +56,74 @@ const checkTokenConditions = (asked, offered) => { return { ok: true, value }; }; +/** + * A token's conditions one round on (4e2a2): every one with rounds left loses one, and those that + * reach none come off. Answers the list to store, the ids that ended, and whether anything changed. + */ +const tickRound = (list) => { + const ended = []; + let changed = false; + const next = []; + for (const c of list) { + if (!Number.isInteger(c.left)) { next.push(c); continue; } + changed = true; + if (c.left <= 1) ended.push(c.id); + else next.push({ ...c, left: c.left - 1 }); + } + return { list: next, ended, changed }; +}; + +/** + * Who of a combat's combatants are tokens, from the tracker's ids (`npc:`, + * `player:`): the NPC tokens by id, and the players by name, since a player's + * conditions follow them onto every token of theirs. Each once, however often listed. + */ +const combatTokens = (combatants) => { + const npcs = new Set(); + const players = new Set(); + for (const c of Array.isArray(combatants) ? combatants : []) { + const m = /^(npc|player):(.+)$/.exec(c && typeof c.id === 'string' ? c.id : ''); + if (!m) continue; + if (m[1] === 'npc' && /^\d+$/.test(m[2])) npcs.add(Number(m[2])); + else if (m[1] === 'player') players.add(m[2]); + } + return { npcs: [...npcs], players: [...players] }; +}; + /** 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 }; +/** + * A new round for a combat (4e2a2): its tokens' conditions with rounds left each lose one, and those + * that reach none come off. Player tokens are counted once per player, every token of theirs + * written alike. cb(err, { tokens }) with how many tokens were written. + */ +const tickCombat = (db, combatants, cb = () => {}) => { + const { npcs, players } = combatTokens(combatants); + const shapes = `'enemy_rhombus', 'friendly_rhombus'`; + const reads = [ + ...npcs.map((id) => ({ sql: `SELECT conditions FROM locations WHERE id = ? AND shape IN (${shapes})`, arg: id, + write: 'UPDATE locations SET conditions = ? WHERE id = ?' })), + ...players.map((name) => ({ sql: `SELECT conditions FROM locations WHERE shape = 'rhombus' AND owner = ? LIMIT 1`, arg: name, + write: `UPDATE locations SET conditions = ? WHERE shape = 'rhombus' AND owner = ?` })), + ]; + let tokens = 0; + let firstError = null; + const step = (i) => { + if (i >= reads.length) return cb(firstError, { tokens }); + const r = reads[i]; + db.get(r.sql, [r.arg], (err, row) => { + if (err) { firstError = firstError || err; return step(i + 1); } + if (!row) return step(i + 1); + const ticked = tickRound(parseTokenConditions(row.conditions)); + if (!ticked.changed) return step(i + 1); + db.run(r.write, [JSON.stringify(ticked.list), r.arg], function (err2) { + if (err2) firstError = firstError || err2; else tokens += this.changes; + step(i + 1); + }); + }); + }; + step(0); +}; + +module.exports = { LIMITS, parseTokenConditions, checkTokenConditions, publicTokenConditions, tickRound, combatTokens, tickCombat };