Skip to content

Sb/3a5a words initiative - #121

Closed
over2take wants to merge 44 commits into
mainfrom
sb/3a5a-words-initiative
Closed

over2take wants to merge 44 commits into
mainfrom
sb/3a5a-words-initiative

Conversation

@over2take

Copy link
Copy Markdown
Owner

Summary

Test plan

  • Tested locally
  • Tests pass

Pre-merge checklist

Code quality:

  • Code follows project style
  • No breaking changes (or clearly documented)
  • No console errors or warnings

Version & Release:

  • Version bumped? If releasing to users, update:
    • frontend/package.json version
    • docker-compose.yml APP_VERSION
    • CHANGELOG.md with release notes
  • GitHub Actions will auto-tag Docker images with the new version

Before merging to main:

  • All tests passing
  • PR reviewed and approved
  • Branch is up to date with main
  • No merge conflicts

Related issues

Developer and others added 30 commits September 29, 2026 10:23
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.
…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.
…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
Developer and others added 14 commits September 30, 2026 11:18
…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).
…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.
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.
@over2take over2take closed this Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant