Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
name: CI Tests

on:
# Runs tests whenever someone opens or updates a Pull Request targeting 'main' or
# 'dev' — the integration branch that publishes development images.
# Runs tests whenever someone opens or updates a Pull Request, whatever branch it targets.
#
# This used to be limited to 'main' and 'dev', so a long-running feature branch that takes
# its pieces as PRs of its own (feature/system-builder, with sb/* pieces merged into it)
# was never tested until the final merge to main. Every PR is a change about to land
# somewhere, so every PR gets the suites.
pull_request:
branches:
- main
- dev

# Runs tests when code is merged or pushed directly to 'main'.
#
Expand Down
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,56 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [Unreleased]

### Changed

- **Each game system has its own bank.** A character's money now belongs to the game it was
earned in: starting a new campaign on another system opens fresh accounts, and switching
back finds the old money exactly where it was. On the first start after updating, every
player's current balance, debt and bonuses are copied into each system they have a
character in, so nothing looks different in any existing game. A full copy of the
database is saved beside it first (when the disk has room), and the old bank records are
kept untouched.

- **Each game system keeps its own token health.** A token's HP, armor and injuries now belong
to the game being played: switching systems puts one game's values away and brings the
other's back, and a character who has never been in that game starts fresh. On the first
start after updating, every token's current values are saved for each system it could be
shown in, so switching looks exactly as it does today for anyone with a character there.

### Security

- **Players can no longer use the GM's tools.** A player's own login worked as a key to the GM's
side of the server: with the right request, a signed-in player could delete buildings, edit the
map, read GM notes, approve accounts, reset passwords, set anyone's bank balance, give themselves
editor rights, or post in chat as anyone. Only the GM, and players the GM has granted editing
rights, can now do those things. Nothing a player normally does changes: their sheet, portrait,
chat, shops and bank all work as before, and granting, revoking and giving back editor rights
work as before.

- **Editor rights go only to the player receiving them.** Granting editor rights, or approving an
edit request, used to send the new editor's key to every connected player, and any of them
could copy it. It now reaches only the player being promoted. Approving, denying and ending an
edit request were also open to anyone: a player could approve their own request. Those buttons
now work only for the GM and granted editors, as they appear in the admin panel.

### Under the hood

- **Custom game systems can be stored.** Each one keeps a draft the GM edits and a published
copy a game would run. A draft can be saved half-built, but it cannot be published until
its problems are fixed, and the system a game is running cannot be deleted. Only the main
admin can reach any of it, and nothing in the game uses these yet.

- **The first piece of the system builder.** An engine that works out a sheet's derived
values (modifiers, saves, maximums) from a written description instead of code, with a
safe formula language that can only do arithmetic. It is not switched on for anything:
every sheet is still worked out exactly as before. It is proven by restating CWN's and
Shadowrun's derived values as data and checking the results match the existing code on
thousands of generated sheets.

---

## [1.14.4] - 2026-09-29

NPC sheets and hidden faces stay with the GM.
Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,14 +327,16 @@ CITY_NET/
│ ├── bulk.js # Reads and deletes by id in pieces of 500, one transaction per delete so it still happens entirely or not at all. A map-sized city is more ids than SQLite takes in one statement
│ ├── updater.js # In-app self-update — paginated registry tag listing so a run of dev builds cannot hide a stable release; release channels selected by IMAGE_TAG alone, the same variable compose pulls with (X.Y.Z-dev tags with an optional counter, ordered so a release supersedes its own dev builds); preflight (compose file mounted, docker socket, compose project labels) so a stack that cannot update says why instead of hanging, and offers updating from the host as an equal option since running without the socket is a supported posture; one update at a time, refused rather than queued, with a stale-run release so a hung pull does not deaden the button; the helper command passed as argv rather than through `sh -c`, so a compose label containing a command substitution is data and not code; upgrade-only semver check; update log on the data volume; boot id so a restart is detectable without a version change; the registry read goes through net/outbound, and the docker probe behind GET /api/version is asked once per process rather than once per request — execSync holds the event loop, so a probe on an open route was a way to stall the server
│ ├── buildingTypes.js # What a building is for, which catalogues it sells, and which of those a shelf can actually show. Distinct from `classification`, which is the mesh a custom structure is drawn from - a ripperdoc and a noodle bar can share a shape
│ ├── bank/
│ │ └── accounts.js # Bank accounts, one per player per game system (`bank_accounts`): a character's money belongs to the game it was earned in. Every read and write goes through here and waits for the one-time move from the old per-player table to finish
│ ├── buildings/
│ │ ├── gmNotes.js # The GM's notes, in their own table rather than a column every player downloads. Kept through a single delete so undo brings them back; pruned on a map clear and replaced on a map load, the two places location ids are reused
│ │ ├── locationRows.js # Putting whole location rows back - a saved map loading, a delete being undone - with every column the table has, read from the table. The hand-kept lists it replaced had fallen behind and dropped building types, buy-back rates, AC and more
│ │ └── photoTypes.js # What a building photo may be. Requires nothing, so the frontend test holds the file picker's accept list to it
│ ├── net/
│ │ └── outbound.js # Every request to a host we do not own goes through here. A named destination (exact hostname, never a suffix test), HTTPS, a deadline covering the body as well as the connection, a byte cap, and no redirect following — none of which a caller can opt out of. Two callers, one auditable surface
│ ├── middleware/
│ │ ├── auth.js # JWT verify middleware (admin + elevated users)
│ │ ├── auth.js # Who a token belongs to, in one place. Every token is signed with the same secret, so a valid signature is not enough: `authenticate` admits the GM (role admin) or a granted editor, `authenticatePlayer` also admits a player's own login for their own sheet and portrait, `optionalAuthenticate` treats players as anonymous; `isMainAdmin` is what every admin socket event checks
│ │ ├── uploadConstraints.js # What an upload may be and how to say so when it is not. One message shape naming the file, what was wrong and what would have worked — plus a handler for multer's own failures, since an oversized file previously reached Express's HTML error page and the client reported a JSON syntax error to the user
│ │ ├── uploadHeaders.js # What a browser may do with a file somebody uploaded. `/uploads` is served with no auth, so a sandbox CSP puts anything opened from it in an opaque origin and nosniff stops it being re-read as HTML — which is what lets the upload allowlists stay as wide as the file pickers
│ │ └── 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
Expand All @@ -352,6 +354,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 or a whole definition), read, save a draft, publish, delete. Main admin only, reading included; delete refused for the running system
│ │ └── sheets.js # Character sheets — admin sheet access, NPC library, portraits, LUCK/Edge reset & grant, import preview. 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
Expand Down Expand Up @@ -397,11 +400,23 @@ CITY_NET/
│ │ ├── catalogueParse.js # Reads a catalogue a GM pasted or uploaded: CSV, TSV or JSON, real RFC-4180 quoting, per-line problems rather than exceptions. The only reader - the preview is a round trip to it
│ │ ├── 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/
│ │ └── 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
│ ├── 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
│ │ └── 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
│ ├── systemBuilder/ # The system builder's engine (Phase 1): derived values from a written definition instead of code. NOT used by the app yet - every sheet is still worked out by sheets/templates.js, and this is held to that code by a parity test before any system moves onto it
│ │ ├── 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
│ │ ├── rules.js # Code-backed rule values a formula can name as $name, for what arithmetic cannot read (installed armor mods, fitted chrome, adept powers). A GM picks from this list, never adds to it
│ │ ├── definitions.js # CWN's and Shadowrun's derived values restated as data, entry for entry in the order their functions write them
│ │ ├── definition.js # The system definition format (1: name, description, words, parts, lookups, derived) and its server-side checks. Fatal (cannot be stored: not an object, too large, not JSON) vs ordinary problems (saved in a draft, block publishing), all reported with where they are. Also the app's renamable terms and switchable parts, with wordFor / partOn
│ │ └── store.js # `custom_systems`: a draft the builder edits and the published copy a game runs. Ids are sys_ + hex, never a built-in id; publishing refuses a draft with problems; the running system cannot be deleted
│ ├── startup/
│ │ ├── backup.js # A whole copy of the database (VACUUM INTO, beside it) before a migration changes real data; skipped, and logged, when the disk lacks room
│ │ ├── bankAccounts.js # The one-time move from one bank per player (`player_banks`, kept untouched) to one per player per system: the database copied first, each balance copied into every system the player has a sheet in plus the running one, in one transaction, with a marker so it never runs twice
│ │ ├── tokenVitals.js # The one-time start of per-system token health: each token's current values saved under every system it could be shown in (a player's sheet systems, or every system for enemies and friendlies, plus the running one), so switching shows what it showed before. Adds rows only; a marker so it runs once
│ │ └── sanity_checks.js # In-memory DB checks on boot
│ ├── utils/
│ │ └── random.js # cryptoRng — uniform [0,1) from OS entropy (crypto.randomInt); default rng for every roll that decides an outcome
Expand All @@ -410,6 +425,7 @@ CITY_NET/
│ │ └── testDb.js # In-memory SQLite factory for isolated test DBs
│ ├── admin.test.js # Admin endpoints (auth, settings, undo access); update routes — 409 with a reason rather than a false success, unauthenticated status, boot id on /version; check-update against a stubbed registry — upgrades only, dev tags per channel, and a prerelease not hiding a stable release
│ ├── large_deletes.test.js # A map-sized city (40,000 buildings, past SQLite's bound-value limit) deleted, purged and undone; a failed delete rolling back whole; the history capped, oversized entries marked too large to undo, and a failed history write logged rather than crashing
│ ├── gm_route_auth.test.js # Walks a player's real login token against every route behind the GM check (all refused), and holds what must keep working: GM login, granted editors (grant, use, revoke, surrender), a player's own sheet and portrait, chat, and a player unable to become a socket admin, grant rights, approve their own edit request or set a bank balance; editor grants reach only the player promoted
│ ├── cpr_stats.test.js # CP:R stat rolls — BODY rollable, MOVE and LUCK not, and every roll button in the template backed by a server-side roll
│ ├── shop_checkout_sockets.test.js # The cart's checkout over the socket: totals in each direction, every way a line fails taking the whole checkout down with it, a changed total, overdraft asked once, and the payer from the socket
│ ├── nginx_config.test.js # The assumptions the app makes about the proxy every request arrives through, which no other test here touches — body ceiling at least the largest upload limit, X-Forwarded-For present, the socket able to upgrade, and every mounted path actually proxied. Two faults in one release lived exactly in that gap
Expand All @@ -432,6 +448,11 @@ CITY_NET/
│ ├── sockets.customdice.test.js # Roll handler: DB vs builtin resolution, numeric summing, count clamp, forged-payload rejection
│ ├── signs.test.js # Sign API (GET / POST / PATCH / DELETE, auth, image-only, filter_intensity clamping, XSS)
│ ├── sheets.test.js # Sheet routes (system switch, admin access, portraits, derived fields, GET /own player self-fetch)
│ ├── system_builder_parity.test.js # CWN and Shadowrun as data against cwnRecompute and sr6Recompute over 3,000 seeded sheets each (blank, text, decimal, huge and stale values, broken JSON): same sheet, same changed fields, same order
│ ├── bank_accounts.test.js # Per-system accounts kept apart; the one-time move (every sheet's system plus the running one, the old table untouched, once only, all or nothing); the database copy and its disk-space check; switching systems in play; and db.js opening a 1.14.4-shaped database file in a child process
│ ├── token_vitals.test.js # Switching swaps and restores every token's health (enemies too, buildings untouched), entirely or not at all, and waits for the one-time start; the start's systems and run-once; map clears and loads; the system picker route and the settings route's guard; db.js on a real file
│ ├── system_builder_store.test.js # The definition checks (every problem at once, fatal vs ordinary, words and parts), and the routes: main admin only, drafts saved with problems but not published, the published copy untouched while the draft moves on, the running system not deletable
│ ├── system_builder_engine.test.js # The formula language (precedence, functions, 0 for NaN, and a list of script-shaped inputs it refuses), limits, and definitions: dependency order, lookups, conditions, rules, and every mistake reported at once
│ ├── npc_privacy.test.js # The map list and token card as anonymous, player and revoked-editor callers see them: no NPC sheet, no silhouetted face, even in the raw response text; the GM and a granted editor still get both
│ ├── npc_sheets.test.js # NPC library routes (CRUD, links, folders, LUCK reset, HP overlay)
│ ├── cpr_attack.test.js # CP:R attack module (to-hit, armor, shield, crits, death saves)
Expand Down
Loading
Loading