diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23124034..b8dd9cb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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'. # diff --git a/CHANGELOG.md b/CHANGELOG.md index a43ac48a..7c8a4b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,111 @@ 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. + +- **The game system picker is a searchable list.** The row of system buttons in the admin + panel's GAME tab is now one dropdown: the built-in systems first, then any the GM has made, + with a search box and arrow keys. Choosing a different system now asks first, since it + changes the game for everyone online. + +- **The heart monitor's beat follows how hurt someone is.** Its color always changed as a + character was hurt; now its rhythm does too. It stays steady over half health, beats twice as + fast at half or less, and turns fast, uneven and weak at a quarter or less, before the + flatline. It still shows no numbers, in the HEALTH folder and on the stream overlay alike. With + reduced motion turned on in the system settings, the trace holds still but keeps its shape. + +### 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 + +- **A custom system's words reach the initiative tracker and the dice log.** START, JOIN and END + INITIATIVE, the INIT score, the turn counter and the dice log's "rolled INITIATIVE" lines use the + words a custom system's GM chose for initiative and the turn. The built-in systems keep their + wording exactly, ROUND and PASS included. The first tracker's 1d20 roll window, which nothing + had opened since players began joining from the tracker itself in July, was removed. + +- **A custom system's words reach the shops and the bank button.** The shop's messages, receipt + and refusals, the steps for stocking an empty shop, and the SHOP, VIEW_BANK and SHOP_CATALOGUES + buttons use the words a custom system's GM chose for shops, money, the bank, the GM and + characters: a game that calls its shops MERCHANTS says "NOTHING HERE THIS MERCHANT WOULD BUY". + A term the system did not rename keeps today's text, and the built-in systems are unchanged. + Custom systems get shops of their own in a later piece. + +- **A custom system's words show on the sheet, the token window and the HEALTH folder.** When the + GM who built a custom system gives hit points, the character, the GM or initiative a name of + their own, those places use it: a game that calls hit points VIGOR shows VIGOR where HIT_POINTS + was. A term it did not rename keeps today's text. Nothing changes for the built-in systems, and + no custom system can be made yet. + +- **Custom game systems can rename the app's words.** A system's own words for terms such as HP, + credits, level and GM now reach every window, ready for the windows to use them. Nothing on + screen changes yet, and the built-in systems keep their wording exactly. + +- **Custom game systems can be shared as files.** A published system exports as one readable + `.citysys` file, and installs on another server after a preview that changes nothing: as a new + system, an update, or a second copy beside it. It is never merged, and a file never carries + characters. Deleting a custom system now hides it instead, so reinstalling its file brings it + back with every character played in it. + +- **Custom game systems choose how health works.** Creating one asks how its core works: the + health model (one pool, two tracks, damage types, harm levels, a wound count, hit locations, or + none), how characters advance, its common dice and its unit of distance. Each health model + takes damage by its own rules and has its own HEALTH folder, and other players still see a + description, never a number. The built-in systems' health is unchanged. + +- **Custom game systems can keep fields for the GM, and give NPCs their own stat blocks.** A + player sees a GM-only field, such as XP or an awarded item, but cannot change it, including by + uploading a sheet. NPCs can have a shorter layout and power tiers for GENERATE_SHEET. + +- **A published custom game system can run the game.** It appears in the game system picker + beside the built-in ones, players' sheets are drawn from its own layout by the same sheet + window, and its derived values are worked out on every save. Players' browsers receive the + sheet's layout and wording, never its formulas. There is no editor for building one yet. + +- **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. diff --git a/README.md b/README.md index 854e1828..0b8a2862 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,8 @@ 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 @@ -334,7 +336,7 @@ CITY_NET/ │ ├── 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 @@ -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 (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. Main admin only, reading included │ │ └── 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 @@ -385,7 +388,7 @@ CITY_NET/ │ │ ├── cwnSkillplugs.js # Skillplugs (p64). A plug grants a skill while loaded, overlaid on read like everything else here. Also the one place the app overrides a roll it has already computed: the worst the dice can do is an automatic failure no reroll can save, and the jack then locks for the scene. The intellectual/physical split is OURS and says so - the book gives a principle and six examples rather than a list │ │ ├── charwn.js # Characters Without Number exports. An ADAPTER, not a second importer: their file is flattened into the same candidates a filled-in PDF produces and handed to the ordinary mapper, so the alias table, the skill normaliser, the inventory parser and the cyberware gather all run unchanged. The rule for extending it is therefore to emit a label the FORM already prints │ │ ├── importers.js # Modular sheet import — PDF form extraction + data-driven per-system field mappers (makeMapFields) -│ │ └── npcTiers.js # Per-system NPC power tiers for GENERATE_SHEET (CP:R: Mook→Elite; CWN: +Spirits; SR6: Ganger→Prime Runner) +│ │ └── npcTiers.js # Per-system NPC power tiers for GENERATE_SHEET (CP:R: Mook→Elite; CWN: +Spirits; SR6: Ganger→Prime Runner); a published custom system's tiers through a hook, built-ins looked up first │ ├── shops/ # Buying and selling. The server decides every price and every payout; the window only prints them │ │ ├── prices.js # The built-in CWN catalogues as id -> [label, price], mirrored from the frontend tables and cross-checked line by line. Labels are here because a sheet records what you own by name │ │ ├── purchase.js # What a purchase does to an account, pure. Short of funds with the overdraft house rule on, it refuses with needs_choice rather than picking between debt and a negative balance for the player @@ -397,11 +400,31 @@ 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 +│ │ ├── sheet.js # A custom system's character sheet as data (tabs, header, sections of text/number/textarea/select fields with visibility, combat sensitivity, who edits it (the owner, or only the GM), max pairs and token/bank links) and its checks; a starter sheet for a system that has none +│ │ ├── terms.js # The glossary: the 13 terms a system may rename, their neutral defaults (for the builder), and ownWords, only the terms a system renamed (for the running game) +│ │ ├── core.js # A custom system's setup answers: the health model (one pool, two tracks, damage types, harm levels, wound count, hit locations, none) with its settings, advancement styles, common dice and distance unit, and their checks; the health part of the starter sheet each model gives (no answer = the one-pool starter every system had) +│ │ ├── 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) +│ │ ├── 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 +│ │ ├── 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 +│ │ └── 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, and deleting hides a system so reinstalling its file brings it back with its characters; export, preview and install (new, update when unchanged here, keep both with a new origin; never a merge) │ ├── 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 @@ -410,6 +433,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 @@ -432,6 +456,19 @@ 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_runtime.test.js # The sheet format's checks and starter sheet; a published system known to the game (never a draft), answering the same helpers as the built-ins without changing them, its render copy free of formulas, switched to from the picker, and a player's edit recomputing its derived values +│ ├── system_builder_core.test.js # The setup answers: every health model's starter sheet (and that it passes the sheet checks), the unanswered starter unchanged to the byte, and every mistake reported with where it is +│ ├── system_builder_health.test.js # Every health model's DAMAGE and HEAL rules; the HIT_POINTS route using them for players and linked NPCs, refusing what a model cannot do, keeping an edit made while damage lands, and leaving the built-in systems and a custom one-pool system on the route as before +│ ├── system_builder_initiative_words.test.js # The dice log's initiative lines, written by the server: today's text under every built-in system and an unrenamed custom one, a custom system's own word (in capitals) where it renamed initiative +│ ├── system_builder_health_view.test.js # Every model's full and described view; the socket sending the full one only to the GM, a granted editor or the owner (never through an NPC's owner field) and answering only the asker; a second track's SET MAX; the moved-up and turned-heavier details +│ ├── system_builder_npc_privacy.test.js # GM-only fields refused to the owner by edit, batch and upload but not to the GM or a granted admin; the NPC layout and tier checks; tiers generating a sheet and setting (or keeping) the token's HP and defense; built-ins unchanged +│ ├── system_builder_citysys.test.js # Export (published only, readable, never a character), reading a file as untrusted input, a preview that changes nothing, installing as new / update / keep both with their refusals, deleting as a hide, and a deleted system coming back under its old id with its characters +│ ├── system_builder_words.test.js # Every term in every form resolved for the builder; only the terms a system renamed sent with its sheet; the server's own text, the starter sheet and a one-pool HEALTH folder using a system's word only where it chose one +│ ├── 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) @@ -522,10 +559,14 @@ CITY_NET/ │ │ │ └── useAmbientHum.ts # The ambient hum as one sound for the session: its first start eases in (slowly after the boot, quickly on a refresh); the volume slider and mute adjust it in place. Only a real 'playing' counts as started, so a start the browser holds back is asked again on the next key or press │ │ ├── components/ │ │ │ ├── AdminPanel.tsx # GM dashboard — CITY / EXPORT / GAME / PLAYERS tabs; CITY_GENERATOR delegates to cityGen/ and exposes LAYOUT, DRAG_RECT/DRAW_AREA bounds, OVERPASS_DENSITY, WATER, PARK_PONDS, an optional SEED and REGENERATE; CUSTOM type integrates into NEXT_STYLE cycle using cross-map custom_structure_library; data-driven HouseRulesPanel for CP:R, CWN, and SR6; SR6 Edge replenishment (reset all / give 1 to player) +│ │ │ ├── SystemPicker.tsx # GAME tab's system picker: a searchable dropdown, BUILT-IN then YOUR SYSTEMS, portalled into the theme, asking before it switches the game for everyone +│ │ │ ├── systemPickerRules.ts # The picker's rules on their own: grouping and order, search, arrow-key movement, what the button says │ │ │ ├── InventorySection.tsx # The inventory table, on every system. Its own file because SheetRenderer is long enough, and generic: which rows carry an extra button, and what pressing it does, is supplied from outside - so a drug offers CONSUME and a skillplug offers LOAD without either knowing about the other │ │ │ ├── PharmaSection.tsx # What is currently in the bloodstream, drawn in the sheet HEADER rather than a tab: a drug that wears off at the end of a scene and bills System Strain for it is not something to hide behind a tab somebody might not open. Draws nothing at all while a character is on nothing │ │ │ ├── XpWindow.tsx # AWARD_EXPERIENCE — points each rather than a pot to divide, with LEVEL_UP and LEVEL_DOWN for correcting a level on purpose │ │ │ ├── HitPoints.tsx # The HEALTH folder's two bodies: HitPointsPanel changes health (own token, or any for the GM) under a live heart monitor, with injuries and STIM_HEAL (CWN); HealthReviewPanel only watches someone else's - the monitor, a stun bar and the injury map, never a number - with STABILIZE for an ally on a mortal wound +│ │ │ ├── healthBands.ts # How hurt, as a band the heart monitor draws: its color and its rhythm (steady over half, twice as fast at half or less, fast, uneven and weakening at a quarter or less, flatline when down), on the same thresholds, for every system and the stream overlay; harm levels read their worst level instead +│ │ │ ├── HealthModelPanels.tsx # The HEALTH folder under a custom system's health model (not one pool): each model's editor (track, damage type, harm level and hit location pickers, harm notes, wound pips and penalty, the GM's SET rows) saying what happened, and what other players see instead of numbers; HitPoints.tsx keeps the monitor, injury map and TEMP_HP around it │ │ │ ├── BankWindows.tsx # Player bank UI; the candle chart is nudged by balance changes on a log scale, so a fortune is a tall candle rather than a spike that flattens the rest │ │ │ ├── ChatWindow.tsx # In-game chat │ │ │ ├── DiceTray.tsx # Dice roller; SR6 pool results show a pulsing GLITCH / CRITICAL GLITCH banner; initiative rolls appear with full breakdown; `sidesForKey` picks the 3D shape (custom dice key results by name and carry their side count in `diceSides`) @@ -584,6 +625,8 @@ CITY_NET/ │ │ │ ├── UpdateModal.tsx # Draggable update notification modal (shown on admin login when update available; Update Now / Remind Me Later / Skip Version; docker-aware) │ │ │ └── __tests__/ # Component unit tests (Vitest + Testing Library) │ │ │ ├── AdminPanel.test.tsx +│ │ │ ├── SystemPicker.test.tsx # The game-system dropdown: search, keys, the themed container, asking before a switch, nothing asked about the running system, and a refused switch said so +│ │ │ ├── systemPickerRules.test.ts # Built-ins in their fixed order then custom A to Z, empty groups left out, every-word search, wrapping arrow keys, the version tag │ │ │ ├── AttackAnimations.test.tsx │ │ │ ├── BankWindows.test.tsx │ │ │ ├── shopCart.test.ts # Cart lines and counts, totals both ways, sells grouped for the server, and the carry projection @@ -597,6 +640,10 @@ CITY_NET/ │ │ │ ├── DraggableWindow.test.tsx │ │ │ ├── BootScreen.test.tsx # Types out by itself and finishes, skips on SKIP and nothing else, reports lines and clicks │ │ │ ├── HitPoints.test.tsx +│ │ │ ├── wordsInBankAndShops.test.tsx # The glossary in the SHOP and VIEW_BANK menu buttons and the empty-shop steps: today's text under every built-in system and an unrenamed custom one, a custom system's own words where it renamed them (the shop window's own are in ShopWindow.test.tsx) +│ │ │ ├── wordsInWindows.test.tsx # The glossary in the sidebar, sheet header, HEALTH folder and token window: exactly today's text under every built-in system and under a custom system that renamed nothing; a custom system's own words, in the label style, where it did +│ │ │ ├── HealthModelPanels.test.tsx # Each model's editor sending what the server expects and reporting it (spills, boxes turning heavier, harm moving up, out, refusals), the GM-only SET rows, other players seeing no numbers or notes, and the built-in systems' folder untouched and never asking +│ │ │ ├── healthBands.test.tsx # The bands' thresholds and colors, each rhythm's beats (the steady one unchanged, beats inside their stretch, weak ones drawn smaller), reduced motion, and the same band in the editing panel, the review panel and the stream │ │ │ ├── MapElements.test.tsx │ │ │ ├── MeasurementTool.test.tsx │ │ │ ├── SignRotation.test.tsx # LAY_FLAT / STAND_UP presets, per-axis sliders, all three axes reaching the PATCH body @@ -648,6 +695,7 @@ CITY_NET/ │ │ │ │ ├── cwn.ts # 1d8+DEX mod roll; ROUND counter; PCs win ties; defaultMode: 'side' │ │ │ │ └── random.ts # cryptoRng — uniform [0,1) from crypto.getRandomValues; shared by every system │ │ │ └── __tests__/ +│ │ │ ├── initiativeWords.test.tsx # The glossary in the tracker, side view and nav panel: today's text under every built-in system (ROUND, PASS, TURN kept) and an unrenamed custom one, a custom system's own words for initiative and the turn where it renamed them │ │ │ ├── systems.test.ts # Registry lookup, generic/SR6/CP:R/CWN formulas, extra dice, breakdown format, diceResults shape │ │ │ ├── npcPortrait.test.ts # A silhouetted NPC enters initiative with no portrait, since the tracker goes to every player │ │ │ ├── random.test.ts # Browser cryptoRng range/uniqueness; every system exercised on its default rng @@ -659,6 +707,8 @@ CITY_NET/ │ │ │ ├── useSocket.ts # Socket.IO connection and all event listeners │ │ │ ├── useApi.ts # Fetch helpers │ │ │ ├── useMapExport.ts # PNG/WebM city export — one cached off-screen renderer for the session, shared ortho camera, GPU size clamp, per-frame render loop for video, MediaRecorder with codec fallback; never touches the live camera +│ │ │ ├── useCustomTemplates.ts # Redraws when a custom system's sheet template arrives, and fetches the running system's ahead of need +│ │ │ ├── useHealthView.ts # A token's health under a custom model, as the server lets this viewer see it: asked over the socket (requestHealthView), again on every sheet or map change │ │ │ ├── useMapData.ts # Location/district/road/overpass/water body/sign data fetching. Sends the GM's sign-in with the location list, held in a ref so signing in does not give fetchLocations a new identity │ │ │ ├── useCustomDice.ts # Custom dice state — fetches GM dice and the active system's built-ins, merges them (built-ins first, flagged `locked`), and applies `customDiceUpdated` broadcasts │ │ │ ├── useEnemyVehicles.ts # The GM's enemy vehicles, and the tokens on the map level that could fill their seats. Asked for rather than pushed, and refused to anyone but the GM — so a player's client never holds enemy pools or armour at all, which is what keeps "what may players see" from being a question the feature has to answer @@ -675,10 +725,12 @@ CITY_NET/ │ │ │ └── useSocket.pendingRequests.test.ts # Pending edit-request state; regression for stale requests on newly-promoted temp admins │ │ ├── data/ │ │ │ ├── buildingTypes.ts # What a building is for and which catalogues it sells, mirrored from the server, plus which systems have shops (every one with a sheet, held equal to the server's gate by a test) and what each game calls a storefront - same ids everywhere, so a Ripperdoc becomes a Street Doc when the system changes rather than disappearing -│ │ │ └── shopRules.ts # The overdraft house rule, the buy-back rate and how it resolves, and the words for every refusal. Mirrored from backend/shops and compared against it value for value, since the window quoting one price and the server paying another is the failure worth fearing +│ │ │ └── shopRules.ts # The overdraft house rule, the buy-back rate and how it resolves, and the words for every refusal (refusalText, in a custom system's own words for shops, money and characters). Mirrored from backend/shops and compared against it value for value, since the window quoting one price and the server paying another is the failure worth fearing │ │ ├── sheets/ │ │ │ ├── types.ts # Sheet template type system (fields, sections, header, death saves, NPC tiers) -│ │ │ ├── index.ts # Template registry, getMaxPairs, GATED_TABS/hiddenTabsFor (house-rule-gated sheet tabs) +│ │ │ ├── index.ts # Template registry, getMaxPairs, GATED_TABS/hiddenTabsFor (house-rule-gated sheet tabs). getTemplate also answers for published custom systems +│ │ │ ├── customTemplates.ts # Custom systems' sheets: the server's render copy turned into a SheetTemplate for the ordinary SheetRenderer (derived values read-only, only armor writing through to the token, GM-only fields marked), with the system's NPC layout and tiers and its words; fetched once and cached, with an event the app redraws on +│ │ │ ├── words.ts # The glossary in the browser: word(term, form, today's text). A built-in system always gets today's text back, so its wording never changes; a custom system gets its own word for a term it renamed, and today's text for the rest. asLabel puts a word in the terminal-label style (HIT_POINTS). useWords redraws when the words arrive │ │ │ ├── SheetPage.tsx # Standalone browser-tab sheet (?sheet=true); reads theme from auth handshake or localStorage; shares logic via usePlayerSheet │ │ │ ├── vehiclePresets.ts # The CWN vehicle table (p.82) — picking a TYPE fills the stat block. Armour left unset on the * and ** vehicles: those are immunities the GM rules on, not numbers │ │ │ ├── vehicleWeapons.ts # The ten weapons a hardpoint can carry (p.81). Damage stored as clean dice; the book's ! rides on the trauma value, since only marked weapons can traumatise a vehicle diff --git a/backend/__tests__/bank_accounts.test.js b/backend/__tests__/bank_accounts.test.js new file mode 100644 index 00000000..a3ba4e06 --- /dev/null +++ b/backend/__tests__/bank_accounts.test.js @@ -0,0 +1,311 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import { createRequire } from 'module'; +import { makeTestDb, get, all, run } from './helpers/testDb.js'; +import { until, untilValue, drain } from './helpers/until.js'; + +/** + * One bank account per player per game system. + * + * A character's money belongs to the game it was earned in, so a CWN character's credits + * must not turn up in a D&D campaign. The move from the old one-bank-per-player table copies + * each balance into every system the player has a sheet in, plus the running one, once, after + * copying the whole database - and never touches the old table, so nothing can be lost. + */ + +process.env.JWT_SECRET = 'test-secret'; +process.env.DICE_ANIM_MS = '0'; +const require_ = createRequire(import.meta.url); +const accounts = require_('../bank/accounts'); +const { migrateBankAccounts, MARKER } = require_('../startup/bankAccounts'); +const { backupDatabase } = require_('../startup/backup'); +const socketsFactory = require_('../sockets/index.js'); +const sqlite3 = require_('sqlite3'); + +const CWN = 'cities_without_number'; +const CPR = 'cyberpunk_red'; +const cb2p = (fn, ...args) => new Promise((resolve, reject) => fn(...args, (err, v) => (err ? reject(err) : resolve(v)))); +const quiet = { log: () => {}, warn: () => {} }; + +let db; +beforeEach(async () => { db = await makeTestDb(); }); +afterEach(() => { accounts.setReady(Promise.resolve()); vi.restoreAllMocks(); }); + +describe('accounts', () => { + it('keeps each system\'s money apart', async () => { + await cb2p(accounts.put, db, 'GHOST', CWN, 500, 20); + await cb2p(accounts.put, db, 'GHOST', CPR, 7, 0); + expect(await cb2p(accounts.get, db, 'GHOST', CWN)).toMatchObject({ balance: 500, debt: 20 }); + expect(await cb2p(accounts.get, db, 'GHOST', CPR)).toMatchObject({ balance: 7, debt: 0 }); + expect(await cb2p(accounts.get, db, 'GHOST', 'shadowrun_6e')).toBeNull(); + }); + + it('opens an account at zero when asked to make sure of one', async () => { + expect(await cb2p(accounts.ensure, db, 'GHOST', CWN)).toMatchObject({ balance: 0, debt: 0, first_pay_done: 0 }); + await cb2p(accounts.put, db, 'GHOST', CWN, 50, 0); + expect((await cb2p(accounts.ensure, db, 'GHOST', CWN)).balance).toBe(50); + }); + + it('adds to a balance, opening the account if needed', async () => { + await cb2p(accounts.addToBalance, db, 'GHOST', CWN, 100); + await cb2p(accounts.addToBalance, db, 'GHOST', CWN, 25.5); + expect((await cb2p(accounts.get, db, 'GHOST', CWN)).balance).toBe(125.5); + }); + + it('moves an existing account, and leaves a missing one alone, as withdrawals always did', async () => { + await cb2p(accounts.put, db, 'GHOST', CWN, 100, 10); + expect(await cb2p(accounts.adjust, db, 'GHOST', CWN, { balance: -30, debt: 5 })).toBe(1); + expect(await cb2p(accounts.get, db, 'GHOST', CWN)).toMatchObject({ balance: 70, debt: 15 }); + expect(await cb2p(accounts.adjust, db, 'NOBODY', CWN, { balance: -30 })).toBe(0); + expect(await cb2p(accounts.get, db, 'NOBODY', CWN)).toBeNull(); + }); + + it('marks one-time events per account, and only the known ones', async () => { + await cb2p(accounts.ensure, db, 'GHOST', CWN); + await cb2p(accounts.markFlag, db, 'GHOST', CWN, 'first_pay_done'); + expect((await cb2p(accounts.get, db, 'GHOST', CWN)).first_pay_done).toBe(1); + await expect(cb2p(accounts.markFlag, db, 'GHOST', CWN, 'balance = 999999, first_pay_done')).rejects.toThrow(/unknown bank flag/); + }); + + it('holds every operation until the move has finished', async () => { + let finish; + accounts.setReady(new Promise((resolve) => { finish = resolve; })); + let seen = 'waiting'; + accounts.ensure(db, 'GHOST', CWN, () => { seen = 'ran'; }); + await drain(db); + expect(seen).toBe('waiting'); + finish(); + await untilValue(() => seen, (s) => s === 'ran', { label: 'the held operation' }); + }); + + it('turns a failed move into an error for each operation, never a crash', async () => { + accounts.setReady(Promise.reject(new Error('move failed'))); + await expect(cb2p(accounts.get, db, 'GHOST', CWN)).rejects.toThrow('move failed'); + }); +}); + +describe('the one-time move from one bank per player', () => { + const legacy = async (rows) => { + await run(db, `CREATE TABLE player_banks (username TEXT PRIMARY KEY, balance REAL DEFAULT 0, debt REAL DEFAULT 0, + first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); + for (const r of rows) { + await run(db, 'INSERT INTO player_banks (username, balance, debt, first_pay_done, high_roller_done) VALUES (?, ?, ?, ?, ?)', + [r.username, r.balance, r.debt ?? 0, r.first ?? 0, r.high ?? 0]); + } + }; + const sheet = (username, system) => run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, '{}', 0)`, [username, system]); + const accountsOf = (username) => all(db, + 'SELECT system, balance, debt, first_pay_done, high_roller_done FROM bank_accounts WHERE username = ? ORDER BY system', [username]); + + beforeEach(async () => { + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [CWN]); + }); + + it('copies each balance into every system the player has a sheet in, and the running one', async () => { + await legacy([ + { username: 'GHOST', balance: 1200.5, debt: 300, first: 1 }, + { username: 'NEWBIE', balance: 40 }, + ]); + await sheet('GHOST', CPR); + await sheet('GHOST', 'shadowrun_6e'); + // An NPC sheet is not a player's game. + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', 'generic', '{}', 1)`); + + const result = await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect(result).toMatchObject({ ran: true, accounts: 4 }); + + const one = { balance: 1200.5, debt: 300, first_pay_done: 1, high_roller_done: 0 }; + expect(await accountsOf('GHOST')).toEqual([ + { system: CWN, ...one }, { system: CPR, ...one }, { system: 'shadowrun_6e', ...one }, + ]); + // No sheets at all: the running game still gets their money. + expect(await accountsOf('NEWBIE')).toEqual([{ system: CWN, balance: 40, debt: 0, first_pay_done: 0, high_roller_done: 0 }]); + }); + + it('never changes the old table', async () => { + await legacy([{ username: 'GHOST', balance: 99, debt: 1 }]); + const before = await all(db, 'SELECT * FROM player_banks'); + await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect(await all(db, 'SELECT * FROM player_banks')).toEqual(before); + }); + + it('runs once: a sheet made later in a new system starts that bank at zero', async () => { + await legacy([{ username: 'GHOST', balance: 500 }]); + await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect(await get(db, 'SELECT value FROM global_settings WHERE key = ?', [MARKER])).toBeTruthy(); + + await sheet('GHOST', CPR); + expect(await migrateBankAccounts(db, ':memory:', { log: quiet })).toEqual({ ran: false }); + expect((await accountsOf('GHOST')).map((a) => a.system)).toEqual([CWN]); + }); + + it('with nothing to move, only records that it ran, and makes no copy', async () => { + const result = await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect(result).toEqual({ ran: true, accounts: 0, backup: null }); + expect(await get(db, 'SELECT value FROM global_settings WHERE key = ?', [MARKER])).toBeTruthy(); + }); + + it('keeps an account that somehow exists already', async () => { + await legacy([{ username: 'GHOST', balance: 500 }]); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('GHOST', ?, 7, 0)`, [CWN]); + await migrateBankAccounts(db, ':memory:', { log: quiet }); + expect((await accountsOf('GHOST'))[0].balance).toBe(7); + }); + + it('lands all or nothing: a failure part way leaves no accounts and no marker, to try again', async () => { + await legacy([{ username: 'AAA', balance: 1 }, { username: 'ZZZ', balance: 2 }]); + // Refuse the second player's account, after the first has been written. + await run(db, `CREATE TRIGGER refuse BEFORE INSERT ON bank_accounts WHEN NEW.username = 'ZZZ' + BEGIN SELECT RAISE(ABORT, 'refused'); END`); + await expect(migrateBankAccounts(db, ':memory:', { log: quiet })).rejects.toThrow('refused'); + expect(await all(db, 'SELECT * FROM bank_accounts')).toEqual([]); + expect(await get(db, 'SELECT value FROM global_settings WHERE key = ?', [MARKER])).toBeUndefined(); + // And the connection is usable afterwards, not stuck in the transaction. + await run(db, 'DROP TRIGGER refuse'); + expect((await migrateBankAccounts(db, ':memory:', { log: quiet })).accounts).toBe(2); + }); +}); + +describe('the database copy before a migration', () => { + let dir; + let fileDb; + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'citynet-backup-')); + fileDb = await new Promise((resolve, reject) => { + const d = new sqlite3.Database(path.join(dir, 'city.db'), (err) => (err ? reject(err) : resolve(d))); + }); + await run(fileDb, 'CREATE TABLE player_banks (username TEXT PRIMARY KEY, balance REAL)'); + await run(fileDb, `INSERT INTO player_banks VALUES ('GHOST', 1234)`); + }); + afterEach(async () => { + await new Promise((resolve) => fileDb.close(resolve)); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('writes a whole, readable copy beside the database', async () => { + const result = await cb2p(backupDatabase, fileDb, path.join(dir, 'city.db'), 'test'); + expect(path.dirname(result.path)).toBe(dir); + expect(path.basename(result.path)).toMatch(/^city\.db\.before-test-\d{4}-\d\d-\d\dT\d\d-\d\d-\d\d\.bak$/); + const copy = await new Promise((resolve, reject) => { + const d = new sqlite3.Database(result.path, sqlite3.OPEN_READONLY, (err) => (err ? reject(err) : resolve(d))); + }); + expect(await get(copy, 'SELECT balance FROM player_banks')).toEqual({ balance: 1234 }); + await new Promise((resolve) => copy.close(resolve)); + }); + + it('makes no copy, and says why, when the disk is too full for one', async () => { + const result = await new Promise((resolve, reject) => backupDatabase(fileDb, path.join(dir, 'city.db'), 'test', + (err, r) => (err ? reject(err) : resolve(r)), { free: () => 1024 })); + expect(result.skipped).toMatch(/not enough disk space/); + expect(fs.readdirSync(dir).filter((f) => f.endsWith('.bak'))).toEqual([]); + }); + + it('has nothing to copy for an in-memory database', async () => { + expect(await cb2p(backupDatabase, db, ':memory:', 'test')).toEqual({ skipped: 'in-memory database' }); + }); +}); + +describe('money in play, with more than one system', () => { + const server = () => { + const sent = []; + let connectionCb; + const io = { + on: (event, cb) => { if (event === 'connection') connectionCb = cb; }, + emit: (event, data) => sent.push({ event, data }), + to: () => ({ emit: (event, data) => sent.push({ event, data }) }), + }; + socketsFactory(io, db, { elevatedUsers: new Set(), emitUpdate: vi.fn(), recordAction: vi.fn() }); + const connect = async (name) => { + const handlers = {}; + connectionCb({ + id: `bank-${Math.random().toString(36).slice(2)}`, + on: (e, fn) => { handlers[e] = fn; }, + emit: (event, data) => sent.push({ event, data, self: true }), + broadcast: { emit: () => {} }, use: () => {}, join: () => {}, disconnect: vi.fn(), + }); + handlers.identify(name); + await drain(db); + return handlers; + }; + return { sent, connect }; + }; + const setSystem = (system) => run(db, `INSERT OR REPLACE INTO global_settings (key, value) VALUES ('game_system', ?)`, [system]); + const latestBalance = (sent, username) => { + const u = sent.filter((e) => e.event === 'bankUpdate' && e.data.username === username).at(-1); + return u ? u.data.balance : undefined; + }; + + beforeEach(() => { vi.spyOn(console, 'log').mockImplementation(() => {}); }); + + it('shows and moves the running system\'s account, and keeps the other one intact', async () => { + await setSystem(CWN); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('GHOST', ?, 1000, 0)`, [CWN]); + const s = server(); + const ghost = await s.connect('GHOST'); + + ghost.requestBankBalance({ username: 'GHOST' }); + expect(await untilValue(() => latestBalance(s.sent, 'GHOST'), (b) => b === 1000, { label: 'CWN balance' })).toBe(1000); + + // A new campaign on another system: a fresh account, not the CWN money. + await setSystem(CPR); + ghost.requestBankBalance({ username: 'GHOST' }); + // until, not untilValue: that one hands back the value, and a balance of 0 reads as "not yet". + await until(() => latestBalance(s.sent, 'GHOST') === 0, { label: 'a fresh CP:R account' }); + ghost.borrowFunds({ amount: 50 }); + await untilValue(() => get(db, 'SELECT debt FROM bank_accounts WHERE username = ? AND system = ?', ['GHOST', CPR]), + (r) => r && r.debt === 50, { label: 'CP:R debt' }); + + // Back to the CWN campaign: its money is exactly where it was. + await setSystem(CWN); + expect(await get(db, 'SELECT balance, debt FROM bank_accounts WHERE username = ? AND system = ?', ['GHOST', CWN])) + .toEqual({ balance: 1000, debt: 0 }); + }); +}); + +describe('the real startup path', () => { + it('moves an existing server\'s banks when db.js opens it, after copying the database', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'citynet-startup-')); + const file = path.join(dir, 'city.db'); + try { + // A database as a server running 1.14.4 left it: one bank per player. + const seed = [ + `CREATE TABLE global_settings (key TEXT PRIMARY KEY, value TEXT)`, + `INSERT INTO global_settings VALUES ('game_system', '${CWN}')`, + `CREATE TABLE character_sheets (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, system TEXT NOT NULL, + data TEXT NOT NULL DEFAULT '{}', portrait_url TEXT, is_npc INTEGER DEFAULT 0, npc_label TEXT, folder TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)`, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', '${CPR}', '{}', 0)`, + `CREATE TABLE player_banks (username TEXT PRIMARY KEY, balance REAL DEFAULT 0.00, debt REAL DEFAULT 0.00, + first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`, + `INSERT INTO player_banks VALUES ('GHOST', 750, 25, 1, 0)`, + ]; + const script = ` + const sqlite3 = require(${JSON.stringify(require_.resolve('sqlite3'))}); + const seedDb = new sqlite3.Database(${JSON.stringify(file)}); + seedDb.serialize(() => { for (const s of ${JSON.stringify(seed)}) seedDb.run(s); }); + seedDb.close(() => { + process.env.DB_PATH = ${JSON.stringify(file)}; + console.log = () => {}; console.warn = () => {}; + const db = require(${JSON.stringify(require_.resolve('../db.js'))}); + const accounts = require(${JSON.stringify(require_.resolve('../bank/accounts.js'))}); + accounts.get(db, 'GHOST', '${CPR}', (err, cpr) => { + accounts.get(db, 'GHOST', '${CWN}', (err2, cwn) => { + // Exit rather than close: db.js's other startup work may still be queued. + process.stdout.write(JSON.stringify({ err: err && err.message, cpr, cwn }), () => process.exit(0)); + }); + }); + });`; + const out = JSON.parse(execFileSync(process.execPath, ['-e', script], { encoding: 'utf8', timeout: 60000 })); + const moved = { balance: 750, debt: 25, first_pay_done: 1, high_roller_done: 0 }; + expect(out).toEqual({ err: null, cpr: moved, cwn: moved }); + const copies = fs.readdirSync(dir).filter((f) => /^city\.db\.before-bank-accounts-.*\.bak$/.test(f)); + expect(copies).toHaveLength(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/backend/__tests__/bank_own_account.test.js b/backend/__tests__/bank_own_account.test.js index b466370d..06ded420 100644 --- a/backend/__tests__/bank_own_account.test.js +++ b/backend/__tests__/bank_own_account.test.js @@ -46,12 +46,9 @@ function boot(db, id = `bank-sock-${(nextSocket += 1)}`) { let db; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); - await run(db, `INSERT INTO player_banks (username, balance, debt) VALUES ('GHOST', 1000, 500)`); - await run(db, `INSERT INTO player_banks (username, balance, debt) VALUES ('VICTIM', 8000, 0)`); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('GHOST', COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), 1000, 500)`); + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('VICTIM', COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), 8000, 0)`); }); const identified = async (name = 'GHOST') => { @@ -62,7 +59,7 @@ const identified = async (name = 'GHOST') => { }; const bank = async (username) => - get(db, 'SELECT balance, debt FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance, debt FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); describe('what these handlers still do, unchanged', () => { it('withdraws from the caller\'s own balance', async () => { @@ -124,7 +121,7 @@ describe('whose account it is', () => { }); it('will not spend someone else\'s balance on their debt', async () => { - await run(db, `UPDATE player_banks SET debt = 1000 WHERE username = 'VICTIM'`); + await run(db, `UPDATE bank_accounts SET debt = 1000 WHERE username = 'VICTIM' AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`); const { handlers } = await identified('GHOST'); handlers['payDebt']({ username: 'VICTIM', amount: 500 }); await drain(db); diff --git a/backend/__tests__/cpr_cyberware_roll.test.js b/backend/__tests__/cpr_cyberware_roll.test.js index 6c1f7298..13a31952 100644 --- a/backend/__tests__/cpr_cyberware_roll.test.js +++ b/backend/__tests__/cpr_cyberware_roll.test.js @@ -66,7 +66,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cyberpunk_red')`); }); diff --git a/backend/__tests__/cwn_seating.test.js b/backend/__tests__/cwn_seating.test.js index 12879b7b..1e011480 100644 --- a/backend/__tests__/cwn_seating.test.js +++ b/backend/__tests__/cwn_seating.test.js @@ -59,7 +59,6 @@ beforeEach(async () => { await run(db, `CREATE TABLE dice_rolls ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, total INTEGER, results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); }); diff --git a/backend/__tests__/cwn_skillplugs_sockets.test.js b/backend/__tests__/cwn_skillplugs_sockets.test.js index fc782b3b..edb282d4 100644 --- a/backend/__tests__/cwn_skillplugs_sockets.test.js +++ b/backend/__tests__/cwn_skillplugs_sockets.test.js @@ -45,7 +45,6 @@ beforeEach(async () => { id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, total INTEGER, results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); }); diff --git a/backend/__tests__/cwn_sockets.test.js b/backend/__tests__/cwn_sockets.test.js index e5d1a754..588c7102 100644 --- a/backend/__tests__/cwn_sockets.test.js +++ b/backend/__tests__/cwn_sockets.test.js @@ -65,7 +65,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); }); diff --git a/backend/__tests__/cwn_vehicle_combat.test.js b/backend/__tests__/cwn_vehicle_combat.test.js index fd8fd125..beaaed82 100644 --- a/backend/__tests__/cwn_vehicle_combat.test.js +++ b/backend/__tests__/cwn_vehicle_combat.test.js @@ -61,7 +61,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); // Trauma multiplies damage on a die roll, which would make every damage assertion // below probabilistic. The rule is tested on its own elsewhere. diff --git a/backend/__tests__/gm_route_auth.test.js b/backend/__tests__/gm_route_auth.test.js new file mode 100644 index 00000000..152ef77f --- /dev/null +++ b/backend/__tests__/gm_route_auth.test.js @@ -0,0 +1,404 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'module'; +import { makeTestDb, get, run } from './helpers/testDb.js'; +import { drain } from './helpers/until.js'; + +/** + * Who may use the GM's doors. + * + * Every token the server issues is signed with one secret: the GM's login (role 'admin'), a + * player's login (role 'player'), and a granted editor's (isTemporary). A valid signature only + * says the server issued it. The checks used to stop there, so a player's own login token got + * past `authenticate` and every "not temporary" test: it deleted buildings, read GM notes, + * approved accounts, and at socket sign-in it made the player a full socket admin (grant editor + * rights, set anyone's bank balance, speak as anyone in chat). + * + * What must keep working, and is held here too: the GM's login, granted editors (grant, use, + * revoke, surrender), players' own sheet and portrait, and chat. + */ + +process.env.JWT_SECRET = 'test-secret'; +process.env.DICE_ANIM_MS = '0'; +const SECRET = 'test-secret'; + +const require_ = createRequire(import.meta.url); +// The same module instances the routes and sockets hold, so a grant made here is seen there. +const auth = require_('../middleware/auth'); +const { authenticate, authenticatePlayer, optionalAuthenticate, elevatedUsers } = auth; +const socketsFactory = require_('../sockets/index.js'); + +/** Exactly what each login signs. */ +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, SECRET); // routes/admin.js +const PLAYER = jwt.sign({ username: 'vex', role: 'player', tempPassword: false }, SECRET, { expiresIn: '7d' }); // routes/player.js +const RESET = jwt.sign({ username: 'vex', role: 'player_reset' }, SECRET, { expiresIn: '15m' }); // routes/player.js +const EDITOR = jwt.sign({ username: 'ghost', isTemporary: true }, SECRET, { expiresIn: '12h' }); // sockets grantElevatedAccess +const FORGED = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'not-the-secret'); +const bearer = (t) => ({ Authorization: `Bearer ${t}` }); + +afterEach(() => { elevatedUsers.clear(); vi.restoreAllMocks(); }); + +describe('the checks themselves', () => { + const app = express(); + app.get('/gm', authenticate, (req, res) => res.json({ user: req.user.username })); + app.get('/player', authenticatePlayer, (req, res) => res.json({ user: req.user.username })); + app.get('/public', optionalAuthenticate, (req, res) => res.json({ user: req.user ? req.user.username : null })); + const hit = (url, token) => (token ? request(app).get(url).set(bearer(token)) : request(app).get(url)); + + it('GM routes let in the GM', async () => { + const res = await hit('/gm', GM); + expect(res.status).toBe(200); + expect(res.body.user).toBe('gm'); + }); + + it('GM routes let in a granted editor while the grant stands, and not after', async () => { + elevatedUsers.add('ghost'); + expect((await hit('/gm', EDITOR)).status).toBe(200); + elevatedUsers.delete('ghost'); + expect((await hit('/gm', EDITOR)).status).toBe(401); + }); + + it('GM routes refuse a player, a reset token, a forged token and no token', async () => { + expect((await hit('/gm', PLAYER)).status).toBe(403); + expect((await hit('/gm', RESET)).status).toBe(403); + expect((await hit('/gm', FORGED)).status).toBe(400); + expect((await hit('/gm')).status).toBe(401); + }); + + it("a player's own routes let in the player, the GM and an editor, but not a reset token", async () => { + elevatedUsers.add('ghost'); + expect((await hit('/player', PLAYER)).body.user).toBe('vex'); + expect((await hit('/player', GM)).status).toBe(200); + expect((await hit('/player', EDITOR)).status).toBe(200); + expect((await hit('/player', RESET)).status).toBe(403); + expect((await hit('/player')).status).toBe(401); + }); + + it('public routes treat a player as anyone else, and only know the GM or an editor', async () => { + elevatedUsers.add('ghost'); + expect((await hit('/public', PLAYER)).body.user).toBeNull(); + expect((await hit('/public', FORGED)).body.user).toBeNull(); + expect((await hit('/public')).body.user).toBeNull(); + expect((await hit('/public', GM)).body.user).toBe('gm'); + expect((await hit('/public', EDITOR)).body.user).toBe('ghost'); + }); + + it('names what each token is', () => { + const d = (t) => jwt.verify(t, SECRET); + expect(auth.isMainAdmin(d(GM))).toBe(true); + for (const t of [PLAYER, RESET, EDITOR]) expect(auth.isMainAdmin(d(t))).toBe(false); + expect(auth.isPlayer(d(PLAYER))).toBe(true); + expect(auth.isPlayer(d(RESET))).toBe(false); + expect(auth.isMainAdmin(null)).toBe(false); + }); +}); + +/** Every router, mounted where server.js mounts it. */ +const MOUNTS = [ + ['/api/locations', '../routes/locations.js', 'full'], + ['/api/locations/:id/battle_maps', '../routes/battle_maps.js', 'full'], + ['/api/locations/:id', '../routes/buildingDetails.js', 'full'], + ['/api/battle_maps', '../routes/battle_maps.js', 'full'], + ['/api/maps', '../routes/maps.js', 'full'], + ['/api/roads', '../routes/roads.js', 'full'], + ['/api/overpasses', '../routes/overpasses.js', 'full'], + ['/api/signs', '../routes/signs.js', 'full'], + ['/api/custom_dice', '../routes/custom_dice.js', 'full'], + ['/api/fonts', '../routes/fonts.js', 'io'], + ['/api/player', '../routes/player.js', 'io'], + ['/api', '../routes/admin.js', 'full'], + ['/api/music', '../routes/music.js', 'io'], + ['/api/sheets', '../routes/sheets.js', 'io'], + ['/api/systems', '../routes/systems.js', 'db'], +]; + +const helpers = { emitUpdate: () => {}, recordAction: () => {} }; +const io = { emit: () => {}, to: () => ({ emit: () => {} }) }; + +const mountAll = (db) => { + const app = express(); + app.use(express.json()); + const routes = []; + for (const [prefix, file, kind] of MOUNTS) { + const factory = require_(file); + const router = kind === 'full' ? factory(db, io, helpers) : kind === 'db' ? factory(db) : factory(db, io); + app.use(prefix, router); + for (const layer of router.stack) { + if (!layer.route) continue; + const handles = layer.route.stack.map((s) => s.handle); + if (!handles.includes(authenticate)) continue; + for (const method of Object.keys(layer.route.methods)) { + routes.push({ method, url: `${prefix}${layer.route.path}`.replace(/:\w+/g, '1') }); + } + } + } + return { app, routes }; +}; + +describe('every GM route, walked with a player token', () => { + it('refuses a player everywhere the GM check stands', async () => { + const db = await makeTestDb(); + await run(db, `INSERT INTO locations (name, x, y, z) VALUES ('CITY HALL', 0, 0, 0)`); + const { app, routes } = mountAll(db); + // The walk proves nothing if it found nothing to walk. + expect(routes.length).toBeGreaterThan(60); + + const let_in = []; + for (const { method, url } of routes) { + const res = await request(app)[method](url).set(bearer(PLAYER)).send({}); + if (res.status !== 403) let_in.push(`${method.toUpperCase()} ${url} -> ${res.status}`); + } + expect(let_in).toEqual([]); + // And nothing was changed on the way. + expect((await get(db, 'SELECT COUNT(*) AS n FROM locations')).n).toBe(1); + }); + + it('still lets the GM through the same routes', async () => { + const db = await makeTestDb(); + const { app } = mountAll(db); + const res = await request(app).get('/api/player/admin/players').set(bearer(GM)); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); +}); + +describe("a player's own routes", () => { + const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000' + + '1f15c4890000000d4944415478da6364f8ffbf1e000501020149a2b8f90000000049454e44ae426082', 'hex'); + let db; + let app; + const written = []; + + beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'generic')`); + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('vex', 'generic', '{"name":"VEX"}', 0)`); + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('rook', 'generic', '{"name":"ROOK"}', 0)`); + app = express(); + app.use(express.json()); + app.use('/api/sheets', require_('../routes/sheets.js')(db, io)); + }); + + afterEach(() => { + for (const f of written.splice(0)) { try { fs.unlinkSync(f); } catch { /* already gone */ } } + }); + + it('loads their own sheet', async () => { + const res = await request(app).get('/api/sheets/own').set(bearer(PLAYER)); + expect(res.status).toBe(200); + expect(res.body.name).toBe('VEX'); + }); + + it("uploads their portrait to their own sheet, and cannot aim it at someone else's", async () => { + const res = await request(app).post('/api/sheets/portrait?username=rook').set(bearer(PLAYER)) + .attach('portrait', PNG, 'me.png'); + expect(res.status).toBe(200); + written.push(path.join(path.dirname(require_.resolve('../routes/sheets.js')), '..', res.body.portrait_url)); + expect((await get(db, `SELECT portrait_url FROM character_sheets WHERE username = 'vex'`)).portrait_url).toBe(res.body.portrait_url); + expect((await get(db, `SELECT portrait_url FROM character_sheets WHERE username = 'rook'`)).portrait_url).toBeNull(); + }); + + it('are not a way into the GM sheet routes', async () => { + expect((await request(app).get('/api/sheets/user/rook').set(bearer(PLAYER))).status).toBe(403); + }); +}); + +describe('sockets: sign-in, chat and editor rights', () => { + let db; + + /** + * One server on the real handlers, several connections to it, sharing the auth module's + * grant list as server.js does. `sent` records every emit with where it went: 'all' for a + * broadcast, the socket id for io.to(id), 'self' for a reply down one socket. + */ + const server = () => { + const sent = []; + let connectionCb; + const ioFake = { + on: (event, cb) => { if (event === 'connection') connectionCb = cb; }, + emit: (event, data) => sent.push({ event, data, to: 'all' }), + to: (id) => ({ emit: (event, data) => sent.push({ event, data, to: id }) }), + }; + socketsFactory(ioFake, db, { elevatedUsers, emitUpdate: vi.fn(), recordAction: vi.fn() }); + const connect = async (identify) => { + const handlers = {}; + const socket = { + id: `auth-${Math.random().toString(36).slice(2)}`, + on: (event, fn) => { handlers[event] = fn; }, + emit: (event, data) => sent.push({ event, data, to: 'self' }), + broadcast: { emit: () => {} }, + use: () => {}, join: () => {}, disconnect: vi.fn(), + }; + connectionCb(socket); + handlers.identify(identify); + await drain(db); + return { id: socket.id, handlers }; + }; + return { sent, connect }; + }; + const events = (sent, name) => sent.filter((e) => e.event === name); + + beforeEach(async () => { + db = await makeTestDb(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + describe('chat', () => { + it('a player chats as themselves', async () => { + const s = server(); + const vex = await s.connect('vex'); + vex.handlers.sendMessage({ sender: 'vex', text: 'on my way' }); + await drain(db); + expect(events(s.sent, 'receiveMessage').at(-1).data).toMatchObject({ sender: 'vex', text: 'on my way' }); + }); + + it('the GM can speak as someone else', async () => { + const s = server(); + const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); + gm.handlers.sendMessage({ sender: 'FIXER', text: 'job is on' }); + await drain(db); + expect(events(s.sent, 'receiveMessage').at(-1).data.sender).toBe('FIXER'); + }); + + it('a player claiming to be the GM with their own login still chats as themselves', async () => { + const s = server(); + const vex = await s.connect({ userName: 'vex', isAdmin: true, token: PLAYER }); + vex.handlers.sendMessage({ sender: 'FIXER', text: 'free money' }); + await drain(db); + expect(events(s.sent, 'receiveMessage').at(-1).data.sender).toBe('vex'); + }); + }); + + describe('temporary admin, granted by the GM', () => { + it('reaches the player it is for, on every connection they have, and nobody else', async () => { + const s = server(); + const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); + const ghostGame = await s.connect('ghost'); + const ghostSheetTab = await s.connect('ghost'); + const bystander = await s.connect('rook'); + + gm.handlers.grantElevatedAccess({ adminToken: GM, targetUser: 'ghost' }); + // The socket module keeps its list of connections for the life of the process, so + // earlier tests' connections are still in it: assert on this test's own. + const grants = events(s.sent, 'accessGranted'); + const to = grants.map((g) => g.to); + expect(to).toContain(ghostGame.id); + expect(to).toContain(ghostSheetTab.id); + expect(to).not.toContain(bystander.id); + expect(to).not.toContain(gm.id); + expect(to).not.toContain('all'); + expect(grants.every((g) => g.data.targetUser === 'ghost' && g.data.token)).toBe(true); + expect(elevatedUsers.has('ghost')).toBe(true); + }); + + it('works as a key to the GM routes until it is revoked', async () => { + const s = server(); + const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); + await s.connect('ghost'); + gm.handlers.grantElevatedAccess({ adminToken: GM, targetUser: 'ghost' }); + const token = events(s.sent, 'accessGranted').at(-1).data.token; + + const app = express(); + app.get('/gm', authenticate, (req, res) => res.json({ ok: true })); + expect((await request(app).get('/gm').set(bearer(token))).status).toBe(200); + + gm.handlers.revokeElevatedAccess({ adminToken: GM, targetUser: 'ghost' }); + expect(elevatedUsers.has('ghost')).toBe(false); + // Revoking still tells everyone: it carries no token, and the client only acts on its own. + expect(events(s.sent, 'accessRevoked').at(-1)).toMatchObject({ to: 'all', data: { targetUser: 'ghost' } }); + expect((await request(app).get('/gm').set(bearer(token))).status).toBe(401); + }); + + it('can be given back by the editor', async () => { + elevatedUsers.add('ghost'); + const s = server(); + const ghost = await s.connect('ghost'); + ghost.handlers.surrenderAccess({ token: EDITOR }); + expect(elevatedUsers.has('ghost')).toBe(false); + }); + + it('cannot be granted by a player with their own login, to themselves or anyone', async () => { + const s = server(); + const vex = await s.connect({ userName: 'vex', isAdmin: true, token: PLAYER }); + vex.handlers.grantElevatedAccess({ adminToken: PLAYER, targetUser: 'vex' }); + expect(elevatedUsers.has('vex')).toBe(false); + expect(events(s.sent, 'accessGranted')).toEqual([]); + }); + }); + + describe('editing requests (REQUEST EDIT on a building)', () => { + it('the GM approves: the player becomes an editor, and only they get the token', async () => { + const s = server(); + const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); + const vex = await s.connect('vex'); + const rook = await s.connect('rook'); + vex.handlers.requestEditing({ userId: 'vex', userName: 'vex', locationId: 1, locationName: 'BAR' }); + expect(events(s.sent, 'editingRequested')).toHaveLength(1); + + gm.handlers.approveEditing({ userId: 'vex', location: { id: 1 } }); + expect(elevatedUsers.has('vex')).toBe(true); + const grants = events(s.sent, 'accessGranted'); + const to = grants.map((g) => g.to); + expect(to).toContain(vex.id); + expect(to).not.toContain(rook.id); + expect(to).not.toContain(gm.id); + expect(to).not.toContain('all'); + expect(grants.every((g) => g.data.targetUser === 'vex' && g.data.forEditing === true)).toBe(true); + expect(events(s.sent, 'editingApproved')).toHaveLength(1); + }); + + it('a granted editor can still approve, as before', async () => { + elevatedUsers.add('ghost'); + const s = server(); + const ghost = await s.connect('ghost'); + await s.connect('vex'); + ghost.handlers.approveEditing({ userId: 'vex' }); + expect(elevatedUsers.has('vex')).toBe(true); + }); + + it('a player cannot approve their own request', async () => { + const s = server(); + const vex = await s.connect('vex'); + vex.handlers.approveEditing({ userId: 'vex' }); + expect(elevatedUsers.has('vex')).toBe(false); + expect(events(s.sent, 'accessGranted')).toEqual([]); + }); + + it('the GM can deny a request and kick an editor; a player can do neither', async () => { + elevatedUsers.add('ghost'); + const s = server(); + const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); + const vex = await s.connect('vex'); + + vex.handlers.revokeEditing({ userId: 'ghost' }); + vex.handlers.denyEditing({ userId: 'ghost' }); + expect(elevatedUsers.has('ghost')).toBe(true); + expect(events(s.sent, 'editingRevoked')).toEqual([]); + expect(events(s.sent, 'editingDenied')).toEqual([]); + + gm.handlers.denyEditing({ userId: 'vex' }); + gm.handlers.revokeEditing({ userId: 'ghost' }); + expect(events(s.sent, 'editingDenied')).toHaveLength(1); + expect(elevatedUsers.has('ghost')).toBe(false); + }); + }); + + it("a player cannot set anyone's bank balance with their own login; the GM still can", async () => { + await run(db, `INSERT INTO bank_accounts (username, system, balance, debt) VALUES ('rook', COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), 100, 0)`); + const s = server(); + const vex = await s.connect('vex'); + vex.handlers.adminUpdateBank({ token: PLAYER, username: 'rook', balance: 999999, debt: 0 }); + await drain(db); + expect((await get(db, `SELECT balance FROM bank_accounts WHERE username = 'rook' AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`)).balance).toBe(100); + + const gm = await s.connect({ userName: 'gm', isAdmin: true, token: GM }); + gm.handlers.adminUpdateBank({ token: GM, username: 'rook', balance: 250, debt: 0 }); + await drain(db); + expect((await get(db, `SELECT balance FROM bank_accounts WHERE username = 'rook' AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`)).balance).toBe(250); + }); +}); diff --git a/backend/__tests__/helpers/testDb.js b/backend/__tests__/helpers/testDb.js index f1656715..0ba5b496 100644 --- a/backend/__tests__/helpers/testDb.js +++ b/backend/__tests__/helpers/testDb.js @@ -223,6 +223,44 @@ function makeTestDb() { FOREIGN KEY(sheet_id) REFERENCES character_sheets(id) ON DELETE CASCADE )`); + // One bank account per player per system (bank/accounts.js). + db.run(`CREATE TABLE bank_accounts ( + username TEXT NOT NULL, + system TEXT NOT NULL, + balance REAL DEFAULT 0, + debt REAL DEFAULT 0, + first_pay_done INTEGER DEFAULT 0, + high_roller_done INTEGER DEFAULT 0, + PRIMARY KEY (username, system) + )`); + + // A token's health in the systems that are not running (tokens/vitals.js). + db.run(`CREATE TABLE token_vitals ( + location_id INTEGER NOT NULL, + system TEXT NOT NULL, + hp_current INTEGER, + hp_max INTEGER, + hp_temp INTEGER, + melee_ac INTEGER, + ranged_ac INTEGER, + injuries TEXT DEFAULT '{}', + PRIMARY KEY (location_id, system) + )`); + + db.run(`CREATE TABLE IF NOT EXISTS custom_systems ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + draft TEXT NOT NULL, + published TEXT, + version INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + published_at DATETIME, + origin TEXT, + source_hash TEXT, + deleted_at DATETIME + )`); + db.run(`CREATE TABLE sqlite_sequence (name TEXT, seq INTEGER)`, () => { // ignore error — it may already exist resolve(db); diff --git a/backend/__tests__/shop_buy_sockets.test.js b/backend/__tests__/shop_buy_sockets.test.js index bfb3957b..d02a496f 100644 --- a/backend/__tests__/shop_buy_sockets.test.js +++ b/backend/__tests__/shop_buy_sockets.test.js @@ -62,9 +62,6 @@ let gunShop; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); const r = await run(db, `INSERT INTO locations (name, x, y, z, shape, building_type) VALUES ('Vic''s', 0, 0, 0, 'box', 'gun_shop')`); @@ -79,11 +76,11 @@ const identified = async (name = 'GHOST') => { }; const fund = (username, balance, debt = 0) => run(db, - 'INSERT OR REPLACE INTO player_banks (username, balance, debt) VALUES (?, ?, ?)', + `INSERT OR REPLACE INTO bank_accounts (username, system, balance, debt) VALUES (?, COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), ?, ?)`, [username, balance, debt]); const bank = async (username = 'GHOST') => - get(db, 'SELECT balance, debt FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance, debt FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); const receipt = (emitted) => [...emitted].reverse().find((e) => e.event === 'shopCheckout'); @@ -283,7 +280,7 @@ describe('when the money is not there', () => { describe('a player with no account yet', () => { it('cannot buy on credit just by never having banked', async () => { - // No player_banks row at all reads as nothing saved, not as unlimited. + // No bank account at all reads as nothing saved, not as unlimited. const { handlers, emitted } = await identified(); buy(handlers); expect((await waitReceipt(emitted)).data).toMatchObject({ ok: false, reason: 'funds' }); diff --git a/backend/__tests__/shop_catalogue_sockets.test.js b/backend/__tests__/shop_catalogue_sockets.test.js index 4a3cd818..ec41092c 100644 --- a/backend/__tests__/shop_catalogue_sockets.test.js +++ b/backend/__tests__/shop_catalogue_sockets.test.js @@ -51,9 +51,6 @@ let gunShop; beforeEach(async () => { store.clear(); db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `CREATE TABLE IF NOT EXISTS shop_catalogues ( system TEXT NOT NULL, catalogue TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, price REAL NOT NULL DEFAULT 0, fields TEXT NOT NULL DEFAULT '{}', @@ -75,7 +72,7 @@ const admin = async (name = 'GM') => { booted.handlers['identify']({ userName: name, isAdmin: true, - token: jwt.sign({ username: name, isTemporary: false }, 'test-secret'), + token: jwt.sign({ id: 1, username: name, role: 'admin', isTemporary: false }, 'test-secret'), }); await drain(db); return booted; @@ -93,11 +90,11 @@ const seed = (data, username = 'GHOST') => run(db, VALUES (?, 'cities_without_number', ?, 0)`, [username, JSON.stringify(data)]); const fund = (username, balance) => run(db, - 'INSERT OR REPLACE INTO player_banks (username, balance, debt) VALUES (?, ?, 0)', + `INSERT OR REPLACE INTO bank_accounts (username, system, balance, debt) VALUES (?, COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), ?, 0)`, [username, balance]); const bank = (username = 'GHOST') => - get(db, 'SELECT balance FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); const last = (emitted, event) => [...emitted].reverse().find((e) => e.event === event); const waitFor = (emitted, event) => diff --git a/backend/__tests__/shop_checkout_sockets.test.js b/backend/__tests__/shop_checkout_sockets.test.js index 3dd9b44c..63bb9f91 100644 --- a/backend/__tests__/shop_checkout_sockets.test.js +++ b/backend/__tests__/shop_checkout_sockets.test.js @@ -50,9 +50,6 @@ let gunShop; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); const r = await run(db, `INSERT INTO locations (name, x, y, z, shape, building_type) VALUES ('Vic''s', 0, 0, 0, 'box', 'gun_shop')`); @@ -65,7 +62,7 @@ const seed = async (data, username = 'GHOST') => run(db, [username, JSON.stringify(data)]); const fund = (username, balance, debt = 0) => run(db, - 'INSERT OR REPLACE INTO player_banks (username, balance, debt) VALUES (?, ?, ?)', + `INSERT OR REPLACE INTO bank_accounts (username, system, balance, debt) VALUES (?, COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), ?, ?)`, [username, balance, debt]); const identified = async (name = 'GHOST') => { @@ -79,7 +76,7 @@ const sheet = async (username = 'GHOST') => JSON.parse((await get(db, `SELECT data FROM character_sheets WHERE username = ?`, [username])).data); const bank = async (username = 'GHOST') => - get(db, 'SELECT balance, debt FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance, debt FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); const result = (emitted) => [...emitted].reverse().find((e) => e.event === 'shopCheckout'); const waitResult = (emitted) => diff --git a/backend/__tests__/shop_sell_sockets.test.js b/backend/__tests__/shop_sell_sockets.test.js index 05e6439b..1f904034 100644 --- a/backend/__tests__/shop_sell_sockets.test.js +++ b/backend/__tests__/shop_sell_sockets.test.js @@ -53,9 +53,6 @@ let gunShop; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks ( - username TEXT PRIMARY KEY, balance REAL, debt REAL, - first_pay_done INTEGER DEFAULT 0, high_roller_done INTEGER DEFAULT 0)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); const r = await run(db, `INSERT INTO locations (name, x, y, z, shape, building_type) VALUES ('Vic''s', 0, 0, 0, 'box', 'gun_shop')`); @@ -68,7 +65,7 @@ const seed = async (data, username = 'GHOST') => run(db, [username, JSON.stringify(data)]); const fund = (username, balance) => run(db, - 'INSERT OR REPLACE INTO player_banks (username, balance, debt) VALUES (?, ?, 0)', + `INSERT OR REPLACE INTO bank_accounts (username, system, balance, debt) VALUES (?, COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic'), ?, 0)`, [username, balance]); const identified = async (name = 'GHOST') => { @@ -82,7 +79,7 @@ const sheet = async (username = 'GHOST') => JSON.parse((await get(db, `SELECT data FROM character_sheets WHERE username = ?`, [username])).data); const bank = async (username = 'GHOST') => - get(db, 'SELECT balance FROM player_banks WHERE username = ?', [username]); + get(db, `SELECT balance FROM bank_accounts WHERE username = ? AND system = COALESCE((SELECT value FROM global_settings WHERE key = 'game_system'), 'generic')`, [username]); const result = (emitted) => [...emitted].reverse().find((e) => e.event === 'shopCheckout'); const waitResult = (emitted) => diff --git a/backend/__tests__/sockets.awardxp.test.js b/backend/__tests__/sockets.awardxp.test.js index 017bc9ec..9d490679 100644 --- a/backend/__tests__/sockets.awardxp.test.js +++ b/backend/__tests__/sockets.awardxp.test.js @@ -59,7 +59,6 @@ const playerToken = () => jwt.sign({ username: 'bob', role: 'player' }, 'test-se let db; beforeEach(async () => { db = await makeTestDb(); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT OR REPLACE INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('ghost', 'cities_without_number', ?, 0)`, [JSON.stringify({ level: 1, xp: 0 })]); diff --git a/backend/__tests__/sockets.deathsave.test.js b/backend/__tests__/sockets.deathsave.test.js index b111ab81..30e28516 100644 --- a/backend/__tests__/sockets.deathsave.test.js +++ b/backend/__tests__/sockets.deathsave.test.js @@ -66,7 +66,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cyberpunk_red')`); }); @@ -198,7 +197,7 @@ describe('generateNpcSheet with tier', () => { `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('Guy', 0, 0, 0, 'enemy_rhombus', 'SYSTEM', 5, 5)`); const loc = await get(db, `SELECT id FROM locations WHERE name = 'Guy'`); const { handlers, emitted } = boot(db); - handlers['identify']({ userName: 'admin', isAdmin: true, token: jwt.sign({ username: 'admin', isTemporary: false }, 'test-secret') }); + handlers['identify']({ userName: 'admin', isAdmin: true, token: jwt.sign({ id: 1, username: 'admin', role: 'admin', isTemporary: false }, 'test-secret') }); await flush(50); handlers['generateNpcSheet']({ location_id: loc.id, tier: 'elite' }); diff --git a/backend/__tests__/sockets.identify.secure.test.js b/backend/__tests__/sockets.identify.secure.test.js index ba35667a..31656e43 100644 --- a/backend/__tests__/sockets.identify.secure.test.js +++ b/backend/__tests__/sockets.identify.secure.test.js @@ -32,7 +32,7 @@ const socketsFactory = (await import('../sockets/index.js')).default; */ const flush = () => drain(db); -const ADMIN_TOKEN = jwt.sign({ username: 'admin', isTemporary: false }, SECRET); +const ADMIN_TOKEN = jwt.sign({ id: 1, username: 'admin', role: 'admin', isTemporary: false }, SECRET); // as routes/admin.js signs it const TEMP_ADMIN_TOKEN = jwt.sign({ username: 'helper', isTemporary: true }, SECRET); const PLAYER_TOKEN = jwt.sign({ username: 'realplayer', role: 'player' }, SECRET); const HELPER_PLAYER_TOKEN = jwt.sign({ username: 'helper', role: 'player' }, SECRET); diff --git a/backend/__tests__/sockets.tokencontrol.test.js b/backend/__tests__/sockets.tokencontrol.test.js index b3df40bc..6e4ce6bb 100644 --- a/backend/__tests__/sockets.tokencontrol.test.js +++ b/backend/__tests__/sockets.tokencontrol.test.js @@ -63,7 +63,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); }); const seedToken = async (shape, owner, controllers = null) => { diff --git a/backend/__tests__/sr6_sockets.test.js b/backend/__tests__/sr6_sockets.test.js index c4555b21..b3c5f746 100644 --- a/backend/__tests__/sr6_sockets.test.js +++ b/backend/__tests__/sr6_sockets.test.js @@ -64,7 +64,6 @@ beforeEach(async () => { results TEXT, color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); - await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'shadowrun_6e')`); }); diff --git a/backend/__tests__/system_builder_citysys.test.js b/backend/__tests__/system_builder_citysys.test.js new file mode 100644 index 00000000..d5c64166 --- /dev/null +++ b/backend/__tests__/system_builder_citysys.test.js @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, vi } 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'; + +/** + * Sharing a custom system as a .citysys file: export a published system, preview a file + * without changing anything, install it as new, as an update, or as a second copy; and delete + * as a hide, so reinstalling a deleted system's file brings it back with its characters. + */ + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const citysys = require_('../systemBuilder/citysys'); +const { checkDefinition, LIMITS } = require_('../systemBuilder/definition'); +const runtime = require_('../systemBuilder/runtime'); +const templates = require_('../sheets/templates'); + +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); +const PLAYER = jwt.sign({ id: 5, username: 'GHOST', role: 'player' }, 'test-secret'); +const gm = { Authorization: `Bearer ${GM}` }; + +const VAULT = { + format: 1, name: 'Vault Knights', author: 'Cody', license: 'CC BY 4.0', description: 'Knights in vaults.', + words: { hp: { singular: 'WOUND' } }, + derived: [{ id: 'guard', formula: '10 + @might' }], + core: { health: { model: 'wounds', count: 3 } }, +}; + +let db; +let app; +beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); + app = express(); + app.use(express.json({ limit: '2mb' })); + app.use('/api/systems', require_('../routes/systems.js')(db)); + await new Promise((resolve) => runtime.load(db, resolve)); + vi.restoreAllMocks(); +}); + +const create = async (definition = VAULT) => (await request(app).post('/api/systems').set(gm).send({ definition })).body.id; +const publish = (id) => request(app).post(`/api/systems/${id}/publish`).set(gm); +const exported = async (id) => (await request(app).get(`/api/systems/${id}/export`).set(gm)).text; +const preview = (file) => request(app).post('/api/systems/install/preview').set(gm).send({ file }); +const install = (file, mode) => request(app).post('/api/systems/install').set(gm).send({ file, mode }); +const rows = () => new Promise((resolve) => db.all('SELECT id, name, version, origin, deleted_at FROM custom_systems ORDER BY created_at, id', (e, r) => resolve(r))); +const fileOf = (definition, origin = 'org_vault', version = 3) => JSON.stringify(citysys.buildFile({ definition, version, origin })); + +describe('the definition\'s cover fields', () => { + it('takes an author and a license as free text, with limits', () => { + expect(checkDefinition(VAULT)).toEqual({ problems: [] }); + expect(checkDefinition({ ...VAULT, author: 'x'.repeat(LIMITS.author + 1), license: 7 }).problems.map((p) => `${p.where}: ${p.message}`)) + .toEqual([`author: Longer than ${LIMITS.author} characters`, 'license: Must be text']); + }); +}); + +describe('exporting', () => { + it('writes the published system as a readable file with its cover', async () => { + const id = await create(); + await publish(id); + const res = await request(app).get(`/api/systems/${id}/export`).set(gm); + expect(res.status).toBe(200); + expect(res.headers['content-disposition']).toBe('attachment; filename="vault-knights.citysys"'); + expect(res.text).toContain('\n "manifest": {'); + const file = JSON.parse(res.text); + expect(file).toMatchObject({ + citysys: 1, + manifest: { name: 'Vault Knights', author: 'Cody', license: 'CC BY 4.0', version: 1, origin: id }, + definition: VAULT, + }); + expect(typeof file.manifest.builder).toBe('string'); + expect(Number.isNaN(Date.parse(file.manifest.exported))).toBe(false); + }); + + it('shares the published copy, not a draft being worked on, and nothing unpublished', async () => { + const id = await create(); + expect((await request(app).get(`/api/systems/${id}/export`).set(gm)).status).toBe(409); + await publish(id); + await request(app).put(`/api/systems/${id}/draft`).set(gm).send({ definition: { ...VAULT, description: 'Secret draft' } }); + expect(await exported(id)).not.toContain('Secret draft'); + }); + + it('never carries a character or a sheet', async () => { + const id = await create(); + await publish(id); + await run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)', ['GHOST', id, '{"name":"Sir Ghost-in-the-file"}']); + expect(await exported(id)).not.toContain('Ghost-in-the-file'); + }); + + it('names the file from the system\'s name, safely', () => { + expect(citysys.fileNameFor('Vault Knights: Ärmor & Ash!')).toBe('vault-knights-armor-ash.citysys'); + expect(citysys.fileNameFor('../../etc')).toBe('etc.citysys'); + expect(citysys.fileNameFor('!!!')).toBe('system.citysys'); + }); +}); + +describe('reading a file', () => { + it('refuses what cannot be installed at all, saying why', () => { + const fatal = (text) => citysys.readFile(text).fatal; + expect(fatal(undefined)).toBe('Not a file'); + expect(fatal('x'.repeat(citysys.MAX_BYTES + 1))).toMatch(/^Larger than/); + expect(fatal('{nope')).toBe('Not a CITY_NET system file'); + expect(fatal('{"name":"x"}')).toBe('Not a CITY_NET system file'); + expect(fatal(JSON.stringify({ citysys: 2, manifest: {}, definition: {} }))).toBe('Made by a newer CITY_NET (file format 2); update to install it'); + expect(fatal(JSON.stringify({ citysys: 1, manifest: { origin: 'o' } }))).toBe('The file is missing its system'); + expect(fatal(JSON.stringify({ citysys: 1, manifest: { origin: '../x' }, definition: VAULT }))).toBe('The file does not say which system it is'); + expect(fatal(JSON.stringify({ citysys: 1, manifest: {}, definition: VAULT }))).toBe('The file does not say which system it is'); + }); + + it('keeps only the cover fields it knows, cut to length, and never trusts the version', () => { + const text = JSON.stringify({ citysys: 1, manifest: { origin: 'o1', name: 'n'.repeat(200), author: 5, version: -2, script: 'alert(1)' }, definition: VAULT }); + const { file } = citysys.readFile(text); + expect(file.manifest).toEqual({ name: 'n'.repeat(80), author: '', license: '', builder: '', version: 0, origin: 'o1' }); + }); + + it('passes the definition through the editor\'s own checks', () => { + const { problems } = citysys.readFile(fileOf({ ...VAULT, derived: [{ id: 'a', formula: '@b' }, { id: 'b', formula: '@a' }] })); + expect(problems.length).toBeGreaterThan(0); + }); +}); + +describe('previewing an install', () => { + it('says what is inside and changes nothing', async () => { + const before = await rows(); + const res = await preview(fileOf(VAULT)); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + name: 'Vault Knights', + manifest: { author: 'Cody', version: 3, origin: 'org_vault' }, + inside: { words: 1, derived: 1, healthModel: 'wounds' }, + problems: [], installed: [], restores: null, + }); + expect(await rows()).toEqual(before); + }); + + it('refuses a file it cannot read', async () => { + const res = await preview('not json'); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Not a CITY_NET system file'); + }); +}); + +describe('installing', () => { + it('as new: published, runnable, and remembering where it came from', async () => { + const res = await install(fileOf(VAULT), 'new'); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ published: true, problems: [] }); + const row = await get(db, 'SELECT * FROM custom_systems WHERE id = ?', [res.body.id]); + expect(row).toMatchObject({ name: 'Vault Knights', version: 1, origin: 'org_vault' }); + expect(row.source_hash).toBe(citysys.hashOf(row.draft)); + expect(templates.isValidSystem(res.body.id)).toBe(true); + }); + + it('as new with problems: kept as a draft to fix, never runnable', async () => { + const broken = { ...VAULT, derived: [{ id: 'a', formula: '@b' }, { id: 'b', formula: '@a' }] }; + const res = await install(fileOf(broken), 'new'); + expect(res.body.published).toBe(false); + expect(res.body.problems.length).toBeGreaterThan(0); + expect((await get(db, 'SELECT published, version FROM custom_systems WHERE id = ?', [res.body.id]))).toEqual({ published: null, version: 0 }); + expect(templates.isValidSystem(res.body.id)).toBe(false); + }); + + it('as new when it is already here: asks for update or keep both', async () => { + const first = (await install(fileOf(VAULT), 'new')).body.id; + const again = await install(fileOf(VAULT), 'new'); + expect(again.status).toBe(409); + expect(again.body).toMatchObject({ error: 'Already installed. Update it or keep both.', installed: [{ id: first, name: 'Vault Knights' }] }); + expect((await preview(fileOf(VAULT))).body.installed).toEqual([{ id: first, name: 'Vault Knights', version: 1, edited: false }]); + }); + + it('as an update: the same system, its next version, while it has not been changed here', async () => { + const id = (await install(fileOf(VAULT), 'new')).body.id; + const v2 = { ...VAULT, description: 'Now with more vaults.' }; + const res = await install(fileOf(v2, 'org_vault', 4), 'update'); + expect(res.body).toMatchObject({ id, published: true }); + expect(await get(db, 'SELECT version, published FROM custom_systems WHERE id = ?', [id])).toMatchObject({ version: 2, published: JSON.stringify(v2) }); + expect(runtime.render(id).name).toBe('Vault Knights'); + }); + + it('as an update: refused over changes made here, over problems, and when not installed', async () => { + expect((await install(fileOf(VAULT), 'update')).status).toBe(404); + const id = (await install(fileOf(VAULT), 'new')).body.id; + const broken = { ...VAULT, derived: [{ id: 'a', formula: '@a' }] }; + expect((await install(fileOf(broken), 'update')).status).toBe(409); + await request(app).put(`/api/systems/${id}/draft`).set(gm).send({ definition: { ...VAULT, description: 'Mine now' } }); + expect((await preview(fileOf(VAULT))).body.installed[0].edited).toBe(true); + const res = await install(fileOf({ ...VAULT, description: 'Theirs' }), 'update'); + expect(res.status).toBe(409); + expect(res.body.error).toBe('This system has been changed here since it was installed. Keep both instead.'); + expect(JSON.parse((await get(db, 'SELECT draft FROM custom_systems WHERE id = ?', [id])).draft).description).toBe('Mine now'); + }); + + it('never overwrites a system made here, even from its own file', async () => { + const id = await create(); + await publish(id); + const file = await exported(id); + expect((await preview(file)).body.installed).toEqual([{ id, name: 'Vault Knights', version: 1, edited: true }]); + expect((await install(file, 'update')).status).toBe(409); + }); + + it('keeping both: a second copy with its own id and origin, so the two never collide', async () => { + const first = (await install(fileOf(VAULT), 'new')).body.id; + const copy = (await install(fileOf(VAULT), 'keep_both')).body.id; + expect(copy).not.toBe(first); + const [a, b] = await Promise.all([first, copy].map((id) => get(db, 'SELECT origin FROM custom_systems WHERE id = ?', [id]))); + expect(a.origin).toBe('org_vault'); + expect(b.origin).toBe(copy); + // The original is still the one the file matches. + expect((await preview(fileOf(VAULT))).body.installed.map((s) => s.id)).toEqual([first]); + }); + + it('refuses a mode it does not know', async () => { + expect((await install(fileOf(VAULT), 'merge')).status).toBe(400); + }); +}); + +describe('deleting', () => { + it('hides the system everywhere but keeps it', async () => { + const id = (await install(fileOf(VAULT), 'new')).body.id; + expect((await request(app).delete(`/api/systems/${id}`).set(gm)).status).toBe(200); + expect((await request(app).get('/api/systems').set(gm)).body.map((s) => s.id)).not.toContain(id); + expect((await request(app).get(`/api/systems/${id}`).set(gm)).status).toBe(404); + expect((await request(app).put(`/api/systems/${id}/draft`).set(gm).send({ definition: VAULT })).status).toBe(404); + expect((await request(app).get(`/api/systems/${id}/export`).set(gm)).status).toBe(404); + expect((await request(app).delete(`/api/systems/${id}`).set(gm)).status).toBe(404); + expect(templates.isValidSystem(id)).toBe(false); + // Still there, for its characters' sake. + expect((await get(db, 'SELECT deleted_at FROM custom_systems WHERE id = ?', [id])).deleted_at).not.toBeNull(); + // And never loaded again on a restart. + await new Promise((resolve) => runtime.load(db, resolve)); + expect(templates.isValidSystem(id)).toBe(false); + }); + + it('comes back, under its old id with its characters, when its file is installed again', async () => { + const id = (await install(fileOf(VAULT), 'new')).body.id; + await run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)', ['GHOST', id, '{"name":"Sir Ghost"}']); + await request(app).delete(`/api/systems/${id}`).set(gm); + expect((await preview(fileOf(VAULT))).body.restores).toEqual({ id, name: 'Vault Knights', replacesChanges: false }); + const res = await install(fileOf(VAULT), 'new'); + expect(res.body).toMatchObject({ id, restored: true, published: true }); + expect(templates.isValidSystem(id)).toBe(true); + expect((await request(app).get('/api/systems').set(gm)).body.map((s) => s.id)).toContain(id); + expect((await get(db, 'SELECT data FROM character_sheets WHERE system = ?', [id])).data).toContain('Sir Ghost'); + }); + + it('warns when bringing one back would replace changes made here', async () => { + const id = await create(); + await publish(id); + const file = await exported(id); + await request(app).put(`/api/systems/${id}/draft`).set(gm).send({ definition: { ...VAULT, description: 'Unshared work' } }); + await request(app).delete(`/api/systems/${id}`).set(gm); + expect((await preview(file)).body.restores).toEqual({ id, name: 'Vault Knights', replacesChanges: true }); + }); +}); + +describe('who may', () => { + it('only the GM exports, previews or installs', async () => { + const id = (await install(fileOf(VAULT), 'new')).body.id; + const player = { Authorization: `Bearer ${PLAYER}` }; + expect((await request(app).get(`/api/systems/${id}/export`).set(player)).status).toBe(403); + expect((await request(app).post('/api/systems/install/preview').set(player).send({ file: fileOf(VAULT) })).status).toBe(403); + expect((await request(app).post('/api/systems/install').set(player).send({ file: fileOf(VAULT), mode: 'keep_both' })).status).toBe(403); + expect((await request(app).post('/api/systems/install').send({ file: fileOf(VAULT), mode: 'keep_both' })).status).toBe(401); + }); +}); diff --git a/backend/__tests__/system_builder_core.test.js b/backend/__tests__/system_builder_core.test.js new file mode 100644 index 00000000..4bfbf9dc --- /dev/null +++ b/backend/__tests__/system_builder_core.test.js @@ -0,0 +1,223 @@ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; + +/** + * A custom system's core rules: the setup questions, answered as data. + * + * The health model shapes the starter sheet a system gets before its GM designs one; a system + * that answers nothing gets exactly the starter it always had. Advancement, dice and distance + * are recorded and checked, and take effect in their own pieces. + */ + +const require_ = createRequire(import.meta.url); +const { checkDefinition } = require_('../systemBuilder/definition'); +const { effectiveSheet } = require_('../systemBuilder/sheet'); +const { healthLayout, HEALTH_MODELS, ADVANCEMENT } = require_('../systemBuilder/core'); +const { metaOf, renderOf } = require_('../systemBuilder/runtime'); + +const problems = (definition) => checkDefinition(definition).problems.map((p) => `${p.where}: ${p.message}`); +const withCore = (core, extra = {}) => ({ format: 1, name: 'Test', ...extra, core }); +const healthOf = (health) => effectiveSheet(withCore({ health })); +const section = (sheet, id) => sheet.sections.find((s) => s.id === id); + +/** Every model, answered fully. */ +const MODELS = { + pool: { model: 'pool', label: 'VIGOR' }, + tracks: { model: 'tracks', tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }], overflow: true }, + typed: { model: 'typed', types: [{ id: 'superficial', label: 'SUPERFICIAL' }, { id: 'aggravated', label: 'AGGRAVATED' }] }, + harm: { model: 'harm', levels: [ + { id: 'lesser', label: 'LESSER', slots: 2, penalty: 'Reduced effect' }, + { id: 'moderate', label: 'MODERATE', slots: 2, penalty: '-1d' }, + { id: 'severe', label: 'SEVERE', slots: 1, penalty: 'Need help' }, + ] }, + wounds: { model: 'wounds', count: 3, penalty: -1 }, + locations: { model: 'locations', locations: [{ id: 'head', label: 'HEAD' }, { id: 'body', label: 'BODY' }] }, + none: { model: 'none' }, +}; + +describe('a system that answers nothing', () => { + it('gets exactly the starter sheet it always had', () => { + const expected = { + tabs: ['STATS', 'GEAR', 'NOTES'], + header: { nameField: 'name', subtitleFields: ['concept'], hpField: 'hp', hpMaxField: 'hp_max' }, + sections: [ + { id: 'identity', label: 'IDENTITY', layout: 'list', tab: 'STATS', fields: [ + { id: 'name', label: 'Name', type: 'text', visibility: 'public' }, + { id: 'concept', label: 'Concept', type: 'text' }, + { id: 'description', label: 'Description', type: 'textarea', visibility: 'public' }, + ] }, + { id: 'health', label: 'HEALTH', layout: 'grid', tab: 'STATS', columns: 2, fields: [ + { id: 'hp', label: 'HP', type: 'number', source: 'token_hp', maxField: 'hp_max' }, + { id: 'hp_max', label: 'HP MAX', type: 'number', source: 'token_hp_max' }, + ] }, + { id: 'inventory', label: 'INVENTORY', layout: 'inventory', tab: 'GEAR', fields: [] }, + { id: 'money', label: 'MONEY', layout: 'list', tab: 'GEAR', fields: [{ id: 'cash', label: 'Cash', type: 'number', source: 'bank_balance' }] }, + { id: 'notes', label: 'NOTES', layout: 'notes', tab: 'NOTES', fields: [{ id: 'notes', label: 'Notes', type: 'textarea' }] }, + ], + }; + // Compared as text, so the order of keys counts too. + for (const definition of [{ format: 1, name: 'Bare' }, withCore({}), withCore({ dice: ['d20'] }), withCore({ health: { model: 'pool' } })]) { + expect(JSON.stringify(effectiveSheet(definition))).toBe(JSON.stringify(expected)); + } + }); + + it('is still valid, and a designed sheet still wins over the starter', () => { + expect(problems({ format: 1, name: 'Bare' })).toEqual([]); + const own = { sections: [{ id: 'a', label: 'A', layout: 'list', fields: [] }] }; + expect(effectiveSheet({ ...withCore({ health: MODELS.harm }), sheet: own })).toBe(own); + }); +}); + +describe('the setup answers', () => { + it('accept every model, and every model gives a starter sheet that passes the sheet checks', () => { + for (const [id, health] of Object.entries(MODELS)) { + const definition = withCore({ health, advancement: ['levels', 'spend'], dice: ['d20', '2d6', '4dF', 'd7', 'd100'], distance: 'feet' }); + expect(problems(definition), id).toEqual([]); + // The starter, saved as the system's own sheet, is a sheet like any other. + expect(problems({ ...definition, sheet: effectiveSheet(definition) }), id).toEqual([]); + } + }); + + it('list every model and advancement style the plan names, in its order', () => { + expect(HEALTH_MODELS.map((m) => m.id)).toEqual(['pool', 'tracks', 'typed', 'harm', 'wounds', 'locations', 'none']); + expect(ADVANCEMENT.map((a) => a.id)).toEqual(['levels', 'milestone', 'spend', 'use']); + for (const m of HEALTH_MODELS) expect(m.label && m.worksLike && m.examples, m.id).toBeTruthy(); + }); +}); + +describe('the starter sheet each health model gives', () => { + it('one pool: the token\'s HP, under the system\'s own name', () => { + const sheet = healthOf(MODELS.pool); + expect(section(sheet, 'health').fields.map((f) => [f.id, f.label, f.source])).toEqual([ + ['hp', 'VIGOR', 'token_hp'], ['hp_max', 'VIGOR MAX', 'token_hp_max'], + ]); + expect(sheet.header).toMatchObject({ hpField: 'hp', hpMaxField: 'hp_max' }); + }); + + it('two tracks: the first on the token, the second on the sheet, each with a maximum', () => { + const sheet = healthOf(MODELS.tracks); + expect(section(sheet, 'health').fields).toEqual([ + { id: 'physical', label: 'PHYSICAL', type: 'number', source: 'token_hp', maxField: 'physical_max' }, + { id: 'physical_max', label: 'PHYSICAL MAX', type: 'number', source: 'token_hp_max' }, + { id: 'stun', label: 'STUN', type: 'number', maxField: 'stun_max' }, + { id: 'stun_max', label: 'STUN MAX', type: 'number' }, + ]); + expect(sheet.header).toMatchObject({ hpField: 'physical', hpMaxField: 'physical_max' }); + }); + + it('damage types: one track on the token, and a count of each kind of damage', () => { + const fields = section(healthOf(MODELS.typed), 'health').fields; + expect(fields.map((f) => [f.id, f.label, f.source])).toEqual([ + ['health', 'HEALTH', 'token_hp'], ['health_max', 'HEALTH MAX', 'token_hp_max'], + ['superficial', 'SUPERFICIAL', undefined], ['aggravated', 'AGGRAVATED', undefined], + ]); + }); + + it('harm levels: a line for each slot, with the penalty as its hint, and nothing on the token', () => { + const sheet = healthOf(MODELS.harm); + expect(section(sheet, 'health')).toBeUndefined(); + expect(section(sheet, 'harm').fields.map((f) => [f.id, f.label, f.hint])).toEqual([ + ['lesser_1', 'LESSER', 'Reduced effect'], ['lesser_2', 'LESSER 2', 'Reduced effect'], + ['moderate_1', 'MODERATE', '-1d'], ['moderate_2', 'MODERATE 2', '-1d'], + ['severe_1', 'SEVERE', 'Need help'], + ]); + expect(sheet.header.hpField).toBeUndefined(); + }); + + it('wound count: the wounds left, on the token', () => { + const fields = section(healthOf(MODELS.wounds), 'health').fields; + expect(fields.map((f) => [f.id, f.label, f.source, f.hint])).toEqual([ + ['wounds', 'WOUNDS LEFT', 'token_hp', undefined], ['wounds_max', 'WOUNDS', 'token_hp_max', 'Out after 3'], + ]); + }); + + it('hit locations: an HP pool, and an injury line per location', () => { + const sheet = healthOf(MODELS.locations); + expect(section(sheet, 'health').fields.map((f) => f.id)).toEqual(['hp', 'hp_max']); + expect(section(sheet, 'injuries').fields.map((f) => [f.id, f.label])).toEqual([['head', 'HEAD'], ['body', 'BODY']]); + }); + + it('none: no health on the sheet at all', () => { + const sheet = healthOf(MODELS.none); + expect(sheet.sections.map((s) => s.id)).toEqual(['identity', 'inventory', 'money', 'notes']); + expect(sheet.header).toEqual({ nameField: 'name', subtitleFields: ['concept'] }); + }); + + it("reaches the running game: the token links, and the browser's copy", () => { + const definition = withCore({ health: MODELS.tracks }); + expect(metaOf(definition).linkedFields).toEqual({ physical: 'token_hp', physical_max: 'token_hp_max', cash: 'bank_balance' }); + expect(metaOf(definition).maxPairs).toEqual({ physical_max: 'physical', stun_max: 'stun' }); + expect(renderOf('sys_0123456789abcdef', definition).sheet).toEqual(effectiveSheet(definition)); + }); + + it('falls back to one pool rather than failing on an unfinished answer', () => { + expect(healthLayout({ model: 'tracks', tracks: [{ id: 'a', label: 'A' }] })).toEqual(healthLayout(undefined)); + }); +}); + +describe('mistakes in the answers', () => { + it('are reported with where they are', () => { + expect(problems(withCore({ mood: 'grim', health: { model: 'tracks', tracks: [{ id: 'a', label: 'A' }], overflow: 'yes', count: 3 } }))).toEqual([ + 'core mood: Not a setup question', + 'core health, count: Not part of the tracks model', + 'core health track: Exactly 2', + 'core health, overflow: true or false', + ]); + expect(problems(withCore({ health: { model: 'hearts' } }))).toEqual([ + 'core health, model: One of pool, tracks, typed, harm, wounds, locations, none', + ]); + expect(problems(withCore({ health: 'pool' }))).toEqual(['core health: Must say which health model']); + expect(problems(withCore('all of them'))).toEqual(['core: Must be a set of answers']); + }); + + it('in the lists of tracks, types, levels and locations', () => { + expect(problems(withCore({ health: { model: 'typed', label: '', types: [{ id: 'Light', label: 'LIGHT' }, { id: 'a', label: 'A' }, { id: 'a', label: 'A' }, 'x', { id: 'b', label: 'B' }] } }))).toEqual([ + 'core health, label: Required', + 'core health type: From 2 to 4', + 'core health type Light: Ids use lowercase letters, digits and _, starting with a letter', + 'core health type a: Defined twice', + 'core health type 4: Needs an id and a label', + ]); + expect(problems(withCore({ health: { model: 'harm', levels: [{ id: 'a', label: 'A', slots: 5, penalty: 'x'.repeat(41) }, { id: 'b', slots: 1 }] } }))).toEqual([ + 'core health level a, slots: A whole number from 1 to 4', + 'core health level a, penalty: Longer than 40 characters', + 'core health level b, label: Required', + ]); + expect(problems(withCore({ health: { model: 'wounds', count: 0, penalty: 2 } }))).toEqual([ + 'core health, count: A whole number from 1 to 10', + 'core health, penalty: A whole number from -5 to 0', + ]); + expect(problems(withCore({ health: { model: 'locations', locations: [] } }))).toEqual(['core health location: From 1 to 12']); + expect(problems(withCore({ health: { model: 'pool', label: 'x'.repeat(21) } }))).toEqual(['core health, label: Longer than 20 characters']); + expect(problems(withCore({ health: { model: 'tracks', tracks: [{ id: 'a'.repeat(41), label: 'A' }, { id: 'b', label: 'B' }] } }))) + .toEqual([`core health track ${'a'.repeat(41)}: Ids use lowercase letters, digits and _, starting with a letter`]); + }); + + it('when a health field would clash with the starter sheet, a derived value or itself', () => { + expect(problems(withCore({ health: { model: 'tracks', tracks: [{ id: 'name', label: 'NAME' }, { id: 'stun', label: 'STUN' }] } }))) + .toEqual(['core health name: Clashes with another field on the starter sheet']); + expect(problems(withCore({ health: { model: 'tracks', tracks: [{ id: 'stun', label: 'A' }, { id: 'stun_max', label: 'B' }] } }))) + .toEqual(['core health stun_max: Clashes with another field on the starter sheet']); + expect(problems(withCore({ health: { model: 'typed', types: [{ id: 'health', label: 'A' }, { id: 'b', label: 'B' }] } }))) + .toEqual(['core health health: Clashes with another field on the starter sheet']); + expect(problems(withCore({ health: { model: 'harm', levels: [{ id: 'grit', label: 'GRIT', slots: 1 }] } }, { derived: [{ id: 'grit_1', formula: '1' }] }))) + .toEqual(['core health grit_1: A derived value has this id']); + }); + + it('in advancement, dice and distance', () => { + expect(problems(withCore({ advancement: ['levels', 'luck', 'levels'], dice: ['d20', 'd1', 'd101', '100d6', 'dX', 7, 'd20'], distance: 'leagues' }))).toEqual([ + 'core advancement: luck is not one of levels, milestone, spend, use', + 'core advancement: Named twice', + 'core dice: d1 is not a die (d2 to d100, or dF, with a count: 2d6)', + 'core dice: d101 is not a die (d2 to d100, or dF, with a count: 2d6)', + 'core dice: 100d6 is not a die (d2 to d100, or dF, with a count: 2d6)', + 'core dice: dX is not a die (d2 to d100, or dF, with a count: 2d6)', + 'core dice: 7 is not a die (d2 to d100, or dF, with a count: 2d6)', + 'core dice: Named twice', + 'core distance: One of meters, feet, yards, squares, hexes, zones', + ]); + expect(problems(withCore({ advancement: 'levels', dice: 'd20' }))).toEqual(['core advancement: Must be a list', 'core dice: Must be a list']); + expect(problems(withCore({ dice: ['d2', 'd3', 'd4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100'] }))).toEqual(['core dice: More than 8']); + expect(problems(withCore({ advancement: [] }))).toEqual([]); + }); +}); diff --git a/backend/__tests__/system_builder_engine.test.js b/backend/__tests__/system_builder_engine.test.js new file mode 100644 index 00000000..c8cbc241 --- /dev/null +++ b/backend/__tests__/system_builder_engine.test.js @@ -0,0 +1,297 @@ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; + +/** + * The system builder's expression language and derived-value engine, on their own. + * + * Two things matter most. A GM's formula runs on the server, so the language must not be able + * to do anything but arithmetic, however it is written. And a GM's mistakes must come back as + * readable problems - all of them, with where they are - rather than a crash or a hang. + */ + +const require_ = createRequire(import.meta.url); +const { parse, evaluate, ExpressionError, LIMITS } = require_('../systemBuilder/expression'); +const { compileSystem, LIMITS: SYSTEM_LIMITS } = require_('../systemBuilder/derived'); +const { RULES, ruleValue } = require_('../systemBuilder/rules'); + +/** Work out one expression with plain field values and no tables. */ +const calc = (src, fields = {}) => evaluate(parse(src), { + field: (n) => (n in fields ? fields[n] : 0), + rule: () => 0, + table: () => 0, +}); + +describe('the expression language', () => { + it('does arithmetic with the usual precedence', () => { + expect(calc('1 + 2 * 3')).toBe(7); + expect(calc('(1 + 2) * 3')).toBe(9); + expect(calc('10 - 4 - 3')).toBe(3); + expect(calc('20 / 4 / 5')).toBe(1); + expect(calc('-2 * -3')).toBe(6); + expect(calc('7 % 3')).toBe(1); + expect(calc('.5 + 1.25')).toBe(1.75); + }); + + it('reads sheet fields', () => { + expect(calc('@str + @level', { str: 14, level: 3 })).toBe(17); + }); + + it('has the built-in functions', () => { + expect(calc('min(4, 2, 9)')).toBe(2); + expect(calc('max(4, 2, 9)')).toBe(9); + expect(calc('floor(2.7) + ceil(2.1) + abs(-3)')).toBe(8); + expect(calc('round(2.5)')).toBe(3); + expect(calc('clamp(15, 1, 10)')).toBe(10); + expect(calc('if(@x > 3, 100, 200)', { x: 5 })).toBe(100); + expect(calc('if(@x > 3, 100, 200)', { x: 1 })).toBe(200); + }); + + it('compares and combines to 1 or 0', () => { + expect(calc('3 < 4')).toBe(1); + expect(calc('3 >= 4')).toBe(0); + expect(calc('2 == 2 and 3 != 4')).toBe(1); + expect(calc('0 or 5')).toBe(1); + expect(calc('not 0')).toBe(1); + expect(calc('not 1 or 1')).toBe(1); + }); + + it('turns a result that is not a number into 0, rather than breaking a sheet', () => { + expect(calc('1 / 0')).toBe(0); + expect(calc('0 / 0')).toBe(0); + expect(calc('5 % 0')).toBe(0); + expect(calc('@x * 2', { x: NaN })).toBe(0); + }); + + it('only evaluates the branch of if() it takes', () => { + let reads = 0; + const tree = parse('if(1, 5, @expensive)'); + evaluate(tree, { field: () => { reads += 1; return 0; }, rule: () => 0, table: () => 0 }); + expect(reads).toBe(0); + }); + + describe('refuses, with where the problem is', () => { + const refuses = (src, message) => { + let error; + try { parse(src); } catch (err) { error = err; } + expect(error, src).toBeInstanceOf(ExpressionError); + expect(error.message, src).toMatch(message); + }; + + it('bad syntax', () => { + refuses('', /Empty/); + refuses('1 +', /Ends too soon/); + refuses('(1 + 2', /Expected "\)"/); + refuses('1 2', /after a complete expression/); + refuses('2 # 3', /Unexpected "#"/); + refuses('1 < 2 < 3', /do not chain/); + }); + + it('a bare word, and says how to write a field', () => { + refuses('str + 1', /written @str/); + }); + + it('functions that do not exist, and the wrong number of values', () => { + refuses('sqrt(4)', /No function or table called "sqrt"/); + refuses('floor(1, 2)', /takes 1 value, not 2/); + refuses('clamp(1, 2)', /takes 3 values, not 2/); + refuses('max()', /takes 1 to 32 values, not 0/); + }); + + it('names with capitals or symbols', () => { + refuses('@Str', /not a valid name/); + }); + + it('the position of the problem', () => { + let error; + try { parse('1 + 2 # 3'); } catch (err) { error = err; } + expect(error.at).toBe(6); + expect(error.message).toMatch(/character 7/); + }); + }); + + describe('cannot be used to do anything but arithmetic', () => { + // Everything here is something a script would try. None of it is in the language. + for (const src of [ + 'constructor', '@constructor.name', 'process.exit(1)', 'require("fs")', 'this', + '@x["y"]', '"text"', "'text'", '@x = 1', 'x => 1', '`${1}`', '@__proto__', 'eval(1)', + 'Function("return 1")()', '1; 2', '[1,2]', '{a: 1}', 'globalThis', + ]) { + it(`refuses ${src}`, () => { + expect(() => parse(src)).toThrow(ExpressionError); + }); + } + }); + + describe('limits', () => { + it('refuses an expression longer than the limit', () => { + expect(() => parse('1+'.repeat(LIMITS.source) + '1')).toThrow(/Longer than/); + }); + + it('refuses more parts than the limit', () => { + const src = Array.from({ length: LIMITS.nodes }, () => '1').join('+'); + expect(src.length).toBeLessThanOrEqual(LIMITS.source); + expect(() => parse(src)).toThrow(/More than \d+ parts/); + }); + + it('refuses nesting deeper than the limit', () => { + const deep = `${'('.repeat(LIMITS.depth + 2)}1${')'.repeat(LIMITS.depth + 2)}`; + expect(() => parse(deep)).toThrow(/Nested more than/); + const deepNeg = `${'-'.repeat(LIMITS.depth + 2)}1`; + expect(() => parse(deepNeg)).toThrow(/Nested more than/); + const deepNot = `${'not '.repeat(LIMITS.depth + 2)}1`; + expect(() => parse(deepNot)).toThrow(/Nested more than/); + }); + + it('accepts nesting up to the limit', () => { + const ok = `${'('.repeat(LIMITS.depth - 1)}1${')'.repeat(LIMITS.depth - 1)}`; + expect(() => parse(ok)).not.toThrow(); + }); + }); +}); + +describe('a system definition', () => { + const MODS = { bands: [{ upTo: 3, value: -1 }, { upTo: 10, value: 0 }, { value: 1 }] }; + + it('works values out in dependency order, whatever order they are listed in', () => { + const { ok, system } = compileSystem({ + lookups: { mod: MODS }, + derived: [ + { id: 'total', formula: '@half + @str_mod' }, + { id: 'half', formula: 'floor(@level / 2)' }, + { id: 'str_mod', formula: 'mod(@str)' }, + ], + }); + expect(ok).toBe(true); + expect(system.evaluate({ level: 5, str: 12 })).toEqual({ half: 2, str_mod: 1, total: 3 }); + expect(system.order.indexOf('total')).toBeGreaterThan(system.order.indexOf('half')); + }); + + it('reads a lookup table band by band, the last band catching the rest', () => { + const { system } = compileSystem({ lookups: { mod: MODS }, derived: [{ id: 'm', formula: 'mod(@x)' }] }); + const m = (x) => system.evaluate({ x }).m; + expect([m(-5), m(3), m(3.5), m(10), m(11), m(1e9)]).toEqual([-1, -1, 0, 0, 1, 1]); + }); + + it('decides a condition', () => { + const { system } = compileSystem({ + derived: [{ id: 'penalty', kind: 'condition', when: '@load > @str', then: '-2', else: '0' }], + }); + expect(system.evaluate({ load: 12, str: 10 }).penalty).toBe(-2); + expect(system.evaluate({ load: 8, str: 10 }).penalty).toBe(0); + }); + + it('uses a code-backed rule', () => { + const { system } = compileSystem({ derived: [{ id: 'soak', formula: '4 + $cwn_armor_soak' }] }); + expect(system.evaluate({ armor_mods: JSON.stringify(['absorption_pads']) }).soak).toBe(9); + }); + + it('writes values and reports what changed, as the hand-written recompute functions do', () => { + const { system } = compileSystem({ derived: [{ id: 'a', formula: '@x + 1' }, { id: 'b', formula: '2' }] }); + const data = { x: 1, a: 2, b: '5' }; + expect(system.apply(data)).toEqual(['b']); + expect(data).toEqual({ x: 1, a: 2, b: 2 }); + }); + + it('treats text, blanks and missing fields as 0, the way sheets always have', () => { + const { system } = compileSystem({ derived: [{ id: 'sum', formula: '@a + @b + @c + @d' }] }); + expect(system.evaluate({ a: '3', b: '', c: 'abc' }).sum).toBe(3); + }); + + describe('reports every problem at once, with where it is', () => { + const problemsOf = (def) => { + const out = compileSystem(def); + expect(out.ok).toBe(false); + return out.problems; + }; + + it('a loop, with its path', () => { + const problems = problemsOf({ + derived: [ + { id: 'a', formula: '@b + 1' }, + { id: 'b', formula: '@c + 1' }, + { id: 'c', formula: '@a + 1' }, + ], + }); + expect(problems).toEqual([{ where: 'derived a', message: 'Depends on itself: a → b → c → a' }]); + }); + + it('a value that reads itself', () => { + expect(problemsOf({ derived: [{ id: 'hp', formula: '@hp + 1' }] })) + .toEqual([{ where: 'derived hp', message: 'Depends on itself: hp → hp' }]); + }); + + it('several mistakes together', () => { + const problems = problemsOf({ + lookups: { max: MODS, bad: { bands: [{ upTo: 5, value: 1 }, { upTo: 2, value: 0 }] } }, + derived: [ + { id: 'ok', formula: '1' }, + { id: 'ok', formula: '2' }, + { id: 'Bad Id', formula: '1' }, + { id: 'broken', formula: '1 +' }, + { id: 'ruled', formula: '$no_such_rule' }, + { id: 'odd', kind: 'script', formula: '1' }, + { id: 'cond', kind: 'condition', when: '@x >', then: '1', else: 'nope(1)' }, + ], + }); + const text = problems.map((p) => `${p.where}: ${p.message}`); + expect(text).toEqual([ + 'lookup max: "max" is a built-in function; pick another name', + 'lookup bad, band 2: "Up to" must rise from band to band', + 'derived ok: Defined twice', + 'derived Bad Id: Ids use lowercase letters, digits and _, starting with a letter', + 'derived broken, formula: Ends too soon (at character 4)', + 'derived ruled: No rule called $no_such_rule', + 'derived odd: Unknown kind "script"', + 'derived cond, when: Ends too soon (at character 5)', + 'derived cond, else: No function or table called "nope" (at character 1)', + ]); + }); + + it('a lookup table that is empty or open in the middle', () => { + const problems = problemsOf({ + lookups: { empty: { bands: [] }, gap: { bands: [{ value: 1 }, { upTo: 5, value: 2 }] } }, + derived: [], + }); + expect(problems.map((p) => p.message)).toEqual([ + 'A table needs at least one band', + 'Only the last band may leave "up to" open', + ]); + }); + + it('something that is not a definition at all', () => { + expect(problemsOf(null)).toEqual([{ where: 'derived', message: 'Derived values must be a list' }]); + expect(problemsOf({ derived: 'x' })[0].message).toMatch(/must be a list/); + }); + + it('more derived values than the limit', () => { + const derived = Array.from({ length: SYSTEM_LIMITS.derived + 1 }, (_, i) => ({ id: `v${i}`, formula: '1' })); + expect(problemsOf({ derived }).map((p) => p.message)).toContain(`More than ${SYSTEM_LIMITS.derived} derived values`); + }); + }); + + it('handles a long chain of values quickly', () => { + const derived = Array.from({ length: SYSTEM_LIMITS.derived }, (_, i) => ({ + id: `v${i}`, formula: i === 0 ? '1' : `@v${i - 1} + 1`, + })).reverse(); + const started = Date.now(); + const { ok, system } = compileSystem({ derived }); + expect(ok).toBe(true); + expect(system.evaluate({})[`v${SYSTEM_LIMITS.derived - 1}`]).toBe(SYSTEM_LIMITS.derived); + expect(Date.now() - started).toBeLessThan(1000); + }); +}); + +describe('code-backed rules', () => { + it('each has a description the builder can show', () => { + for (const [name, rule] of Object.entries(RULES)) { + expect(typeof rule.describe, name).toBe('string'); + expect(typeof rule.value, name).toBe('function'); + } + }); + + it('an unknown name, or one inherited from Object, is 0 rather than a crash', () => { + expect(ruleValue('no_such_rule', {})).toBe(0); + expect(ruleValue('constructor', {})).toBe(0); + expect(ruleValue('__proto__', {})).toBe(0); + }); +}); diff --git a/backend/__tests__/system_builder_health.test.js b/backend/__tests__/system_builder_health.test.js new file mode 100644 index 00000000..c5156508 --- /dev/null +++ b/backend/__tests__/system_builder_health.test.js @@ -0,0 +1,279 @@ +import { describe, it, expect, beforeEach, vi } 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'; + +/** + * A custom system's health model in play: what DAMAGE and HEAL do under each model, and the + * HIT_POINTS route using them for a custom system while the built-in systems (and a custom + * one-pool system) go through the route exactly as before. + */ + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const { applyHealthAction } = require_('../systemBuilder/health'); +const runtime = require_('../systemBuilder/runtime'); + +const token = (current, max, temp = 0) => ({ current, max, temp }); +const damage = (amount, extra = {}) => ({ kind: 'damage', amount, ...extra }); +const heal = (amount, extra = {}) => ({ kind: 'heal', amount, ...extra }); + +const TRACKS = { model: 'tracks', tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }], overflow: true }; +const TYPED = { model: 'typed', types: [{ id: 'superficial', label: 'S' }, { id: 'aggravated', label: 'A' }] }; +const TYPED3 = { model: 'typed', types: [{ id: 'light', label: 'L' }, { id: 'heavy', label: 'H' }, { id: 'lethal', label: 'X' }] }; +const HARM = { model: 'harm', levels: [{ id: 'lesser', label: 'L', slots: 2 }, { id: 'moderate', label: 'M', slots: 2 }, { id: 'severe', label: 'S', slots: 1 }] }; +const WOUNDS = { model: 'wounds', count: 3, penalty: -1 }; +const LOCATIONS = { model: 'locations', locations: [{ id: 'head', label: 'HEAD' }, { id: 'body', label: 'BODY' }] }; + +describe('one pool', () => { + it('takes damage through temp HP first, and heals up to the max', () => { + expect(applyHealthAction({ model: 'pool' }, token(10, 20, 3), {}, damage(5))).toMatchObject({ ok: true, token: token(8, 20, 0), out: false }); + expect(applyHealthAction({ model: 'pool' }, token(10, 20, 8), {}, damage(5)).token).toEqual(token(10, 20, 3)); + expect(applyHealthAction({ model: 'pool' }, token(2, 20), {}, damage(9))).toMatchObject({ token: token(0, 20, 0), out: true }); + expect(applyHealthAction({ model: 'pool' }, token(18, 20), {}, heal(9)).token.current).toBe(20); + }); +}); + +describe('two tracks', () => { + it('damages the first track, the token, unless told otherwise', () => { + expect(applyHealthAction(TRACKS, token(10, 10), {}, damage(4))).toMatchObject({ token: token(6, 10), sheetPatch: {} }); + expect(applyHealthAction(TRACKS, token(10, 10), {}, damage(4, { track: 'physical' })).token.current).toBe(6); + }); + + it('fills the second track on the sheet, and spills what is past its maximum into the first', () => { + const sheet = { stun: 7, stun_max: 9 }; + expect(applyHealthAction(TRACKS, token(10, 10), sheet, damage(1, { track: 'stun' }))) + .toMatchObject({ token: token(10, 10), sheetPatch: { stun: 8 } }); + expect(applyHealthAction(TRACKS, token(10, 10, 1), sheet, damage(5, { track: 'stun' }))) + .toMatchObject({ token: token(8, 10, 0), sheetPatch: { stun: 9 }, overflow: 3 }); + }); + + it('stops at the maximum without overflow, counts up with no maximum, and heals the track it is told', () => { + const noSpill = { ...TRACKS, overflow: false }; + expect(applyHealthAction(noSpill, token(10, 10), { stun: 7, stun_max: 9 }, damage(5, { track: 'stun' }))) + .toMatchObject({ token: token(10, 10), sheetPatch: { stun: 9 } }); + expect(applyHealthAction(TRACKS, token(10, 10), {}, damage(12, { track: 'stun' }))) + .toMatchObject({ token: token(10, 10), sheetPatch: { stun: 12 } }); + expect(applyHealthAction(TRACKS, token(10, 10), { stun: 3 }, heal(5, { track: 'stun' })).sheetPatch).toEqual({ stun: 0 }); + expect(applyHealthAction(TRACKS, token(4, 10), { stun: 3 }, heal(5)).token.current).toBe(9); + }); + + it('refuses a track the system does not have', () => { + expect(applyHealthAction(TRACKS, token(10, 10), {}, damage(1, { track: 'mana' }))).toEqual({ ok: false, error: 'Not one of this system\'s tracks' }); + }); +}); + +describe('damage types on one track', () => { + it('marks boxes of the type named, the lightest when none is, and the token shows the boxes left', () => { + expect(applyHealthAction(TYPED, token(5, 5), {}, damage(2))).toMatchObject({ token: token(3, 5), sheetPatch: { superficial: 2, aggravated: 0 }, out: false }); + expect(applyHealthAction(TYPED, token(3, 5), { superficial: 2 }, damage(1, { type: 'aggravated' })).sheetPatch) + .toEqual({ superficial: 2, aggravated: 1 }); + }); + + it('turns a lighter box heavier once the track is full, and is out when every box is the heaviest', () => { + const full = { superficial: 3, aggravated: 2 }; + expect(applyHealthAction(TYPED, token(0, 5), full, damage(2))).toMatchObject({ + token: token(0, 5), sheetPatch: { superficial: 1, aggravated: 4 }, out: false, + }); + expect(applyHealthAction(TYPED, token(0, 5), full, damage(9))).toMatchObject({ sheetPatch: { superficial: 0, aggravated: 5 }, out: true }); + // Three types: a full track moves the lightest box one step, not straight to the heaviest. + expect(applyHealthAction(TYPED3, token(0, 3), { light: 1, heavy: 1, lethal: 1 }, damage(1)).sheetPatch) + .toEqual({ light: 0, heavy: 2, lethal: 1 }); + }); + + it('heals the lightest marks first, or the type named', () => { + const marks = { superficial: 2, aggravated: 2 }; + expect(applyHealthAction(TYPED, token(1, 5), marks, heal(3))).toMatchObject({ token: token(4, 5), sheetPatch: { superficial: 0, aggravated: 1 } }); + expect(applyHealthAction(TYPED, token(1, 5), marks, heal(1, { type: 'aggravated' })).sheetPatch).toEqual({ superficial: 2, aggravated: 1 }); + }); + + it('needs the track to have a size, and knows its own types', () => { + expect(applyHealthAction(TYPED, token(0, 0), {}, damage(1))).toEqual({ ok: false, error: 'Set the size of the track first' }); + expect(applyHealthAction(TYPED, token(5, 5), {}, damage(1, { type: 'fire' }))).toEqual({ ok: false, error: 'Not one of this system\'s damage types' }); + }); +}); + +describe('harm levels', () => { + it('writes the note in the first free slot of the level, the lowest when none is named', () => { + expect(applyHealthAction(HARM, token(0, 0), {}, damage(0, { note: 'Bruised' }))).toMatchObject({ + token: token(4, 5), sheetPatch: { lesser_1: 'Bruised' }, out: false, + }); + expect(applyHealthAction(HARM, token(4, 5), { lesser_1: 'Bruised' }, damage(0, { level: 'moderate', note: 'Cut' })).sheetPatch) + .toEqual({ moderate_1: 'Cut' }); + expect(applyHealthAction(HARM, token(5, 5), {}, damage(0)).sheetPatch).toEqual({ lesser_1: 'Harm' }); + }); + + it('moves harm up a level when its level is full, and is out past the top', () => { + const sheet = { lesser_1: 'a', lesser_2: 'b', moderate_1: 'c', moderate_2: 'd' }; + expect(applyHealthAction(HARM, token(1, 5), sheet, damage(0, { note: 'Shot' }))).toMatchObject({ + token: token(0, 5), sheetPatch: { severe_1: 'Shot' }, out: false, + }); + expect(applyHealthAction(HARM, token(0, 5), { ...sheet, severe_1: 'e' }, damage(0, { level: 'moderate', note: 'Again' }))) + .toMatchObject({ token: token(0, 5), sheetPatch: {}, out: true }); + }); + + it('heals the last harm at a level, or the slot named', () => { + const sheet = { lesser_1: 'a', lesser_2: 'b' }; + expect(applyHealthAction(HARM, token(3, 5), sheet, heal(0, { level: 'lesser' }))).toMatchObject({ token: token(4, 5), sheetPatch: { lesser_2: '' } }); + expect(applyHealthAction(HARM, token(3, 5), sheet, heal(0, { level: 'lesser', slot: 1 })).sheetPatch).toEqual({ lesser_1: '' }); + expect(applyHealthAction(HARM, token(5, 5), {}, heal(0, { level: 'moderate' }))).toEqual({ ok: false, error: 'Nothing to heal at that level' }); + expect(applyHealthAction(HARM, token(5, 5), {}, damage(0, { level: 'mortal' }))).toEqual({ ok: false, error: 'Not one of this system\'s harm levels' }); + }); +}); + +describe('wound count', () => { + it('takes wounds off what is left, and says the penalty for the wounds taken', () => { + expect(applyHealthAction(WOUNDS, token(3, 3), {}, damage(2))).toMatchObject({ token: token(1, 3), penalty: -2, out: false }); + expect(applyHealthAction(WOUNDS, token(1, 3), {}, damage(5))).toMatchObject({ token: token(0, 3), penalty: -3, out: true }); + expect(applyHealthAction(WOUNDS, token(1, 3), {}, heal(9))).toMatchObject({ token: token(3, 3), penalty: -0 }); + }); + + it('takes its size from the setup when the token has none yet', () => { + expect(applyHealthAction(WOUNDS, token(0, 0), {}, heal(3)).token).toEqual(token(3, 3)); + }); +}); + +describe('hit locations', () => { + it('damages the pool, and notes the hit on the location\'s line, adding to what is there', () => { + expect(applyHealthAction(LOCATIONS, token(10, 10), {}, damage(3, { location: 'head', note: 'Graze' }))) + .toMatchObject({ token: token(7, 10), sheetPatch: { head: 'Graze' } }); + expect(applyHealthAction(LOCATIONS, token(7, 10), { head: 'Graze' }, damage(1, { location: 'head' })).sheetPatch).toEqual({ head: 'Graze; Hit' }); + expect(applyHealthAction(LOCATIONS, token(7, 10), {}, damage(1)).sheetPatch).toEqual({}); + }); + + it('clears a location\'s note with a heal of nothing, leaving the pool, but wants an amount otherwise', () => { + expect(applyHealthAction(LOCATIONS, token(7, 10), { head: 'Graze' }, heal(0, { location: 'head' }))) + .toMatchObject({ ok: true, token: token(7, 10), sheetPatch: { head: '' } }); + expect(applyHealthAction(LOCATIONS, token(7, 10), {}, heal(0))).toEqual({ ok: false, error: 'An amount above 0' }); + expect(applyHealthAction(LOCATIONS, token(7, 10), {}, damage(0, { location: 'head' }))).toEqual({ ok: false, error: 'An amount above 0' }); + }); + + it('clears the location it heals, and knows its own locations', () => { + expect(applyHealthAction(LOCATIONS, token(7, 10), { head: 'Graze' }, heal(2, { location: 'head' }))) + .toMatchObject({ token: token(9, 10), sheetPatch: { head: '' } }); + expect(applyHealthAction(LOCATIONS, token(7, 10), {}, damage(1, { location: 'tail' }))).toEqual({ ok: false, error: 'Not one of this system\'s hit locations' }); + }); +}); + +describe('any model', () => { + it('refuses what it cannot do', () => { + expect(applyHealthAction({ model: 'none' }, token(0, 0), {}, damage(1))).toEqual({ ok: false, error: 'This system tracks harm as conditions, not health' }); + expect(applyHealthAction(TRACKS, token(5, 5), {}, damage(0))).toEqual({ ok: false, error: 'An amount above 0' }); + expect(applyHealthAction(TRACKS, token(5, 5), {}, { kind: 'set', amount: 1 })).toEqual({ ok: false, error: 'Damage or heal' }); + expect(applyHealthAction(null, token(5, 5), {}, damage(1))).toEqual({ ok: false, error: 'This system has no health model' }); + expect(applyHealthAction({ model: 'tracks', tracks: [] }, token(5, 5), {}, damage(1))).toEqual({ ok: false, error: 'This system\'s tracks are not set up' }); + }); +}); + +describe('the HIT_POINTS route', () => { + const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); + let db; + let app; + let emitted; + + /** Publish a system with this health model and make it the running one. */ + const running = async (health) => { + const definition = { format: 1, name: `Test ${Math.random()}`, core: { health } }; + const { id } = (await request(app).post('/api/systems').set('Authorization', `Bearer ${GM}`).send({ definition })).body; + await request(app).post(`/api/systems/${id}/publish`).set('Authorization', `Bearer ${GM}`); + await run(db, `UPDATE global_settings SET value = ? WHERE key = 'game_system'`, [id]); + return id; + }; + const tokenOf = (owner, current, max, temp = 0) => run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max, hp_temp) VALUES (?, 0, 0, 0, 'rhombus', ?, ?, ?, ?)`, + [owner, owner, current, max, temp]).then((r) => r.lastID); + const sheetOf = (owner, system, data) => run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)`, [owner, system, JSON.stringify(data)]); + const readSheet = async (owner, system) => JSON.parse((await get(db, + 'SELECT data FROM character_sheets WHERE username = ? AND system = ?', [owner, system])).data); + const readToken = (id) => get(db, 'SELECT hp_current, hp_max, hp_temp FROM locations WHERE id = ?', [id]); + const hit = (id, body) => request(app).put(`/api/locations/${id}/health`).send(body); + + beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); + emitted = []; + const io = { emit: (event, data) => emitted.push({ event, data }), to: () => ({ emit: () => {} }) }; + app = express(); + app.use(express.json()); + app.use('/api/systems', require_('../routes/systems.js')(db)); + app.use('/api/locations', require_('../routes/locations.js')(db, io, { emitUpdate: vi.fn(), recordAction: vi.fn() })); + await new Promise((resolve) => runtime.load(db, resolve)); + }); + + it('fills a second track on the sheet and spills the rest onto the token', async () => { + const system = await running(TRACKS); + const id = await tokenOf('GHOST', 10, 10, 1); + await sheetOf('GHOST', system, { stun: 7, stun_max: 9 }); + const res = await hit(id, { action: 'damage', amount: 5, track: 'stun' }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ hp_current: 8, hp_max: 10, hp_temp: 0, out: false, overflow: 3 }); + expect(await readToken(id)).toEqual({ hp_current: 8, hp_max: 10, hp_temp: 0 }); + expect(await readSheet('GHOST', system)).toMatchObject({ stun: 9, stun_max: 9 }); + expect(emitted).toContainEqual({ event: 'sheetUpdated', data: { username: 'GHOST' } }); + }); + + it('marks damage types on the sheet of an NPC linked to the token', async () => { + const system = await running(TYPED); + const loc = await run(db, `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('Ghoul', 0, 0, 0, 'enemy_rhombus', 'gm', 4, 4)`); + const sheet = await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('gm', ?, '{}', 1, 'Ghoul')`, [system]); + await run(db, 'INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (?, ?)', [loc.lastID, sheet.lastID]); + const res = await request(app).put(`/api/locations/${loc.lastID}/health`).set('Authorization', `Bearer ${GM}`) + .send({ action: 'damage', amount: 3, type: 'aggravated' }); + expect(res.body).toMatchObject({ hp_current: 1, hp_max: 4 }); + expect(JSON.parse((await get(db, 'SELECT data FROM character_sheets WHERE id = ?', [sheet.lastID])).data)) + .toEqual({ superficial: 0, aggravated: 3 }); + }); + + it('writes harm notes, and says the wound penalty', async () => { + const harmSystem = await running(HARM); + const id = await tokenOf('GHOST', 5, 5); + await sheetOf('GHOST', harmSystem, {}); + expect((await hit(id, { action: 'damage', level: 'moderate', note: 'Broken arm' })).body).toMatchObject({ hp_current: 4, hp_max: 5 }); + expect(await readSheet('GHOST', harmSystem)).toEqual({ moderate_1: 'Broken arm' }); + + await running(WOUNDS); + const rook = await tokenOf('ROOK', 3, 3); + expect((await hit(rook, { action: 'damage', amount: 2 })).body).toMatchObject({ hp_current: 1, hp_max: 3, penalty: -2 }); + }); + + it('refuses what the model cannot do, and what needs a sheet the token does not have', async () => { + await running({ model: 'none' }); + const id = await tokenOf('GHOST', 5, 5); + const none = await hit(id, { action: 'damage', amount: 1 }); + expect(none.status).toBe(400); + expect(none.body.error).toBe('This system tracks harm as conditions, not health'); + + await running(TRACKS); + const noSheet = await hit(id, { action: 'damage', amount: 1, track: 'stun' }); + expect(noSheet.status).toBe(409); + expect(await readToken(id)).toEqual({ hp_current: 5, hp_max: 5, hp_temp: 0 }); + // The first track needs no sheet. + expect((await hit(id, { action: 'damage', amount: 1 })).body).toMatchObject({ hp_current: 4 }); + }); + + it('keeps a player\'s edit made while the damage lands', async () => { + const system = await running(TYPED); + const id = await tokenOf('GHOST', 5, 5); + await sheetOf('GHOST', system, { notes: 'old' }); + const { mutateSheet } = require_('../sheets/mutate'); + const sheetId = (await get(db, 'SELECT id FROM character_sheets WHERE username = ?', ['GHOST'])).id; + const edit = new Promise((resolve) => mutateSheet(db, sheetId, (d) => ({ ...d, notes: 'new' }), resolve)); + await Promise.all([edit, hit(id, { action: 'damage', amount: 2 })]); + expect(await readSheet('GHOST', system)).toMatchObject({ notes: 'new', superficial: 2 }); + }); + + it('leaves the built-in systems, and a custom one-pool system, on the route as before', async () => { + const id = await tokenOf('GHOST', 10, 20, 3); + // The pool model's route would refuse an amount of 0; the built-in route shrugs it off. + expect((await hit(id, { action: 'damage', amount: 0 })).status).toBe(200); + expect((await hit(id, { action: 'damage', amount: 5 })).body).toEqual({ id: String(id), hp_current: 8, hp_max: 20, hp_temp: 0 }); + expect((await hit(id, { action: 'heal', amount: 50 })).body).toEqual({ id: String(id), hp_current: 20, hp_max: 20, hp_temp: 0 }); + + await running({ model: 'pool', label: 'VIGOR' }); + expect((await hit(id, { action: 'damage', amount: 0 })).status).toBe(200); + expect((await hit(id, { action: 'damage', amount: 4 })).body).toEqual({ id: String(id), hp_current: 16, hp_max: 20, hp_temp: 0 }); + }); +}); diff --git a/backend/__tests__/system_builder_health_view.test.js b/backend/__tests__/system_builder_health_view.test.js new file mode 100644 index 00000000..6d39b05f --- /dev/null +++ b/backend/__tests__/system_builder_health_view.test.js @@ -0,0 +1,255 @@ +import { describe, it, expect, beforeEach, vi } 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 { untilValue, drain } from './helpers/until.js'; + +/** + * What the HEALTH folder is sent under a custom health model: the full detail to whoever may + * change the token's health (the GM, a granted editor, the owner) and a description, never a + * number or a note, to everyone else. Plus the details the window reports after an action. + */ + +process.env.JWT_SECRET = 'test-secret'; +process.env.DICE_ANIM_MS = '0'; +const require_ = createRequire(import.meta.url); +const { healthView } = require_('../systemBuilder/healthView'); +const { applyHealthAction } = require_('../systemBuilder/health'); +const runtime = require_('../systemBuilder/runtime'); +const socketsFactory = require_('../sockets/index.js'); + +const TRACKS = { model: 'tracks', tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }], overflow: true }; +const TYPED = { model: 'typed', types: [{ id: 'superficial', label: 'SUPERFICIAL' }, { id: 'aggravated', label: 'AGGRAVATED' }] }; +const HARM = { model: 'harm', levels: [ + { id: 'lesser', label: 'LESSER', slots: 2, penalty: 'Reduced effect' }, + { id: 'moderate', label: 'MODERATE', slots: 2, penalty: '-1d' }, + { id: 'severe', label: 'SEVERE', slots: 1, penalty: 'Need help' }, +] }; +const WOUNDS = { model: 'wounds', count: 3, penalty: -1 }; +const LOCATIONS = { model: 'locations', locations: [{ id: 'head', label: 'HEAD' }, { id: 'body', label: 'BODY' }] }; +const token = (current, max) => ({ current, max, temp: 0 }); +const both = (health, t, sheet) => [healthView(health, t, sheet, { full: true }), healthView(health, t, sheet)]; + +describe('the view', () => { + it('is nothing for a built-in system, which draws its own health', () => { + expect(healthView(null, token(5, 10), {})).toEqual({ model: null }); + expect(healthView({ model: 'tracks', tracks: [] }, token(5, 10), {})).toEqual({ model: null }); + }); + + it('one pool: its own word for health', () => { + expect(healthView({ model: 'pool', label: 'VIGOR' }, token(5, 10), {})).toEqual({ model: 'pool', full: false, label: 'VIGOR' }); + expect(healthView({ model: 'pool' }, token(5, 10), {}).label).toBe('HP'); + }); + + it('two tracks: the second track\'s numbers in full, only its fill to others', () => { + const [full, others] = both(TRACKS, token(8, 10), { stun: 6, stun_max: 9 }); + expect(full).toEqual({ + model: 'tracks', full: true, overflow: true, + tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }], + second: { id: 'stun', label: 'STUN', current: 6, max: 9 }, + }); + expect(others.second).toEqual({ label: 'STUN', fill: 6 / 9, full: false }); + expect(healthView(TRACKS, token(8, 10), { stun: 9, stun_max: 9 }).second).toMatchObject({ fill: 1, full: true }); + expect(healthView(TRACKS, token(8, 10), { stun: 4 }).second).toMatchObject({ fill: 0, full: false }); + }); + + it('damage types: each type\'s marks in full, light against heavy as shares to others', () => { + const [full, others] = both(TYPED, token(3, 8), { superficial: 3, aggravated: 2 }); + expect(full).toMatchObject({ boxes: 8, types: [{ id: 'superficial', label: 'SUPERFICIAL', marks: 3 }, { id: 'aggravated', label: 'AGGRAVATED', marks: 2 }] }); + expect(others).toEqual({ model: 'typed', full: false, light: 3 / 8, heavy: 2 / 8 }); + }); + + it('harm levels: the notes in full, the worst level\'s name to others, and out only when full and down', () => { + const sheet = { lesser_1: 'Bruised ribs', moderate_1: 'Twisted knee' }; + const [full, others] = both(HARM, token(3, 5), sheet); + expect(full.levels).toEqual([ + { id: 'lesser', label: 'LESSER', penalty: 'Reduced effect', slots: ['Bruised ribs', ''] }, + { id: 'moderate', label: 'MODERATE', penalty: '-1d', slots: ['Twisted knee', ''] }, + { id: 'severe', label: 'SEVERE', penalty: 'Need help', slots: [''] }, + ]); + expect(full).toMatchObject({ worst: 1, out: false }); + expect(others).toEqual({ model: 'harm', full: false, levelCount: 3, worst: 1, worstLabel: 'MODERATE', out: false }); + expect(JSON.stringify(others)).not.toContain('Twisted'); + const all = { lesser_1: 'a', lesser_2: 'b', moderate_1: 'c', moderate_2: 'd', severe_1: 'e' }; + expect(healthView(HARM, token(0, 5), all)).toMatchObject({ worstLabel: 'SEVERE', out: true }); + expect(healthView(HARM, token(1, 5), all).out).toBe(false); + expect(healthView(HARM, token(5, 5), {})).toMatchObject({ worst: -1, worstLabel: null }); + }); + + it('wound count: the penalty in full, a word to others', () => { + const [full, others] = both(WOUNDS, token(1, 3), {}); + expect(full).toEqual({ model: 'wounds', full: true, penalty: -2 }); + expect(others).toEqual({ model: 'wounds', full: false, state: 'wounded' }); + expect(healthView(WOUNDS, token(3, 3), {}).state).toBe('unhurt'); + expect(healthView(WOUNDS, token(0, 3), {}).state).toBe('incapacitated'); + // No size on the token yet: the setup's count. + expect(healthView(WOUNDS, token(2, 0), {}, { full: true }).penalty).toBe(-1); + }); + + it('hit locations: the notes in full, only which are hurt to others', () => { + const [full, others] = both(LOCATIONS, token(9, 15), { body: 'Graze' }); + expect(full.locations).toEqual([{ id: 'head', label: 'HEAD', note: '' }, { id: 'body', label: 'BODY', note: 'Graze' }]); + expect(others.locations).toEqual([{ id: 'head', label: 'HEAD', hit: false }, { id: 'body', label: 'BODY', hit: true }]); + }); + + it('none: only which model it is', () => { + expect(healthView({ model: 'none' }, token(0, 0), {})).toEqual({ model: 'none', full: false }); + }); +}); + +describe('what an action reports', () => { + it('how many boxes turned heavier, and which level harm landed on', () => { + expect(applyHealthAction(TYPED, token(0, 5), { superficial: 3, aggravated: 2 }, { kind: 'damage', amount: 2 })).toMatchObject({ turned: 2 }); + expect(applyHealthAction(TYPED, token(5, 5), {}, { kind: 'damage', amount: 2 }).turned).toBeUndefined(); + const sheet = { lesser_1: 'a', lesser_2: 'b' }; + expect(applyHealthAction(HARM, token(3, 5), sheet, { kind: 'damage', level: 'lesser', note: 'Cut' })).toMatchObject({ placed: 'moderate', sheetPatch: { moderate_1: 'Cut' } }); + expect(applyHealthAction(HARM, token(5, 5), {}, { kind: 'damage', note: 'Cut' }).placed).toBe('lesser'); + const all = { ...sheet, moderate_1: 'c', moderate_2: 'd', severe_1: 'e' }; + expect(applyHealthAction(HARM, token(0, 5), all, { kind: 'damage', note: 'x' }).placed).toBeUndefined(); + }); + + it('sets the second track\'s maximum, bringing its current down to it, and refuses any other', () => { + expect(applyHealthAction(TRACKS, token(8, 10), { stun: 7 }, { kind: 'set_max', track: 'stun', amount: 12 }).sheetPatch).toEqual({ stun_max: 12 }); + expect(applyHealthAction(TRACKS, token(8, 10), { stun: 7 }, { kind: 'set_max', track: 'stun', amount: 5 }).sheetPatch).toEqual({ stun_max: 5, stun: 5 }); + expect(applyHealthAction(TRACKS, token(8, 10), {}, { kind: 'set_max', track: 'physical', amount: 5 })).toEqual({ ok: false, error: 'The first track\'s maximum is set on the token' }); + expect(applyHealthAction(WOUNDS, token(2, 3), {}, { kind: 'set_max', amount: 5 })).toEqual({ ok: false, error: 'This maximum is set on the token' }); + }); +}); + +describe('in the running game', () => { + const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); + let db; + let app; + let elevatedUsers; + + const running = async (health) => { + const definition = { format: 1, name: `Test ${Math.random()}`, core: { health } }; + const { id } = (await request(app).post('/api/systems').set('Authorization', `Bearer ${GM}`).send({ definition })).body; + await request(app).post(`/api/systems/${id}/publish`).set('Authorization', `Bearer ${GM}`); + await run(db, `UPDATE global_settings SET value = ? WHERE key = 'game_system'`, [id]); + return id; + }; + + /** A socket for `userName` (the GM when `admin`), with what it was sent. */ + const connectAs = async (userName, { admin = false } = {}) => { + let connect; + socketsFactory({ on: (e, cb) => { if (e === 'connection') connect = cb; }, emit: () => {}, to: () => ({ emit: () => {} }) }, + db, { elevatedUsers, emitUpdate: vi.fn(), recordAction: vi.fn() }); + const handlers = {}; + const sent = []; + connect({ id: `hv-${Math.random()}`, on: (e, fn) => { handlers[e] = fn; }, emit: (e, d) => sent.push({ e, d }), + broadcast: { emit: () => {} }, use: () => {}, join: () => {}, disconnect: () => {} }); + handlers.identify(admin ? { userName, isAdmin: true, token: GM } : userName); + await drain(db); + return { handlers, sent }; + }; + const viewFor = async (who, locationId) => { + who.handlers.requestHealthView({ location_id: locationId }); + const found = await untilValue(() => who.sent.find((s) => s.e === 'healthView' && s.d.location_id === locationId), Boolean, { label: 'the health view' }); + who.sent.length = 0; + return found.d; + }; + + beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); + elevatedUsers = new Set(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + app = express(); + app.use(express.json()); + app.use('/api/systems', require_('../routes/systems.js')(db)); + app.use('/api/locations', require_('../routes/locations.js')(db, { emit: () => {} }, { emitUpdate: vi.fn(), recordAction: vi.fn() })); + await new Promise((resolve) => runtime.load(db, resolve)); + }); + + const playerToken = async (owner, system, data) => { + await run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)', [owner, system, JSON.stringify(data)]); + return (await run(db, `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES (?, 0, 0, 0, 'rhombus', ?, 8, 10)`, [owner, owner])).lastID; + }; + + it('sends a player\'s own token in full, and another player only the description', async () => { + const system = await running(HARM); + const id = await playerToken('GHOST', system, { moderate_1: 'Twisted knee' }); + const owner = await connectAs('GHOST'); + const other = await connectAs('ROOK'); + expect(await viewFor(owner, id)).toMatchObject({ location_id: id, model: 'harm', full: true, worst: 1 }); + const seen = await viewFor(other, id); + expect(seen).toMatchObject({ model: 'harm', full: false, worstLabel: 'MODERATE' }); + expect(JSON.stringify(seen)).not.toContain('Twisted'); + }); + + it('sends the GM and a granted editor everything, an NPC included', async () => { + const system = await running(TRACKS); + const id = await playerToken('GHOST', system, { stun: 3, stun_max: 9 }); + const npc = await run(db, `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('Ghoul', 0, 0, 0, 'enemy_rhombus', 'gm', 4, 4)`); + const sheet = await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('gm', ?, '{"stun":2,"stun_max":5}', 1, 'Ghoul')`, [system]); + await run(db, 'INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (?, ?)', [npc.lastID, sheet.lastID]); + + const gm = await connectAs('gm', { admin: true }); + expect((await viewFor(gm, id)).second).toEqual({ id: 'stun', label: 'STUN', current: 3, max: 9 }); + expect((await viewFor(gm, npc.lastID)).second).toEqual({ id: 'stun', label: 'STUN', current: 2, max: 5 }); + + elevatedUsers.add('ROOK'); + const granted = await connectAs('ROOK'); + expect((await viewFor(granted, npc.lastID)).full).toBe(true); + // Nobody else sees an NPC's numbers, even with the NPC's owner field naming the GM. + const player = await connectAs('GHOST'); + expect((await viewFor(player, npc.lastID)).second).toEqual({ label: 'STUN', fill: 2 / 5, full: false }); + }); + + it("does not give a player an NPC's numbers because the NPC's owner field names them", async () => { + // An enemy made by a player while the GM had granted them editing carries their name. + // Once the grant is gone, so is the full view. + const system = await running(TRACKS); + const npc = await run(db, `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('Drone', 0, 0, 0, 'enemy_rhombus', 'ROOK', 4, 4)`); + const sheet = await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('ROOK', ?, '{"stun":1,"stun_max":4}', 1, 'Drone')`, [system]); + await run(db, 'INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (?, ?)', [npc.lastID, sheet.lastID]); + const rook = await connectAs('ROOK'); + expect(await viewFor(rook, npc.lastID)).toMatchObject({ full: false, second: { label: 'STUN', fill: 0.25, full: false } }); + }); + + it('names a one-pool system\'s health in its own word for hit points', async () => { + const definition = { format: 1, name: 'Hearth', words: { hp: { singular: 'WOUND', short: 'WND' } }, core: { health: { model: 'pool' } } }; + const { id: system } = (await request(app).post('/api/systems').set('Authorization', `Bearer ${GM}`).send({ definition })).body; + await request(app).post(`/api/systems/${system}/publish`).set('Authorization', `Bearer ${GM}`); + await run(db, `UPDATE global_settings SET value = ? WHERE key = 'game_system'`, [system]); + const id = await playerToken('GHOST', system, {}); + expect(await viewFor(await connectAs('GHOST'), id)).toMatchObject({ model: 'pool', label: 'WND' }); + }); + + it('says there is nothing to draw for a built-in system, and answers only the one who asked', async () => { + const id = await playerToken('GHOST', 'cities_without_number', {}); + const asker = await connectAs('GHOST'); + const bystander = await connectAs('ROOK'); + expect(await viewFor(asker, id)).toEqual({ location_id: id, model: null }); + expect(bystander.sent.some((s) => s.e === 'healthView')).toBe(false); + // Always an answer, so a window never waits on one: even for a token not on the map. + expect(await viewFor(asker, 9999)).toEqual({ location_id: 9999, model: null }); + }); + + it('sets a second track\'s maximum on the sheet, and any other maximum on the token as before', async () => { + const system = await running(TRACKS); + const id = await playerToken('GHOST', system, { stun: 7, stun_max: 9 }); + const res = await request(app).put(`/api/locations/${id}/health`).send({ action: 'set_max', track: 'stun', amount: 5 }); + expect(res.status).toBe(200); + expect(JSON.parse((await get(db, 'SELECT data FROM character_sheets WHERE username = ?', ['GHOST'])).data)).toMatchObject({ stun: 5, stun_max: 5 }); + expect(await get(db, 'SELECT hp_max FROM locations WHERE id = ?', [id])).toEqual({ hp_max: 10 }); + + await request(app).put(`/api/locations/${id}/health`).send({ action: 'set_max', track: 'physical', hp_max: 14 }); + expect(await get(db, 'SELECT hp_max FROM locations WHERE id = ?', [id])).toEqual({ hp_max: 14 }); + }); + + it('passes the details on to the window', async () => { + const system = await running(HARM); + const id = await playerToken('GHOST', system, { lesser_1: 'a', lesser_2: 'b' }); + const res = await request(app).put(`/api/locations/${id}/health`).send({ action: 'damage', level: 'lesser', note: 'Cut' }); + expect(res.body).toMatchObject({ placed: 'moderate' }); + + await running(TYPED); + const full = await playerToken('ROOK', (await get(db, `SELECT value FROM global_settings WHERE key = 'game_system'`)).value, { superficial: 4, aggravated: 0 }); + await run(db, 'UPDATE locations SET hp_current = 0, hp_max = 4 WHERE id = ?', [full]); + expect((await request(app).put(`/api/locations/${full}/health`).send({ action: 'damage', amount: 1 })).body).toMatchObject({ turned: 1 }); + }); +}); diff --git a/backend/__tests__/system_builder_initiative_words.test.js b/backend/__tests__/system_builder_initiative_words.test.js new file mode 100644 index 00000000..37c1aae8 --- /dev/null +++ b/backend/__tests__/system_builder_initiative_words.test.js @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import sqlite3 from 'sqlite3'; +import { createRequire } from 'module'; + +/** + * The dice log's initiative lines, written by the server (3a5a). Under every built-in system, + * and a custom system that did not rename initiative, they read exactly as today; under a + * custom system that renamed it, they use its word. The combat records which system it runs. + */ + +const require_ = createRequire(import.meta.url); +const runtime = require_('../systemBuilder/runtime'); +const mod = await import('../sockets/initiative.js'); +const registerInitiativeHandlers = mod.registerInitiativeHandlers || mod.default.registerInitiativeHandlers; + +const RENAMED = 'sys_aaaaaaaaaaaaaaaa'; +const PLAIN = 'sys_bbbbbbbbbbbbbbbb'; + +const run = (db, sql, p = []) => new Promise((res, rej) => db.run(sql, p, function (e) { e ? rej(e) : res(this); })); +const all = (db, sql, p = []) => new Promise((res, rej) => db.all(sql, p, (e, r) => (e ? rej(e) : res(r)))); +const waitFor = async (fn, timeout = 2000) => { + const start = Date.now(); + for (;;) { + const v = await fn(); + if (v) return v; + if (Date.now() - start > timeout) throw new Error('timed out'); + await new Promise((r) => setTimeout(r, 5)); + } +}; + +const makeDb = async () => { + const db = new sqlite3.Database(':memory:'); + await run(db, `CREATE TABLE 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 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 dice_rolls (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, total INTEGER, results TEXT, + color TEXT, historyString TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)`); + // Two published custom systems, loaded the way the server loads them on start. + await run(db, `CREATE TABLE custom_systems (id TEXT PRIMARY KEY, name TEXT, draft TEXT, published TEXT, version INTEGER, deleted_at DATETIME)`); + const renamed = JSON.stringify({ format: 1, name: 'Hearth', words: { initiative: { singular: 'order', short: 'ORD' } } }); + const plain = JSON.stringify({ format: 1, name: 'Plain' }); + await run(db, 'INSERT INTO custom_systems (id, name, draft, published, version) VALUES (?, ?, ?, ?, 1), (?, ?, ?, ?, 1)', + [RENAMED, 'Hearth', renamed, renamed, PLAIN, 'Plain', plain, plain]); + await new Promise((resolve) => runtime.load(db, resolve)); + return db; +}; + +const boot = (db) => { + let connect; + registerInitiativeHandlers({ on: (e, cb) => { if (e === 'connection') connect = cb; }, emit: () => {}, to: () => ({ emit: () => {} }) }, db); + const handlers = {}; + connect({ id: 'sock-1', on: (e, fn) => { handlers[e] = fn; }, emit: () => {}, use: () => {}, join: () => {} }); + return handlers; +}; + +let db; +let handlers; +beforeEach(async () => { + db = await makeDb(); + handlers = boot(db); +}); + +/** Start a combat under `system`, roll as asked, and return what the dice log says. */ +const logged = async (system, mode, roll) => { + handlers['initiative:start']({ sceneKey: 'city:0', system, mode }); + await waitFor(async () => (await all(db, 'SELECT scene_key FROM initiative_scene')).length); + roll(); + return (await waitFor(async () => { + const rows = await all(db, 'SELECT historyString FROM dice_rolls'); + return rows.length ? rows : null; + }))[0].historyString; +}; +const rollOne = (breakdown) => () => handlers['initiative:roll']({ + sceneKey: 'city:0', combatant: { id: 'player:jade', name: 'JADE', score: 14, ...(breakdown ? { breakdown } : {}) }, +}); +const rollSide = (breakdown) => () => handlers['initiative:roll_side']({ sceneKey: 'city:0', score: 9, ...(breakdown ? { breakdown } : {}) }); + +describe('a combatant\'s roll in the dice log', () => { + it('reads as today under every built-in system, and a custom one that renamed nothing', async () => { + for (const system of ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic', PLAIN]) { + db = await makeDb(); + handlers = boot(db); + expect(await logged(system, 'individual', rollOne()), system).toBe('JADE rolled INITIATIVE [14]'); + } + db = await makeDb(); + handlers = boot(db); + expect(await logged('generic', 'individual', rollOne('1d20 (14)'))).toBe('JADE INITIATIVE: 1d20 (14)'); + }); + + it('uses a custom system\'s own word, in capitals like the rest of the line', async () => { + expect(await logged(RENAMED, 'individual', rollOne())).toBe('JADE rolled ORDER [14]'); + db = await makeDb(); + handlers = boot(db); + expect(await logged(RENAMED, 'individual', rollOne('1d20 (14)'))).toBe('JADE ORDER: 1d20 (14)'); + }); +}); + +describe('the NPC side\'s roll in the dice log', () => { + it('reads as today under a built-in system, and uses a custom system\'s own word', async () => { + expect(await logged('cities_without_number', 'side', rollSide())).toBe('NPC SIDE rolled INITIATIVE [9]'); + db = await makeDb(); + handlers = boot(db); + expect(await logged(PLAIN, 'side', rollSide('1d8 (9)'))).toBe('NPC SIDE INITIATIVE: 1d8 (9)'); + db = await makeDb(); + handlers = boot(db); + expect(await logged(RENAMED, 'side', rollSide())).toBe('NPC SIDE rolled ORDER [9]'); + db = await makeDb(); + handlers = boot(db); + expect(await logged(RENAMED, 'side', rollSide('1d8 (9)'))).toBe('NPC SIDE ORDER: 1d8 (9)'); + }); +}); diff --git a/backend/__tests__/system_builder_npc_privacy.test.js b/backend/__tests__/system_builder_npc_privacy.test.js new file mode 100644 index 00000000..d68772bb --- /dev/null +++ b/backend/__tests__/system_builder_npc_privacy.test.js @@ -0,0 +1,297 @@ +import { describe, it, expect, beforeEach, afterEach, vi } 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 { untilValue, drain } from './helpers/until.js'; + +/** + * A custom system's privacy and its NPCs, as data. + * + * A field can be the GM's to set (XP, awarded items): the owner sees it and cannot change it, + * by a single edit, a batch edit or an upload, while the GM still can. NPCs get a layout of + * their own and power tiers that GENERATE_SHEET uses. The built-in systems are untouched. + */ + +process.env.JWT_SECRET = 'test-secret'; +process.env.DICE_ANIM_MS = '0'; +const require_ = createRequire(import.meta.url); +const { checkDefinition } = require_('../systemBuilder/definition'); +const runtime = require_('../systemBuilder/runtime'); +const templates = require_('../sheets/templates'); +const npcTiers = require_('../sheets/npcTiers'); +const socketsFactory = require_('../sockets/index.js'); + +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); +const gm = { Authorization: `Bearer ${GM}` }; + +const SHEET = { + tabs: ['KNIGHT'], + header: { nameField: 'name', hpField: 'wounds', hpMaxField: 'wounds_max' }, + sections: [ + { id: 'who', label: 'WHO', layout: 'list', tab: 'KNIGHT', fields: [ + { id: 'name', label: 'Name', type: 'text', visibility: 'public' }, + { id: 'renown', label: 'Renown', type: 'number', edit: 'gm' }, + { id: 'boon', label: 'Boon', type: 'text', edit: 'gm' }, + { id: 'order', label: 'Order', type: 'select', edit: 'player', options: [{ value: 'dawn', label: 'Dawn' }, { value: 'dusk', label: 'Dusk' }] }, + ] }, + { id: 'stats', label: 'STATS', layout: 'grid', tab: 'KNIGHT', fields: [ + { id: 'might', label: 'MIGHT', type: 'number' }, + { id: 'might_mod', label: 'MOD', type: 'number' }, + { id: 'wounds', label: 'WOUNDS', type: 'number', source: 'token_hp', maxField: 'wounds_max' }, + { id: 'wounds_max', label: 'MAX', type: 'number', source: 'token_hp_max' }, + ] }, + ], +}; + +const NPC_SHEET = { + header: { nameField: 'name', hpField: 'wounds' }, + sections: [ + { id: 'block', label: 'STAT BLOCK', layout: 'grid', fields: [ + { id: 'name', label: 'Name', type: 'text' }, + { id: 'might', label: 'MIGHT', type: 'number' }, + { id: 'might_mod', label: 'MOD', type: 'number' }, + { id: 'threat', label: 'THREAT', type: 'number', sensitivity: 'combat' }, + { id: 'wounds', label: 'WOUNDS', type: 'number', source: 'token_hp' }, + // Only NPCs show their armor, and it still lives on the token. + { id: 'ward', label: 'WARD', type: 'number', source: 'token_ac' }, + // Public on the NPC layout, which says nothing: NPC sheets are never shown to players. + { id: 'tactics', label: 'Tactics', type: 'text', visibility: 'public' }, + ] }, + ], +}; + +const VAULT = { + format: 1, + name: 'Vault Knights', + lookups: { mod: { bands: [{ upTo: 9, value: -1 }, { upTo: 13, value: 0 }, { value: 1 }] } }, + derived: [{ id: 'might_mod', formula: 'mod(@might)' }], + sheet: SHEET, + npc: { + sheet: NPC_SHEET, + tiers: [ + { id: 'squire', label: 'SQUIRE', hp: 6, defense: 11, values: { might: 9, threat: 1, tactics: 'Runs' } }, + { id: 'champion', label: 'CHAMPION', hp: 30, defense: 17, values: { might: 16, threat: 4 } }, + { id: 'ghost', label: 'GHOST', values: { tactics: 'Haunts' } }, + ], + }, +}; + +const problems = (definition) => checkDefinition(definition).problems.map((p) => `${p.where}: ${p.message}`); + +describe('the format', () => { + it('accepts a system with GM-only fields, an NPC layout and tiers', () => { + expect(problems(VAULT)).toEqual([]); + }); + + it('knows who may edit a field, and that nobody edits a derived value', () => { + const sheet = JSON.parse(JSON.stringify(SHEET)); + sheet.sections[0].fields[1].edit = 'owner'; + sheet.sections[1].fields[1].edit = 'gm'; + expect(problems({ ...VAULT, sheet })).toEqual([ + 'sheet field renown, edit: player or gm', + 'sheet field might_mod, edit: A derived value is worked out, so nobody edits it', + ]); + }); + + it('checks the NPC layout as a sheet, and holds it to linking fields as the character sheet does', () => { + const npcSheet = JSON.parse(JSON.stringify(NPC_SHEET)); + npcSheet.sections[0].layout = 'cards'; + npcSheet.sections[0].fields.push({ id: 'might', label: 'Again', type: 'number' }); + npcSheet.sections[0].fields[1].source = 'bank_balance'; + expect(problems({ ...VAULT, npc: { sheet: npcSheet } })).toEqual([ + 'npc sheet section block, layout: One of list, grid, notes, inventory', + 'npc sheet field might: Defined twice', + 'npc sheet field might: Linked differently on the character sheet', + ]); + }); + + it('reports every mistake in the tiers, with where it is', () => { + expect(problems({ ...VAULT, npc: { sheet: NPC_SHEET, extra: 1, tiers: [ + { id: 'Squire', label: '', hp: -1, defense: 100, colour: 'red' }, + { id: 'b', label: 'B', values: { might_mod: 3, wounds: 5, nope: 1, might: 'lots', tactics: 7 } }, + { id: 'b', label: 'B' }, + 'x', + ] } })).toEqual([ + 'npc extra: Not part of the NPC settings', + 'npc tier Squire, colour: Not part of a tier', + 'npc tier Squire: Ids use lowercase letters, digits and _, starting with a letter', + 'npc tier Squire, label: Required', + 'npc tier Squire, hp: A whole number from 0 to 9999', + 'npc tier Squire, defense: A whole number from 0 to 99', + 'npc tier b, might_mod: A derived value is worked out, not set', + 'npc tier b, wounds: Lives on the token or in the bank; use the tier\'s hp and defense', + 'npc tier b, nope: Not a field on the NPC sheet', + 'npc tier b, might: Must be a number', + 'npc tier b, tactics: Must be text', + 'npc tier b: Defined twice', + 'npc tier 4: Must be a tier', + ]); + }); + + it('checks tier values against the character sheet when NPCs have no layout of their own', () => { + expect(problems({ ...VAULT, npc: { tiers: [{ id: 'a', label: 'A', values: { order: 'noon', tactics: 'x' } }] } })).toEqual([ + 'npc tier a, order: Not one of the field\'s options', + 'npc tier a, tactics: Not a field on the NPC sheet', + ]); + }); + + it('refuses npc settings that are not settings', () => { + expect(problems({ ...VAULT, npc: [] })).toEqual(['npc: Must be a set of NPC settings']); + expect(problems({ ...VAULT, npc: { tiers: 'many' } })).toEqual(['npc tiers: Must be a list of tiers']); + }); +}); + +describe('a published system with them', () => { + let db; + let app; + let id; + let elevatedUsers; + const emitted = []; + + beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); + emitted.length = 0; + const io = { emit: (event, data) => emitted.push({ event, data }), to: () => ({ emit: () => {} }) }; + app = express(); + app.use(express.json({ limit: '2mb' })); + app.use('/api/systems', require_('../routes/systems.js')(db)); + app.use('/api/sheets', require_('../routes/sheets.js')(db, io)); + id = (await request(app).post('/api/systems').set(gm).send({ definition: VAULT })).body.id; + await new Promise((resolve) => runtime.load(db, resolve)); + await request(app).post(`/api/systems/${id}/publish`).set(gm); + elevatedUsers = new Set(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => vi.restoreAllMocks()); + + /** A connected socket for `userName`, the GM when `admin`. */ + const connectAs = async (userName, { admin = false } = {}) => { + let connect; + socketsFactory({ on: (e, cb) => { if (e === 'connection') connect = cb; }, emit: () => {}, to: () => ({ emit: () => {} }) }, + db, { elevatedUsers, emitUpdate: vi.fn(), recordAction: vi.fn() }); + const handlers = {}; + const sent = []; + connect({ id: `np-${Math.random()}`, on: (e, fn) => { handlers[e] = fn; }, emit: (e, d) => sent.push({ e, d }), + broadcast: { emit: () => {} }, use: () => {}, join: () => {}, disconnect: () => {} }); + handlers.identify(admin ? { userName, isAdmin: true, token: GM } : userName); + await drain(db); + return { handlers, sent }; + }; + + const sheetOf = async (username) => JSON.parse((await get(db, + 'SELECT data FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0', [username, id])).data); + + const playing = async (username, data) => { + await request(app).put('/api/sheets/system').set(gm).send({ system: id }); + await run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)', [username, id, JSON.stringify(data)]); + }; + + describe('GM-only fields', () => { + it('are listed for the server, and every built-in sheet has none', () => { + expect(templates.metaFor(id).gmFields).toEqual(['renown', 'boon']); + expect(templates.playerMayEdit(id, 'renown')).toBe(false); + expect(templates.playerMayEdit(id, 'might')).toBe(true); + for (const system of ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic']) { + expect(templates.metaFor(system).gmFields, system).toBeUndefined(); + expect(templates.playerMayEdit(system, 'xp'), system).toBe(true); + } + }); + + it("cannot be changed by the character's owner, who can still change everything else", async () => { + await playing('GHOST', { renown: 2, might: 10 }); + const { handlers } = await connectAs('GHOST'); + handlers.updateSheetField({ fieldId: 'renown', value: 99 }); + handlers.updateSheetField({ fieldId: 'might', value: 14 }); + const saved = await untilValue(() => sheetOf('GHOST'), (d) => d.might === 14, { label: 'the edit' }); + await drain(db); + expect(await sheetOf('GHOST')).toMatchObject({ renown: 2, might: 14, might_mod: 1 }); + expect(saved.renown).toBe(2); + }); + + it('are left out of an upload or a batch edit, and the rest goes in', async () => { + await playing('GHOST', { renown: 2, boon: 'Blessed blade' }); + const { handlers } = await connectAs('GHOST'); + handlers.importSheetFields({ fields: { renown: 50, boon: 'Crown', name: 'Sir Ash', might: 16 } }); + const saved = await untilValue(() => sheetOf('GHOST'), (d) => d.name === 'Sir Ash', { label: 'the import' }); + expect(saved).toMatchObject({ renown: 2, boon: 'Blessed blade', name: 'Sir Ash', might: 16, might_mod: 1 }); + }); + + it('are the GM\'s to change, on their own sheet or by a grant', async () => { + await playing('gm', { renown: 1 }); + const { handlers } = await connectAs('gm', { admin: true }); + handlers.updateSheetField({ fieldId: 'renown', value: 5 }); + expect((await untilValue(() => sheetOf('gm'), (d) => d.renown === 5, { label: 'the GM edit' })).renown).toBe(5); + + await run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, 0)', ['ROOK', id, '{"renown":1}']); + elevatedUsers.add('ROOK'); + const granted = await connectAs('ROOK'); + granted.handlers.importSheetFields({ fields: { renown: 3 } }); + expect((await untilValue(() => sheetOf('ROOK'), (d) => d.renown === 3, { label: 'the granted edit' })).renown).toBe(3); + }); + + it("are the GM's to change from the GM's sheet window", async () => { + await playing('GHOST', { renown: 2 }); + const res = await request(app).put('/api/sheets/user/GHOST').set(gm).send({ fields: { renown: 7 } }); + expect(res.status).toBe(200); + expect((await sheetOf('GHOST')).renown).toBe(7); + }); + }); + + describe('NPCs', () => { + it('reach the browser with their layout and tier names', async () => { + const res = await request(app).get(`/api/systems/render/${id}`); + expect(res.body.npc).toEqual({ + sheet: NPC_SHEET, + tiers: [{ id: 'squire', label: 'SQUIRE' }, { id: 'champion', label: 'CHAMPION' }, { id: 'ghost', label: 'GHOST' }], + }); + }); + + it("fold the NPC layout's links into the system's rules", () => { + expect(templates.getLinkedFields(id)).toEqual({ wounds: 'token_hp', wounds_max: 'token_hp_max', ward: 'token_ac' }); + expect(templates.metaFor(id).combatFields).toEqual(['threat']); + // Never shown to players: only the character sheet says what is public. + expect(templates.filterPublicData(id, { name: 'Sir Ash', tactics: 'Runs', threat: 3 })).toEqual({ name: 'Sir Ash' }); + }); + + it('offer their tiers to GENERATE_SHEET, built with derived values worked out', () => { + expect(npcTiers.getTierOptions(id).map((t) => t.id)).toEqual(['squire', 'champion', 'ghost']); + expect(npcTiers.buildTier(id, 'champion')).toEqual({ + tierId: 'champion', data: { might: 16, threat: 4, might_mod: 1 }, hp: 30, dv: { melee: 17, ranged: 17 }, + }); + // An unknown tier is the first, as with the built-in ones. + expect(npcTiers.buildTier(id, 'dragon').tierId).toBe('squire'); + }); + + it('leave the built-in tiers as they were', () => { + expect(npcTiers.getTierOptions('cyberpunk_red').map((t) => t.id)).toEqual(['mook', 'skilled', 'pro', 'elite']); + expect(npcTiers.buildTier('cities_without_number', 'elite')).toMatchObject({ tierId: 'elite', hp: 50, dv: { melee: 18, ranged: 18 } }); + expect(npcTiers.buildTier('generic', 'mook')).toBeNull(); + }); + + it("generate a sheet and set the token's HP and defense from a tier", async () => { + await request(app).put('/api/sheets/system').set(gm).send({ system: id }); + await run(db, `INSERT INTO locations (id, name, x, y, z, shape, hp_current, hp_max, melee_ac, ranged_ac) VALUES (40, 'Bandit', 0, 0, 0, 'enemy_rhombus', 3, 3, 8, 8)`); + const { handlers, sent } = await connectAs('gm', { admin: true }); + handlers.generateNpcSheet({ location_id: 40, tier: 'champion' }); + const made = await untilValue(() => sent.find((s) => s.e === 'npcSheetGenerated'), Boolean, { label: 'the NPC sheet' }); + expect(made.d).toMatchObject({ tier: 'champion', system: id }); + const sheet = await get(db, 'SELECT data FROM character_sheets WHERE id = ?', [made.d.sheet_id]); + expect(JSON.parse(sheet.data)).toMatchObject({ name: 'Bandit', might: 16, might_mod: 1, threat: 4 }); + expect(await get(db, 'SELECT hp_current, hp_max, melee_ac, ranged_ac FROM locations WHERE id = 40')) + .toEqual({ hp_current: 30, hp_max: 30, melee_ac: 17, ranged_ac: 17 }); + }); + + it("keep the token's own HP and defense when a tier leaves them out", async () => { + await request(app).put('/api/sheets/system').set(gm).send({ system: id }); + await run(db, `INSERT INTO locations (id, name, x, y, z, shape, hp_current, hp_max, melee_ac, ranged_ac) VALUES (41, 'Wisp', 0, 0, 0, 'enemy_rhombus', 4, 9, 12, 13)`); + const { handlers, sent } = await connectAs('gm', { admin: true }); + handlers.generateNpcSheet({ location_id: 41, tier: 'ghost' }); + await untilValue(() => sent.find((s) => s.e === 'npcSheetGenerated'), Boolean, { label: 'the NPC sheet' }); + expect(await get(db, 'SELECT hp_current, hp_max, melee_ac, ranged_ac FROM locations WHERE id = 41')) + .toEqual({ hp_current: 4, hp_max: 9, melee_ac: 12, ranged_ac: 13 }); + }); + }); +}); diff --git a/backend/__tests__/system_builder_parity.test.js b/backend/__tests__/system_builder_parity.test.js new file mode 100644 index 00000000..33e0cbc6 --- /dev/null +++ b/backend/__tests__/system_builder_parity.test.js @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; + +/** + * The system builder's engine, held to the hand-written code it would one day replace. + * + * CWN's and Shadowrun's derived values are written out as data (systemBuilder/definitions.js) + * and worked out by the engine, then compared with cwnRecompute and sr6Recompute - the + * functions every sheet actually uses - over thousands of generated sheets. Blank fields, + * text where a number belongs, decimals, negatives, huge values, broken JSON, stale derived + * values: every sheet must come out identical, with the same list of changed fields in the + * same order. This is the proof the engine can carry a real system before any is moved onto + * it; until then the app does not call it at all. + */ + +const require_ = createRequire(import.meta.url); +const { TEMPLATES } = require_('../sheets/templates'); +const { ARMOR_MODS } = require_('../sheets/cwnGearMods'); +const { compileSystem } = require_('../systemBuilder/derived'); +const { CITIES_WITHOUT_NUMBER, SHADOWRUN_6E } = require_('../systemBuilder/definitions'); + +const SHEETS = 3000; + +/** A small seeded generator, so a failure names a sheet that can be made again. */ +const mulberry32 = (seed) => () => { + let t = (seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +}; + +const makeGen = (seed) => { + const r = mulberry32(seed); + const int = (lo, hi) => lo + Math.floor(r() * (hi - lo + 1)); + const pick = (list) => list[Math.floor(r() * list.length)]; + const chance = (p) => r() < p; + /** A value as a sheet might really hold one - usually a sensible number, sometimes not. */ + const value = (lo, hi) => { + if (chance(0.65)) return int(lo, hi); + return pick([ + undefined, null, '', ' ', 'abc', true, false, 0, -1, -0.5, 2.5, 13.999, + String(int(lo, hi)), ` ${int(lo, hi)} `, '1e2', 1e9, -1e9, Infinity, NaN, [], [7], {}, + ]); + }; + return { r, int, pick, chance, value }; +}; + +const cwnSheet = (g) => { + const sheet = {}; + for (const s of ['str', 'dex', 'con', 'int', 'wis', 'cha']) sheet[s] = g.value(1, 20); + for (const [f, lo, hi] of [ + ['level', 0, 12], ['strain_mod', -2, 2], ['armor_trauma_mod', 0, 3], ['armor_soak', 0, 20], + ['move_mod', -5, 5], ['cast_skill', 0, 4], ['summon_skill', 0, 4], + ]) { + if (g.chance(0.85)) sheet[f] = g.value(lo, hi); + } + if (g.chance(0.5)) { + const ids = Array.from({ length: g.int(0, 4) }, () => g.pick(ARMOR_MODS).id); + sheet.armor_mods = g.pick([JSON.stringify(ids), ids, ids.join(','), '{broken', null]); + } + if (g.chance(0.5)) { + sheet.cyberware = Array.from({ length: g.int(0, 3) }, () => { + const mods = Array.from({ length: g.int(0, 3) }, () => ({ + target: g.pick(['Move (meters)', 'Move (metres)', 'move', 'base AC', 'Base AC', 'strain', '']), + value: g.value(0, 12), + })); + return { + name: 'Implant', + equipped: g.chance(0.8), + placed: g.chance(0.8), + mods: g.chance(0.3) ? JSON.stringify(mods) : mods, + }; + }); + if (g.chance(0.1)) sheet.cyberware.push(null, 'junk'); + } + return sheet; +}; + +const sr6Sheet = (g) => { + const sheet = {}; + for (const f of ['body', 'willpower', 'reaction', 'intuition', 'charisma', 'magic']) { + if (g.chance(0.9)) sheet[f] = g.value(1, 9); + } + if (g.chance(0.6)) { + const powers = Array.from({ length: g.int(0, 5) }, () => ({ + name: 'Power', + cost: g.pick([0.25, 0.5, 1, 1.5, '0.25', 'x', null, 0.1, 0.2]), + })); + sheet.adept_powers = g.pick([JSON.stringify(powers), '{broken', '', '"text"', '{}']); + } + return sheet; +}; + +/** + * Derived fields as a sheet would carry them before a write: missing, already right, stale, + * or the right number stored as text. `truth` is the sheet as the real code leaves it. + */ +const withStoredDerived = (g, sheet, ids, truth) => { + for (const id of ids) { + const roll = g.r(); + if (roll < 0.25) continue; + if (roll < 0.5) sheet[id] = truth[id]; + else if (roll < 0.7) sheet[id] = String(truth[id]); + else sheet[id] = g.value(-5, 25); + } + return sheet; +}; + +const clone = (x) => structuredClone(x); + +const holdsTo = (name, definition, recompute, makeSheet, seed) => { + describe(`${name}: the engine against the hand-written function`, () => { + const compiled = compileSystem(definition); + + it('compiles', () => { + expect(compiled.problems).toBeUndefined(); + expect(compiled.ok).toBe(true); + }); + + it(`gives the same sheet and the same changed fields on ${SHEETS} generated sheets`, () => { + const g = makeGen(seed); + const { ids } = compiled.system; + for (let n = 0; n < SHEETS; n += 1) { + const base = makeSheet(g); + const truth = clone(base); + recompute(truth); + const sheet = withStoredDerived(g, base, ids, truth); + + const expected = clone(sheet); + const expectedChanged = recompute(expected); + const actual = clone(sheet); + const actualChanged = compiled.system.apply(actual); + + const where = `sheet ${n} (seed ${seed}): ${JSON.stringify(sheet)}`; + expect(actualChanged, where).toEqual(expectedChanged); + expect(actual, where).toEqual(expected); + } + }); + + it('agrees on a sheet with nothing filled in at all', () => { + const expected = {}; + const actual = {}; + expect(compiled.system.apply(actual)).toEqual(recompute(expected)); + expect(actual).toEqual(expected); + }); + }); +}; + +holdsTo('Cities Without Number', CITIES_WITHOUT_NUMBER, + TEMPLATES.cities_without_number.recompute, cwnSheet, 20260929); +holdsTo('Shadowrun 6E', SHADOWRUN_6E, + TEMPLATES.shadowrun_6e.recompute, sr6Sheet, 6); + +describe('a few CWN characters, by hand', () => { + const { system } = compileSystem(CITIES_WITHOUT_NUMBER); + + it('works out a level 3 street samurai', () => { + const v = system.evaluate({ str: 16, dex: 14, con: 12, int: 9, wis: 7, cha: 10, level: 3 }); + expect(v).toMatchObject({ + str_mod: 1, dex_mod: 1, con_mod: 0, int_mod: 0, wis_mod: -1, cha_mod: 0, + save_physical: 12, save_evasion: 12, save_mental: 13, save_luck: 13, + system_strain_max: 12, trauma_target: 6, move: 10, spells_prepared_max: 2, + }); + }); + + it('counts Coordination Augment II toward Move, and a heavy suit toward Trauma Target', () => { + const v = system.evaluate({ + armor_trauma_mod: 3, + armor_mods: JSON.stringify(['active_response']), + cyberware: [{ equipped: true, placed: true, mods: [{ target: 'Move (meters)', value: 10 }] }], + }); + expect(v.move).toBe(20); + expect(v.trauma_target).toBe(10); + }); +}); diff --git a/backend/__tests__/system_builder_runtime.test.js b/backend/__tests__/system_builder_runtime.test.js new file mode 100644 index 00000000..6961cf1a --- /dev/null +++ b/backend/__tests__/system_builder_runtime.test.js @@ -0,0 +1,229 @@ +import { describe, it, expect, beforeEach, afterEach, vi } 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 { untilValue, drain } from './helpers/until.js'; + +/** + * A published custom system, running in the game. + * + * Its sheet is plain data drawn by the ordinary renderer; the server answers for it through the + * same helpers the built-in systems use - which fields are public, linked, paired - and + * recomputes its derived values on every save. The browser gets a render copy with no formulas. + */ + +process.env.JWT_SECRET = 'test-secret'; +process.env.DICE_ANIM_MS = '0'; +const require_ = createRequire(import.meta.url); +const { checkSheet, effectiveSheet } = require_('../systemBuilder/sheet'); +const { checkDefinition } = require_('../systemBuilder/definition'); +const runtime = require_('../systemBuilder/runtime'); +const templates = require_('../sheets/templates'); +const socketsFactory = require_('../sockets/index.js'); + +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); +const gm = { Authorization: `Bearer ${GM}` }; + +/** A small but real system: two abilities, a lookup, derived values, and a sheet that uses them. */ +const VAULT = { + format: 1, + name: 'Vault Knights', + words: { hp: { singular: 'WOUND', plural: 'WOUNDS' } }, + lookups: { mod: { bands: [{ upTo: 9, value: -1 }, { upTo: 13, value: 0 }, { value: 1 }] } }, + derived: [ + { id: 'might_mod', formula: 'mod(@might)' }, + { id: 'guard', formula: '10 + @might_mod + @armor' }, + ], + sheet: { + tabs: ['KNIGHT', 'NOTES'], + header: { nameField: 'name', hpField: 'wounds', hpMaxField: 'wounds_max', chips: [{ field: 'guard', label: 'GUARD' }] }, + sections: [ + { id: 'who', label: 'WHO', layout: 'list', tab: 'KNIGHT', fields: [ + { id: 'name', label: 'Name', type: 'text', visibility: 'public' }, + { id: 'order', label: 'Order', type: 'select', options: [{ value: 'dawn', label: 'Dawn' }, { value: 'dusk', label: 'Dusk' }] }, + ] }, + { id: 'stats', label: 'STATS', layout: 'grid', tab: 'KNIGHT', columns: 4, fields: [ + { id: 'might', label: 'MIGHT', type: 'number' }, + { id: 'might_mod', label: 'MOD', type: 'number' }, + { id: 'armor', label: 'ARMOR', type: 'number', sensitivity: 'combat', source: 'token_ac' }, + { id: 'guard', label: 'GUARD', type: 'number', sensitivity: 'combat' }, + { id: 'wounds', label: 'WOUNDS', type: 'number', source: 'token_hp', maxField: 'wounds_max' }, + { id: 'wounds_max', label: 'MAX', type: 'number', source: 'token_hp_max' }, + ] }, + { id: 'purse', label: 'PURSE', layout: 'list', tab: 'KNIGHT', fields: [{ id: 'gold', label: 'Gold', type: 'number', source: 'bank_balance' }] }, + { id: 'notes', label: 'NOTES', layout: 'notes', tab: 'NOTES', fields: [{ id: 'notes', label: 'Notes', type: 'textarea' }] }, + ], + }, +}; + +const problemsOf = (sheet, derived = []) => { + const problems = []; + checkSheet(sheet, new Set(derived), problems); + return problems.map((p) => `${p.where}: ${p.message}`); +}; + +describe('the sheet format', () => { + it('accepts a real sheet', () => { + expect(checkDefinition(VAULT)).toEqual({ problems: [] }); + }); + + it('gives a system with no sheet a starter one that passes its own checks and shows its derived values', () => { + const bare = { format: 1, name: 'Bare', derived: [{ id: 'speed', formula: '6' }] }; + const sheet = effectiveSheet(bare); + expect(checkDefinition({ ...bare, sheet })).toEqual({ problems: [] }); + const derived = sheet.sections.find((s) => s.id === 'derived'); + expect(derived.fields.map((f) => f.id)).toEqual(['speed']); + expect(effectiveSheet(VAULT)).toBe(VAULT.sheet); + }); + + it('reports every mistake, with where it is', () => { + expect(problemsOf({ + tabs: ['A'], + header: { nameField: 'nope', chips: [{ field: 'x' }] }, + sections: [ + { id: 'one', label: 'ONE', layout: 'cards', tab: 'B', columns: 9, fields: [ + { id: 'a', label: 'A', type: 'dice' }, + { id: 'a', label: 'Again', type: 'text' }, + { id: 'b', label: 'B', type: 'text', options: [{ value: 'x', label: 'X' }] }, + { id: 'c', label: 'C', type: 'select' }, + { id: 'd', label: 'D', type: 'number', sensitivity: 'combat', visibility: 'public', maxField: 'zz' }, + { id: 'e', label: 'E', type: 'number', source: 'token_mana' }, + { id: 'f', label: 'F', type: 'number', source: 'token_hp' }, + { id: 'g', label: 'G', type: 'number', onClick: 'x' }, + ] }, + { id: 'one', label: '', layout: 'list', fields: [] }, + ], + }, ['f'])).toEqual([ + 'sheet section one, layout: One of list, grid, notes, inventory', + 'sheet section one, tab: Not one of the sheet\'s tabs', + 'sheet section one, columns: A whole number from 1 to 8', + 'sheet field a, type: One of number, text, textarea, select', + 'sheet field a: Defined twice', + 'sheet field b, options: Only a select field has options', + 'sheet field c, options: A select field needs options', + 'sheet field d: A combat value is never public', + 'sheet field e, source: One of token_hp, token_hp_max, token_ac, bank_balance', + 'sheet field f: Cannot be both a derived value and a linked one', + 'sheet field g, onClick: Not part of a field', + 'sheet section one: Defined twice', + 'sheet section one, label: Cannot be blank', + 'sheet field d, maxField: Not a field on this sheet', + 'sheet header nameField: Not a field on this sheet', + 'sheet header chip 1: Must name a field on this sheet', + ]); + }); + + it('refuses a sheet that is not a layout', () => { + expect(problemsOf('columns')).toEqual(['sheet: Must be a layout']); + expect(problemsOf({ sections: 'x' })).toEqual(['sheet sections: Must be a list of sections']); + }); +}); + +describe('a published system, in the running game', () => { + let db; + let app; + let id; + const emitted = []; + + beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); + emitted.length = 0; + const io = { emit: (event, data) => emitted.push({ event, data }), to: () => ({ emit: () => {} }) }; + app = express(); + app.use(express.json({ limit: '2mb' })); + app.use('/api/systems', require_('../routes/systems.js')(db)); + app.use('/api/sheets', require_('../routes/sheets.js')(db, io)); + id = (await request(app).post('/api/systems').set(gm).send({ definition: VAULT })).body.id; + await new Promise((resolve) => runtime.load(db, resolve)); + }); + afterEach(() => vi.restoreAllMocks()); + + const publish = () => request(app).post(`/api/systems/${id}/publish`).set(gm); + + it('is only known to the game once published', async () => { + expect(templates.isValidSystem(id)).toBe(false); + expect((await request(app).get(`/api/systems/render/${id}`)).status).toBe(404); + expect((await request(app).put('/api/sheets/system').set(gm).send({ system: id })).status).toBe(400); + + await publish(); + expect(templates.isValidSystem(id)).toBe(true); + expect((await request(app).get('/api/sheets/system')).body.systems).toContainEqual({ id, name: 'Vault Knights', custom: true, version: 1 }); + }); + + it('answers the same questions the built-in systems do', async () => { + await publish(); + expect(templates.getLinkedFields(id)).toEqual({ armor: 'token_ac', wounds: 'token_hp', wounds_max: 'token_hp_max', gold: 'bank_balance' }); + expect(templates.getMaxPairs(id)).toEqual({ wounds_max: 'wounds' }); + expect(templates.filterPublicData(id, { name: 'Sir Ash', might: 15, guard: 12, notes: 'secret' })).toEqual({ name: 'Sir Ash' }); + const data = { might: 15, armor: 3, guard: 99 }; + expect(templates.applyDerived(id, data)).toEqual(['might_mod', 'guard']); + expect(data).toMatchObject({ might_mod: 1, guard: 14 }); + }); + + it('leaves the built-in systems exactly as they were', async () => { + const before = JSON.stringify(['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic'] + .map((s) => [templates.getLinkedFields(s), templates.getMaxPairs(s)])); + await publish(); + const after = JSON.stringify(['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic'] + .map((s) => [templates.getLinkedFields(s), templates.getMaxPairs(s)])); + expect(after).toBe(before); + }); + + it('gives the browser its layout and words, never its formulas', async () => { + await publish(); + const res = await request(app).get(`/api/systems/render/${id}`); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ id, name: 'Vault Knights', derived: ['might_mod', 'guard'], words: VAULT.words }); + expect(res.body.sheet).toEqual(VAULT.sheet); + const text = JSON.stringify(res.body); + expect(text).not.toContain('@might'); + expect(text).not.toContain('bands'); + }); + + it('can be switched to from the system picker', async () => { + await publish(); + const res = await request(app).put('/api/sheets/system').set(gm).send({ system: id }); + expect(res.status).toBe(200); + expect((await get(db, `SELECT value FROM global_settings WHERE key = 'game_system'`)).value).toBe(id); + }); + + it('goes away from the game when deleted', async () => { + await publish(); + expect((await request(app).delete(`/api/systems/${id}`).set(gm)).status).toBe(200); + expect(templates.isValidSystem(id)).toBe(false); + expect((await request(app).get(`/api/systems/render/${id}`)).status).toBe(404); + }); + + it('runs the published copy, not the draft being worked on', async () => { + await publish(); + const draft = { ...VAULT, derived: [{ id: 'might_mod', formula: '100' }, { id: 'guard', formula: '0' }] }; + await request(app).put(`/api/systems/${id}/draft`).set(gm).send({ definition: draft }); + const data = { might: 15, armor: 3 }; + templates.applyDerived(id, data); + expect(data.might_mod).toBe(1); + }); + + it("recomputes a player's derived values when they edit their sheet", async () => { + await publish(); + await request(app).put('/api/sheets/system').set(gm).send({ system: id }); + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', ?, '{"might":10}', 0)`, [id]); + vi.spyOn(console, 'log').mockImplementation(() => {}); + let connect; + socketsFactory({ on: (e, cb) => { if (e === 'connection') connect = cb; }, emit: () => {}, to: () => ({ emit: () => {} }) }, + db, { elevatedUsers: new Set(), emitUpdate: vi.fn(), recordAction: vi.fn() }); + const handlers = {}; + connect({ id: `rt-${Math.random()}`, on: (e, fn) => { handlers[e] = fn; }, emit: () => {}, broadcast: { emit: () => {} }, use: () => {}, join: () => {}, disconnect: () => {} }); + handlers.identify('GHOST'); + await drain(db); + // Armor lives on the token (a linked field), so it is never in the saved data: the save + // works from what is stored, and a read lays the token's armor over it and recomputes. + handlers.updateSheetField({ fieldId: 'might', value: 16 }); + const stored = await untilValue( + async () => JSON.parse((await get(db, 'SELECT data FROM character_sheets WHERE username = ? AND system = ?', ['GHOST', id])).data), + (d) => d.might === 16, { label: 'the saved sheet' }); + expect(stored).toMatchObject({ might: 16, might_mod: 1, guard: 11 }); + }); +}); diff --git a/backend/__tests__/system_builder_store.test.js b/backend/__tests__/system_builder_store.test.js new file mode 100644 index 00000000..50ad8d66 --- /dev/null +++ b/backend/__tests__/system_builder_store.test.js @@ -0,0 +1,231 @@ +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'; + +/** + * Custom game systems: the definition format, its checks, and storage. + * + * A definition is typed into the builder or installed from someone else's file, so it is + * checked on the server. A draft may hold problems - a system half-built is normal - but + * cannot be published until it has none, so a game never runs a broken system. Only the main + * admin reaches any of it. + */ + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const def = require_('../systemBuilder/definition'); +const store = require_('../systemBuilder/store'); +const { CITIES_WITHOUT_NUMBER } = require_('../systemBuilder/definitions'); +const { elevatedUsers } = require_('../middleware/auth'); +const systemsRoute = require_('../routes/systems.js'); + +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); +const EDITOR = jwt.sign({ username: 'ghost', isTemporary: true }, 'test-secret'); +const PLAYER = jwt.sign({ username: 'vex', role: 'player' }, 'test-secret'); +const bearer = (t) => ({ Authorization: `Bearer ${t}` }); + +const messages = (checked) => checked.problems.map((p) => `${p.where}: ${p.message}`); + +describe('the definition format', () => { + it('a name is enough to be valid', () => { + expect(def.checkDefinition({ format: 1, name: 'Vault Knights' })).toEqual({ problems: [] }); + expect(def.checkDefinition(def.blankDefinition(' Vault Knights '))).toEqual({ problems: [] }); + }); + + it('carries a whole built-in system as data without a problem', () => { + const cwn = { format: 1, name: 'CWN, as data', ...CITIES_WITHOUT_NUMBER }; + expect(def.checkDefinition(cwn)).toEqual({ problems: [] }); + }); + + it('refuses, as fatal, what cannot be stored at all', () => { + for (const bad of [null, 'text', 42, [], [{ name: 'x' }]]) { + expect(def.checkDefinition(bad).fatal, JSON.stringify(bad)).toBeTruthy(); + } + const huge = { format: 1, name: 'Big', description: 'x'.repeat(def.LIMITS.bytes) }; + expect(def.checkDefinition(huge).fatal).toMatch(/Larger than/); + }); + + it('reads files and bodies as text, refusing what is not JSON or too large', () => { + expect(def.parseDefinition('{"format":1,"name":"A"}')).toEqual({ ok: true, definition: { format: 1, name: 'A' } }); + expect(def.parseDefinition('{nope').fatal).toBe('Not valid JSON'); + expect(def.parseDefinition('x'.repeat(def.LIMITS.bytes + 1)).fatal).toMatch(/Larger than/); + expect(def.parseDefinition(null).fatal).toBe('Not text'); + }); + + it('reports every problem at once, each with where it is', () => { + const checked = def.checkDefinition({ + format: 2, + name: ' ', + description: 'd'.repeat(def.LIMITS.description + 1), + skills: [], + words: { hp: { singular: 'WOUNDS', feminine: 'X' }, mana: { singular: 'MANA' }, xp: 'GLORY', money: { short: '' } }, + parts: { vehicles: { on: false }, cyberware: { on: 'no' }, dragons: { on: true }, bank: { on: true, colour: 'red' } }, + derived: [{ id: 'a', formula: '@a + 1' }], + }); + expect(messages(checked)).toEqual([ + 'skills: Not a section this version knows', + 'format: This version reads format 1', + 'name: Cannot be blank', + `description: Longer than ${def.LIMITS.description} characters`, + 'words hp, feminine: Only singular, plural and short', + 'words mana: Not a term the app uses', + 'words xp: Must give singular, plural or short', + 'words money, short: Cannot be blank', + 'parts cyberware: Must say on: true or on: false', + 'parts dragons: Not a part of the app', + 'parts bank, colour: Only "on" is set here', + 'derived a: Depends on itself: a → a', + ]); + }); + + it('a missing name, or one that is not text, is a problem', () => { + expect(messages(def.checkDefinition({ format: 1 }))).toEqual(['name: Required']); + expect(messages(def.checkDefinition({ name: 7 }))).toEqual(['name: Must be text']); + }); + + it("names things in the system's own words, or the app's when it has none", () => { + const d = { words: { hp: { singular: 'WOUND', plural: 'WOUNDS' }, money: { short: 'GP' } } }; + expect(def.wordFor(d, 'hp')).toBe('WOUND'); + expect(def.wordFor(d, 'hp', 'plural')).toBe('WOUNDS'); + expect(def.wordFor(d, 'money', 'short')).toBe('GP'); + expect(def.wordFor(d, 'money')).toBe('CREDIT'); + expect(def.wordFor(d, 'class', 'short')).toBe('CLASS'); + expect(def.wordFor(null, 'level', 'short')).toBe('LVL'); + }); + + it('has every part on unless the system turns it off', () => { + const d = { parts: { vehicles: { on: false }, bank: { on: true } } }; + expect(def.partOn(d, 'vehicles')).toBe(false); + expect(def.partOn(d, 'bank')).toBe(true); + expect(def.partOn(d, 'shops')).toBe(true); + expect(def.partOn(undefined, 'cyberware')).toBe(true); + }); +}); + +describe('the systems routes', () => { + let db; + let app; + beforeEach(async () => { + db = await makeTestDb(); + app = express(); + app.use(express.json({ limit: '2mb' })); + app.use('/api/systems', systemsRoute(db)); + }); + afterEach(() => elevatedUsers.clear()); + + const create = (body) => request(app).post('/api/systems').set(bearer(GM)).send(body); + const list = async () => (await request(app).get('/api/systems').set(bearer(GM))).body; + + describe('who may use them', () => { + it('only the main admin: not a granted editor, not a player, not anyone', async () => { + elevatedUsers.add('ghost'); + for (const [who, headers, status] of [ + ['editor', bearer(EDITOR), 403], ['player', bearer(PLAYER), 403], ['nobody', {}, 401], + ]) { + const res = await request(app).get('/api/systems').set(headers); + expect(res.status, who).toBe(status); + } + expect((await request(app).get('/api/systems').set(bearer(GM))).status).toBe(200); + }); + }); + + it('creates a system from a name, with a stable id of its own', async () => { + const res = await create({ name: 'Vault Knights' }); + expect(res.status).toBe(200); + expect(res.body.id).toMatch(/^sys_[0-9a-f]{16}$/); + expect(res.body.problems).toEqual([]); + const [only] = await list(); + expect(only).toMatchObject({ id: res.body.id, name: 'Vault Knights', version: 0, published: false, unpublishedChanges: true }); + }); + + it('never collides with a built-in system id', () => { + for (const builtin of ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic']) { + expect(store.isCustomId(builtin)).toBe(false); + } + }); + + it('creates from a whole definition (an import), problems and all', async () => { + const res = await create({ definition: { format: 1, name: 'Imported', words: { mana: { singular: 'MANA' } } } }); + expect(res.status).toBe(200); + expect(res.body.problems).toEqual([{ where: 'words mana', message: 'Not a term the app uses' }]); + }); + + it('refuses to create what cannot be stored, or has no name', async () => { + expect((await create({ definition: ['not', 'a', 'system'] })).status).toBe(400); + expect((await create({ name: ' ' })).status).toBe(400); + expect((await create({})).status).toBe(400); + expect(await list()).toEqual([]); + }); + + it('saves a draft with problems, and will not publish it until they are fixed', async () => { + const { id } = (await create({ name: 'Draft' })).body; + const broken = { format: 1, name: 'Draft', derived: [{ id: 'hp', formula: '@hp + 1' }] }; + const saved = await request(app).put(`/api/systems/${id}/draft`).set(bearer(GM)).send({ definition: broken }); + expect(saved.status).toBe(200); + expect(saved.body.problems).toEqual([{ where: 'derived hp', message: 'Depends on itself: hp → hp' }]); + + const refused = await request(app).post(`/api/systems/${id}/publish`).set(bearer(GM)); + expect(refused.status).toBe(409); + expect(refused.body.problems).toHaveLength(1); + expect((await get(db, 'SELECT published, version FROM custom_systems WHERE id = ?', [id]))).toEqual({ published: null, version: 0 }); + + const fixed = { format: 1, name: 'Draft', derived: [{ id: 'hp', formula: '@con + 10' }] }; + await request(app).put(`/api/systems/${id}/draft`).set(bearer(GM)).send({ definition: fixed }); + const published = await request(app).post(`/api/systems/${id}/publish`).set(bearer(GM)); + expect(published.status).toBe(200); + expect(published.body).toEqual({ version: 1 }); + }); + + it('keeps the published copy as it was while the draft moves on', async () => { + const { id } = (await create({ name: 'Live' })).body; + await request(app).post(`/api/systems/${id}/publish`).set(bearer(GM)); + expect((await list())[0]).toMatchObject({ published: true, unpublishedChanges: false, version: 1 }); + + await request(app).put(`/api/systems/${id}/draft`).set(bearer(GM)) + .send({ definition: { format: 1, name: 'Live, renamed', parts: { vehicles: { on: false } } } }); + const sys = (await request(app).get(`/api/systems/${id}`).set(bearer(GM))).body; + expect(sys.name).toBe('Live, renamed'); + expect(sys.draft.parts).toEqual({ vehicles: { on: false } }); + expect(sys.published).toEqual({ format: 1, name: 'Live' }); + expect((await list())[0]).toMatchObject({ unpublishedChanges: true, version: 1 }); + + await request(app).post(`/api/systems/${id}/publish`).set(bearer(GM)); + const after = (await request(app).get(`/api/systems/${id}`).set(bearer(GM))).body; + expect(after.published.name).toBe('Live, renamed'); + expect(after.version).toBe(2); + }); + + it('refuses a draft that cannot be stored, leaving the old one', async () => { + const { id } = (await create({ name: 'Keep' })).body; + const res = await request(app).put(`/api/systems/${id}/draft`).set(bearer(GM)).send({ definition: 'gibberish' }); + expect(res.status).toBe(400); + expect((await request(app).get(`/api/systems/${id}`).set(bearer(GM))).body.draft).toEqual({ format: 1, name: 'Keep' }); + }); + + it('answers 404 for a system that is not there, or an id that is not one', async () => { + for (const id of ['sys_0000000000000000', 'cities_without_number', '1; DROP TABLE custom_systems']) { + const res = await request(app).get(`/api/systems/${encodeURIComponent(id)}`).set(bearer(GM)); + expect(res.status, id).toBe(404); + } + }); + + it('will not delete the system the game is running; deletes any other', async () => { + const { id } = (await create({ name: 'Running' })).body; + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [id]); + expect((await request(app).delete(`/api/systems/${id}`).set(bearer(GM))).status).toBe(409); + + await run(db, `UPDATE global_settings SET value = 'cities_without_number' WHERE key = 'game_system'`); + expect((await request(app).delete(`/api/systems/${id}`).set(bearer(GM))).status).toBe(200); + expect((await request(app).get(`/api/systems/${id}`).set(bearer(GM))).status).toBe(404); + }); + + it('lists the most recently changed first', async () => { + const a = (await create({ name: 'A' })).body.id; + const b = (await create({ name: 'B' })).body.id; + await run(db, `UPDATE custom_systems SET updated_at = '2020-01-01' WHERE id = ?`, [b]); + expect((await list()).map((s) => s.id)).toEqual([a, b]); + }); +}); diff --git a/backend/__tests__/system_builder_words.test.js b/backend/__tests__/system_builder_words.test.js new file mode 100644 index 00000000..301ce4ff --- /dev/null +++ b/backend/__tests__/system_builder_words.test.js @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { createRequire } from 'module'; +import { makeTestDb, run } from './helpers/testDb.js'; + +/** + * The glossary's plumbing (Layer 1): a custom system's words for the app's terms, every form + * resolved on the server, and a lookup for text the server writes that leaves the built-in + * systems' wording exactly as it is. + */ + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const { resolveWords, ownWords, TERMS, WORD_FORMS } = require_('../systemBuilder/definition'); +const { effectiveSheet } = require_('../systemBuilder/sheet'); +const { healthView } = require_('../systemBuilder/healthView'); +const runtime = require_('../systemBuilder/runtime'); + +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); +const gm = { Authorization: `Bearer ${GM}` }; +const FANTASY = { format: 1, name: 'Hearth', words: { hp: { singular: 'WOUND', plural: 'WOUNDS' }, money: { plural: 'GOLD', short: 'GP' }, gm: { singular: 'WARDEN' } } }; + +describe('resolving a system\'s words', () => { + it('gives every term in every form', () => { + const words = resolveWords(FANTASY); + expect(Object.keys(words).sort()).toEqual(Object.keys(TERMS).sort()); + for (const term of Object.keys(TERMS)) expect(Object.keys(words[term]).sort(), term).toEqual([...WORD_FORMS].sort()); + }); + + it('uses the system\'s own words, and the neutral defaults for the rest', () => { + const words = resolveWords(FANTASY); + expect(words.hp).toEqual({ singular: 'WOUND', plural: 'WOUNDS', short: 'HP' }); + expect(words.money).toEqual({ singular: 'CREDIT', plural: 'GOLD', short: 'GP' }); + expect(words.gm.singular).toBe('WARDEN'); + expect(words.level).toEqual({ singular: 'LEVEL', plural: 'LEVELS', short: 'LVL' }); + // A term with no short form uses its singular. + expect(words.character.short).toBe('CHARACTER'); + expect(resolveWords({ name: 'Bare' })).toEqual(resolveWords({})); + }); + + it('sends the browser only the terms a system renamed, every form filled from its own', () => { + expect(runtime.renderOf('sys_0123456789abcdef', FANTASY).words).toEqual({ + hp: { singular: 'WOUND', plural: 'WOUNDS', short: 'WOUND' }, + money: { singular: 'GOLD', plural: 'GOLD', short: 'GP' }, + gm: { singular: 'WARDEN', plural: 'WARDEN', short: 'WARDEN' }, + }); + expect(runtime.renderOf('sys_0123456789abcdef', { name: 'Bare' }).words).toEqual({}); + }); +}); + +describe('a system\'s own words', () => { + it('ignore blank forms, a term with no usable form, and terms the app does not have', () => { + expect(ownWords({ words: { + hp: { singular: ' ', plural: 'WOUNDS' }, + xp: { singular: '', short: ' ' }, + mana: { singular: 'MANA' }, + level: 'RANK', + } })).toEqual({ hp: { singular: 'WOUNDS', plural: 'WOUNDS', short: 'WOUNDS' } }); + expect(ownWords({ words: 'none' })).toEqual({}); + expect(ownWords(null)).toEqual({}); + }); + + it('name the starter sheet\'s hit points, and nothing else changes', () => { + const pool = (definition) => effectiveSheet(definition).sections.find((s) => s.id === 'health').fields.map((f) => f.label); + expect(pool({ name: 'A' })).toEqual(['HP', 'HP MAX']); + expect(pool({ name: 'A', words: { gm: { singular: 'WARDEN' } } })).toEqual(['HP', 'HP MAX']); + expect(pool({ name: 'A', words: { hp: { singular: 'WOUND', plural: 'WOUNDS', short: 'WND' } } })).toEqual(['WND', 'WND MAX']); + expect(pool({ name: 'A', words: { hp: { short: 'WND' } }, core: { health: { model: 'locations', locations: [{ id: 'head', label: 'HEAD' }] } } })) + .toEqual(['WND', 'WND MAX']); + // A label the setup gave wins over the word. + expect(pool({ name: 'A', words: { hp: { short: 'WND' } }, core: { health: { model: 'pool', label: 'VIGOR' } } })).toEqual(['VIGOR', 'VIGOR MAX']); + }); + + it('name a one-pool system\'s HEALTH folder, unless the setup gave it a label', () => { + const token = { current: 5, max: 10 }; + expect(healthView({ model: 'pool' }, token, {}).label).toBe('HP'); + expect(healthView({ model: 'pool' }, token, {}, { hpWord: 'WND' }).label).toBe('WND'); + expect(healthView({ model: 'pool', label: 'VIGOR' }, token, {}, { hpWord: 'WND' }).label).toBe('VIGOR'); + }); +}); + +describe('the server\'s own text', () => { + let db; + let app; + beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cities_without_number')`); + app = express(); + app.use(express.json()); + app.use('/api/systems', require_('../routes/systems.js')(db)); + await new Promise((resolve) => runtime.load(db, resolve)); + }); + + it('keeps every built-in system\'s wording exactly as it is', () => { + for (const system of ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic', null, undefined]) { + expect(runtime.wordIn(system, 'money', 'plural', 'EDDIES'), String(system)).toBe('EDDIES'); + } + }); + + it('uses a published custom system\'s words, defaults included', async () => { + const { id } = (await request(app).post('/api/systems').set(gm).send({ definition: FANTASY })).body; + expect(runtime.wordIn(id, 'gm', 'singular', 'GM')).toBe('GM'); + await request(app).post(`/api/systems/${id}/publish`).set(gm); + expect(runtime.wordIn(id, 'gm', 'singular', 'GM')).toBe('WARDEN'); + expect(runtime.wordIn(id, 'money', 'short', 'CR')).toBe('GP'); + // A term it did not rename keeps the text that place shows today. + expect(runtime.wordIn(id, 'xp', 'short', 'EXP')).toBe('EXP'); + expect(runtime.wordIn(id, 'nonsense', 'singular', 'KEPT')).toBe('KEPT'); + await request(app).delete(`/api/systems/${id}`).set(gm); + expect(runtime.wordIn(id, 'gm', 'singular', 'GM')).toBe('GM'); + }); +}); diff --git a/backend/__tests__/token_vitals.test.js b/backend/__tests__/token_vitals.test.js new file mode 100644 index 00000000..f0d5dd11 --- /dev/null +++ b/backend/__tests__/token_vitals.test.js @@ -0,0 +1,277 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import { createRequire } from 'module'; +import { makeTestDb, get, all, run } from './helpers/testDb.js'; +import { drain } from './helpers/until.js'; + +/** + * A token's health, defense and injuries, per game system. + * + * The map is shared by every system, but a character's state belongs to its game. The token's + * own columns hold the running system's values - so combat, damage and the health monitor are + * untouched - and switching systems swaps them with the others', in one transaction together + * with the system setting itself. + */ + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const vitals = require_('../tokens/vitals'); +const { migrateTokenVitals, MARKER } = require_('../startup/tokenVitals'); + +const CWN = 'cities_without_number'; +const CPR = 'cyberpunk_red'; +const quiet = { log: () => {}, warn: () => {} }; +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); + +let db; +beforeEach(async () => { + db = await makeTestDb(); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [CWN]); +}); +afterEach(() => { vitals.setReady(Promise.resolve()); vi.restoreAllMocks(); }); + +const addToken = async (shape, { owner = null, hp = null, max = null, temp = null, ac = null, rac = null, injuries = '{}' } = {}) => (await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max, hp_temp, melee_ac, ranged_ac, injuries) + VALUES (?, 0, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)`, + [shape.toUpperCase(), shape, owner, hp, max, temp, ac, rac, injuries])).lastID; +const tokenOf = (id) => get(db, 'SELECT hp_current, hp_max, hp_temp, melee_ac, ranged_ac, injuries FROM locations WHERE id = ?', [id]); +const running = async () => (await get(db, `SELECT value FROM global_settings WHERE key = 'game_system'`)).value; +const BLANK = { hp_current: null, hp_max: null, hp_temp: null, melee_ac: null, ranged_ac: null, injuries: '{}' }; + +describe('switching systems', () => { + it("puts each token's values away and brings the new system's back, and back again", async () => { + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7, max: 12, temp: 2, ac: 14, rac: 13, injuries: '{"head":true}' }); + const cwnValues = await tokenOf(ghost); + + expect(await vitals.switchSystem(db, CPR)).toMatchObject({ from: CWN, to: CPR, switched: true }); + expect(await running()).toBe(CPR); + expect(await tokenOf(ghost)).toEqual(BLANK); + + // Play in Cyberpunk RED changes that game's values only. + await run(db, 'UPDATE locations SET hp_current = 30, hp_max = 40 WHERE id = ?', [ghost]); + + await vitals.switchSystem(db, CWN); + expect(await tokenOf(ghost)).toEqual(cwnValues); + await vitals.switchSystem(db, CPR); + expect(await tokenOf(ghost)).toMatchObject({ hp_current: 30, hp_max: 40 }); + }); + + it('carries enemy and friendly tokens the same way', async () => { + const enemy = await addToken('enemy_rhombus', { hp: 5, max: 9, ac: 12 }); + const friend = await addToken('friendly_rhombus', { hp: 3, max: 3 }); + await vitals.switchSystem(db, CPR); + expect(await tokenOf(enemy)).toEqual(BLANK); + expect(await tokenOf(friend)).toEqual(BLANK); + await vitals.switchSystem(db, CWN); + expect(await tokenOf(enemy)).toMatchObject({ hp_current: 5, hp_max: 9, melee_ac: 12 }); + expect(await tokenOf(friend)).toMatchObject({ hp_current: 3, hp_max: 3 }); + }); + + it('leaves buildings alone', async () => { + const building = (await run(db, `INSERT INTO locations (name, x, y, z, shape, hp_current, melee_ac) VALUES ('BUNKER', 0, 0, 0, 'box', 50, 18)`)).lastID; + await vitals.switchSystem(db, CPR); + expect(await get(db, 'SELECT hp_current, melee_ac FROM locations WHERE id = ?', [building])).toEqual({ hp_current: 50, melee_ac: 18 }); + expect(await all(db, 'SELECT * FROM token_vitals WHERE location_id = ?', [building])).toEqual([]); + }); + + it('switching to the system already running changes nothing', async () => { + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7, max: 12 }); + expect(await vitals.switchSystem(db, CWN)).toMatchObject({ switched: false, tokens: 0 }); + expect(await tokenOf(ghost)).toMatchObject({ hp_current: 7, hp_max: 12 }); + expect(await all(db, 'SELECT * FROM token_vitals')).toEqual([]); + }); + + it('happens entirely or not at all', async () => { + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7, max: 12 }); + await run(db, `CREATE TRIGGER refuse BEFORE UPDATE ON locations BEGIN SELECT RAISE(ABORT, 'refused'); END`); + await expect(vitals.switchSystem(db, CPR)).rejects.toThrow('refused'); + expect(await running()).toBe(CWN); + expect(await tokenOf(ghost)).toMatchObject({ hp_current: 7, hp_max: 12 }); + expect(await all(db, 'SELECT * FROM token_vitals')).toEqual([]); + await run(db, 'DROP TRIGGER refuse'); + expect((await vitals.switchSystem(db, CPR)).switched).toBe(true); + }); + + it('waits for the one-time start to finish', async () => { + let finish; + vitals.setReady(new Promise((resolve) => { finish = resolve; })); + let done = false; + const pending = vitals.switchSystem(db, CPR).then(() => { done = true; }); + await drain(db); + expect(done).toBe(false); + expect(await running()).toBe(CWN); + finish(); + await pending; + expect(await running()).toBe(CPR); + }); +}); + +describe('the one-time start', () => { + const sheet = (username, system, isNpc = 0) => run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, '{}', ?)`, [username, system, isNpc]); + const savedFor = async (id) => (await all(db, 'SELECT system FROM token_vitals WHERE location_id = ? ORDER BY system', [id])).map((r) => r.system); + + it("saves a player's token under every system they have a sheet in, and the running one", async () => { + await sheet('GHOST', CPR); + await sheet('GHOST', 'shadowrun_6e'); + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7, max: 12, ac: 14 }); + await migrateTokenVitals(db, { log: quiet }); + expect(await savedFor(ghost)).toEqual([CWN, CPR, 'shadowrun_6e'].sort()); + expect(await get(db, 'SELECT hp_current, hp_max, melee_ac FROM token_vitals WHERE location_id = ? AND system = ?', [ghost, CPR])) + .toEqual({ hp_current: 7, hp_max: 12, melee_ac: 14 }); + }); + + it('saves an enemy or friendly token under every system in use, NPC sheets included', async () => { + await sheet('GHOST', CPR); + await sheet('gm', 'shadowrun_6e', 1); + const enemy = await addToken('enemy_rhombus', { hp: 5 }); + await migrateTokenVitals(db, { log: quiet }); + expect(await savedFor(enemy)).toEqual([CWN, CPR, 'shadowrun_6e'].sort()); + }); + + it('so that switching afterwards shows exactly what it showed before', async () => { + await sheet('GHOST', CPR); + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7, max: 12, injuries: '{"arm":true}' }); + const before = await tokenOf(ghost); + await migrateTokenVitals(db, { log: quiet }); + await vitals.switchSystem(db, CPR); + expect(await tokenOf(ghost)).toEqual(before); + }); + + it('changes no token, and runs once', async () => { + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7 }); + const before = await tokenOf(ghost); + expect((await migrateTokenVitals(db, { log: quiet })).ran).toBe(true); + expect(await tokenOf(ghost)).toEqual(before); + expect(await get(db, 'SELECT value FROM global_settings WHERE key = ?', [MARKER])).toBeTruthy(); + expect(await migrateTokenVitals(db, { log: quiet })).toEqual({ ran: false }); + }); +}); + +describe('map clears and loads', () => { + it("keep the players' saved values and drop every other token's", async () => { + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7 }); + const enemy = await addToken('enemy_rhombus', { hp: 5 }); + await vitals.switchSystem(db, CPR); + await run(db, 'DELETE FROM locations WHERE id = ?', [enemy]); + await new Promise((resolve) => vitals.pruneAfterMapChange(db, resolve)); + expect((await all(db, 'SELECT location_id FROM token_vitals')).map((r) => r.location_id)).toEqual([ghost]); + }); + + it('through the real clear route', async () => { + const mapsRoute = require_('../routes/maps.js'); + const app = express(); + app.use(express.json()); + app.use('/api/maps', mapsRoute(db, { emit: () => {} }, { emitUpdate: () => {}, recordAction: () => {} })); + await addToken('rhombus', { owner: 'GHOST', hp: 7 }); + const enemy = await addToken('enemy_rhombus', { hp: 5 }); + await vitals.switchSystem(db, CPR); + expect(await all(db, 'SELECT * FROM token_vitals WHERE location_id = ?', [enemy])).toHaveLength(1); + expect((await request(app).post('/api/maps/clear').set('Authorization', `Bearer ${GM}`)).status).toBe(200); + await drain(db); + expect(await all(db, 'SELECT * FROM token_vitals WHERE location_id = ?', [enemy])).toEqual([]); + }); +}); + +describe('the routes', () => { + const makeApp = (emitted) => { + const app = express(); + app.use(express.json()); + const io = { emit: (event, data) => emitted.push({ event, data }), to: () => ({ emit: () => {} }) }; + app.use('/api/sheets', require_('../routes/sheets.js')(db, io)); + app.use('/api', require_('../routes/admin.js')(db, io, { emitUpdate: () => {}, recordAction: () => {} })); + return app; + }; + + it('the system picker switches tokens with the system and has every screen redraw', async () => { + const emitted = []; + const app = makeApp(emitted); + const ghost = await addToken('rhombus', { owner: 'GHOST', hp: 7 }); + const res = await request(app).put('/api/sheets/system').set('Authorization', `Bearer ${GM}`).send({ system: CPR }); + expect(res.status).toBe(200); + expect(await running()).toBe(CPR); + expect((await tokenOf(ghost)).hp_current).toBeNull(); + expect(emitted.map((e) => e.event)).toEqual(expect.arrayContaining(['gameSystemChanged', 'dataUpdated'])); + expect(emitted.find((e) => e.event === 'dataUpdated').data).toEqual({ isRhombusOnly: true }); + }); + + it('the generic settings route cannot change the system or the migration markers', async () => { + const app = makeApp([]); + for (const key of ['game_system', 'migration_bank_accounts', 'migration_token_vitals']) { + const res = await request(app).post('/api/settings').set('Authorization', `Bearer ${GM}`).send({ key, value: 'x' }); + expect(res.status, key).toBe(400); + } + expect(await running()).toBe(CWN); + // Everything else still goes through as before. + expect((await request(app).post('/api/settings').set('Authorization', `Bearer ${GM}`).send({ key: 'buyback_pct', value: '50' })).status).toBe(200); + }); +}); + +describe('the real startup path', () => { + it("saves an existing server's token health when db.js opens it", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'citynet-tokens-')); + const file = path.join(dir, 'city.db'); + try { + // The backend's own folder, however the checkout is named. Matching a folder name instead + // passed on a Windows checkout under F:\MapSystem and cleared nothing on the CI runner, + // whose path has no such name - so the second open got the first, closed connection. + const backendDir = path.dirname(require_.resolve('../db.js')); + const script = ` + const path = require('path'); + const backendDir = ${JSON.stringify(backendDir)}; + const ours = (k) => k.startsWith(backendDir + path.sep) && !k.includes(path.sep + 'node_modules' + path.sep); + process.env.DB_PATH = ${JSON.stringify(file)}; + console.log = () => {}; console.warn = () => {}; + // First open: a 1.14.4-era database is made, with a token carrying health. + const first = require(${JSON.stringify(require_.resolve('../db.js'))}); + // Its own startup work first (the one-time moves run after the tables exist), so + // nothing of it is still running when this connection closes. That includes the admin + // seed: it waits on a bcrypt hash, and on a slow runner its INSERT landed after close() + // and killed the process with "Database is closed". + const adminSeeded = () => new Promise((resolve, reject) => { + const started = Date.now(); + const poll = () => first.get('SELECT COUNT(*) AS n FROM admin', (err, row) => { + if (!err && row && row.n > 0) return resolve(); + if (Date.now() - started > 30000) return reject(new Error('admin was never seeded')); + setTimeout(poll, 20); + }); + poll(); + }); + require(${JSON.stringify(require_.resolve('../tokens/vitals.js'))}).whenReady().then(adminSeeded).then(() => { + first.serialize(() => { + first.run("DELETE FROM global_settings WHERE key = 'migration_token_vitals'"); + first.run("INSERT OR REPLACE INTO global_settings (key, value) VALUES ('game_system', '${CWN}')"); + first.run("INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', '${CPR}', '{}', 0)"); + first.run("INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max, melee_ac) VALUES ('GHOST', 0, 0, 0, 'rhombus', 'GHOST', 7, 12, 14)"); + first.run("DELETE FROM token_vitals"); + }); + first.close(() => { + // Second open, as after updating: the start runs, then a switch keeps the health. + const cleared = Object.keys(require.cache).filter(ours); + if (!cleared.some((k) => k.endsWith(path.sep + 'db.js'))) { + process.stdout.write(JSON.stringify({ err: 'db.js was not cleared from the module cache' }), () => process.exit(0)); + return; + } + for (const k of cleared) delete require.cache[k]; + const db = require(${JSON.stringify(require_.resolve('../db.js'))}); + const vitals = require(${JSON.stringify(require_.resolve('../tokens/vitals.js'))}); + vitals.switchSystem(db, '${CPR}').then(() => { + db.get("SELECT hp_current, hp_max, melee_ac FROM locations WHERE owner = 'GHOST'", (err, row) => { + process.stdout.write(JSON.stringify({ err: err && err.message, row }), () => process.exit(0)); + }); + }, (e) => { process.stdout.write(JSON.stringify({ err: e.message }), () => process.exit(0)); }); + }); + });`; + const out = JSON.parse(execFileSync(process.execPath, ['-e', script], { encoding: 'utf8', timeout: 60000 })); + expect(out).toEqual({ err: null, row: { hp_current: 7, hp_max: 12, melee_ac: 14 } }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/backend/bank/accounts.js b/backend/bank/accounts.js new file mode 100644 index 00000000..c73af65a --- /dev/null +++ b/backend/bank/accounts.js @@ -0,0 +1,96 @@ +// Bank accounts: one per player per game system. +// +// A character's money belongs to the game it was earned in (decided with the user, +// 2026-09-29): a CWN character's credits must not turn up in a D&D campaign. So an account +// is keyed by player AND system, in `bank_accounts`. The account a handler uses is the +// running system's, which `activeSystem` reads. +// +// Every read and write of an account goes through here, and each one waits for the one-time +// move from the old per-player table (startup/bankAccounts.js) to finish. That wait is not +// decoration: browsers reconnect within milliseconds of a restart, and an empty account +// opened before the move would block the player's real balance from being copied in. + +const { DEFAULT_SYSTEM } = require('../sheets/templates'); + +let ready = Promise.resolve(); + +/** + * Hold every account operation until `promise` settles. Set once at startup by db.js. A + * failure is handled here as well as by each operation, so it can never surface as an + * unhandled rejection, which would stop the whole server. + */ +const setReady = (promise) => { + ready = Promise.resolve(promise); + ready.catch(() => {}); +}; + +/** Run `fn` once accounts are ready, or hand `cb` the reason they never became ready. */ +const whenReady = (cb, fn) => { + ready.then(fn, (err) => cb(err || new Error('Bank accounts are not ready'))); +}; + +/** The system the game is running. */ +const activeSystem = (db, cb) => { + db.get(`SELECT value FROM global_settings WHERE key = 'game_system'`, [], (err, row) => { + cb(err, row && row.value ? row.value : DEFAULT_SYSTEM); + }); +}; + +const COLUMNS = 'balance, debt, first_pay_done, high_roller_done'; + +/** A player's account in `system`, or null when they have none yet. */ +const get = (db, username, system, cb) => whenReady(cb, () => { + db.get(`SELECT ${COLUMNS} FROM bank_accounts WHERE username = ? AND system = ?`, [username, system], + (err, row) => cb(err || null, row || null)); +}); + +/** A player's account in `system`, opened at zero if they have none. */ +const ensure = (db, username, system, cb) => whenReady(cb, () => { + db.run(`INSERT OR IGNORE INTO bank_accounts (username, system, balance, debt) VALUES (?, ?, 0, 0)`, [username, system], (err) => { + if (err) return cb(err); + db.get(`SELECT ${COLUMNS} FROM bank_accounts WHERE username = ? AND system = ?`, [username, system], + (err2, row) => cb(err2 || null, row || null)); + }); +}); + +/** Set an account's balance and debt, opening it if needed. */ +const put = (db, username, system, balance, debt, cb) => whenReady(cb, () => { + db.run( + `INSERT INTO bank_accounts (username, system, balance, debt) VALUES (?, ?, ?, ?) + ON CONFLICT(username, system) DO UPDATE SET balance = excluded.balance, debt = excluded.debt`, + [username, system, balance, debt], + (err) => cb(err || null), + ); +}); + +/** Add to an account's balance, opening it at that amount if needed. */ +const addToBalance = (db, username, system, amount, cb) => whenReady(cb, () => { + db.run( + `INSERT INTO bank_accounts (username, system, balance, debt) VALUES (?, ?, ?, 0) + ON CONFLICT(username, system) DO UPDATE SET balance = COALESCE(balance, 0) + excluded.balance`, + [username, system, amount], + (err) => cb(err || null), + ); +}); + +/** + * Move an existing account's balance and debt by these amounts. An account that does not + * exist is left alone, as the old handlers did (a withdrawal never opens an account). + */ +const adjust = (db, username, system, { balance = 0, debt = 0 }, cb) => whenReady(cb, () => { + db.run( + `UPDATE bank_accounts SET balance = balance + ?, debt = debt + ? WHERE username = ? AND system = ?`, + [balance, debt, username, system], + function (err) { cb(err || null, err ? 0 : this.changes); }, + ); +}); + +const FLAGS = new Set(['first_pay_done', 'high_roller_done']); + +/** Mark a one-time bank event (first payday, high roller) done for this account. */ +const markFlag = (db, username, system, flag, cb) => whenReady(cb, () => { + if (!FLAGS.has(flag)) return cb(new Error(`unknown bank flag ${flag}`)); + db.run(`UPDATE bank_accounts SET ${flag} = 1 WHERE username = ? AND system = ?`, [username, system], (err) => cb(err || null)); +}); + +module.exports = { setReady, activeSystem, get, ensure, put, addToBalance, adjust, markFlag }; diff --git a/backend/db.js b/backend/db.js index f68733a4..a30e4925 100644 --- a/backend/db.js +++ b/backend/db.js @@ -335,6 +335,32 @@ db.serialize(() => { // Migrate existing rows that predate the first_pay_done column db.run(`ALTER TABLE player_banks ADD COLUMN first_pay_done INTEGER DEFAULT 0`, () => {}); db.run(`ALTER TABLE player_banks ADD COLUMN high_roller_done INTEGER DEFAULT 0`, () => {}); + // player_banks above is the old one-bank-per-player table. It is kept, never changed, as the + // record of balances before banks became per system; nothing reads it after the one-time move + // (startup/bankAccounts.js). Every account now lives here, one per player per system. + db.run(`CREATE TABLE IF NOT EXISTS bank_accounts ( + username TEXT NOT NULL, + system TEXT NOT NULL, + balance REAL DEFAULT 0, + debt REAL DEFAULT 0, + first_pay_done INTEGER DEFAULT 0, + high_roller_done INTEGER DEFAULT 0, + PRIMARY KEY (username, system) + )`); + + // A token's health, defense and injuries in the systems that are NOT running. The running + // system's live on the token itself; switching swaps them (tokens/vitals.js). + db.run(`CREATE TABLE IF NOT EXISTS token_vitals ( + location_id INTEGER NOT NULL, + system TEXT NOT NULL, + hp_current INTEGER, + hp_max INTEGER, + hp_temp INTEGER, + melee_ac INTEGER, + ranged_ac INTEGER, + injuries TEXT DEFAULT '{}', + PRIMARY KEY (location_id, system) + )`); db.run(`CREATE TABLE IF NOT EXISTS water_bodies ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -474,6 +500,28 @@ db.serialize(() => { )`); db.run(`ALTER TABLE initiative_scene ADD COLUMN sides TEXT NOT NULL DEFAULT '[]'`, () => {}); + // Game systems a GM built: a draft the builder edits and the published copy a game runs. + // See systemBuilder/store.js; published ones are loaded into the game by systemBuilder/runtime.js. + db.run(`CREATE TABLE IF NOT EXISTS custom_systems ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + draft TEXT NOT NULL, + published TEXT, + version INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + published_at DATETIME + )`); + // Sharing systems as files (systemBuilder/citysys.js). `origin` is who the system is across + // servers, kept by every copy installed from its file; `source_hash` is what was installed, + // so an update can tell a copy edited since; `deleted_at` hides a deleted system while + // keeping it, so reinstalling its file brings it back with every character played in it. + db.run(`ALTER TABLE custom_systems ADD COLUMN origin TEXT`, () => {}); + db.run(`ALTER TABLE custom_systems ADD COLUMN source_hash TEXT`, () => {}); + db.run(`ALTER TABLE custom_systems ADD COLUMN deleted_at DATETIME`, () => {}); + // A system made before files existed is its own origin. + db.run(`UPDATE custom_systems SET origin = id WHERE origin IS NULL`, () => {}); + // Migration: CP:R's name field was stored as 'handle'; it is now 'name' // (uniform across systems — the sheet is the source of truth for player // identity, see backend/sheets/identity.js). Copy handle → name once. @@ -519,6 +567,28 @@ db.serialize(() => { db.run(`UPDATE character_sheets SET data = ? WHERE id = ?`, [JSON.stringify(data), row.id]); }); }); + + // Move the old per-player banks into per-system accounts, once. It starts last in the queue, + // so every table above exists, but bank operations are told to wait for it right now, while + // the database is still being opened - before any socket can connect. + const banksMoved = new Promise((resolve, reject) => { + db.get('SELECT 1', () => { + require('./startup/bankAccounts').migrateBankAccounts(db, dbPath).then(resolve, (err) => { + console.error('[bank] Moving banks to per-system accounts failed, so the bank is unavailable until the next start:', err.message); + reject(err); + }); + }); + }); + require('./bank/accounts').setReady(banksMoved); + + // Save each token's current health under every system it could be shown in, once; a system + // switch waits for it. After the bank move, so the two never share a transaction. + const tokensSaved = banksMoved.catch(() => {}).then(() => require('./startup/tokenVitals').migrateTokenVitals(db)) + .catch((err) => { + console.error('[tokens] Saving token health per system failed, so switching systems is unavailable until the next start:', err.message); + throw err; + }); + require('./tokens/vitals').setReady(tokensSaved); }); module.exports = db; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index e708e471..b1814424 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -3,34 +3,84 @@ const jwt = require('jsonwebtoken'); const SECRET = process.env.JWT_SECRET; const elevatedUsers = new Set(); +// Who a token belongs to, decided in one place. +// +// Three kinds of token are signed with the same secret: the GM's (routes/admin.js, role +// 'admin'), a player's (routes/player.js, role 'player'), and a granted editor's (sockets, +// isTemporary). A valid signature therefore says only that the server issued the token, not +// that its holder is the GM - and the checks used to stop there. A player's own login token +// passed `authenticate` and every "not temporary" test, which opened the GM's routes and admin +// socket events to any signed-in player. These say what each check actually means. + +/** The GM's own login. Only admin login signs role 'admin'. */ +const isMainAdmin = (v) => !!v && v.role === 'admin' && !v.isTemporary; + +/** A player the GM has granted editing rights, while the grant still stands. */ +const isGrantedEditor = (v) => !!v && !!v.isTemporary && elevatedUsers.has(v.username); + +/** The GM or a granted editor: who the GM-facing routes are for. */ +const canEdit = (v) => isMainAdmin(v) || isGrantedEditor(v); + +/** A player's own login (not a password-reset token). */ +const isPlayer = (v) => !!v && v.role === 'player' && !v.isTemporary; + +/** The verified payload of an `Authorization: Bearer` header, or null. */ +const verifyHeader = (header) => { + try { return jwt.verify(String(header).split(' ')[1], SECRET); } catch { return null; } +}; + +/** GM-facing routes: the GM or a granted editor. */ const authenticate = (req, res, next) => { - const token = req.headers['authorization']; - if (!token) return res.status(401).json({ error: 'Access denied' }); - try { - const verified = jwt.verify(token.split(' ')[1], SECRET); - if (verified.isTemporary && !elevatedUsers.has(verified.username)) { - return res.status(401).json({ error: 'Temporary access revoked' }); - } + const header = req.headers['authorization']; + if (!header) return res.status(401).json({ error: 'Access denied' }); + const verified = verifyHeader(header); + if (!verified) return res.status(400).json({ error: 'Invalid token' }); + if (canEdit(verified)) { req.user = verified; - next(); - } catch (err) { - res.status(400).json({ error: 'Invalid token' }); + return next(); } + if (verified.isTemporary) return res.status(401).json({ error: 'Temporary access revoked' }); + return res.status(403).json({ error: 'GM only' }); }; -const optionalAuthenticate = (req, res, next) => { - const token = req.headers['authorization']; - if (!token) { - req.user = null; +/** + * The few routes a player calls about themselves (their own sheet, their portrait): a + * player's login, or anyone `authenticate` accepts. + */ +const authenticatePlayer = (req, res, next) => { + const header = req.headers['authorization']; + if (!header) return res.status(401).json({ error: 'Access denied' }); + const verified = verifyHeader(header); + if (!verified) return res.status(400).json({ error: 'Invalid token' }); + if (canEdit(verified) || isPlayer(verified)) { + req.user = verified; return next(); } - try { - const verified = jwt.verify(token.split(' ')[1], SECRET); - req.user = (verified.isTemporary && !elevatedUsers.has(verified.username)) ? null : verified; - } catch (err) { - req.user = null; - } + if (verified.isTemporary) return res.status(401).json({ error: 'Temporary access revoked' }); + return res.status(403).json({ error: 'Not allowed' }); +}; + +/** + * After `authenticate`: the GM's own login only, not a granted editor. For what is the GM's + * alone (building game systems). + */ +const requireMainAdmin = (req, res, next) => { + if (isMainAdmin(req.user)) return next(); + return res.status(403).json({ error: 'Only the main admin can do that' }); +}; + +/** + * Public routes that show the GM more: `req.user` is set only for someone `authenticate` + * would accept. Anyone else, players included, is treated as anonymous. + */ +const optionalAuthenticate = (req, res, next) => { + const header = req.headers['authorization']; + const verified = header ? verifyHeader(header) : null; + req.user = canEdit(verified) ? verified : null; next(); }; -module.exports = { authenticate, optionalAuthenticate, elevatedUsers }; +module.exports = { + authenticate, authenticatePlayer, optionalAuthenticate, requireMainAdmin, elevatedUsers, + isMainAdmin, isGrantedEditor, canEdit, isPlayer, +}; diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 6334b5db..fdb2483f 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -34,6 +34,12 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { router.post('/settings', authenticate, (req, res) => { const { key, value } = req.body; + // Not through here: the game system changes with every token's health in one transaction + // (PUT /api/sheets/system), and the migration markers record one-time moves that must not + // be undone by hand. + if (key === 'game_system' || String(key || '').startsWith('migration_')) { + return res.status(400).json({ error: 'That setting is not changed here' }); + } db.run('INSERT INTO global_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=?', [key, value, value], (err) => { if (err) return res.status(500).json({ error: err.message }); io.emit('settingsUpdated'); diff --git a/backend/routes/locations.js b/backend/routes/locations.js index 65618b5e..11dedeca 100644 --- a/backend/routes/locations.js +++ b/backend/routes/locations.js @@ -4,7 +4,9 @@ const path = require('path'); const { authenticate, optionalAuthenticate } = require('../middleware/auth'); const identity = require('../sheets/identity'); const { mutateSheet, patchSheet } = require('../sheets/mutate'); -const { DEFAULT_SYSTEM } = require('../sheets/templates'); +const { DEFAULT_SYSTEM, applyDerived } = require('../sheets/templates'); +const customSystems = require('../systemBuilder/runtime'); +const { applyHealthAction } = require('../systemBuilder/health'); const { BUILDING_TYPES, isValidType } = require('../buildingTypes'); const { readPct } = require('../shops/buyback'); const sheetSlots = require('../shops/sheetSlots'); @@ -715,9 +717,90 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { } ); }); + } else if (action === 'damage' || action === 'heal' || (action === 'set_max' && req.body.track !== undefined)) { + // A custom system whose health is not one pool takes damage by its own model + // (systemBuilder/health.js). Everything else, the built-in systems included, goes + // through runHealth exactly as before. So does every SET MAX but a second track's, + // whose maximum lives on the sheet. + db.get(`SELECT value FROM global_settings WHERE key = 'game_system'`, (gErr, gRow) => { + const system = !gErr && gRow ? gRow.value : null; + const health = system ? customSystems.health(system) : null; + if (!health || health.model === 'pool') return runHealth(action); + if (action === 'set_max') { + const second = health.model === 'tracks' && Array.isArray(health.tracks) ? health.tracks[1] : null; + if (!second || req.body.track !== second.id) return runHealth(action); + } + runModelHealth(system, health); + }); } else { runHealth(action); } + + /** + * DAMAGE or HEAL under a custom health model. The detail lives on the sheet behind the + * token (a second track, marked boxes, harm notes), so the rule runs inside that sheet's + * write queue: it reads the sheet as it is at write time, and a player typing into it + * at that moment cannot lose their edit or have the damage worked out from stale marks. + */ + function runModelHealth(system, health) { + const act = { + kind: action, amount, + track: req.body.track, type: req.body.type, level: req.body.level, + slot: req.body.slot, note: req.body.note, location: req.body.location, + }; + const token = { current: row.hp_current, max: row.hp_max, temp: row.hp_temp }; + const findSheet = (cb) => (row.shape === 'rhombus' && row.owner + ? db.get(`SELECT id, username, is_npc FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0`, + [row.owner, system], (e, s) => cb(e ? null : s || null)) + : db.get(`SELECT cs.id, cs.username, cs.is_npc FROM npc_sheet_links l + JOIN character_sheets cs ON cs.id = l.sheet_id WHERE l.location_id = ? AND cs.system = ?`, + [id, system], (e, s) => cb(e ? null : s || null))); + + const writeToken = (result) => { + const t = result.token; + const [sql, params] = row.shape === 'rhombus' && row.owner + ? ['UPDATE locations SET hp_current = ?, hp_max = ?, hp_temp = ? WHERE shape = "rhombus" AND owner = ?', [t.current, t.max, t.temp, row.owner]] + : ['UPDATE locations SET hp_current = ?, hp_max = ?, hp_temp = ? WHERE id = ?', [t.current, t.max, t.temp, id]]; + db.run(sql, params, (err2) => { + if (err2) return res.status(500).json({ error: err2.message }); + // As runHealth: the map redraws, and a player's open sheet re-reads. An NPC's sheet + // window re-reads on the map update. + emitUpdate(); + if (row.shape === 'rhombus' && row.owner) io.emit('sheetUpdated', { username: row.owner }); + res.json({ + id, hp_current: t.current, hp_max: t.max, hp_temp: t.temp, out: result.out, + ...(result.overflow ? { overflow: result.overflow } : {}), + ...(result.penalty !== undefined ? { penalty: result.penalty } : {}), + ...(result.turned ? { turned: result.turned } : {}), + ...(result.placed ? { placed: result.placed } : {}), + }); + }); + }; + + findSheet((sheet) => { + if (!sheet) { + const result = applyHealthAction(health, token, {}, act); + if (!result.ok) return res.status(400).json({ error: result.error }); + if (Object.keys(result.sheetPatch).length) { + return res.status(409).json({ error: 'This token has no sheet to keep that on. Give it one first.' }); + } + return writeToken(result); + } + let result = null; + mutateSheet(db, sheet.id, (data) => { + result = applyHealthAction(health, token, data, act); + if (!result.ok || !Object.keys(result.sheetPatch).length) return undefined; + const next = { ...data, ...result.sheetPatch }; + applyDerived(system, next); + return next; + }, (err3) => { + if (err3) return res.status(500).json({ error: err3.message }); + if (!result) return res.status(500).json({ error: 'Could not read the sheet' }); + if (!result.ok) return res.status(400).json({ error: result.error }); + writeToken(result); + }); + }); + } }); }); diff --git a/backend/routes/maps.js b/backend/routes/maps.js index de8ff8dc..c82e2c79 100644 --- a/backend/routes/maps.js +++ b/backend/routes/maps.js @@ -1,210 +1,216 @@ -const express = require('express'); -const { authenticate } = require('../middleware/auth'); -const gmNotes = require('../buildings/gmNotes'); -const { columnsOf, queueInserts } = require('../buildings/locationRows'); - -/** - * Remember which saved map is live, so exports can name their files after it. - * - * Nothing tracked this before: loading a map replaced the world and forgot where it - * came from. Kept in global_settings rather than client state so every admin agrees - * and it survives a restart. - */ -const setActiveMapName = (db, name) => { - if (name) { - db.run( - `INSERT INTO global_settings (key, value) VALUES ('active_map_name', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, - [String(name).slice(0, 120)], - () => {}, - ); - } else { - db.run(`DELETE FROM global_settings WHERE key = 'active_map_name'`, () => {}); - } -}; - -module.exports = (db, io, { emitUpdate, recordAction }) => { - const router = express.Router(); - - // --- Saved Maps --- - router.get('/', (req, res) => { - db.all('SELECT id, name, timestamp FROM saved_maps ORDER BY timestamp DESC', [], (err, rows) => { - if (err) return res.status(500).json({ error: err.message }); - res.json(rows); - }); - }); - - router.post('/save', authenticate, (req, res) => { - const { name } = req.body; - if (!name) return res.status(400).json({ error: 'Map name required' }); - // Saving under a name makes that the live map. - setActiveMapName(db, name); - - db.serialize(() => { - db.all("SELECT * FROM locations WHERE shape != 'rhombus' OR shape IS NULL", (err1, locations) => { - if (err1) return res.status(500).json({ error: err1.message }); - db.all('SELECT * FROM districts', (err2, districts) => { - if (err2) return res.status(500).json({ error: err2.message }); - db.all('SELECT * FROM roads', (err3, roads) => { - if (err3) return res.status(500).json({ error: err3.message }); - db.all('SELECT * FROM overpasses', (err4, overpasses) => { - if (err4) return res.status(500).json({ error: err4.message }); - db.all('SELECT * FROM water_bodies', (err5, waterBodies) => { - if (err5) return res.status(500).json({ error: err5.message }); - db.all('SELECT * FROM signs', (err6, signs) => { - if (err6) return res.status(500).json({ error: err6.message }); - // The GM's notes on these buildings travel with the map: location ids are - // reused on load, so notes left behind would land on the next map's - // buildings. Only ever read back through the authenticated load below - - // the public listing returns names and timestamps, nothing else. - gmNotes.all(db, (err7, notes) => { - if (err7) return res.status(500).json({ error: err7.message }); - - const sql = `INSERT INTO saved_maps (name, locations_data, districts_data, roads_data, overpasses_data, water_bodies_data, signs_data, gm_notes_data) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(name) DO UPDATE SET - locations_data=excluded.locations_data, - districts_data=excluded.districts_data, - roads_data=excluded.roads_data, - overpasses_data=excluded.overpasses_data, - water_bodies_data=excluded.water_bodies_data, - signs_data=excluded.signs_data, - gm_notes_data=excluded.gm_notes_data, - timestamp=CURRENT_TIMESTAMP`; - db.run(sql, [name, JSON.stringify(locations), JSON.stringify(districts), JSON.stringify(roads), JSON.stringify(overpasses), JSON.stringify(waterBodies), JSON.stringify(signs), JSON.stringify(notes)], function(err) { - if (err) return res.status(500).json({ error: err.message }); - res.json({ message: 'Map saved successfully' }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - }); - - router.post('/load/:name', authenticate, (req, res) => { - db.get('SELECT * FROM saved_maps WHERE name = ?', [req.params.name], (err, row) => { - if (err) return res.status(500).json({ error: err.message }); - if (!row) return res.status(404).json({ error: 'Map not found' }); - setActiveMapName(db, req.params.name); - - const locations = JSON.parse(row.locations_data || '[]'); - const districts = JSON.parse(row.districts_data || '[]'); - const roads = JSON.parse(row.roads_data || '[]'); - const overpasses = JSON.parse(row.overpasses_data || '[]'); - const waterBodies = JSON.parse(row.water_bodies_data || '[]'); - const signs = JSON.parse(row.signs_data || '[]'); - // Null for a map saved before notes traveled with it. That map had none to carry, - // so whatever is in the table now belongs to a different map and goes. - const notes = JSON.parse(row.gm_notes_data || '[]'); - - // The table's columns, looked up before anything is queued so the inserts below keep - // their place in the serialized order - the id sequence is reset after them. - columnsOf(db, 'locations', (colErr, columns) => { - if (colErr) return res.status(500).json({ error: colErr.message }); - - db.serialize(() => { - // Delete all locations except live player rhombuses; enemy/friendly tokens are map content and get replaced - db.run(`DELETE FROM locations WHERE shape IS NULL OR shape != 'rhombus'`); - db.run('DELETE FROM districts'); - db.run('DELETE FROM roads'); - db.run('DELETE FROM overpasses'); - db.run('DELETE FROM water_bodies'); - db.run('DELETE FROM signs'); - - // Every column each building was saved with. The hand-kept list this replaced had - // fallen behind the table, so a loaded map lost every building's type, buy-back - // rate, AC, sidewalk and signage settings and hidden flag. A column the snapshot - // predates takes the table's default, as it would for a new building. - queueInserts(db, columns, locations, { orIgnore: true }); - - if (districts.length > 0) { - const stmtD = db.prepare(`INSERT INTO districts (id, name, color) VALUES (?, ?, ?)`); - districts.forEach(d => stmtD.run([d.id, d.name, d.color])); - stmtD.finalize(); - } - - if (roads.length > 0) { - const stmtR = db.prepare(`INSERT INTO roads (id, x1, z1, x2, z2, width) VALUES (?, ?, ?, ?, ?, ?)`); - roads.forEach(r => stmtR.run([r.id, r.x1, r.z1, r.x2, r.z2, r.width])); - stmtR.finalize(); - } - - if (overpasses.length > 0) { - const stmtO = db.prepare(`INSERT INTO overpasses (id, points, height, width, ramp_length, ramp_length_start, ramp_length_end, pillar_spacing) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`); - overpasses.forEach(o => stmtO.run([o.id, o.points, o.height, o.width, o.ramp_length, o.ramp_length_start ?? null, o.ramp_length_end ?? null, o.pillar_spacing])); - stmtO.finalize(); - } - - db.run('UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM locations) WHERE name="locations"'); - db.run('UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM districts) WHERE name="districts"'); - db.run('UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM roads) WHERE name="roads"'); - db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM overpasses), 0) WHERE name="overpasses"'); - - if (waterBodies.length > 0) { - const stmtW = db.prepare(`INSERT INTO water_bodies (id, points_json, map_scale_multiplier) VALUES (?, ?, ?)`); - waterBodies.forEach(w => stmtW.run([w.id, w.points_json, w.map_scale_multiplier])); - stmtW.finalize(); - } - db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM water_bodies), 0) WHERE name="water_bodies"'); - - if (signs.length > 0) { - const stmtS = db.prepare(`INSERT INTO signs (id, text, x, y, z, rotation_y, font_size, font_family, image_url, use_tv_filter, lines, filter_intensity) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); - signs.forEach(s => stmtS.run([s.id, s.text, s.x, s.y, s.z, s.rotation_y, s.font_size, s.font_family, s.image_url ?? null, s.use_tv_filter ?? 0, s.lines ?? null, s.filter_intensity ?? 1.0])); - stmtS.finalize(); - } - db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM signs), 0) WHERE name="signs"'); - - db.run('SELECT 1', () => { - // After the buildings are in, so a note is only restored onto a building the map - // actually has. - gmNotes.replaceAll(db, notes, () => { - emitUpdate(); - res.json({ message: 'Map loaded successfully' }); - }); - }); - }); - }); - }); - }); - - router.post('/clear', authenticate, (req, res) => { - // Wiping the world leaves no map loaded. - setActiveMapName(db, null); - db.serialize(() => { - // Preserve only live player rhombuses; enemy/friendly tokens are map content - db.run(`DELETE FROM locations WHERE shape IS NULL OR shape != 'rhombus'`); - db.run('DELETE FROM districts'); - db.run('DELETE FROM roads'); - db.run('DELETE FROM overpasses'); - db.run('DELETE FROM water_bodies'); - db.run('DELETE FROM signs'); - db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM locations), 0) WHERE name="locations"'); - db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="districts"'); - db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="roads"'); - db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="overpasses"'); - db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="water_bodies"'); - db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="signs"'); - // The id sequence was just wound back, so the next building made gets an id a - // cleared one had. Its notes must not be waiting for it. - gmNotes.pruneOrphans(db); - - db.run('SELECT 1', () => { - emitUpdate(); - res.json({ message: 'Map cleared completely' }); - }); - }); - }); - - router.delete('/:id', authenticate, (req, res) => { - db.run('DELETE FROM saved_maps WHERE id = ?', [req.params.id], function(err) { - if (err) return res.status(500).json({ error: err.message }); - res.json({ message: 'Map deleted' }); - }); - }); - - return router; -}; +const express = require('express'); +const { authenticate } = require('../middleware/auth'); +const gmNotes = require('../buildings/gmNotes'); +const tokenVitals = require('../tokens/vitals'); +const { columnsOf, queueInserts } = require('../buildings/locationRows'); + +/** + * Remember which saved map is live, so exports can name their files after it. + * + * Nothing tracked this before: loading a map replaced the world and forgot where it + * came from. Kept in global_settings rather than client state so every admin agrees + * and it survives a restart. + */ +const setActiveMapName = (db, name) => { + if (name) { + db.run( + `INSERT INTO global_settings (key, value) VALUES ('active_map_name', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [String(name).slice(0, 120)], + () => {}, + ); + } else { + db.run(`DELETE FROM global_settings WHERE key = 'active_map_name'`, () => {}); + } +}; + +module.exports = (db, io, { emitUpdate, recordAction }) => { + const router = express.Router(); + + // --- Saved Maps --- + router.get('/', (req, res) => { + db.all('SELECT id, name, timestamp FROM saved_maps ORDER BY timestamp DESC', [], (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(rows); + }); + }); + + router.post('/save', authenticate, (req, res) => { + const { name } = req.body; + if (!name) return res.status(400).json({ error: 'Map name required' }); + // Saving under a name makes that the live map. + setActiveMapName(db, name); + + db.serialize(() => { + db.all("SELECT * FROM locations WHERE shape != 'rhombus' OR shape IS NULL", (err1, locations) => { + if (err1) return res.status(500).json({ error: err1.message }); + db.all('SELECT * FROM districts', (err2, districts) => { + if (err2) return res.status(500).json({ error: err2.message }); + db.all('SELECT * FROM roads', (err3, roads) => { + if (err3) return res.status(500).json({ error: err3.message }); + db.all('SELECT * FROM overpasses', (err4, overpasses) => { + if (err4) return res.status(500).json({ error: err4.message }); + db.all('SELECT * FROM water_bodies', (err5, waterBodies) => { + if (err5) return res.status(500).json({ error: err5.message }); + db.all('SELECT * FROM signs', (err6, signs) => { + if (err6) return res.status(500).json({ error: err6.message }); + // The GM's notes on these buildings travel with the map: location ids are + // reused on load, so notes left behind would land on the next map's + // buildings. Only ever read back through the authenticated load below - + // the public listing returns names and timestamps, nothing else. + gmNotes.all(db, (err7, notes) => { + if (err7) return res.status(500).json({ error: err7.message }); + + const sql = `INSERT INTO saved_maps (name, locations_data, districts_data, roads_data, overpasses_data, water_bodies_data, signs_data, gm_notes_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + locations_data=excluded.locations_data, + districts_data=excluded.districts_data, + roads_data=excluded.roads_data, + overpasses_data=excluded.overpasses_data, + water_bodies_data=excluded.water_bodies_data, + signs_data=excluded.signs_data, + gm_notes_data=excluded.gm_notes_data, + timestamp=CURRENT_TIMESTAMP`; + db.run(sql, [name, JSON.stringify(locations), JSON.stringify(districts), JSON.stringify(roads), JSON.stringify(overpasses), JSON.stringify(waterBodies), JSON.stringify(signs), JSON.stringify(notes)], function(err) { + if (err) return res.status(500).json({ error: err.message }); + res.json({ message: 'Map saved successfully' }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + + router.post('/load/:name', authenticate, (req, res) => { + db.get('SELECT * FROM saved_maps WHERE name = ?', [req.params.name], (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + if (!row) return res.status(404).json({ error: 'Map not found' }); + setActiveMapName(db, req.params.name); + + const locations = JSON.parse(row.locations_data || '[]'); + const districts = JSON.parse(row.districts_data || '[]'); + const roads = JSON.parse(row.roads_data || '[]'); + const overpasses = JSON.parse(row.overpasses_data || '[]'); + const waterBodies = JSON.parse(row.water_bodies_data || '[]'); + const signs = JSON.parse(row.signs_data || '[]'); + // Null for a map saved before notes traveled with it. That map had none to carry, + // so whatever is in the table now belongs to a different map and goes. + const notes = JSON.parse(row.gm_notes_data || '[]'); + + // The table's columns, looked up before anything is queued so the inserts below keep + // their place in the serialized order - the id sequence is reset after them. + columnsOf(db, 'locations', (colErr, columns) => { + if (colErr) return res.status(500).json({ error: colErr.message }); + + db.serialize(() => { + // Delete all locations except live player rhombuses; enemy/friendly tokens are map content and get replaced + db.run(`DELETE FROM locations WHERE shape IS NULL OR shape != 'rhombus'`); + db.run('DELETE FROM districts'); + db.run('DELETE FROM roads'); + db.run('DELETE FROM overpasses'); + db.run('DELETE FROM water_bodies'); + db.run('DELETE FROM signs'); + + // Every column each building was saved with. The hand-kept list this replaced had + // fallen behind the table, so a loaded map lost every building's type, buy-back + // rate, AC, sidewalk and signage settings and hidden flag. A column the snapshot + // predates takes the table's default, as it would for a new building. + queueInserts(db, columns, locations, { orIgnore: true }); + + if (districts.length > 0) { + const stmtD = db.prepare(`INSERT INTO districts (id, name, color) VALUES (?, ?, ?)`); + districts.forEach(d => stmtD.run([d.id, d.name, d.color])); + stmtD.finalize(); + } + + if (roads.length > 0) { + const stmtR = db.prepare(`INSERT INTO roads (id, x1, z1, x2, z2, width) VALUES (?, ?, ?, ?, ?, ?)`); + roads.forEach(r => stmtR.run([r.id, r.x1, r.z1, r.x2, r.z2, r.width])); + stmtR.finalize(); + } + + if (overpasses.length > 0) { + const stmtO = db.prepare(`INSERT INTO overpasses (id, points, height, width, ramp_length, ramp_length_start, ramp_length_end, pillar_spacing) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`); + overpasses.forEach(o => stmtO.run([o.id, o.points, o.height, o.width, o.ramp_length, o.ramp_length_start ?? null, o.ramp_length_end ?? null, o.pillar_spacing])); + stmtO.finalize(); + } + + db.run('UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM locations) WHERE name="locations"'); + db.run('UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM districts) WHERE name="districts"'); + db.run('UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM roads) WHERE name="roads"'); + db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM overpasses), 0) WHERE name="overpasses"'); + + if (waterBodies.length > 0) { + const stmtW = db.prepare(`INSERT INTO water_bodies (id, points_json, map_scale_multiplier) VALUES (?, ?, ?)`); + waterBodies.forEach(w => stmtW.run([w.id, w.points_json, w.map_scale_multiplier])); + stmtW.finalize(); + } + db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM water_bodies), 0) WHERE name="water_bodies"'); + + if (signs.length > 0) { + const stmtS = db.prepare(`INSERT INTO signs (id, text, x, y, z, rotation_y, font_size, font_family, image_url, use_tv_filter, lines, filter_intensity) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + signs.forEach(s => stmtS.run([s.id, s.text, s.x, s.y, s.z, s.rotation_y, s.font_size, s.font_family, s.image_url ?? null, s.use_tv_filter ?? 0, s.lines ?? null, s.filter_intensity ?? 1.0])); + stmtS.finalize(); + } + db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM signs), 0) WHERE name="signs"'); + + db.run('SELECT 1', () => { + // After the buildings are in, so a note is only restored onto a building the map + // actually has. + gmNotes.replaceAll(db, notes, () => { + // The loaded tokens are new here: no other system has seen them. + tokenVitals.pruneAfterMapChange(db, () => { + emitUpdate(); + res.json({ message: 'Map loaded successfully' }); + }); + }); + }); + }); + }); + }); + }); + + router.post('/clear', authenticate, (req, res) => { + // Wiping the world leaves no map loaded. + setActiveMapName(db, null); + db.serialize(() => { + // Preserve only live player rhombuses; enemy/friendly tokens are map content + db.run(`DELETE FROM locations WHERE shape IS NULL OR shape != 'rhombus'`); + db.run('DELETE FROM districts'); + db.run('DELETE FROM roads'); + db.run('DELETE FROM overpasses'); + db.run('DELETE FROM water_bodies'); + db.run('DELETE FROM signs'); + db.run('UPDATE sqlite_sequence SET seq = COALESCE((SELECT MAX(id) FROM locations), 0) WHERE name="locations"'); + db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="districts"'); + db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="roads"'); + db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="overpasses"'); + db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="water_bodies"'); + db.run('UPDATE sqlite_sequence SET seq = 0 WHERE name="signs"'); + // The id sequence was just wound back, so the next building made gets an id a + // cleared one had. Its notes must not be waiting for it. + gmNotes.pruneOrphans(db); + // Likewise a token's health saved under other systems. + tokenVitals.pruneAfterMapChange(db); + + db.run('SELECT 1', () => { + emitUpdate(); + res.json({ message: 'Map cleared completely' }); + }); + }); + }); + + router.delete('/:id', authenticate, (req, res) => { + db.run('DELETE FROM saved_maps WHERE id = ?', [req.params.id], function(err) { + if (err) return res.status(500).json({ error: err.message }); + res.json({ message: 'Map deleted' }); + }); + }); + + return router; +}; diff --git a/backend/routes/sheets.js b/backend/routes/sheets.js index c777970c..96de85e4 100644 --- a/backend/routes/sheets.js +++ b/backend/routes/sheets.js @@ -4,8 +4,11 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); -const { authenticate, optionalAuthenticate } = require('../middleware/auth'); +const { authenticate, authenticatePlayer, optionalAuthenticate } = require('../middleware/auth'); const { canReadNpcSheets, redactTokenCard } = require('../sheets/npcPrivacy'); +const bankAccounts = require('../bank/accounts'); +const tokenVitals = require('../tokens/vitals'); +const customSystems = require('../systemBuilder/runtime'); const { TEMPLATES, DEFAULT_SYSTEM, isValidSystem, getLinkedFields, applyDerived, cwnEffectiveAc, TOKEN_SOURCES, rangedAcOf, acColumns, @@ -63,30 +66,33 @@ module.exports = (db, io) => { router.get('/system', (req, res) => { getGameSystem((err, system) => { if (err) return res.status(500).json({ error: err.message }); - res.json({ system, systems: Object.entries(TEMPLATES).map(([id, t]) => ({ id, name: t.name })) }); + // Built-in systems, then published custom ones (systemBuilder/runtime.js). + res.json({ system, systems: [...Object.entries(TEMPLATES).map(([id, t]) => ({ id, name: t.name })), ...customSystems.list()] }); }); }); router.put('/system', authenticate, requireAdmin, (req, res) => { const { system } = req.body; if (!isValidSystem(system)) return res.status(400).json({ error: 'Unknown game system' }); - db.run( - `INSERT INTO global_settings (key, value) VALUES ('game_system', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, - [system], - (err) => { - if (err) return res.status(500).json({ error: err.message }); - /** - * The uploaded catalogues in memory belong to the system that was running a - * moment ago. Left alone they would price the new game's shops from the old - * game's list, so they are swapped before anybody is told the system changed. - */ - catalogueDb.refresh(db, system, () => { - io.emit('gameSystemChanged', { system }); - res.json({ message: 'Game system updated', system }); - }); - } - ); + // Tokens carry each system's health, defense and injuries: the swap and the setting change + // are one transaction (tokens/vitals.js). + tokenVitals.switchSystem(db, system, { defaultSystem: DEFAULT_SYSTEM }).then((switched) => { + /** + * The uploaded catalogues in memory belong to the system that was running a + * moment ago. Left alone they would price the new game's shops from the old + * game's list, so they are swapped before anybody is told the system changed. + */ + catalogueDb.refresh(db, system, () => { + io.emit('gameSystemChanged', { system }); + // Every token's health may have changed with it: have every screen redraw the map. + // Rhombus-only, so it does not mark the map as having unsaved changes. + if (switched.switched) io.emit('dataUpdated', { isRhombusOnly: true }); + res.json({ message: 'Game system updated', system }); + }); + }, (err) => { + console.error('[system switch]', err.message); + res.status(500).json({ error: 'Could not switch systems; nothing was changed' }); + }); }); // --- Admin sheet access --- @@ -104,7 +110,7 @@ module.exports = (db, io) => { }); // Player's own sheet — used by non-admin players to fetch stats (e.g. SR6 initiative roll). - router.get('/own', authenticate, (req, res) => { + router.get('/own', authenticatePlayer, (req, res) => { getGameSystem((err, system) => { if (err) return res.status(500).json({ error: err.message }); db.get( @@ -136,9 +142,9 @@ module.exports = (db, io) => { const done = () => res.json({ ...row, data }); const overlayCash = () => { if (!Object.values(linked).includes('bank_balance')) return done(); - db.get(`SELECT balance FROM player_banks WHERE username = ?`, [req.params.username], (e3, bank) => { + bankAccounts.get(db, req.params.username, system, (e3, account) => { Object.entries(linked).forEach(([fieldId, source]) => { - if (source === 'bank_balance') data[fieldId] = bank ? bank.balance : 0; + if (source === 'bank_balance') data[fieldId] = account ? account.balance : 0; }); done(); }); @@ -621,7 +627,7 @@ module.exports = (db, io) => { // Portrait upload — player uploads their own portrait; admin can upload // for any username via ?username= query param. - router.post('/portrait', authenticate, upload.single('portrait'), (req, res) => { + router.post('/portrait', authenticatePlayer, upload.single('portrait'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'portrait file required' }); const ext = path.extname(req.file.originalname).toLowerCase() || '.jpg'; const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.gif']; diff --git a/backend/routes/systems.js b/backend/routes/systems.js new file mode 100644 index 00000000..1b34aa87 --- /dev/null +++ b/backend/routes/systems.js @@ -0,0 +1,95 @@ +const express = require('express'); +const { authenticate, requireMainAdmin } = require('../middleware/auth'); +const store = require('../systemBuilder/store'); +const runtime = require('../systemBuilder/runtime'); + +// Custom game systems: the builder's storage (see systemBuilder/store.js). +// +// Main admin only, reading included. Building systems is the GM's (decided with the user, +// 2026-09-29), and a draft can hold the GM's unannounced rules. Granted editors pass +// `authenticate` but not `requireMainAdmin`. + +module.exports = (db) => { + const router = express.Router(); + // On every route rather than router.use, so the route walk in gm_route_auth.test.js sees them. + const gm = [authenticate, requireMainAdmin]; + + /** The status a store error carries, or 500. */ + const answer = (res, err, body) => { + if (!err) return res.json(body); + if (err.status) return res.status(err.status).json({ error: err.message, ...(err.problems ? { problems: err.problems } : {}) }); + console.error('[systems]', err.message); + return res.status(500).json({ error: 'Could not reach the systems store' }); + }; + + router.get('/', gm, (req, res) => store.listSystems(db, (err, systems) => answer(res, err, systems))); + + router.post('/', gm, (req, res) => { + const { name, definition } = req.body || {}; + store.createSystem(db, { name, definition }, (err, made) => answer(res, err, made)); + }); + + router.get('/:id', gm, (req, res) => store.getSystem(db, req.params.id, (err, sys) => answer(res, err, sys))); + + router.put('/:id/draft', gm, (req, res) => { + const { definition } = req.body || {}; + store.saveDraft(db, req.params.id, definition, (err, saved) => answer(res, err, saved)); + }); + + // Publishing and deleting change what the game can run, so the running copy is reloaded. + router.post('/:id/publish', gm, (req, res) => { + store.publishSystem(db, req.params.id, (err, done) => { + if (err) return answer(res, err); + runtime.refresh(db, req.params.id, () => answer(res, null, done)); + }); + }); + + // ─── Sharing as files (systemBuilder/citysys.js) ──────────────────────────── + + // A published system as a .citysys file to download. + router.get('/:id/export', gm, (req, res) => { + store.exportSystem(db, req.params.id, (err, file) => { + if (err) return answer(res, err); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${file.fileName}"`); + res.send(file.text); + }); + }); + + // What installing a file would do, changing nothing. The file arrives as text so its size is + // checked before it is parsed. + router.post('/install/preview', gm, (req, res) => { + store.previewInstall(db, req.body && req.body.file, (err, preview) => answer(res, err, preview)); + }); + + router.post('/install', gm, (req, res) => { + const { file, mode } = req.body || {}; + store.installSystem(db, file, mode, (err, installed) => { + if (err) return res.status(err.status || 500).json({ + error: err.status ? err.message : 'Could not reach the systems store', + ...(err.problems ? { problems: err.problems } : {}), + ...(err.installed ? { installed: err.installed } : {}), + }); + runtime.refresh(db, installed.id, () => answer(res, null, installed)); + }); + }); + + router.delete('/:id', gm, (req, res) => { + store.deleteSystem(db, req.params.id, (err) => { + if (err) return answer(res, err); + runtime.refresh(db, req.params.id, () => answer(res, null, { deleted: true })); + }); + }); + + /** + * What a published system's sheet is drawn from. Public: every player draws their own sheet. + * Layout and words only - never the formulas, lookups or rule names (systemBuilder/runtime.js). + */ + router.get('/render/:id', (req, res) => { + const render = runtime.render(req.params.id); + if (!render) return res.status(404).json({ error: 'No such published system' }); + res.json(render); + }); + + return router; +}; diff --git a/backend/server.js b/backend/server.js index 9b5ac207..ef25a35f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -14,6 +14,8 @@ const path = require('path'); const http = require('http'); const { Server } = require('socket.io'); const db = require('./db'); +// Published custom game systems, into memory for the running game (systemBuilder/runtime.js). +require('./systemBuilder/runtime').load(db); const app = express(); const server = http.createServer(app); @@ -62,6 +64,7 @@ app.use('/api/player', require('./routes/player')(db, io)); app.use('/api', require('./routes/admin')(db, io, helpers)); app.use('/api/music', require('./routes/music')(db, io)); app.use('/api/sheets', require('./routes/sheets')(db, io)); +app.use('/api/systems', require('./routes/systems')(db)); // Frontend static serving const frontendDist = path.join(__dirname, '../frontend/dist'); diff --git a/backend/sheets/npcTiers.js b/backend/sheets/npcTiers.js index 46cc10e6..17e43700 100644 --- a/backend/sheets/npcTiers.js +++ b/backend/sheets/npcTiers.js @@ -280,14 +280,26 @@ const TIERS = { }, }; -const getTierOptions = (system) => TIERS[system]?.options ?? []; +/** + * Published custom systems' tiers (systemBuilder/runtime.js), through a hook it sets, as + * templates.js does for sheet meta. Built-in systems are looked up first, so a custom + * system can never change what a built-in one generates. A custom tier may leave out HP + * or defense (null), which leaves the token's own value alone. + */ +let customTiers = () => null; +const setCustomTiers = (fn) => { customTiers = typeof fn === 'function' ? fn : () => null; }; + +const getTierOptions = (system) => TIERS[system]?.options ?? customTiers(system)?.options ?? []; // Returns { data, hp, dv } or null when the system has no tiers / unknown id. const buildTier = (system, tierId) => { const t = TIERS[system]; - if (!t) return null; + if (!t) { + const custom = customTiers(system); + return custom ? custom.build(tierId) : null; + } const id = t.build[tierId] ? tierId : t.default; return t.build[id] ? { tierId: id, ...t.build[id]() } : null; }; -module.exports = { TIERS, getTierOptions, buildTier }; +module.exports = { TIERS, getTierOptions, buildTier, setCustomTiers }; diff --git a/backend/sheets/templates.js b/backend/sheets/templates.js index 64f1228c..77b7fbc6 100644 --- a/backend/sheets/templates.js +++ b/backend/sheets/templates.js @@ -325,11 +325,24 @@ const TEMPLATES = { const DEFAULT_SYSTEM = 'generic'; -const isValidSystem = (system) => Object.prototype.hasOwnProperty.call(TEMPLATES, system); +const isBuiltIn = (system) => Object.prototype.hasOwnProperty.call(TEMPLATES, system); + +/** + * Published custom systems (systemBuilder/runtime.js), looked up through a hook it sets: + * requiring it here would make a circle. Built-in systems are always found first, so nothing + * a custom system does can change how a built-in one behaves. + */ +let customMeta = () => null; +const setCustomMeta = (fn) => { customMeta = typeof fn === 'function' ? fn : () => null; }; + +/** A system's meta: built-in, published custom, or the default's for anything unknown. */ +const metaFor = (system) => (isBuiltIn(system) ? TEMPLATES[system] : customMeta(system) || TEMPLATES[DEFAULT_SYSTEM]); + +const isValidSystem = (system) => isBuiltIn(system) || !!customMeta(system); // Strip a sheet's data down to what non-owners may see. const filterPublicData = (system, data) => { - const meta = TEMPLATES[system] || TEMPLATES[DEFAULT_SYSTEM]; + const meta = metaFor(system); const parsed = typeof data === 'string' ? JSON.parse(data || '{}') : (data || {}); const out = {}; meta.publicFields.forEach((f) => { @@ -391,12 +404,17 @@ const acColumns = (linked, fields) => { return sets.length ? { sets: sets.join(', '), values } : null; }; -const getLinkedFields = (system) => - (TEMPLATES[system] || TEMPLATES[DEFAULT_SYSTEM]).linkedFields || {}; +const getLinkedFields = (system) => metaFor(system).linkedFields || {}; + +/** + * May a character's owner change this field themselves? Everything, except what a custom + * system marks as the GM's to set (XP, awarded items). The built-in sheets mark nothing, so + * this is always true for them. The GM's own edits do not ask. + */ +const playerMayEdit = (system, fieldId) => !(metaFor(system).gmFields || []).includes(fieldId); // Returns a map of maxFieldId → currentFieldId for the system. -const getMaxPairs = (system) => - (TEMPLATES[system] || TEMPLATES[DEFAULT_SYSTEM]).maxPairs || {}; +const getMaxPairs = (system) => metaFor(system).maxPairs || {}; // Recompute derived fields after a write. Mutates data; returns the ids of // fields it changed (empty when the changed field derives nothing). @@ -405,7 +423,7 @@ const getMaxPairs = (system) => // - recompute: whole-sheet function for systems whose derived fields have // multiple sources (CWN mods, saves, effort maxes) const applyDerived = (system, data, changedFieldId) => { - const meta = TEMPLATES[system] || TEMPLATES[DEFAULT_SYSTEM]; + const meta = metaFor(system); const changed = []; const rule = (meta.derived || {})[changedFieldId]; if (rule) { @@ -420,7 +438,7 @@ const applyDerived = (system, data, changedFieldId) => { }; module.exports = { - TEMPLATES, DEFAULT_SYSTEM, isValidSystem, filterPublicData, getLinkedFields, getMaxPairs, + TEMPLATES, DEFAULT_SYSTEM, isValidSystem, isBuiltIn, metaFor, setCustomMeta, filterPublicData, getLinkedFields, getMaxPairs, playerMayEdit, applyDerived, cwnEffectiveAc, cwnImplantAc, cwnMoveBonus, CWN_BASE_MOVE, TOKEN_SOURCES, rangedAcOf, acColumns, }; diff --git a/backend/sockets/index.js b/backend/sockets/index.js index 029ce456..54c3db43 100644 --- a/backend/sockets/index.js +++ b/backend/sockets/index.js @@ -1,4 +1,6 @@ const jwt = require('jsonwebtoken'); +const { isMainAdmin } = require('../middleware/auth'); +const bank = require('../bank/accounts'); const { cryptoRng } = require('../utils/random'); const { registerInitiativeHandlers } = require('./initiative'); const sheetTemplates = require('../sheets/templates'); @@ -15,6 +17,8 @@ const awardXpModule = require('../sheets/awardXp'); const tokenControl = require('./tokenControl'); const attackSr6 = require('../sheets/attackSr6'); const npcTiers = require('../sheets/npcTiers'); +const customSystems = require('../systemBuilder/runtime'); +const { healthView } = require('../systemBuilder/healthView'); const headshots = require('../sheets/headshots'); const identity = require('../sheets/identity'); const vehicleState = require('../sheets/vehicleState'); @@ -128,6 +132,20 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { return !!info && (info.isAdmin || elevatedUsers.has(info.userName)); }; + /** + * Send to every connection signed in as `username`, and to nobody else. + * + * For what only that person may see. A grant of editor rights carries a working token, and + * it used to go out with io.emit: every connected client received it, and any of them could + * copy it and act as the editor. The client only ever used the one addressed to itself, so + * sending it there alone changes nothing for the person being granted. + */ + const emitToUser = (username, event, data) => { + userSockets.forEach((info, id) => { + if (info && info.userName === username) io.to(id).emit(event, data); + }); + }; + const buildActiveUsers = () => { const userMap = new Map(); userSockets.forEach((info) => { @@ -143,18 +161,23 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { io.emit('activeUsersUpdated', buildActiveUsers()); }; + /** + * Tell everyone a player's balance, from their account in the running system (bank/accounts.js: + * one per player per system). An account that does not exist yet is opened at zero, as before. + */ const sendBankUpdate = (username) => { - db.get('SELECT balance, debt, first_pay_done, high_roller_done FROM player_banks WHERE username = ?', [username], (err, row) => { - if (!err && row) { + bank.activeSystem(db, (sErr, system) => { + if (sErr) return; + bank.ensure(db, username, system, (err, row) => { + if (err || !row) return; io.emit('bankUpdate', { username, balance: row.balance, debt: row.debt, firstPayDone: !!row.first_pay_done, highRollerDone: !!row.high_roller_done }); - } else if (!err && !row) { - db.run('INSERT INTO player_banks (username, balance, debt) VALUES (?, 0, 0)', [username], () => { - io.emit('bankUpdate', { username, balance: 0, debt: 0, firstPayDone: false, highRollerDone: false }); - }); - } + }); }); }; + /** Run `fn(system)` with the running system, the one whose accounts money moves in. */ + const withBankSystem = (fn) => bank.activeSystem(db, (err, system) => { if (!err) fn(system); }); + // Load NPCs from DB on startup db.all('SELECT username, isActive FROM fake_users', (err, rows) => { if (err) { console.error('Error loading fake_users:', err.message); return; } @@ -218,7 +241,9 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (info.isAdmin && info.token) { try { const verified = jwt.verify(info.token, SECRET); - if (verified.isTemporary) info.isAdmin = false; + // The GM's own login only. A player's login token verifies too, and used to pass + // here as "not temporary", which made any player a socket admin. + if (!isMainAdmin(verified)) info.isAdmin = false; } catch (err) { console.warn(`User ${info.userName} claimed admin but provided invalid token.`); info.isAdmin = false; @@ -320,11 +345,11 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('grantElevatedAccess', (data) => { try { const verified = jwt.verify(data.adminToken, SECRET); - if (verified && !verified.isTemporary) { + if (isMainAdmin(verified)) { elevatedUsers.add(data.targetUser); const tempToken = jwt.sign({ username: data.targetUser, isTemporary: true }, SECRET, { expiresIn: '12h' }); console.log(`Admin ${verified.username} granted temporary access to ${data.targetUser}`); - io.emit('accessGranted', { targetUser: data.targetUser, token: tempToken }); + emitToUser(data.targetUser, 'accessGranted', { targetUser: data.targetUser, token: tempToken }); broadcastActiveUsers(); } } catch (err) { console.warn('Unauthorized attempt to grant access:', err.message); } @@ -333,7 +358,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('revokeElevatedAccess', (data) => { try { const verified = jwt.verify(data.adminToken, SECRET); - if (verified && !verified.isTemporary) { + if (isMainAdmin(verified)) { elevatedUsers.delete(data.targetUser); console.log(`Admin ${verified.username} revoked temporary access from ${data.targetUser}`); io.emit('accessRevoked', { targetUser: data.targetUser }); @@ -357,7 +382,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('createNPC', (data) => { try { const verified = jwt.verify(data.adminToken, SECRET); - if (verified && !verified.isTemporary) { + if (isMainAdmin(verified)) { db.run('INSERT INTO fake_users (username, isActive) VALUES (?, 1)', [data.npcName], function(err) { if (!err) { activeNPCs.push({ userName: data.npcName, isActive: true }); @@ -371,7 +396,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('toggleNPCStatus', (data) => { try { const verified = jwt.verify(data.adminToken, SECRET); - if (verified && !verified.isTemporary) { + if (isMainAdmin(verified)) { db.run('UPDATE fake_users SET isActive = ? WHERE username = ?', [data.isActive ? 1 : 0, data.npcName], function(err) { if (!err) { const npc = activeNPCs.find(n => n.userName === data.npcName); @@ -385,7 +410,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('deleteNPC', (data) => { try { const verified = jwt.verify(data.adminToken, SECRET); - if (verified && !verified.isTemporary) { + if (isMainAdmin(verified)) { db.run('DELETE FROM fake_users WHERE username = ?', [data.npcName], function(err) { if (!err) { activeNPCs = activeNPCs.filter(n => n.userName !== data.npcName); @@ -441,16 +466,20 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('requestEditing', (data) => { io.emit('editingRequested', data); }); + // Approving, denying and ending an edit are the GM's (or a granted editor's) buttons in the + // admin panel. They had no check, so any player could send approveEditing for themselves and + // become an editor; the connection's verified sign-in now decides. socket.on('approveEditing', (data) => { + if (!isAdminSocket(socket) || !data || !data.userId) return; elevatedUsers.add(data.userId); const tempToken = jwt.sign({ username: data.userId, isTemporary: true }, SECRET, { expiresIn: '12h' }); - io.emit('accessGranted', { targetUser: data.userId, token: tempToken, forEditing: true }); + emitToUser(data.userId, 'accessGranted', { targetUser: data.userId, token: tempToken, forEditing: true }); io.emit('editingStarted', data); io.emit('editingApproved', data); }); - socket.on('denyEditing', (data) => { io.emit('editingDenied', data); }); - socket.on('revokeEditing', (data) => { elevatedUsers.delete(data.userId); io.emit('editingStopped'); io.emit('editingRevoked', data); broadcastActiveUsers(); }); + socket.on('denyEditing', (data) => { if (!isAdminSocket(socket)) return; io.emit('editingDenied', data); }); + socket.on('revokeEditing', (data) => { if (!isAdminSocket(socket) || !data) return; elevatedUsers.delete(data.userId); io.emit('editingStopped'); io.emit('editingRevoked', data); broadcastActiveUsers(); }); socket.on('editingFinished', (data) => { if (data?.userId) elevatedUsers.delete(data.userId); io.emit('editingStopped'); }); socket.on('requestRhombusPurge', (data) => { @@ -779,7 +808,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('purgeDiceHistory', (data) => { if (!data.token) return; jwt.verify(data.token, SECRET, (err, decoded) => { - if (err || decoded.isTemporary) return; + if (err || !isMainAdmin(decoded)) return; db.run('DELETE FROM dice_rolls', (err) => { if (err) console.error('Error purging dice rolls:', err); io.emit('diceRollHistory', []); @@ -822,7 +851,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { }; // Linked fields (declared per-template) live in other systems: token HP - // in locations, cash in player_banks. Overlay their live values onto the + // in locations, cash in bank_accounts. Overlay their live values onto the // sheet data at read time - they are never stored in the sheet's JSON. const overlayLinkedData = (username, system, data, cb) => { const linked = sheetTemplates.getLinkedFields(system); @@ -854,9 +883,10 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (source === 'token_ac_ranged') out[fieldId] = tokenRow ? sheetTemplates.rangedAcOf(tokenRow) : null; }); if (!wantsCash) return done(out); - db.get(`SELECT balance FROM player_banks WHERE username = ?`, [username], (err, bank) => { + // The account in this sheet's own system: a sheet shows the money of its game. + bank.get(db, username, system, (err, account) => { Object.entries(linked).forEach(([fieldId, source]) => { - if (source === 'bank_balance') out[fieldId] = bank ? bank.balance : 0; + if (source === 'bank_balance') out[fieldId] = account ? account.balance : 0; }); done(out); }); @@ -1228,6 +1258,9 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!payload || typeof payload.fieldId !== 'string') return; getGameSystem((err, system) => { if (err) return; + // A value the system gives the GM to set (a custom system's XP, say): the owner + // sees it but cannot change it. The GM editing their own sheet still can. + if (!sheetTemplates.playerMayEdit(system, payload.fieldId) && !isAdminSocket(socket)) return; // Linked fields are owned by other systems (token HP, bank) - never // stored in sheet JSON. The AC links are the writable ones: a sheet edit // routes to the player's token, keeping the token the source of truth. @@ -1336,8 +1369,12 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { getGameSystem((err, system) => { if (err) return; const linked = sheetTemplates.getLinkedFields(system); + // The GM's values are left out of a player's import or batch edit, as from a single + // edit: uploading a sheet does not set your own XP. + const gm = isAdminSocket(socket); const entries = Object.entries(payload.fields) - .filter(([k, v]) => !linked[k] && (typeof v === 'string' || typeof v === 'number')); + .filter(([k, v]) => !linked[k] && (gm || sheetTemplates.playerMayEdit(system, k)) + && (typeof v === 'string' || typeof v === 'number')); if (entries.length === 0 && !cyber) return; // Through the queue: an import replaces the whole sheet, so a concurrent edit // does not merely lose a field, it disappears entirely. The occupancy carried @@ -1708,6 +1745,42 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { }); }); + // A token's health under a custom system's model, for its HEALTH folder (systemBuilder/ + // healthView.js). The full view, numbers and notes, goes to whoever may change that + // token's health: the GM, a player granted editing, or the token's owner. Everyone else + // gets the description. Over the socket rather than a route because the socket is where a + // player's identity is known in every mode; a player in non-secure mode has no login to + // send with a request. Answers only the asker. + socket.on('requestHealthView', (data) => { + const info = userSockets.get(socket.id); + if (!info || !data || !Number.isInteger(Number(data.location_id))) return; + const locationId = Number(data.location_id); + getGameSystem((err, system) => { + if (err) return; + const health = customSystems.health(system); + const reply = (view) => socket.emit('healthView', { location_id: locationId, ...view }); + if (!health) return reply({ model: null }); + db.get(`SELECT id, shape, owner, hp_current, hp_max, hp_temp FROM locations WHERE id = ?`, [locationId], (e2, loc) => { + if (e2 || !loc || !RHOMBUS_SHAPES.includes(loc.shape)) return; + const playerToken = loc.shape === 'rhombus' && !!loc.owner; + const full = isAdminSocket(socket) || (playerToken && loc.owner === info.userName); + const send = (row) => { + let sheet = {}; + try { sheet = row ? JSON.parse(row.data || '{}') : {}; } catch { sheet = {}; } + reply(healthView(health, { current: loc.hp_current, max: loc.hp_max, temp: loc.hp_temp }, sheet, + { full, hpWord: customSystems.wordIn(system, 'hp', 'short', 'HP') })); + }; + if (playerToken) { + db.get(`SELECT data FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0`, + [loc.owner, system], (e3, row) => send(e3 ? null : row)); + } else { + db.get(`SELECT cs.data FROM npc_sheet_links l JOIN character_sheets cs ON cs.id = l.sheet_id + WHERE l.location_id = ? AND cs.system = ?`, [loc.id, system], (e3, row) => send(e3 ? null : row)); + } + }); + }); + }); + // 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. @@ -1767,9 +1840,11 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { }); } else if (tier) { // Other systems (CWN): the tier's own defense values stand - no - // CP:R melee-DV formula, no take-10 house rule. + // CP:R melee-DV formula, no take-10 house rule. A custom system's tier may + // leave HP or defense out (null), which keeps the token's own. db.run( - `UPDATE locations SET hp_current = ?, hp_max = ?, melee_ac = ?, ranged_ac = ? WHERE id = ?`, + `UPDATE locations SET hp_current = COALESCE(?, hp_current), hp_max = COALESCE(?, hp_max), + melee_ac = COALESCE(?, melee_ac), ranged_ac = COALESCE(?, ranged_ac) WHERE id = ?`, [tier.hp, tier.hp, tier.dv.melee, tier.dv.ranged, location_id], () => { emitUpdate({ isRhombusOnly: true }); insertSheet(); } ); @@ -1782,12 +1857,12 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('markFirstPayDone', (data) => { if (!data || !data.username) return; - db.run('UPDATE player_banks SET first_pay_done = 1 WHERE username = ?', [data.username]); + withBankSystem((system) => bank.markFlag(db, data.username, system, 'first_pay_done', () => {})); }); socket.on('markHighRollerDone', (data) => { if (!data || !data.username) return; - db.run('UPDATE player_banks SET high_roller_done = 1 WHERE username = ?', [data.username]); + withBankSystem((system) => bank.markFlag(db, data.username, system, 'high_roller_done', () => {})); }); /** @@ -1816,9 +1891,9 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!username || !data || !data.amount) return; const amount = parseFloat(data.amount); if (isNaN(amount) || amount <= 0) return; - db.run('UPDATE player_banks SET balance = balance - ? WHERE username = ?', [amount, username], (err) => { + withBankSystem((system) => bank.adjust(db, username, system, { balance: -amount }, (err) => { if (!err) sendBankUpdate(username); - }); + })); }); socket.on('borrowFunds', (data) => { @@ -1826,9 +1901,9 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!username || !data || !data.amount) return; const amount = parseFloat(data.amount); if (isNaN(amount) || amount <= 0) return; - db.run('UPDATE player_banks SET debt = debt + ? WHERE username = ?', [amount, username], (err) => { + withBankSystem((system) => bank.adjust(db, username, system, { debt: amount }, (err) => { if (!err) sendBankUpdate(username); - }); + })); }); socket.on('payDebt', (data) => { @@ -1836,15 +1911,15 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!username || !data || !data.amount) return; let amount = parseFloat(data.amount); if (isNaN(amount) || amount <= 0) return; - db.get('SELECT balance, debt FROM player_banks WHERE username = ?', [username], (err, row) => { + withBankSystem((system) => bank.get(db, username, system, (err, row) => { if (err || !row) return; if (amount > row.balance) amount = row.balance; if (amount > row.debt) amount = row.debt; if (amount <= 0) return; - db.run('UPDATE player_banks SET balance = balance - ?, debt = debt - ? WHERE username = ?', [amount, amount, username], (err2) => { + bank.adjust(db, username, system, { balance: -amount, debt: -amount }, (err2) => { if (!err2) sendBankUpdate(username); }); - }); + })); }); /** @@ -1912,18 +1987,16 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!sale.ok) return refuse(sale.reason, { itemId: sale.itemId }); } - db.get( - 'SELECT balance, debt FROM player_banks WHERE username = ?', - [username], - (bErr, bank) => { + bank.get(db, username, system, + (bErr, account) => { if (bErr) return refuse('no_account'); const plan = shopCheckout.planCheckout({ buys: data.buys, priceOf: shopPrices.priceOf, shelved: catalogues, sale, - balance: bank ? Number(bank.balance) || 0 : 0, - debt: bank ? Number(bank.debt) || 0 : 0, + balance: account ? Number(account.balance) || 0 : 0, + debt: account ? Number(account.debt) || 0 : 0, overdraftAllowed, settle: data.settle, expectedNet: data.expectedNet, @@ -1934,12 +2007,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { } const payBank = () => { - const write = bank - ? ['UPDATE player_banks SET balance = ?, debt = ? WHERE username = ?', - [plan.balance, plan.debt, username]] - : ['INSERT INTO player_banks (username, balance, debt) VALUES (?, ?, ?)', - [username, plan.balance, plan.debt]]; - db.run(write[0], write[1], (wErr) => { + bank.put(db, username, system, plan.balance, plan.debt, (wErr) => { if (wErr) return refuse('write'); sendBankUpdate(username); if (sale) io.emit('sheetUpdated', { username, system }); @@ -2069,20 +2137,14 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!data || !data.token || !Array.isArray(data.usernames) || data.totalAmount === undefined) return; jwt.verify(data.token, SECRET, (err, decoded) => { if (err) return; - if (decoded.isTemporary || (decoded.role && decoded.role !== 'admin')) return; + if (!isMainAdmin(decoded)) return; const count = data.usernames.length; if (count === 0) return; const amountPerPlayer = Math.ceil((parseFloat(data.totalAmount) / count) * 100) / 100; if (isNaN(amountPerPlayer) || amountPerPlayer <= 0) return; - data.usernames.forEach(uname => { - db.get('SELECT username FROM player_banks WHERE username = ?', [uname], (err, row) => { - if (row) { - db.run('UPDATE player_banks SET balance = COALESCE(balance, 0) + ? WHERE username = ?', [amountPerPlayer, uname], () => sendBankUpdate(uname)); - } else { - db.run('INSERT INTO player_banks (username, balance, debt) VALUES (?, ?, 0)', [uname, amountPerPlayer], () => sendBankUpdate(uname)); - } - }); - }); + withBankSystem((system) => data.usernames.forEach((uname) => { + bank.addToBalance(db, uname, system, amountPerPlayer, () => sendBankUpdate(uname)); + })); }); }); @@ -2098,7 +2160,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!data || !data.token || !Array.isArray(data.usernames)) return; jwt.verify(data.token, SECRET, (err, decoded) => { if (err) return; - if (decoded.isTemporary || (decoded.role && decoded.role !== 'admin')) return; + if (!isMainAdmin(decoded)) return; getGameSystem((sysErr, system) => { if (sysErr) return; // Which column the table advances on, so the award can carry the level with it. @@ -2130,7 +2192,7 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (!data || !data.token || !Array.isArray(data.usernames)) return; jwt.verify(data.token, SECRET, (err, decoded) => { if (err) return; - if (decoded.isTemporary || (decoded.role && decoded.role !== 'admin')) return; + if (!isMainAdmin(decoded)) return; getGameSystem((sysErr, system) => { if (sysErr) return; awardXpModule.adjustLevel(db, { system, usernames: data.usernames, delta: data.delta }, (reason, results) => { @@ -2147,17 +2209,11 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { socket.on('adminUpdateBank', (data) => { if (!data || !data.token || !data.username) return; jwt.verify(data.token, SECRET, (err, decoded) => { - if (err || decoded.isTemporary) return; + if (err || !isMainAdmin(decoded)) return; const balance = parseFloat(data.balance); const debt = parseFloat(data.debt); if (isNaN(balance) || isNaN(debt)) return; - db.get('SELECT username FROM player_banks WHERE username = ?', [data.username], (err2, row) => { - if (row) { - db.run('UPDATE player_banks SET balance = ?, debt = ? WHERE username = ?', [balance, debt, data.username], () => sendBankUpdate(data.username)); - } else { - db.run('INSERT INTO player_banks (username, balance, debt) VALUES (?, ?, ?)', [data.username, balance, debt], () => sendBankUpdate(data.username)); - } - }); + withBankSystem((system) => bank.put(db, data.username, system, balance, debt, () => sendBankUpdate(data.username))); }); }); diff --git a/backend/sockets/initiative.js b/backend/sockets/initiative.js index 2d237847..14a2c267 100644 --- a/backend/sockets/initiative.js +++ b/backend/sockets/initiative.js @@ -1,4 +1,8 @@ const { cryptoRng } = require('../utils/random'); +const customSystems = require('../systemBuilder/runtime'); + +/** 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(); // Initiative Tracker — socket event handlers // All events namespaced under initiative:* to avoid collisions. @@ -228,7 +232,7 @@ function registerInitiativeHandlers(io, db) { (err) => { if (err) return; broadcastScene(io, db, sceneKey); - logRoll(io, db, combatant); + logRoll(io, db, combatant, system); } ); } else { @@ -254,7 +258,7 @@ function registerInitiativeHandlers(io, db) { (err) => { if (err) return; broadcastScene(io, db, sceneKey); - logRoll(io, db, combatant); + logRoll(io, db, combatant, system); } ); } @@ -268,7 +272,8 @@ function registerInitiativeHandlers(io, db) { if (!sceneKey || score === undefined) return; db.get( - `SELECT s.sides, s.combatants FROM initiative_scene s WHERE s.scene_key = ?`, + `SELECT s.sides, s.combatants, c.system FROM initiative_scene s + LEFT JOIN initiative_combat c ON c.id = s.combat_id WHERE s.scene_key = ?`, [sceneKey], (err, row) => { if (err || !row) return; @@ -287,8 +292,8 @@ function registerInitiativeHandlers(io, db) { broadcastScene(io, db, sceneKey); // Log to dice tray const historyString = breakdown - ? `NPC SIDE INITIATIVE: ${breakdown}` - : `NPC SIDE rolled INITIATIVE [${score}]`; + ? `NPC SIDE ${initiativeWord(row.system)}: ${breakdown}` + : `NPC SIDE rolled ${initiativeWord(row.system)} [${score}]`; const results = diceResults || { 8: [score] }; db.run( `INSERT INTO dice_rolls (username, total, results, color, historyString) VALUES (?, ?, ?, ?, ?)`, @@ -547,11 +552,11 @@ function registerInitiativeHandlers(io, db) { } // ── Shared helper: log a roll to dice tray ──────────────────────────────────── -function logRoll(io, db, combatant) { +function logRoll(io, db, combatant, system) { const explodSuffix = combatant.exploded ? ' 💥EXPLOD' : ''; const historyString = combatant.breakdown - ? `${combatant.name} INITIATIVE: ${combatant.breakdown}${explodSuffix}` - : `${combatant.name} rolled INITIATIVE [${combatant.score}]${explodSuffix}`; + ? `${combatant.name} ${initiativeWord(system)}: ${combatant.breakdown}${explodSuffix}` + : `${combatant.name} rolled ${initiativeWord(system)} [${combatant.score}]${explodSuffix}`; const results = combatant.diceResults || { 20: [combatant.score] }; const resultsJson = JSON.stringify(results); db.run( diff --git a/backend/startup/backup.js b/backend/startup/backup.js new file mode 100644 index 00000000..3f2ee723 --- /dev/null +++ b/backend/startup/backup.js @@ -0,0 +1,50 @@ +// A copy of the whole database, taken before a migration changes real data. +// +// `VACUUM INTO` writes a complete, consistent copy while the database stays open, which a +// plain file copy of a live database cannot promise. It is only attempted when the disk has +// room: the copy is as large as the database, and running a disk out of space is exactly how +// a delete once took the whole server down (SQLITE_FULL, 1.14.3). With too little room the +// migration still has its own safety net - the tables it moves data out of are left as they +// were - and the log says plainly that no copy was made. + +const fs = require('fs'); +const path = require('path'); + +/** Room to leave free beyond the copy itself, so the copy never fills the disk. */ +const HEADROOM = 50 * 1024 * 1024; + +/** Free bytes on the disk holding `dir`, or null when the platform cannot say. */ +const freeBytes = (dir) => { + try { + const s = fs.statfsSync(dir); + return Number(s.bavail) * Number(s.bsize); + } catch { + return null; + } +}; + +/** `city.db` → `city.db.before-