Conversation
A safe formula language (no eval: numbers, @fields, $rules, fixed functions, lookup tables, capped size and nesting), a definition checker that reports every problem with its place and shows loops as a path, dependency ordering, and a small registry of code-backed rules. Nothing live calls it. CWN's and Shadowrun's derived values are restated as data and held to cwnRecompute and sr6Recompute by a parity test over 3,000 seeded sheets each; mutation checks confirm the test catches a wrong band, a dropped rule, a wrong rounding and a wrong minimum.
…ocket events Every token is signed with the same secret, and the checks only asked whether a token was valid and not temporary, which a player's login token is. It passed authenticate (delete buildings, GM notes, approve accounts, reset passwords) and every admin socket check (socket admin at identify, grant editor rights, set any bank balance, NPC users, purge dice history). The checks now say what they mean: authenticate admits the GM or a granted editor; authenticatePlayer adds a player's own login for their own sheet and portrait; optionalAuthenticate treats players as anonymous; admin socket events require the GM's own login. Three tests signed a GM token without the role the real login always carries, and now sign it the real way. Tests walk a player token against every route behind the GM check, and hold GM login, granted editors, player sheet and portrait, and chat. Verified end to end on a real server in secure mode.
…d the GM grantElevatedAccess and approveEditing sent the new editor's token with io.emit, so every connected client received a working key. They now send it only to the target's own connections (the client already ignored grants for anyone else, so the promoted player sees no difference). approveEditing, denyEditing and revokeEditing had no check at all: a player could approve their own edit request. They now require the GM or a granted editor, the same people who see those buttons. Tests run several connections on one server; mutation-checked. Verified on a real secure-mode server: grant, use, revoke, surrender, approve and kick all work, a bystander receives nothing, a player cannot approve themselves.
The trigger listed only main and dev, so PRs into a feature branch (feature/system-builder and its sb/* pieces) were never tested until the final merge to main.
Sb/1b gm route auth
…rmat and its checks custom_systems keeps a draft the builder edits and the published copy a game runs. Definition format 1 (name, description, words, parts, lookups, derived) is checked on the server: fatal problems (not an object, too large, not JSON) refuse storage; ordinary ones are saved in a draft and block publishing; all reported with where they are. Ids are sys_ + hex and never collide with a built-in id. GM-only routes at /api/systems (requireMainAdmin: granted editors are refused): list, create from a name or a definition, read, save draft, publish, delete (refused for the running system). Nothing in the game reads them yet (2d). Tests: definition checks, words and parts defaults, the routes' rules, and the auth walk now covers the systems routes; mutation-checked.
… with a database copy first bank_accounts replaces the one-bank-per-player player_banks, which is kept untouched as the record. Every bank read and write goes through bank/accounts.js, scoped to the running system (a sheet's cash field shows its own system's account), and waits for the one-time move to finish - browsers reconnect within milliseconds of a restart, and an empty account opened first would block a real balance from being copied in. The move (startup/bankAccounts.js) copies the database first when the disk has room (startup/backup.js, VACUUM INTO), then copies each balance, debt and flag into every system the player has a sheet in plus the running one, in one transaction, with a marker so it runs once. Tests: accounts kept apart, the move's rules (mutation-checked: marker, wait, systems, transaction), the copy and its space check, switching systems in play, and db.js opening a 1.14.4-shaped file. The 15 test files that seeded player_banks now use the running system's account. Verified on a read-only copy of the real database: 21 banks into 29 accounts, every value identical.
… defense and injuries The token's columns keep the running system's values, so combat, damage, the health monitor and linked sheet fields are unchanged; the other systems' values wait in token_vitals. tokens/vitals.js switchSystem swaps them and writes game_system in one transaction, reading the system being left inside it, so a failure or two quick switches can never split tokens from the setting. The system picker route uses it and has every screen redraw; the generic settings route can no longer change game_system or the migration markers. A one-time start (startup/tokenVitals.js, adds rows only, run once) saves each token's current values under every system it could be shown in. Map clears and loads drop saved values for tokens that are gone, since they wind the id sequence back. Tests mutation-checked (restore, transaction, systems, settings guard, prune). Verified on a read-only copy of the real database: 18 tokens switched away and back came back identical.
… sheet drawn from its definition Definition format gains `sheet` (systemBuilder/sheet.js): tabs, header and sections of text/number/textarea/select fields with visibility, combat sensitivity, max pairs and token/bank links, checked like the rest; a system with no sheet gets a starter one. systemBuilder/runtime.js loads PUBLISHED systems (never drafts) into memory, compiled into the meta the built-in templates carry, and sheets/templates.js reaches it through a hook (a require would be circular). Built-ins are always found first, so they cannot change. Publish and delete reload it; the picker lists published systems and can switch to them; GET /api/systems/render/:id gives browsers layout and words, never formulas. Frontend: sheets/customTemplates.ts turns the render copy into a SheetTemplate for the ordinary SheetRenderer; getTemplate answers for custom systems; a hook redraws when one arrives. Tests mutation-checked (render leak, drafts, reload, writable links, fetch dedupe). Verified end to end on a throwaway secure-mode server: create, publish, switch, a player's socket edit saved with derived values.
feat(system builder 2a): store custom systems, with the definition fo…
feat(system builder 2b): one bank account per player per game system,…
…own folder It matched the folder name MapSystem, which is only in a Windows checkout's path: on the CI runner it cleared nothing, the second open of db.js returned the first, closed connection, and the test failed with "Database is closed". It now matches the backend directory as resolved, says so plainly if db.js was not cleared, and waits for the first open's startup work (tokens/vitals whenReady) before closing it.
Sb/2c token health
Sb/2d load and render
…sks before switching The GAME tab's row of system buttons held four systems; custom ones make that any number. Now one dropdown: BUILT-IN in a fixed order, then YOUR SYSTEMS A to Z with their published version, a search box and arrow keys, portalled into the theme. Picking a different system opens a confirmation, since it changes the game for everyone online; a refused switch says nothing was changed. The runtime's system list now carries each custom system's version.
…he first connection db.js seeds the admin login behind a bcrypt hash that finishes about 40ms after the token move. The test closed the first connection on the token move alone, so the seed's INSERT landed on a closed database; locally the process exited first, on the slower CI runner the INSERT won and killed it with SQLITE_MISUSE. Reproduced by letting the second half run 500ms longer: fails without the wait, passes with it.
feat: the game system picker is a grouped, searchable dropdown that a…
…eir own layout and tiers A sheet field can say edit: 'gm' (XP, awarded items): its owner sees it and cannot change it, by a single edit, a batch edit or an upload, while the GM, a granted admin and the GM's own sheet window still can. Derived values stay locked by the recompute, as on the built-ins. A system's npc section gives NPCs a stat-block layout of their own and GENERATE_SHEET tiers (label, token HP and defense, starting values, derived values worked out). Both layouts share the system's rules, so a field on both must be linked the same way; only the character sheet says what players may see. A tier that leaves out HP or defense keeps the token's own. Built-in systems mark no GM fields and keep their code tiers, looked up first.
feat: custom systems mark fields as the GM's to set, and give NPCs th…
… shapes the starter sheet A system's core section answers the setup questions: the health model (one pool, two tracks, damage types, harm levels, wound count, hit locations or none) with its settings, how characters advance, the system's common dice and its distance unit. Initiative on or off stays a part. Every answer is checked, each problem reported with where it is. The health model shapes the starter sheet a system has before its GM designs one: two tracks put the first on the token and the second on the sheet, harm levels give a line per slot with the penalty as its hint, and so on. A system that answers nothing gets the same starter sheet it always had, byte for byte. How each model takes damage in play comes in its own piece.
feat: custom systems record their setup answers, and the health model…
…own rules systemBuilder/health.js works out DAMAGE and HEAL under each health model from the token and the sheet behind it: a second track that spills into the first when overflow is on; damage types whose lighter boxes turn heavier once the track is full; harm notes that move up a level when theirs is full and put the character out past the top; wounds with a per-wound penalty; hit locations noted on their line; and none, which refuses. The token keeps the "how hurt" the heart monitor reads under every model. The HIT_POINTS route uses it only when the running system is a custom one whose health is not one pool, and runs the rule inside the sheet's write queue so an edit made at the same moment is kept. The built-in systems, and a custom one-pool system, go through the route as before.
feat: a custom system's health model takes damage and healing by its …
…its color The monitor's color always changed with health; now its rhythm does too, on the same thresholds: steady over half, twice as fast at half or less, fast, uneven and weakening at a quarter or less, then the flatline. Still no numbers, in the HEALTH folder and on the stream overlay alike, which now share one band rather than two copies of the thresholds. With reduced motion the trace holds still but keeps its shape, and a screen reader hears how it looks. The steady beat is the trace the monitor has always drawn.
feat: the heart monitor's beat follows how hurt someone is, not just …
systemBuilder/healthView.js works out the view, and the socket's requestHealthView sends it to the asker alone. Whoever may change the token's health (the GM, a player granted editing, the token's owner) gets the full detail: a second track's numbers, box marks, harm notes, the wound penalty, location notes. Everyone else gets a description only: a fill, light against heavy, the worst harm's name, WOUNDED, which locations are hurt. An NPC's owner field never grants the full view. Over the socket because that is where a player's identity is known in every mode. Actions also report what the window needs to say: how many boxes turned heavier, and which level harm landed on. A second track's maximum is set on the sheet; every other SET MAX stays on the token as before.
feat: what a token's HEALTH folder is sent under a custom health model
…roved mockup Under a custom system whose health is not one pool, the HEALTH folder shows that model: two tracks with a picker and the second as a bar, damage types as boxes, harm levels as written slots with their penalties, wounds as pips with the current penalty, hit locations with their notes, and for none a note that the system uses conditions. Each says what happened: harm moving up a level, boxes turning heavier, a spill into the first track, out of action. The GM gets the SET rows. The heart monitor, the injury map and TEMP_HP stay around it, and a custom one-pool system shows its own word for health. Other players see a description only, never a number or a note: the second track's fill, light against heavy, the worst harm's name with the beat it sets, WOUNDED, which locations are hurt. The built-in systems' folder is unchanged and never asks for any of it. A hit location's x clears its note: a heal of nothing to the pool.
feat: the HEALTH folder under each custom health model, as in the app…
…that a reinstall undoes A published system exports as one readable JSON file with a cover: name, author, version, the CITY_NET version it came from, license, and its origin, who the system is across servers. A file is read on the server as untrusted input: capped in size, checked like the editor's own work, text kept as text, and it never carries characters or sheets. Installing is a preview that changes nothing, then new, update or keep both. An update only replaces a copy nobody has changed here, and never a system made here; keep both gives the copy its own id and origin, so the two never collide. Never a merge. A file with problems installs as a draft to fix, never as something a game can run. Deleting now hides a system instead of removing it: it leaves every list and cannot be run, and reinstalling its file brings it back under its old id, with every character, bank and token health played in it. The preview warns when that would replace changes made here. New custom_systems columns (origin, source_hash, deleted_at) are added on start; a system made before them is its own origin. Checked on a copy of a real database: nothing else changes.
feat: share a custom system as a .citysys file, and delete as a hide …
…eft exactly as they read
The glossary's plumbing, with nothing on screen changed yet. Every place that will show one of
the app's terms asks with the text it shows today: word('money', 'plural', 'CREDITS'). Under a
built-in system the answer is always that text, so CWN, Cyberpunk RED, Shadowrun and Generic
read exactly as they always have. Under a custom system it is that system's word, or the
neutral default where it set none.
The server resolves every term in every form and sends them with a custom system's sheet, so
the browser holds no table of defaults (sheets/words.ts, with a hook that redraws when they
arrive). Text the server writes itself gets the same lookup (runtime.wordIn).
The plan has every piece add a changelog line; the pieces without anything visible yet had been skipped. Added under Under the hood: GM-only fields and NPC stat blocks (2f), the setup questions and health models (2g to 2g2d), sharing as files with restorable delete (2h), and the words plumbing (3a1).
Sb/3a1 words plumbing
…HEALTH folder Every place listed for this piece in the words inventory now asks for its word with the text it shows today: the sidebar's HIT_POINTS and CHARACTER_CONTROLS, the sheet header's HP bar and its tooltip, the HEALTH folder's TEMP_HP and MAX_HP, the token window's GM NOTES, INITIATIVE SCORE and ADD TO INIT, the starter sheet's HP and a one-pool system's HEALTH label. Underscore labels keep their style (HIT POINTS as HIT_POINTS, WOUNDS as TEMP_WOUNDS), and text naming another label changes with it, so the sheet's tooltip always names the sidebar's button. A place changes only when a custom system renamed that term; otherwise it shows today's text, so HIT_POINTS stays HIT_POINTS until a system renames hit points. To keep that, a custom system's sheet now carries only the words it chose, every form filled from its own (systemBuilder/terms.js, the glossary in a module of its own). The built-in systems read exactly as they did, tested place by place.
Sb/3a3 words sheet and health
The shop window's messages, its receipt and the server's refusals (data/shopRules refusalText), the steps for stocking an empty shop, and the SHOP, VIEW_BANK and SHOP_CATALOGUES buttons ask for their words with the text they show today. A custom system that calls its shops MERCHANTS reads 'NOTHING HERE THIS MERCHANT WOULD BUY' and 'THE MERCHANT PAYS YOU'; one that renamed nothing, and every built-in system, reads exactly as before. The admin's SHOP_CATALOGUES button moved into this piece because the shop's own text names it. Custom systems have no shops yet (shopsAvailable knows only the built-in systems), so none of this shows until shops are turned on for them. Lines no custom system can reach yet - the cyberware notes, and placement into CWN's slots - keep today's text and are noted for the pieces that make them reachable.
feat: a custom system's words in the shops and the bank button
START, JOIN and END INITIATIVE, the INIT score, the 'no active initiative' notices and the turn counter in the tracker, its side view and the sidebar's panel ask for their words with the text they show today. The counter keeps each built-in's own word (CWN and Cyberpunk RED count ROUNDs, Shadowrun PASSes) and generic's TURN, and a custom system that renamed the turn uses its word instead. The dice log's initiative lines are written by the server, so they take the word from the system the combat was started under: 'JADE rolled ORDER [14]' in a game that calls initiative ORDER, exactly as before everywhere else.
InitiativeRollPrompt was tracker v1's 1d20 pop-up. App stopped rendering it on 2026-07-22, when players began joining from the tracker with each system's own roll, but the component, its export and its test stayed. Nothing imported it; nothing on screen changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Test plan
Pre-merge checklist
Code quality:
Version & Release:
frontend/package.jsonversiondocker-compose.ymlAPP_VERSIONCHANGELOG.mdwith release notesBefore merging to main:
Related issues