23 Commits

Author SHA1 Message Date
8be7e5607e Merge pull request 'feat(rust): Guarded Crate — feed, map, Warden, Place a guarded crate (PLAN_REDESIGNS §14.3.5)' (#48) from feat/guarded-crate into edge
Reviewed-on: #48
2026-10-15 23:19:14 +00:00
da1d74bdff feat(rust): Guarded Crate — feed, map, Warden, Place a guarded crate (PLAN_REDESIGNS §14.3.5)
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 34s
PR Checks / frozen-manifest (pull_request) Successful in -1m34s
Stage 6 of the §14.3 integration plan, on protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.9.

- gc.started and gc.ended public, gc.failed staff's; guardedCrate.js reads
  each row per viewer (the clearer's name for the presence audience, Steam id,
  position and key for staff, `won` for everyone).
- Triggers rust.gc.started and rust.gc.ended (a crate a player cleared only).
- The step rust.gc.place: an event from rust.options.gc_events and a world
  call's location; refused at verify for Guarded Crate absent, NpcSpawn missing
  (D364), console unavailable, or an unknown event. Kept (D373); the run log's
  success line says so (D380). Budget rust.gc.crates.
- Warden (D375): guarded_won, title category guardedwon, Crates cleared column.
- Map marker, server settings line, swagger, engagement manifest, bundle, README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 12:26:50 -05:00
140bc67fe1 Merge pull request 'feat(rust): Dangerous Treasures — feed, map, Treasure Hunter, Start a Dangerous Treasures event (PLAN_REDESIGNS §14.3.4)' (#47) from feat/dangerous-treasures into edge
Reviewed-on: #47
2026-10-15 12:04:31 +00:00
bfe9b5b18d feat(rust): Dangerous Treasures — feed, map, Treasure Hunter, Start a Dangerous Treasures event (PLAN_REDESIGNS §14.3.4)
All checks were successful
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in 46s
PR Checks / server-tests (pull_request) Successful in 7m23s
Stage 5 of the §14.3 integration plan, on protocol 14 (docs
rust-link/PROTOCOL.md §20.8).

- dt.started and dt.won are public kinds, dt.ended a staff kind. The new
  dangerousTreasures.js reads each row per viewer: grid and invaderCount for
  everyone, the winner's name for the presence audience (D376), the position,
  the step's key, the winner's Steam id and the invaders for staff.
- Feed lines in the World events filter: "A Dangerous Treasures event has
  started · K12", "Alice won the treasure · K12 · 3 players fought for it".
- Triggers rust.dt.started (with byEvent) and rust.dt.won, public, cooldown
  subject the server, names only.
- eventDangerousTreasures.js: rust.dt.start, "Start a Dangerous Treasures
  event" (risk change, reversible none, budget rust.dt.events), server and
  difficulty. Refused at verify where the server said DT is not loaded or
  console.plugins is false (Rust-Plugins#34). dt-not-ready answers "Dangerous
  Treasures is not ready yet" with retryAfterMs 600000 (D365, D371);
  dt-no-spot retries on core's delay; DT's own refusals are permanent.
- Treasure Hunter (D375): treasuresWon -> treasures_won, the title category
  treasureswon, and a sortable Treasures column shown where the server
  reports DT or a row counts one.
- The map draws a running chest on the world layer; the server settings'
  plugin line names DT and whether a step can start one, from the
  integrations read's new `console`.
- coreApi ^1.15.0, ci/core-ref.json pinned to website main at #215's merge.
  ci/bundle.json ships the two new files; swagger fragment and engagement
  manifest regenerated; README lists DT.

Server 627/627 (16 new in test/dangerousTreasures.test.js) and every check;
client 92/92, build and externals. Booted against the pinned core: 45
triggers, 13 actions, 11 budgets, 21 option sources; 89 routes unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 06:45:30 -05:00
6802dbf5f9 Merge pull request 'feat(rust): Dynamic PVP's zones on the feed and the map (PLAN_REDESIGNS §14.3.3)' (#46) from feat/dynamic-pvp into edge
Reviewed-on: #46
2026-10-15 11:17:59 +00:00
549b3a5ffd feat(rust): Dynamic PVP's zones on the feed and the map (PLAN_REDESIGNS §14.3.3)
All checks were successful
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 31s
PR Checks / frozen-manifest (pull_request) Successful in 44s
Stage 4 of the §14.3 integration plan, on protocol 14 (docs
rust-link/PROTOCOL.md §20.7).

- pvp.zone.created is a public kind, pvp.zone.deleted a staff kind. Each row
  is read per viewer by the new dynamicPvp.js: grid for everyone, the event's
  name only for Dynamic PVP's own vanilla events, position and zone id for
  staff only.
- The feed's world filter carries the opening: "A PvP zone opened around the
  downed patrol helicopter · G7 · for 10 min". It names nobody.
- The map gains a fifth layer, `pvp` (PvP zones), public by default with its
  own audience in Admin → Rust visibility, drawn as a red ring with its
  event's name.
- Server settings' plugin line names Dynamic PVP; the swagger schemas and
  route descriptions carry the layer and the integration (fragment
  regenerated); ci/bundle.json ships dynamicPvp.js; README lists it.

Tests: server 611/611 (dynamicPvp.test.js new, catalogue and map extended)
and every check; client 89/89 (feed and map tooltip), build and externals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 06:12:07 -05:00
b5f7fa0f13 Merge pull request 'feat(rust): TruePVE in the zone step — no PvP protection, or a ruleset (PLAN_REDESIGNS §14.3.2)' (#45) from feat/truepve-zone into edge
Reviewed-on: #45
2026-10-15 11:06:11 +00:00
e3cc95f8ab feat(rust): TruePVE in the zone step — no PvP protection, or a ruleset (PLAN_REDESIGNS §14.3.2)
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 7m23s
Stage 3 of the §14.3 integration plan (D362, D368), on protocol 14 (docs
rust-link/PROTOCOL.md §20.6).

- rust.zone.open gains noPvpProtection (boolean) and pvpRuleset (string),
  sent as world.zone's truePve { exclude: true } or { ruleset }.
- Both set, a name over 64 characters, or a server whose hello says TruePVE
  is not loaded is refused for good, at verify too. A server that has not
  said is left to the plugin, as a dome is.
- truepve-missing and truepve-ruleset are permanent failures, with the
  plugin's words.
- The zone servers list carries each server's truePve from hello; Server
  settings' plugin line says whether TruePVE is loaded; the integrations
  schema documents it (swagger fragment regenerated).
- README lists TruePVE among the optional plugins.

Tests: five new in zones.test.js; server 603/603 and all checks, client
84/84 and the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 05:48:00 -05:00
4af731288a feat: TruePVE in the zone step (PLAN_REDESIGNS §14.3.2, WIP)
Stage 3 of the §14.3 integration plan (D362, D368): world.zone's optional
truePve ({exclude: true} or {ruleset}), all or nothing, unmapped on every
path that erases one of our zones, with truepve.json for erases while
TruePVE is away. Docs and PRs still to come.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 03:03:36 -05:00
176ded67a8 Merge pull request 'feat(rust): Rust's own world events — feed, credit rule, triggers, Call a world event (PLAN_REDESIGNS §14.3.1)' (#44) from feat/world-events into edge
Reviewed-on: #44
2026-10-15 07:19:10 +00:00
e7d5c8ed1a feat(rust): Rust's own world events — feed, credit rule, triggers, Call a world event (PLAN_REDESIGNS §14.3.1)
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 36s
PR Checks / frozen-manifest (pull_request) Successful in -1m32s
The site half of stage 1, on protocol 14:

- server/worldEvents.js: the 24 types, D372's four credit rules, the grid
  from the map's geometry (none off the map), and the per-viewer reading.
- The feed serves `world.event` (a public kind): an unknown type is staff's;
  the event and its grid are public; names only for the presence audience
  (D376); Steam ids and positions only for staff (D353). A downed one carries
  `credited`/`creditedCount` by the server's rule. Client lines for all 24
  types and a World events filter.
- rust_servers.credit_rule (blow, last, most, all; default blow), set on the
  server's settings. Ingest counts heli_kills (patrol helicopters and
  Chinooks, D379) and apc_kills from `world.event` by that rule, so the line
  and Rotorbane / Tankbreaker agree. A protocol-13 tally's heliKills/apcKills
  still add as before.
- The tally's cratesHacked / dropsLooted → crates_hacked / drops_looted;
  title categories Safecracker and Scavenger (D356); leaderboard columns
  Crates hacked and Drops looted, sortable (D377).
- 24 engagement triggers rust.world.<event> (D355): ceiling everyone,
  cooldown subject the server and event (crates add where), names only.
- server/eventWorldCall.js: rust.world.call, "Call a world event"
  (risk change, reversible none, budget rust.world.calls). A position for an
  airdrop or a patrol helicopter only; the Chinook takes none (D378).

Server 585/585 (22 new in test/worldEvents.test.js), client 84/84, every
check; booted against the pinned core (43 triggers, 12 actions, 10 budgets,
20 option sources), routes.manifest unchanged at 89. Engagement manifest and
swagger fragment regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 00:39:00 -05:00
f65d96ebb6 Merge pull request 'chore(ci): pin the core to website main at #213's merge' (#43) from chore/core-ref-213 into edge
Reviewed-on: #43
2026-10-14 09:35:21 +00:00
11d32436a4 chore(ci): pin the core to website main at #213's merge
All checks were successful
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / frozen-manifest (pull_request) Successful in -1m34s
website#213 (MODULE_API 1.14.0, the `multiple` param) merged as a981b2b.
Its tree is identical to the branch head 743f1bc this was pinned to, so
routes.manifest.json is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-14 04:18:53 -05:00
aefd752372 Merge pull request 'feat(rust): permissions as event rewards — lend or give any registered permission (PLAN_REDESIGNS §12)' (#42) from feat/rust-perm-rewards into edge
Reviewed-on: #42
2026-10-13 01:03:39 +00:00
63c82d5a2f feat(rust): permissions as event rewards — lend or give any registered permission (PLAN_REDESIGNS §12)
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in -1m32s
D342-D346 (docs#342). Two steps over the permission mirror's own tables, so
no plugin, sidecar or protocol change:

- rust.perm.lend, "Permissions until the event ends": a kit reward's lease,
  one rust_perm_run_grants row per recipient per permission, the message on
  one row each; change/ledger with rust.kit.entitle's revert and reconcile;
  budget rust.permissions.
- rust.perm.give, "Permissions for good": a standing rust_perm_grants row per
  website user per permission, scoped to the step's server, source `event`,
  note "event run <run>, step <step>"; a grant already there is left alone,
  and a retry's own grants are counted as given; the message rides a
  notice-only run-grant row; irreversible/none; budget rust.permissions.kept.
- Both: several permissions through core's `multiple` param (MODULE_API
  1.14.0, at most 10); a required "I am sure" toggle refused unticked in the
  dry run and at run time, naming the permissions (D343); a permission the
  server's catalogue lacks refused by name; a blank message tells nobody.
- rust.options.permissions: the enabled servers' catalogues, grouped by the
  registering plugin (by prefix when none), sorted plugin then permission
  without case, a row noted "Only on X" when not on every server.
- Permission manager: an event-given grant shows "given by event run N,
  step M"; staff turn it off like any grant.
- coreApi ^1.14.0; ci/core-ref.json pinned to website#213's head until it
  merges; engagement-triggers.json regenerated; routes.manifest.json
  unchanged (no new routes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 15:49:44 -05:00
89557ddf66 Merge pull request 'feat(rust): Backpacks — staff and player views, the Bigger backpack step (PLAN_REDESIGNS §11.3)' (#41) from feat/rust-backpacks into edge
Reviewed-on: #41
2026-10-12 19:41:20 +00:00
e4591af906 feat(rust): Backpacks — staff and player views, the Bigger backpack step (PLAN_REDESIGNS §11.3)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / frozen-manifest (pull_request) Successful in 1m0s
PR Checks / client-build (pull_request) Successful in 7m23s
The site half of §11.3.2, on protocol 14:

- `rust.backpack.size` ("Bigger backpack"): a kit reward with a different
  permission (D337) — one rust_perm_run_grants row per website user with
  backpacks.size.N, no kit, no credit; the perm mirror pushes it and the run's
  revert deletes it. risk change, reversible ledger, a new `rust.backpacks`
  budget, rust.kit.entitle's recipient modes including `linked`. Refused with
  "Backpacks is not installed on this server" in the dry run too (D199), and a
  size the server does not offer is refused by name. Its message is never
  silent: empty is the take-back warning (D338).
- `rust.options.backpacksizes`: each server's sizes above its smallest, from
  the last hello, grouped by server.
- GET /player/rust/backpacks (own links only, 20 reads a minute) and
  GET /admin/users/:id/rust/backpacks (that user's links only). Live, never
  stored; a stranger's id is a 404 and never reaches the game.
- Client: a shared Backpack panel (pick server and account, open, read once)
  on the account page and the admin user page; the grid draws pages of
  pageSize, each slot named by the game (D340, no icons), blueprints by what
  they make (D341), overflow marked. Backpacks joins the server's mod line.

Server 548/548 (13 new in test/backpacks.test.js), client 79/79 (5 new),
routes.manifest regenerated against the pinned core (89 routes), swagger
fragment regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 14:35:55 -05:00
69ec0f6c46 Merge pull request 'feat(rust): Economics — pay coins, balances, staff adjust, the richest (PLAN_REDESIGNS §11.2)' (#40) from feat/protocol-14-economics into edge
Reviewed-on: #40
2026-10-12 12:40:53 +00:00
845caef817 feat(rust): Economics — pay coins, balances, staff adjust, the richest (PLAN_REDESIGNS §11.2)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Successful in -1m29s
The site half of §11.2.2, on protocol 14:

- `rust.coins.pay` ("Pay coins"): irreversible, reversible none (D335). The
  recipient modes of rust.kit.entitle including `linked`, one payment per
  website user (D103's rule), an optional message with {amount}, a new
  `rust.coins` budget. Refused with "Economics is not installed on this
  server" in the dry run too (D199). Core's key goes to the plugin, so a
  retried step answers `repeat` and pays nobody twice.
- `rust_player_wipe_stats.balance`: the tally's balance, written as a value.
  A nameless balance frame (an offline player) moves nobody's last-seen.
  `economics.wiped` clears the wipe's balances, except at a new save.
- The richest: a Coins leaderboard column on one wipe of a server that
  reports Economics, `sort=balance`, and a `richest` title category
  (Magnate); both rank only above the starting balance (D336).
- GET /player/rust/economics (live, falling back to the last known), and
  GET/POST /admin/users/:id/rust/economics for staff — scoped to the user's
  own links, keyed per submission, an activity row with before and after.
- Client: the account page's Coins, the admin user page's Coins panel with
  its form, the leaderboard column, and Economics in the server's mod line
  (saying when its config could not be read).

Server 535/535 (14 new in test/economics.test.js), client 74/74,
routes.manifest regenerated against the pinned core (87 routes), swagger
fragment regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 07:07:05 -05:00
1777cb6ce1 Merge pull request 'chore(ci): re-pin core to website main at #212's merge' (#39) from chore/repin-core-212 into edge
Reviewed-on: #39
2026-10-12 11:41:31 +00:00
a64ae68158 chore(ci): re-pin core to website main at #212's merge
All checks were successful
PR Checks / server-tests (pull_request) Successful in 33s
PR Checks / frozen-manifest (pull_request) Successful in 55s
PR Checks / client-build (pull_request) Successful in 7m23s
website#212 (MODULE_API 1.13.0) merged as ca7ec53, whose tree is the
same as the branch head e58857c this repo was pinned to while it was
open. Only the pin moves; routes.manifest.json is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 05:08:39 -05:00
21e40053ab Merge pull request 'feat(rust): the kit weekend, linked rewards, reward messages and the kit warning (protocol 14)' (#38) from feat/protocol-14-step-editor into edge
Reviewed-on: #38
2026-10-12 09:51:57 +00:00
d21a9e2c0d feat(rust): the kit weekend, linked rewards, reward messages and the kit warning (protocol 14)
All checks were successful
PR Checks / client-build (pull_request) Successful in 3m46s
PR Checks / frozen-manifest (pull_request) Successful in 6m12s
PR Checks / server-tests (pull_request) Successful in 7m30s
PLAN_REDESIGNS §2, the Rust half (D164, D192, D332, D333):
- rust.template.kit_weekend (core registerEventTemplates, MODULE_API 1.13.0):
  inputs kit/minutes/message/delivery; one phase advance after the weekend,
  core.lease rust.group.permission default/<kit permission> + rust.kit.entitle
  linked. Steps only where the kit can use them; validated by the real core.
- rust.kit.entitle: recipients 'linked' (every linked player who has played
  on the server, no tally, refused past the bound), optional message ({kit})
  and delivery, stored per row (rust_perm_run_grants.notice*).
- perm.sync notices: the untold set to the picked account, keyed
  run:step:user, in the digest; the report's noticesTold marks them told.
- rust.options.grouppermissions notes a kit's permission rows via kits.uses
  (cached a minute per server; a failure is no note).
- duration: 'minutes' on rust.zone.open and rust.participation.open; the
  group-permission lease's value example is true.
- protocol 13 -> 14, coreApi ^1.13.0; ci/core-ref.json pinned to website#212's
  head while it is open; engagement-triggers.json regenerated (coreApi line).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 02:25:06 -05:00
87 changed files with 11499 additions and 122 deletions

View File

@@ -38,11 +38,13 @@ rows here; the website core never learns there is more than one.
| Public | `GET /api/v1/public/rust/servers` — every server and what it last reported |
| Public | `GET …/servers/:id` — one server, or a `404`; the only route under `:id` that can say a server does not exist |
| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) |
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed; each row carries the player's chat titles |
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed; each row carries the player's chat titles, and on one wipe of a server with Economics their coin balance |
| Public | `GET …/servers/:id/wipes` and `…/online` |
| Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) |
| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
| Player | `GET /api/v1/player/rust/economics` — your coin balances on every server, per linked account |
| Player | `GET /api/v1/player/rust/backpacks?serverId=&steamId=` — your own backpack on one server, read live |
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` — the `PUT` carries the wipe schedule |
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
| Admin | `PUT …/servers/:id/titles`, `GET …/servers/:id/integrations`, `GET/PUT /api/v1/admin/rust/voice` — chat titles, the optional mods a server has, and the announcement voice |
@@ -82,7 +84,18 @@ forced wipe (first Thursday, 19:00 UK time) — plus an optional one-off date th
computed wipe. It is computed on every read and never stored, so it cannot go stale after a wipe.
The server list, the server page, the Android app and `/wipe` all show it.
**Two uMod plugins are optional, and the module works without either** (`docs/modules/rust/PLAN.md`
**Rust's own world events need no plugin** (`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.1). The patrol
helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the
satellite and the Deep Sea reach the feed (a **World events** filter) and each is an engagement trigger
(`rust.world.<event>`), so a notice or an event's phase can follow one. The event and its grid are public;
a player's name in it only for the presence audience, a Steam id only for staff. **Who downs a helicopter
or a Bradley** is the operator's rule per server — killing blow (the default), last hit, most damage, or
everyone who hurt it — and the same rule writes the line and counts toward **Rotorbane** and
**Tankbreaker**. **Safecracker** (locked crates hacked) and **Scavenger** (airdrops looted first) are
title categories with leaderboard columns, and the **Call a world event** step (`rust.world.call`) calls
one now, kept when the run ends: an airdrop or a patrol helicopter can be sent to a point.
**Four uMod plugins are optional, and the module works without any of them** (`docs/modules/rust/PLAN.md`
§33). The bridge plugin's hard requirements are **Kits** and **ZoneManager**.
- **BetterChat** (LaserHydra, 5.2.15). With it: **chat titles** an operator sets per server — "top 3
@@ -94,6 +107,43 @@ The server list, the server page, the Android app and `/wipe` all show it.
- **PopupNotifications** (k1lly0u, 0.2.1). With it: `rust.announce` and a server's news posts can be
a popup instead of a chat line. Without it a popup is refused with a sentence saying so, and chat
works as before.
- **Economics** (Wulf, 3.9.2). With it: the **Pay coins** event step (`rust.coins.pay`, kept once
paid), each player's balance on their account page and in the app, staff deposit / withdraw / set
from a user's admin page, a **Coins** leaderboard column and the **Magnate** title — both ranking
only balances above the server's starting balance. Without it each of those says "Economics is not
installed on this server" (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2).
- **Backpacks** (WhiteThunder, 3.17.8). With it: staff open any player's backpack from their admin
page and a player sees their own on the account page and in the app — read live, never stored, and
never written — and the **Bigger backpack** event step (`rust.backpack.size`) lends one of the
server's `backpacks.size.N` sizes until the run ends, pushed like a kit reward. Without it each of
those says "Backpacks is not installed on this server" (`docs/modules/rust/PLAN_REDESIGNS.md` §11.3).
- **TruePVE** (nivex, 2.4.6). With it: the **Open a zone** step (`rust.zone.open`) can say **No PvP
protection in this zone**, or name one of the server's TruePVE rulesets, which holds for the zone's
life and goes with it. A ruleset TruePVE does not have fails the step when it runs, with the name.
Without it either one says "TruePVE is not installed on this server"
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.2).
- **Dynamic PVP** (HunterZ, 5.2.0; needs ZoneManager and TruePVE). With it: the PvP zones it opens
around a downed patrol helicopter, a Bradley, the cargo ship and the rest show on the live map's
**PvP zones** layer (public by default, set in Admin → Rust visibility) and as a public feed line —
*"A PvP zone opened around the downed patrol helicopter · G7 · for 10 min"* — that names nobody.
Its zones are its own: no step and no trigger (`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.3).
- **Dangerous Treasures** (nivex, 3.0.0). With it: a treasure event starting and being won are public
feed lines and triggers (*"Alice won the treasure · K12 · 3 players fought for it"*, the name only
for the presence audience), a running chest is on the live map's world layer, the **Treasures**
leaderboard column and the *Treasure Hunter* title count the chests a player emptied, and the step
**Start a Dangerous Treasures event** opens one at a chosen difficulty where DT picks. In the first
ten minutes after DT loads the step says "Dangerous Treasures is not ready yet" and tries again ten
minutes later. Refused where DT is absent, or where the framework cannot run DT's console command
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.4).
- **Guarded Crate** (Bazz3l, 2.0.17), with **NpcSpawn** (Codefling) for its guards. With both: a
guarded crate landing and being cleared are public feed lines and triggers (*"Alice cleared the
guarded crate · M4"*, the name only for the presence audience), a running crate is on the live map's
world layer, the **Crates cleared** leaderboard column and the *Warden* title count the crates a
player cleared by killing the last guard, and the step **Place a guarded crate** places one of its
events at a monument or a point. The crate is kept when the run ends — Guarded Crate cannot end one
alone — and the run log says so. Refused where Guarded Crate or NpcSpawn is absent, where the event
is not one the server has, or where the framework cannot run its console command
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.5).
News and event lines said in chat can wear one styled group's title and colours — the
**announcement voice**, chosen in Admin → Rust servers. The line is said by the bridge plugin with no

View File

@@ -32,12 +32,20 @@
"commands",
"configEdit.js",
"core.js",
"dangerousTreasures.js",
"db",
"dynamicPvp.js",
"engagement",
"eventDangerousTreasures.js",
"eventGuardedCrate.js",
"eventLeases.js",
"eventPermRewards.js",
"eventProgress.js",
"eventRewards.js",
"eventTemplates.js",
"eventWorld.js",
"eventWorldCall.js",
"guardedCrate.js",
"index.js",
"ingest.js",
"mapImages.js",
@@ -48,7 +56,8 @@
"permSync.js",
"router",
"sidecarClient.js",
"titleSync.js"
"titleSync.js",
"worldEvents.js"
],
"root": [
"engagement-triggers.json",

View File

@@ -1,6 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as.",
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "f0e7d2aa2a4dbdd848f92648085181611b7418f0",
"refName": "main @ MODULE_API 1.11.0 (website#209 merged)"
"ref": "eb773693d32781f2ec717f9be07816b6afefd162",
"refName": "main @ MODULE_API 1.15.0 (website#215 merged)"
}

View File

@@ -129,6 +129,18 @@ export const playerPermissions = {
list: () => req('/player/rust/permissions'),
}
// Your coin balances on every server, per linked account (PLAN_REDESIGNS §11.2.2):
// live where a server answers, the last one it reported where it does not.
export const playerEconomics = {
list: () => req('/player/rust/economics'),
}
// Your own backpack on one server, from one of your linked accounts
// (PLAN_REDESIGNS §11.3.2): read live from the game, once per open.
export const playerBackpacks = {
read: (serverId, steamId) => req(`/player/rust/backpacks${query({ serverId, steamId })}`),
}
// ── admin ─────────────────────────────────────────────────────────────────
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
// and a save that omits the field leaves the stored credential alone — so an
@@ -323,6 +335,21 @@ export const adminUserPermissions = {
),
}
// The same panel's Economics half (§11.2.2): this person's coins, and staff's
// hand on them. `key` is one per submission, so a resent form moves nothing.
export const adminUserEconomics = {
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/economics`),
adjust: (userId, body) =>
req(`/admin/users/${encodeURIComponent(userId)}/rust/economics`, { method: 'POST', body }),
}
// …and its Backpacks half (§11.3.2): what this person carries, read live. There
// is no write, here or in the game (R2).
export const adminUserBackpacks = {
read: (userId, serverId, steamId) =>
req(`/admin/users/${encodeURIComponent(userId)}/rust/backpacks${query({ serverId, steamId })}`),
}
// ── core's public event calendar ───────────────────────────────────────────
// The one CORE route this module reads, and core published `runId` on it for
// exactly this: a map marker holds a run id and nothing else, and the calendar is
@@ -342,6 +369,8 @@ export default {
playerServers,
playerLinks,
playerPermissions,
playerEconomics,
playerBackpacks,
admin,
adminPermissions,
adminConfig,
@@ -351,6 +380,8 @@ export default {
playerNpcKills,
adminUserLinks,
adminUserPermissions,
adminUserEconomics,
adminUserBackpacks,
coreEvents,
BASE,
}

View File

@@ -0,0 +1,153 @@
// ── A Backpacks backpack, read live (PLAN_REDESIGNS §11.3.2) ──────────────
//
// One component for both readers — staff on a user's admin page, a player on
// their own account page — because the backpack is the same thing to both and
// only the route differs. The caller hands over `read(serverId, steamId)`.
//
// **Opening reads once; nothing polls.** A read runs on the game's main thread
// (§11.3.1), so the panel asks when somebody asks it to and not again until
// they do. Nothing is stored on the site either.
//
// **No icons (D340).** The module has no item art (R9 kept it out of the asset
// bridge), so a slot says the game's own English name, which the plugin sends.
import { useState } from 'react'
import { count, shortId } from '../lib/format.js'
import { backpackPages, overflowCount, stackLabel } from '../lib/backpack.js'
const CELL = 92
/** One slot: its name, amount, and what rides inside it. */
function Cell({ cell }) {
const { stack, overflow } = cell
const border = overflow ? '1px dashed #d89b3c' : '1px solid var(--line-soft)'
const title = stack
? [stackLabel(stack), stack.name ? `“${stack.name}”` : null, stack.skin ? `skin ${stack.skin}` : null, overflow ? 'past the capacity' : null]
.filter(Boolean)
.join(' · ')
: overflow
? 'past the capacity'
: `slot ${cell.slot + 1}`
return (
<div
className="sans"
title={title}
style={{
border,
borderRadius: 4,
minHeight: 58,
padding: '4px 6px',
fontSize: '0.72rem',
lineHeight: 1.25,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
minWidth: 0,
overflow: 'hidden',
background: stack ? 'transparent' : 'var(--line-soft)',
opacity: stack ? 1 : 0.35,
}}
>
{stack && (
<>
<span style={{ color: 'var(--head)', overflowWrap: 'anywhere' }}>{stackLabel(stack)}</span>
{stack.name && <span className="dim" style={{ overflowWrap: 'anywhere' }}>“{stack.name}”</span>}
{(stack.contents || []).map((c) => (
<span key={c.slot} className="dim" style={{ overflowWrap: 'anywhere' }}>+ {stackLabel(c)}{c.amount > 1 ? ` ×${count(c.amount)}` : ''}</span>
))}
<span style={{ display: 'flex', justifyContent: 'space-between', gap: 4 }}>
<span className="dim">{stack.skin ? 'skinned' : ''}</span>
{stack.amount > 1 && <strong>×{count(stack.amount)}</strong>}
</span>
</>
)}
</div>
)
}
/** The grid itself, one block per page. */
export function BackpackGrid({ backpack }) {
if (!backpack.exists) {
return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>No backpack on {backpack.server} yet — it has never been opened.</p>
}
const pages = backpackPages(backpack)
const waiting = overflowCount(backpack)
const held = (backpack.items || []).length
return (
<div>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 8px' }}>
{count(held)} stack{held === 1 ? '' : 's'} in {count(backpack.capacity)} slots on {backpack.server}
{waiting > 0 && (
<span style={{ color: '#d89b3c' }}>
{' '}· {count(waiting)} past the capacity, moved to the owner’s inventory (or dropped at their feet if it is full) the next time they open it
</span>
)}
</p>
{pages.map((page) => (
<div key={page.index} style={{ marginBottom: 10 }}>
{pages.length > 1 && <div className="field-label" style={{ margin: '0 0 6px', fontSize: '0.7rem' }}>Page {page.index + 1}</div>}
<div style={{ display: 'grid', gridTemplateColumns: `repeat(auto-fill, minmax(${CELL}px, 1fr))`, gap: 4 }}>
{page.cells.map((cell) => (
<Cell key={cell.slot} cell={cell} />
))}
</div>
</div>
))}
</div>
)
}
/**
* Pick a server and an account, open, read once. `servers` is `[{ id, name }]`,
* `accounts` `[{ steamId, name }]`, and `read` the route's binding.
*/
export default function Backpack({ servers, accounts, read }) {
const [serverId, setServerId] = useState(servers.length ? servers[0].id : '')
const [steamId, setSteamId] = useState(accounts.length ? accounts[0].steamId : '')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [backpack, setBackpack] = useState(null)
if (!servers.length || !accounts.length) return null
async function open(event) {
event.preventDefault()
if (busy || !serverId || !steamId) return
setBusy(true)
setError('')
try {
setBackpack(await read(serverId, steamId))
} catch (err) {
// "Backpacks is not installed on this server (Main)" is the server's sentence (D199).
setBackpack(null)
setError(err.message || 'That backpack could not be read.')
} finally {
setBusy(false)
}
}
return (
<div>
<form onSubmit={open} style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
{servers.length > 1 && (
<select className="input" value={serverId} onChange={(e) => setServerId(e.target.value)} aria-label="Server">
{servers.map((s) => <option key={s.id} value={s.id}>{s.name || s.id}</option>)}
</select>
)}
{accounts.length > 1 && (
<select className="input" value={steamId} onChange={(e) => setSteamId(e.target.value)} aria-label="Steam account">
{accounts.map((a) => <option key={a.steamId} value={a.steamId}>{a.name || shortId(a.steamId)}</option>)}
</select>
)}
<button type="submit" className="btn" disabled={busy}>
{busy ? 'Reading…' : backpack ? 'Read again' : 'Open backpack'}
</button>
</form>
{error && <p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '0 0 6px' }}>{error}</p>}
{backpack && <BackpackGrid backpack={backpack} />}
</div>
)
}

View File

@@ -18,7 +18,7 @@ import { useState } from 'react'
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
import { ago, contrastInk, count, duration, shortId } from '../lib/format.js'
import { ago, coins, contrastInk, count, duration, shortId } from '../lib/format.js'
import api from '../api.js'
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
@@ -30,8 +30,28 @@ const COLUMNS = [
{ key: 'npcKills', label: 'NPC kills', sort: 'npcKills', value: (r) => count(r.npcKills) },
{ key: 'structures', label: 'Structures', sort: null, value: (r) => count(r.structures) },
{ key: 'playtimeSec', label: 'Played', sort: 'playtime', value: (r) => duration(r.playtimeSec) },
// Rust's own world events (protocol 14, D356, D377): the counts Safecracker
// and Scavenger rank, on every server, since the game itself raises them.
{ key: 'cratesHacked', label: 'Crates hacked', sort: 'cratesHacked', value: (r) => count(r.cratesHacked) },
{ key: 'dropsLooted', label: 'Drops looted', sort: 'dropsLooted', value: (r) => count(r.dropsLooted) },
]
// The richest (PLAN_REDESIGNS §11.2.2): the last known Economics balance. The
// server adds it only for one wipe on a server that reports Economics, and says
// so with `economics` on the answer — so the column is there exactly when the
// rows carry it. Sorting by it ranks only balances above the starting balance
// (D336), which is why a fresh wipe's sort shows nobody.
// Treasure Hunter (PLAN_REDESIGNS §14.3.4, D375): Dangerous Treasures won. The
// server says `dangerousTreasures` where the server reports the plugin, or the
// rows already count a treasure, so the column is there exactly then.
const TREASURES = { key: 'treasuresWon', label: 'Treasures', sort: 'treasuresWon', value: (r) => count(r.treasuresWon) }
// Warden (PLAN_REDESIGNS §14.3.5, D375): guarded crates cleared, shown on the
// same rule from the server's `guardedCrate`.
const GUARDED = { key: 'guardedWon', label: 'Crates cleared', sort: 'guardedWon', value: (r) => count(r.guardedWon) }
const BALANCE = { key: 'balance', label: 'Coins', sort: 'balance', value: (r) => coins(r.balance) }
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
const { data: npc } = useAsync(() => api.servers.npcProfiles(serverId).catch(() => ({ profiles: [] })), [serverId])
const [profileId, setProfileId] = useState('')
@@ -85,6 +105,12 @@ function Overall({ serverId, wipeId, sort, onSort }) {
)
const rows = data ? data.leaderboard : []
const columns = [
...COLUMNS,
...(data && data.dangerousTreasures ? [TREASURES] : []),
...(data && data.guardedCrate ? [GUARDED] : []),
...(data && data.economics ? [BALANCE] : []),
]
// Present on every row or on none — the server decides per request.
const showLastSeen = rows.some((row) => 'lastSeen' in row)
@@ -96,9 +122,11 @@ function Overall({ serverId, wipeId, sort, onSort }) {
<Empty
title="No scores yet"
message={
wipeId
? 'Nobody has done anything countable on this wipe yet.'
: 'This server has not reported anything countable yet.'
sort === 'balance' && wipeId
? 'Nobody holds more coins than the starting balance on this wipe yet.'
: wipeId
? 'Nobody has done anything countable on this wipe yet.'
: 'This server has not reported anything countable yet.'
}
/>
)
@@ -110,7 +138,7 @@ function Overall({ serverId, wipeId, sort, onSort }) {
<thead>
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em' }}>
<th style={{ ...cell, textTransform: 'uppercase' }}>Player</th>
{COLUMNS.map((column) => (
{columns.map((column) => (
<th key={column.key} style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>
{column.sort ? (
<button
@@ -179,7 +207,7 @@ function Overall({ serverId, wipeId, sort, onSort }) {
</span>
))}
</td>
{COLUMNS.map((column) => (
{columns.map((column) => (
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>
{column.value(row)}
</td>
@@ -190,7 +218,7 @@ function Overall({ serverId, wipeId, sort, onSort }) {
</tr>,
open === row.steamId && (
<tr key={`${row.steamId}-npcs`}>
<td colSpan={COLUMNS.length + 1 + (showLastSeen ? 1 : 0)} style={{ ...cell, whiteSpace: 'normal', paddingLeft: 34 }}>
<td colSpan={columns.length + 1 + (showLastSeen ? 1 : 0)} style={{ ...cell, whiteSpace: 'normal', paddingLeft: 34 }}>
<PlayerNpcKills serverId={serverId} steamId={row.steamId} wipeId={wipeId} />
</td>
</tr>

View File

@@ -21,7 +21,7 @@ import Empty from './Empty.jsx'
import usePolled from '../hooks/usePolled.js'
import { ago } from '../lib/format.js'
import { boundsOf, countdown, fromLatLng, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
import { eventTip, isBoss, titlesFrom, unknownRuns } from '../lib/mapEvents.js'
import { eventTip, isBoss, pvpTip, titlesFrom, unknownRuns } from '../lib/mapEvents.js'
import api, { BASE } from '../api.js'
const STYLE_ID = 'rust-leaflet-css'
@@ -35,6 +35,7 @@ const LAYERS = [
{ id: 'events', label: 'Site events' },
{ id: 'players', label: 'Players' },
{ id: 'bases', label: 'Bases' },
{ id: 'pvp', label: 'PvP zones' },
]
const COLOURS = {
@@ -45,6 +46,8 @@ const COLOURS = {
bradley: '#a1887f',
supply: '#66bb6a',
crate: '#ffee58',
treasure: '#ffab00',
guarded: '#ff7043',
event: '#ce93d8',
boss: '#d500f9',
online: '#ffffff',
@@ -53,6 +56,7 @@ const COLOURS = {
vending: '#26a69a',
self: '#00e5ff',
mate: '#7cffb2',
pvp: '#ff1744',
}
const WORLD_NAMES = {
@@ -62,6 +66,10 @@ const WORLD_NAMES = {
bradley: 'Bradley APC',
supply: 'Supply drop',
crate: 'Locked crate',
// Dangerous Treasures (§14.3.4): its running chests.
treasure: 'Dangerous Treasures chest',
// Guarded Crate (§14.3.5): a crate whose guards still stand.
guarded: 'Guarded crate',
boss: 'Boss',
}
@@ -79,7 +87,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
const [leaflet, setLeaflet] = useState(null)
const [leafletError, setLeafletError] = useState(null)
const [shown, setShown] = useState({ grid: true, world: true, events: true, players: true, bases: true, mates: true })
const [shown, setShown] = useState({ grid: true, world: true, events: true, players: true, bases: true, pvp: true, mates: true })
useEffect(() => {
let alive = true
@@ -160,7 +168,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
map.fitBounds(bounds)
const made = {}
for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'mates', 'pins']) made[id] = L.layerGroup().addTo(map)
for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'pvp', 'mates', 'pins']) made[id] = L.layerGroup().addTo(map)
// The labels are a layer of their own inside the grid's, shown only when a cell
// is wide enough on screen to hold one: at the fitted zoom a 20-cell map's
// labels overlap into a wall of text (found on the phase 14 walk).
@@ -238,7 +246,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
const { L } = leaflet
const answer = live.data || {}
const at = (p) => toLatLng(geometry, p.x, p.z)
for (const id of ['world', 'events', 'players', 'bases', 'mates']) made[id].clearLayers()
for (const id of ['world', 'events', 'players', 'bases', 'pvp', 'mates']) made[id].clearLayers()
for (const w of answer.world || []) {
let tip = WORLD_NAMES[w.kind] || w.kind
@@ -274,6 +282,13 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
dot(L, at(b), COLOURS[b.kind] || COLOURS.tc, 4).bindTooltip(b.kind === 'tc' ? 'Tool cupboard' : 'Vending machine').addTo(made.bases)
}
// Dynamic PVP's own zones (§14.3.3): a red ring with its event's name.
for (const z of answer.pvp || []) {
L.circle(at(z), { radius: px(Number(z.radius) || 0), color: COLOURS.pvp, weight: 2, fillOpacity: 0.1 })
.bindTooltip(escape(pvpTip(z)))
.addTo(made.pvp)
}
for (const m of answer.mates || []) {
dot(L, at(m), m.self ? COLOURS.self : COLOURS.mate, m.self ? 8 : 6, 2)
.bindTooltip(escape(m.self ? `You${m.online ? '' : ' (asleep, offline)'}` : m.name || 'Clan mate'))
@@ -299,7 +314,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
const map = mapRef.current
const made = groups.current
if (!map || !made) return
const want = { grid: shown.grid, monuments: shown.world, world: shown.world, events: shown.events, players: shown.players, bases: shown.bases, mates: shown.mates }
const want = { grid: shown.grid, monuments: shown.world, world: shown.world, events: shown.events, players: shown.players, bases: shown.bases, pvp: shown.pvp, mates: shown.mates }
for (const [id, on] of Object.entries(want)) {
if (on && !map.hasLayer(made[id])) made[id].addTo(map)
if (!on && map.hasLayer(made[id])) map.removeLayer(made[id])
@@ -381,6 +396,7 @@ function Legend({ meta, live, shown, onToggle }) {
events: [COLOURS.event],
players: [COLOURS.online, COLOURS.sleeping],
bases: [COLOURS.tc, COLOURS.vending],
pvp: [COLOURS.pvp],
}
return (

View File

@@ -0,0 +1,52 @@
// ── A backpack as a grid (PLAN_REDESIGNS §11.3.2) ─────────────────────────
//
// Pure, like `format.js`, so the suite can ask it without a DOM. The page that
// draws a backpack only maps over what these return.
//
// The read's `slot` runs across pages: page two of a 96-slot backpack starts at
// slot 48 when the page size is 48 (§11.3.1). A stack at or past the capacity
// is overflow — it waits for the owner's next open to be moved to their
// inventory, or the ground (D338) — so the grid draws those slots too, after
// the backpack's own, marked.
/**
* The pages to draw: `[{ index, cells: [{ slot, stack, overflow }] }]`.
* Enough slots for the capacity and for every stack, whichever reaches
* further; a backpack that does not exist is no pages at all.
*/
export function backpackPages(backpack) {
if (!backpack || !backpack.exists) return []
const items = Array.isArray(backpack.items) ? backpack.items : []
const capacity = Math.max(0, Number(backpack.capacity) || 0)
const pageSize = Math.max(1, Number(backpack.pageSize) || 48)
const bySlot = new Map(items.map((s) => [Number(s.slot), s]))
const furthest = items.reduce((max, s) => Math.max(max, Number(s.slot) + 1), 0)
const total = Math.max(capacity, furthest)
const pages = []
for (let start = 0; start < total; start += pageSize) {
const cells = []
for (let slot = start; slot < Math.min(start + pageSize, total); slot++) {
cells.push({ slot, stack: bySlot.get(slot) || null, overflow: slot >= capacity })
}
pages.push({ index: pages.length, cells })
}
return pages
}
/**
* What a slot says: the game's name (D340), "… blueprint" for a blueprint
* (D341), then the shortname, then the bare item id for an item the game no
* longer knows.
*/
export function stackLabel(stack) {
if (!stack) return ''
const name = stack.display || stack.shortname || (stack.itemId !== undefined ? `item ${stack.itemId}` : 'unknown item')
return stack.blueprint ? `${name} blueprint` : name
}
/** How many stacks wait to be moved out at the owner's next open. */
export function overflowCount(backpack) {
return backpack && Array.isArray(backpack.items) ? backpack.items.filter((s) => s.overflow).length : 0
}

View File

@@ -44,6 +44,21 @@ export const FEED_KINDS = Object.freeze([
// The death names its killer, so the server shows it only where presence is on.
'npc.boss.spawned',
'npc.boss.died',
// Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1): the server
// reads each one for the viewer, so a name is here only where it may be.
'world.event',
// Dynamic PVP's zones opening (§14.3.3). A closing (`pvp.zone.deleted`) is
// staff's and is not asked for here; the map shows a zone go.
'pvp.zone.created',
// Dangerous Treasures (§14.3.4): a chest opening, and one won. The server reads
// each for the viewer, so the winner's name is here only where it may be; the
// ending (`dt.ended`) is staff's, and the map shows the chest go.
'dt.started',
'dt.won',
// Guarded Crate (§14.3.5): a crate landing, and one cleared, read per viewer
// like a treasure. A timeout (`gc.failed`) is staff's; the map shows it go.
'gc.started',
'gc.ended',
])
/** The filters the feed offers, and the kinds each one asks the API for. */
@@ -58,6 +73,7 @@ export const FILTERS = Object.freeze([
},
{ id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] },
{ id: 'bosses', label: 'Bosses', kinds: ['npc.boss.spawned', 'npc.boss.died'] },
{ id: 'world', label: 'World events', kinds: ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended'] },
])
export function kindsFor(filterId) {
@@ -144,6 +160,23 @@ export function describe(row) {
: { tone: 'death', actor: name, verb: 'was killed', subject: null, detail: fought }
}
case 'world.event':
return worldLine(frame)
case 'pvp.zone.created':
case 'pvp.zone.deleted':
return pvpLine(row.kind, frame)
case 'dt.started':
case 'dt.won':
case 'dt.ended':
return treasureLine(row.kind, frame)
case 'gc.started':
case 'gc.ended':
case 'gc.failed':
return guardedLine(row.kind, frame)
default:
return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' }
}
@@ -193,4 +226,185 @@ function death(frame, name) {
}
}
/** Where a locked crate is, as the end of a sentence. */
const CRATE_PLACES = Object.freeze({
oilrig_large: 'on Large Oil Rig',
oilrig_small: 'on Small Oil Rig',
cargo: 'on the cargo ship',
ghostship: 'on a ghost ship',
chinook: 'dropped by a Chinook',
})
/**
* Who a downed helicopter, Chinook or Bradley credits, as a phrase: "Alice",
* "Alice and Bob", "Alice, Bob and 2 others". Null when the line names nobody
* — below the presence audience the server sends `creditedCount` and no names.
*/
export function creditedPhrase(frame) {
const names = (Array.isArray(frame.credited) ? frame.credited : []).map((c) => c && c.name).filter(Boolean)
if (names.length === 0) return null
const total = Math.max(names.length, Number(frame.creditedCount) || 0)
const shown = names.slice(0, 2)
const more = total - shown.length
if (more > 0) return `${shown.join(', ')} and ${more} other${more === 1 ? '' : 's'}`
return shown.length === 2 ? `${shown[0]} and ${shown[1]}` : shown[0]
}
/**
* Rust's own world events (protocol 14), one sentence per type. The server has
* already read the frame for this viewer: it carries `grid`, a `name` only for
* the presence audience, and `credited` by the server's rule (D372, D376). A
* line therefore never decides who may read a name; it only says what it has.
*/
function worldLine(frame) {
const grid = frame.grid || ''
const who = frame.name || null
const place = CRATE_PLACES[frame.where] || 'somewhere on the map'
const by = creditedPhrase(frame)
const downed = (what) => (by
? { tone: 'kill', actor: null, verb: `${what} by`, subject: by, detail: grid }
: { tone: 'kill', actor: null, verb: what, subject: null, detail: grid })
const say = (verb, detail = grid) => ({ tone: 'server', actor: null, verb, subject: null, detail })
const harbour = frame.harbourName || 'the harbour'
switch (frame.event) {
case 'heli.arrived':
return say('A patrol helicopter has arrived')
case 'heli.left':
return say('The patrol helicopter has left', '')
case 'heli.downed':
return downed('Patrol helicopter downed')
case 'chinook.downed':
return downed('Chinook shot down')
case 'bradley.destroyed':
return downed(frame.where === 'launch_site' ? "Launch Site's Bradley destroyed" : frame.where === 'road' ? 'Road Bradley destroyed' : 'Bradley destroyed')
case 'bradley.spawned': {
const n = Number(frame.count) || 1
if (frame.where === 'road') return say(n > 1 ? `${n} road Bradleys are out` : 'A road Bradley is out', '')
return say('Bradley is patrolling Launch Site', '')
}
case 'cargo.arrived':
return say('The cargo ship has arrived')
case 'cargo.docking':
return say(`The cargo ship is docking at ${harbour}`)
case 'cargo.docked':
return say(`The cargo ship has docked at ${harbour}`)
case 'cargo.undocked':
return say(`The cargo ship has cast off from ${harbour}`)
case 'cargo.left':
return say('The cargo ship is leaving', '')
case 'chinook.arrived':
return say('A Chinook is inbound with a locked crate')
case 'chinook.dropped':
return say(frame.monumentName ? `A Chinook dropped a locked crate at ${frame.monumentName}` : 'A Chinook dropped a locked crate')
case 'airdrop.called':
return who ? { tone: 'server', actor: who, verb: 'called an airdrop', subject: null, detail: grid } : say('An airdrop is on its way')
case 'airdrop.landed':
return say('An airdrop has landed', [who ? `called by ${who}` : null, grid].filter(Boolean).join(' · '))
case 'airdrop.looted':
return who ? { tone: 'server', actor: who, verb: 'looted an airdrop', subject: null, detail: grid } : say('An airdrop was looted')
case 'crate.hack.started':
return who
? { tone: 'server', actor: who, verb: `started hacking the locked crate ${place}`, subject: null, detail: grid }
: say(`A locked crate is being hacked ${place}`)
case 'crate.unlocked':
return say(`The locked crate ${place} is open`)
case 'crate.looted':
return who
? { tone: 'server', actor: who, verb: `looted the locked crate ${place}`, subject: null, detail: grid }
: say(`The locked crate ${place} was looted`)
case 'vendor.arrived':
return say('The Travelling Vendor has arrived')
case 'vendor.left':
return say('The Travelling Vendor has left', '')
case 'satellite.crashed':
return say('A satellite has crashed')
case 'deepsea.opened':
return say('The Deep Sea is open', '')
case 'deepsea.closed':
return say('The Deep Sea has closed', '')
default:
// A type this build has no words for, which only staff are sent: its own name.
return { tone: 'other', actor: who, verb: String(frame.event || 'world event'), subject: null, detail: grid }
}
}
/**
* What a Dynamic PVP zone is around, by its event (Dynamic PVP 5.2.0's own
* names). The server sends `event` to the public only for these; a monument's or
* an operator's own event reaches staff as written.
*/
export const PVP_AROUND = Object.freeze({
Helicopter: 'the downed patrol helicopter',
Bradley: 'the destroyed Bradley',
Satellite: 'the crashed satellite',
TimedSupply: 'an airdrop',
SupplySignal: 'a signalled airdrop',
CargoShip: 'the cargo ship',
HackableCrate: 'a locked crate',
ExcavatorIgnition: 'the Giant Excavator',
GhostShip: 'a ghost ship',
DeepSeaIsland: 'a Deep Sea island',
IslandCannon: 'an island cannon',
})
/** How long a zone was given, as a detail: "for 10 min", or cargo's whole visit. */
function pvpLasts(frame) {
const sec = Number(frame.durationSec)
if (!(sec > 0)) return frame.event === 'CargoShip' ? 'while the ship is here' : ''
return sec < 120 ? `for ${Math.round(sec)} s` : `for ${Math.round(sec / 60)} min`
}
/** Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3). The line names nobody. */
function pvpLine(kind, frame) {
const around = PVP_AROUND[frame.event] ? ` around ${PVP_AROUND[frame.event]}` : frame.event ? ` (${frame.event})` : ''
if (kind === 'pvp.zone.deleted') {
return { tone: 'server', actor: null, verb: `The PvP zone${around} has closed`, subject: null, detail: '' }
}
const detail = [frame.grid || null, pvpLasts(frame) || null].filter(Boolean).join(' · ')
return { tone: 'kill', actor: null, verb: `A PvP zone opened${around}`, subject: null, detail }
}
/**
* Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4). The server has read
* the frame for this viewer: `grid`, `invaderCount`, and the winner's `name` only
* for the presence audience (D376).
*/
function treasureLine(kind, frame) {
const grid = frame.grid || ''
if (kind === 'dt.started') {
return { tone: 'server', actor: null, verb: 'A Dangerous Treasures event has started', subject: null, detail: grid }
}
if (kind === 'dt.ended') {
return { tone: 'server', actor: null, verb: 'A Dangerous Treasures event has ended', subject: null, detail: grid }
}
const n = Number(frame.invaderCount)
const fought = n > 1 ? `${n} players fought for it` : ''
const detail = [grid || null, fought || null].filter(Boolean).join(' · ')
return frame.name
? { tone: 'kill', actor: frame.name, verb: 'won the treasure', subject: null, detail }
: { tone: 'kill', actor: null, verb: 'The treasure was won', subject: null, detail }
}
/**
* Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5). The server has read the
* frame for this viewer: `grid`, `won` (whether a player killed the last
* guard), and the clearer's `name` only for the presence audience (D376).
*/
function guardedLine(kind, frame) {
const detail = frame.grid || ''
if (kind === 'gc.started') {
return { tone: 'server', actor: null, verb: 'A guarded crate landed', subject: null, detail }
}
if (kind === 'gc.failed') {
return { tone: 'server', actor: null, verb: 'A guarded crate timed out with its guards standing', subject: null, detail }
}
if (frame.won === false) {
return { tone: 'server', actor: null, verb: 'A guarded crate ended with nobody clearing it', subject: null, detail }
}
return frame.name
? { tone: 'kill', actor: frame.name, verb: 'cleared the guarded crate', subject: null, detail }
: { tone: 'kill', actor: null, verb: 'The guarded crate was cleared', subject: null, detail }
}
export default { describe, FEED_KINDS, FILTERS, kindsFor }

View File

@@ -105,6 +105,17 @@ export function count(value) {
return Number.isFinite(n) ? n.toLocaleString() : '0'
}
/**
* An Economics balance (PLAN_REDESIGNS §11.2.2): up to two places, and a dash
* for one the server never reported. Not `count`'s zero — "0 coins" is a claim
* about somebody's balance, and an unknown one is not it.
*/
export function coins(value) {
if (value === null || value === undefined || value === '') return '—'
const n = Number(value)
return Number.isFinite(n) ? n.toLocaleString(undefined, { maximumFractionDigits: 2 }) : '—'
}
/**
* A prefab short name as something readable — `patrolhelicopter` stays itself,
* `rifle.ak` becomes `rifle ak`.

View File

@@ -42,3 +42,26 @@ export function eventTip(row, titles) {
export function isBoss(row) {
return Boolean(row && (row.boss || row.kind === 'boss'))
}
// ── Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3) ──────────────
/** Dynamic PVP's own events as the map names them; a monument's or an operator's is shown as written. */
export const PVP_NAMES = Object.freeze({
Helicopter: 'Patrol helicopter',
Bradley: 'Bradley APC',
Satellite: 'Satellite crash',
TimedSupply: 'Airdrop',
SupplySignal: 'Signalled airdrop',
CargoShip: 'Cargo ship',
HackableCrate: 'Locked crate',
ExcavatorIgnition: 'Giant Excavator',
GhostShip: 'Ghost ship',
DeepSeaIsland: 'Deep Sea island',
IslandCannon: 'Island cannon',
})
/** A Dynamic PVP ring's tooltip: "PvP zone · Patrol helicopter". */
export function pvpTip(z) {
const name = PVP_NAMES[z.event] || z.event
return name ? `PvP zone · ${name}` : 'PvP zone'
}

View File

@@ -259,7 +259,88 @@ export function integrationsLine(r) {
: 'This server’s plugin is older than protocol 12, so it cannot say which optional mods it has.'
}
const one = (name, x, without) => (x && x.loaded ? `${name} ${x.version || ''} is loaded`.trim() : `${name} is not loaded — ${without}`)
return `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
const line = `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
const withEconomics = r.integrations.economics ? `${line} ${economicsLine(r.integrations.economics)}` : line
const withBackpacks = r.integrations.backpacks ? `${withEconomics} ${backpacksLine(r.integrations.backpacks)}` : withEconomics
const withTruePve = r.integrations.truePve ? `${withBackpacks} ${truePveLine(r.integrations.truePve)}` : withBackpacks
const withDynamicPvp = r.integrations.dynamicPvp ? `${withTruePve} ${dynamicPvpLine(r.integrations.dynamicPvp)}` : withTruePve
const withDt = r.integrations.dangerousTreasures ? `${withDynamicPvp} ${dangerousTreasuresLine(r.integrations.dangerousTreasures, r.console)}` : withDynamicPvp
return r.integrations.guardedCrate ? `${withDt} ${guardedCrateLine(r.integrations.guardedCrate, r.console)}` : withDt
}
/**
* Guarded Crate (PLAN_REDESIGNS §14.3.5), from protocol 14, with NpcSpawn beside it
* (D364): its crates on the feed and the map, and whether a step can place one,
* which needs NpcSpawn for the guards and the framework to run its console command.
*/
export function guardedCrateLine(gc, probe) {
const npc = gc.npcSpawn && gc.npcSpawn.loaded ? `NpcSpawn ${gc.npcSpawn.version || ''}`.trim() : null
if (!gc.loaded) {
return `Guarded Crate is not loaded — a step cannot place a guarded crate.${npc ? ` ${npc} is loaded.` : ''}`
}
const name = `Guarded Crate ${gc.version || ''}`.trim()
const events = Array.isArray(gc.events) && gc.events.length ? ` Its events: ${gc.events.join(', ')}.` : ''
if (!npc) {
return `${name} is loaded but NpcSpawn is missing: its events cannot start, and a step cannot place one.`
}
if (probe && probe.plugins === false) {
return `${name} is loaded with ${npc} — its crates show on the feed and the map, but this framework cannot run its console command, so a step cannot place one (Rust-Plugins#34).${events}`
}
return `${name} is loaded with ${npc} — its crates show on the feed and the map, and a step may place one.${events}`
}
/**
* Dangerous Treasures (PLAN_REDESIGNS §14.3.4), from protocol 14: its events on the
* feed and the map, and whether a step can start one, which needs the framework
* to run its console command (hello's `console.plugins`, D363).
*/
export function dangerousTreasuresLine(dt, probe) {
if (!dt.loaded) return 'Dangerous Treasures is not loaded — a step cannot start a treasure event.'
const name = `Dangerous Treasures ${dt.version || ''}`.trim()
if (probe && probe.plugins === false) {
return `${name} is loaded — its events show on the feed and the map, but this framework cannot run its console command, so a step cannot start one (Rust-Plugins#34).`
}
return `${name} is loaded — its events show on the feed and the map, and a step may start one.`
}
/** Dynamic PVP (PLAN_REDESIGNS §14.3.3), from protocol 14: its zones on the feed and the map. */
export function dynamicPvpLine(d) {
if (!d.loaded) return 'Dynamic PVP is not loaded — no PvP zones of its own on the feed or the map.'
return `${`Dynamic PVP ${d.version || ''}`.trim()} is loaded — its PvP zones show on the feed and the map.`
}
/** TruePVE (PLAN_REDESIGNS §14.3.2), from protocol 14: what a zone step may do with PvP. */
export function truePveLine(tp) {
if (!tp.loaded) return 'TruePVE is not loaded — a zone step cannot change PvP rules.'
return `${`TruePVE ${tp.version || ''}`.trim()} is loaded — a zone step may turn off PvP protection or name a ruleset.`
}
/**
* Backpacks (PLAN_REDESIGNS §11.3.2), from protocol 14. Its sizes are what a
* Bigger backpack step may lease (D337), so they are said.
*/
export function backpacksLine(b) {
if (!b.loaded) return 'Backpacks is not loaded — backpack views and bigger-backpack rewards wait for it.'
const name = `Backpacks ${b.version || ''}`.trim()
const sizes = Array.isArray(b.sizes) ? b.sizes : []
return sizes.length > 1
? `${name} is loaded — sizes ${sizes.join(', ')}; an event may lend any above ${sizes[0]}.`
: `${name} is loaded, but offers no size above its smallest, so an event has nothing bigger to lend.`
}
/**
* Economics (PLAN_REDESIGNS §11.2.2), from protocol 14. Its starting balance
* decides who the richest column ranks (D336), so a config the plugin could not
* read is said: the site then assumes Economics' default of 1000.
*/
export function economicsLine(e) {
if (!e.loaded) return 'Economics is not loaded — coin rewards, balances and the richest column wait for it.'
const name = `Economics ${e.version || ''}`.trim()
if (e.startingBalance === undefined || e.startingBalance === null) {
return `${name} is loaded, but its config could not be read — the site assumes a starting balance of 1,000.`
}
const wipes = e.wipesWithMap === true ? 'balances wipe with the map' : e.wipesWithMap === false ? 'balances carry over a wipe' : null
return `${name} is loaded — starting balance ${Number(e.startingBalance).toLocaleString()}${wipes ? `, ${wipes}` : ''}.`
}
export function VoiceCard() {

View File

@@ -191,12 +191,15 @@ function playerState(facts, view, player, permission) {
if (direct) {
const wider = direct.sources.some((s) => (s.type === 'userGrant' || s.type === 'steamGrant') && s.scope !== view.server.id)
const fromEvent = direct.sources.every((s) => s.type === 'runGrant')
// Given for good by an event (PLAN_REDESIGNS.md §12.5): a standing grant like
// a staff member's, toggled off like one, and marked with its note.
const given = direct.sources.find((s) => s.type === 'userGrant' && s.origin === 'event')
let chip
if (!facts.registered.has(permission)) chip = <Chip tone="bad" title="No loaded plugin registers it here">not registered here</Chip>
else if (facts.notLanded.has(`${player.steamId}:${permission}`)) chip = <Chip tone="bad">did not land</Chip>
else if (facts.landed.has(`grant ${player.steamId} ${permission}`)) chip = <Chip tone="good">granted</Chip>
else chip = <Chip tone="warn">waiting for a sync</Chip>
return { on: true, chip, wider, fromEvent, excepted: null, viaGroups }
return { on: true, chip, wider, fromEvent, given: given ? given.note || 'an event' : null, excepted: null, viaGroups }
}
if (viaGroups.length) {
@@ -294,6 +297,7 @@ function PlayerPanel({ view, player, act, busy }) {
<Row key={perm}>
<code style={{ flex: 1, fontSize: '0.8rem' }}>{perm}</code>
{s.fromEvent && <Chip tone="info" title="An event gave it; the event’s revert takes it back">from an event</Chip>}
{s.given && <Chip tone="info" title={`Given for good by ${s.given}; turn it off to take it back`}>given by {s.given}</Chip>}
{s.chip}
<Toggle on={s.on} disabled={busy || s.readOnly || s.fromEvent} onChange={(on) => toggle(perm, on)} />
</Row>

View File

@@ -38,6 +38,16 @@ const RULES = [
{ id: 'biweekly', label: 'Every other week', hint: 'Every second week, on the weeks the date below falls in, and the forced wipe.' },
]
// Who a downed patrol helicopter, Chinook or Bradley credits (protocol 14, D372).
// The same rule writes the feed line and the Rotorbane / Tankbreaker counts; the
// server's own list, mirrored here so the form needs no round trip.
const CREDIT_RULES = [
{ id: 'blow', label: 'Killing blow', hint: 'The player whose hit destroyed it — for the patrol helicopter, the crippling hit, else the last hit. The default.' },
{ id: 'last', label: 'Last hit', hint: 'The last player to hurt it.' },
{ id: 'most', label: 'Most damage', hint: 'The player who dealt it the most damage.' },
{ id: 'all', label: 'Everyone who hurt it', hint: 'Every player who damaged it, one each.' },
]
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const SOURCE = { forced: 'the forced wipe', rule: 'its own schedule', once: 'the one-off date' }
@@ -110,6 +120,7 @@ function blankForm() {
wipeTz: localZone(),
wipeAnchor: '',
wipeOnce: '',
creditRule: 'blow',
}
}
@@ -131,6 +142,7 @@ function formFrom(server) {
wipeAnchor: s.anchor || '',
wipeOnce: toLocalInput(s.onceAt),
storedOnceAt: s.onceAt || null,
creditRule: server.creditRule || 'blow',
}
}
@@ -155,6 +167,7 @@ function bodyFrom(form) {
? null
: new Date(form.wipeOnce).toISOString(),
}
if (form.creditRule) body.creditRule = form.creditRule
if (form.sidecarToken) body.sidecarToken = form.sidecarToken
if (form.protocol !== undefined) body.protocol = form.protocol
return body
@@ -409,6 +422,17 @@ function ServerForm({ form, set, busy, error, onSave, onCancel }) {
</p>
)}
</fieldset>
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '12px 14px', display: 'grid', gap: 12 }}>
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>World events</legend>
<Field
label="Who downs a helicopter or a Bradley"
hint={`${(CREDIT_RULES.find((r) => r.id === form.creditRule) || CREDIT_RULES[0]).hint} It names them in the feed and counts toward Rotorbane and Tankbreaker, from now on.`}
>
<select value={form.creditRule} onChange={set('creditRule')} style={inputStyle}>
{CREDIT_RULES.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
</select>
</Field>
</fieldset>
<datalist id="rust-zones">{ZONES.map((z) => <option key={z} value={z} />)}</datalist>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>

View File

@@ -14,10 +14,11 @@
// majority of them. Silence is the honest answer to "what does the Rust module
// know about this person" when it is nothing.
import { useCallback, useState } from 'react'
import { ago, count, duration } from '../../lib/format.js'
import { useCallback, useRef, useState } from 'react'
import { ago, coins, count, duration } from '../../lib/format.js'
import { useAsync } from '../../core.js'
import api from '../../api.js'
import Backpack from '../../components/Backpack.jsx'
/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */
function SectionTitle({ children }) {
@@ -229,6 +230,151 @@ function PermissionsPanel({ userId, data, onChanged }) {
)
}
/** One submission's key, so a resent form moves nothing (§11.2.2). */
function submissionKey() {
const c = globalThis.crypto
return c && typeof c.randomUUID === 'function' ? c.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
}
/** One server's coins for this person, and the form that changes them. */
function EconomicsServer({ userId, server, onChanged }) {
const [steamId, setSteamId] = useState(server.accounts.length ? server.accounts[0].steamId : '')
const [op, setOp] = useState('deposit')
const [amount, setAmount] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [done, setDone] = useState('')
// D199: said, not hidden — staff asking why the form is missing are owed it.
if (!server.economics) {
return (
<div className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
<span style={{ color: 'var(--head)' }}>{server.server}</span>{' '}
<span className="dim" style={{ fontSize: '0.76rem' }}>{server.reason}</span>
</div>
)
}
async function submit(event) {
event.preventDefault()
if (busy || !steamId || amount === '') return
setBusy(true)
setError('')
setDone('')
try {
const result = await api.adminUserEconomics.adjust(userId, { serverId: server.serverId, steamId, op, amount: Number(amount), key: submissionKey() })
setDone(`${coins(result.before)} → ${coins(result.after)}`)
setAmount('')
await onChanged()
} catch (err) {
// "They have only 340 coins on Main" is the server's sentence, and the useful one.
setError(err.message || 'That did not work.')
} finally {
setBusy(false)
}
}
return (
<div style={{ padding: '6px 0', borderTop: '1px solid var(--line-soft)' }}>
<div className="sans" style={{ fontSize: '0.84rem' }}>
<span style={{ color: 'var(--head)' }}>{server.server}</span>{' '}
{server.accounts.map((a, i) => (
<span key={a.steamId}>
{i > 0 && <span className="dim"> · </span>}
{server.accounts.length > 1 && <span className="dim">{a.name || a.steamId} </span>}
<strong>{coins(a.balance)}</strong>
{a.source === 'last-known' && <span className="dim"> (last known)</span>}
</span>
))}
{!server.startingKnown && (
<span className="dim" style={{ fontSize: '0.74rem' }}>
{' '}· its Economics config could not be read, so a starting balance of {coins(server.startingBalance)} is assumed
</span>
)}
</div>
<form onSubmit={submit} style={{ display: 'flex', gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
{server.accounts.length > 1 && (
<select className="input" value={steamId} onChange={(e) => setSteamId(e.target.value)} aria-label="Steam account">
{server.accounts.map((a) => <option key={a.steamId} value={a.steamId}>{a.name || a.steamId}</option>)}
</select>
)}
<select className="input" value={op} onChange={(e) => setOp(e.target.value)} aria-label="What to do">
<option value="deposit">Deposit</option>
<option value="withdraw">Withdraw</option>
<option value="set">Set to</option>
</select>
<input
className="input"
type="number"
min={op === 'set' ? 0 : 0.01}
max={1000000}
step="0.01"
placeholder="500"
value={amount}
onChange={(e) => setAmount(e.target.value)}
aria-label="Coins"
style={{ width: 120 }}
/>
<button type="submit" className="btn" disabled={busy || amount === ''}>
{busy ? 'Working…' : 'Apply'}
</button>
</form>
{done && <p className="sans dim" style={{ fontSize: '0.78rem', margin: '6px 0 0' }}>Done: {done}. Recorded in the activity log.</p>}
{error && <p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>}
</div>
)
}
/**
* Economics (PLAN_REDESIGNS §11.2.2): this person's coins on every server, and
* staff's hand on them — the only way coins come back after an event paid them
* (D335). Its own reload, and it keeps showing the last answer while it
* re-reads, so a deposit's "1,000 → 1,500" is not blanked by the read it caused.
*/
function EconomicsPanel({ userId }) {
const [reloads, setReloads] = useState(0)
const { data } = useAsync(() => api.adminUserEconomics.list(userId), [userId, reloads])
const last = useRef(null)
if (data) last.current = data
const shown = data || last.current
const onChanged = useCallback(() => setReloads((n) => n + 1), [])
const list = (shown && shown.servers) || []
if (!list.length) return null
return (
<div className="panel" style={{ padding: '14px 16px' }}>
<div className="field-label" style={{ marginBottom: 8 }}>Coins</div>
{list.map((server) => (
<EconomicsServer key={server.serverId} userId={userId} server={server} onChanged={onChanged} />
))}
</div>
)
}
/**
* Backpacks (PLAN_REDESIGNS §11.3.2): what this person carries, per server and
* linked account, read live when staff open it. Read only (R2): staff see the
* overflow too, and only the owner's own open moves it (D338).
*/
function BackpackPanel({ userId, links }) {
const { data } = useAsync(() => api.servers.list(), [])
const list = (data && data.servers) || []
if (!list.length) return null
return (
<div className="panel" style={{ padding: '14px 16px' }}>
<div className="field-label" style={{ marginBottom: 8 }}>Backpack</div>
<Backpack
servers={list.map((s) => ({ id: s.id, name: s.name }))}
accounts={links.map((l) => ({ steamId: l.steamId, name: l.name }))}
read={(serverId, steamId) => api.adminUserBackpacks.read(userId, serverId, steamId)}
/>
</div>
)
}
export default function UserRustSections({ userId }) {
// Core's `useAsync` has no refresh, so a counter in the deps is how this
// re-reads after its own write (the same shape the player page uses).
@@ -278,6 +424,12 @@ export default function UserRustSections({ userId }) {
above belongs to the links, so it sits with them rather than under
the panel it would otherwise appear to describe. */}
<PermissionsPanel userId={userId} data={permissions} onChanged={reload} />
{/* Coins belong to a Steam account, so only with one linked. */}
{data.links.length > 0 && <EconomicsPanel userId={userId} />}
{/* A backpack belongs to a Steam account too. */}
{data.links.length > 0 && <BackpackPanel userId={userId} links={data.links} />}
</div>
</section>
)

View File

@@ -384,6 +384,7 @@ const MAP_LAYERS = [
{ id: 'events', label: 'Site events', hint: 'The zones, crates and NPCs this site’s events placed, while they run.' },
{ id: 'players', label: 'Players', hint: 'Where everybody is, with names, and the sleepers.' },
{ id: 'bases', label: 'Bases', hint: 'Tool cupboards and vending machines, as positions only.' },
{ id: 'pvp', label: 'PvP zones', hint: 'Dynamic PVP’s own PvP zones around a downed helicopter, a Bradley, cargo and the rest. Needs Dynamic PVP on the server.' },
]
const RANK = { public: 0, signed_in: 1, staff: 2 }

View File

@@ -16,8 +16,9 @@
import { useCallback, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago, shortId } from '../../lib/format.js'
import { ago, coins, shortId } from '../../lib/format.js'
import api from '../../api.js'
import Backpack from '../../components/Backpack.jsx'
/** The code field, and the four answers it can produce. */
function LinkForm({ onLinked }) {
@@ -292,6 +293,59 @@ function NpcKills() {
)
}
/**
* Economics (PLAN_REDESIGNS §11.2.2): your coins on each server, per linked
* account. Live where the server answers; otherwise the last balance it
* reported, marked so. A server without Economics says so (D199) rather than
* vanishing — a player asking "where are my coins" is owed the reason.
*/
function Coins() {
const { data, loading, error } = useAsync(() => api.playerEconomics.list(), [])
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
const list = (data && data.servers) || []
if (list.length === 0) return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>No servers yet.</p>
return (
<ul className="sans" style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.86rem', display: 'grid', gap: 6 }}>
{list.map((server) => (
<li key={server.serverId}>
<span className="dim">{server.server}: </span>
{!server.economics ? (
<span className="dim">{server.reason}</span>
) : (
server.accounts.map((a, i) => (
<span key={a.steamId}>
{i > 0 && <span className="dim"> · </span>}
{server.accounts.length > 1 && <span className="dim">{a.name || shortId(a.steamId)} </span>}
<strong>{coins(a.balance)}</strong>
{a.source === 'last-known' && <span className="dim"> (last known — the server did not answer)</span>}
</span>
))
)}
</li>
))}
</ul>
)
}
/**
* Backpacks (PLAN_REDESIGNS §11.3.2): your own backpack, from one of your
* linked accounts, read live when you open it. A server without Backpacks
* says so when asked (D199).
*/
function OwnBackpack({ links }) {
const { data } = useAsync(() => api.playerServers.list(), [])
const list = (data && data.servers) || []
if (!list.length) return null
return (
<Backpack
servers={list.map((s) => ({ id: s.id, name: s.name }))}
accounts={links.map((l) => ({ steamId: l.steamId, name: l.name }))}
read={(serverId, steamId) => api.playerBackpacks.read(serverId, steamId)}
/>
)
}
export default function Account() {
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
// the person looking at it changes it, and a page that re-asked every twenty
@@ -360,6 +414,12 @@ export default function Account() {
<div className="field-label" style={{ margin: '30px 0 12px' }}>NPCs you have killed</div>
{data && links.length > 0 ? <NpcKills /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see them.</p>}
<div className="field-label" style={{ margin: '30px 0 12px' }}>Coins</div>
{data && links.length > 0 ? <Coins /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see your balance.</p>}
<div className="field-label" style={{ margin: '30px 0 12px' }}>Backpack</div>
{data && links.length > 0 ? <OwnBackpack links={links} /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see your backpack.</p>}
</div>
)
}

View File

@@ -0,0 +1,62 @@
// ── A backpack as a grid (PLAN_REDESIGNS §11.3.2) ─────────────────────────
//
// The grid is drawn from these, so each test is a backpack that could be drawn
// wrong: a two-page one, one with overflow past its capacity, one that never
// existed, and the labels a slot can carry (D340, D341).
import test from 'node:test'
import assert from 'node:assert/strict'
import { backpackPages, overflowCount, stackLabel } from '../src/lib/backpack.js'
test('a backpack that does not exist is no pages', () => {
assert.deepEqual(backpackPages(null), [])
assert.deepEqual(backpackPages({ exists: false }), [])
})
test('slots run across pages of the page size', () => {
const pages = backpackPages({
exists: true,
capacity: 96,
pageSize: 48,
items: [{ slot: 0, shortname: 'wood', amount: 1000 }, { slot: 50, shortname: 'note', amount: 1 }],
})
assert.equal(pages.length, 2)
assert.equal(pages[0].cells.length, 48)
assert.equal(pages[1].cells.length, 48)
assert.equal(pages[0].cells[0].stack.shortname, 'wood')
assert.equal(pages[1].cells[2].slot, 50)
assert.equal(pages[1].cells[2].stack.shortname, 'note')
assert.ok(pages.every((p) => p.cells.every((c) => !c.overflow)))
})
test('stacks past the capacity are drawn after it, marked overflow', () => {
// A size revoked at the end of a run: capacity 6, five notes still on what was page two.
const backpack = {
exists: true,
capacity: 6,
pageSize: 48,
items: [{ slot: 0, shortname: 'wood', amount: 1 }, ...[48, 49, 50, 51, 52].map((slot) => ({ slot, shortname: 'note', amount: 1, overflow: true }))],
}
const pages = backpackPages(backpack)
assert.equal(pages.length, 2)
assert.equal(pages[0].cells.slice(0, 6).filter((c) => c.overflow).length, 0)
assert.ok(pages[0].cells.slice(6).every((c) => c.overflow))
assert.equal(pages[1].cells.length, 5)
assert.equal(overflowCount(backpack), 5)
})
test('an empty backpack still draws its capacity', () => {
const pages = backpackPages({ exists: true, capacity: 12, pageSize: 48, items: [] })
assert.equal(pages.length, 1)
assert.equal(pages[0].cells.length, 12)
assert.ok(pages[0].cells.every((c) => c.stack === null))
})
test('a slot is named by the game, a blueprint by what it makes', () => {
assert.equal(stackLabel({ shortname: 'rifle.ak', display: 'Assault Rifle' }), 'Assault Rifle')
assert.equal(stackLabel({ shortname: 'blueprintbase', display: 'Assault Rifle', blueprint: 'rifle.ak' }), 'Assault Rifle blueprint')
assert.equal(stackLabel({ shortname: 'wood' }), 'wood')
assert.equal(stackLabel({ shortname: null, itemId: 999 }), 'item 999')
assert.equal(stackLabel(null), '')
})

View File

@@ -155,3 +155,147 @@ test('an unknown filter falls back to everything rather than to nothing', () =>
assert.deepEqual(kindsFor('nonsense'), FEED_KINDS)
assert.deepEqual(kindsFor(undefined), FEED_KINDS)
})
// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ─────────
//
// The frames here are what the SERVER sends a viewer (`worldEvents.shapeFor`),
// not what the plugin emitted: a grid instead of a position, names only for the
// presence audience, `credited` by the server's rule.
const world = (frame) => row('world.event', { kind: 'world.event', ...frame })
test('a downed helicopter names whom the rule credits, and only them', () => {
const one = describe(world({ event: 'heli.downed', grid: 'G7', creditRule: 'blow', creditedCount: 1, credited: [{ name: 'Alice' }] }))
assert.deepEqual([one.tone, one.verb, one.subject, one.detail], ['kill', 'Patrol helicopter downed by', 'Alice', 'G7'])
const many = describe(world({ event: 'heli.downed', grid: 'G7', creditRule: 'all', creditedCount: 4, credited: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Cleo' }, { name: 'Dov' }] }))
assert.equal(many.subject, 'Alice, Bob and 2 others')
})
test('below the presence audience the same line names nobody (D376)', () => {
const line = describe(world({ event: 'heli.downed', grid: 'G8', creditRule: 'blow', creditedCount: 1 }))
assert.deepEqual([line.verb, line.subject, line.detail], ['Patrol helicopter downed', null, 'G8'])
const hack = describe(world({ event: 'crate.hack.started', where: 'oilrig_large', grid: 'C3' }))
assert.equal(hack.actor, null)
assert.equal(hack.verb, 'A locked crate is being hacked on Large Oil Rig')
})
test('a named line reads as the plan writes it', () => {
const hack = describe(world({ event: 'crate.hack.started', where: 'oilrig_large', name: 'Bob' }))
assert.equal(`${hack.actor} ${hack.verb}`, 'Bob started hacking the locked crate on Large Oil Rig')
const road = describe(world({ event: 'bradley.spawned', where: 'road', count: 12 }))
assert.equal(road.verb, '12 road Bradleys are out')
assert.equal(describe(world({ event: 'deepsea.opened' })).verb, 'The Deep Sea is open')
assert.equal(describe(world({ event: 'cargo.docked', harbourName: 'Harbor', grid: 'Q9' })).verb, 'The cargo ship has docked at Harbor')
assert.equal(describe(world({ event: 'chinook.dropped', monumentName: 'Water Treatment Plant' })).verb, 'A Chinook dropped a locked crate at Water Treatment Plant')
})
test('every type the server knows has its own words, and an unknown one says its name', () => {
const { TYPE_IDS } = createRequire(import.meta.url)('../../server/worldEvents.js')
for (const event of TYPE_IDS) {
const line = describe(world({ event }))
assert.notEqual(line.tone, 'other', `${event} has no words of its own`)
assert.ok(line.verb && line.verb !== event, event)
}
assert.equal(describe(world({ event: 'meteor.landed' })).verb, 'meteor.landed')
})
test('the world filter asks for world events, Dynamic PVP zones opening, Dangerous Treasures and Guarded Crate, which the server serves publicly', () => {
assert.deepEqual(kindsFor('world'), ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended'])
assert.ok(!FEED_KINDS.includes('dt.ended'), 'a treasure event ending is a staff line')
assert.ok(!FEED_KINDS.includes('gc.failed'), 'a guarded crate timing out is a staff line')
assert.ok(FEED_KINDS.includes('pvp.zone.created'))
assert.ok(!FEED_KINDS.includes('pvp.zone.deleted'), 'a zone closing is a staff line')
})
// ── Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3) ─────────────
const pvp = (kind, frame) => row(kind, { kind, ...frame })
test('a zone opening says what it is around, where, and for how long, and names nobody', () => {
const heli = describe(pvp('pvp.zone.created', { event: 'Helicopter', grid: 'G7', durationSec: 600 }))
assert.equal(heli.verb, 'A PvP zone opened around the downed patrol helicopter')
assert.equal(heli.detail, 'G7 · for 10 min')
assert.equal(heli.actor, null)
assert.equal(heli.subject, null)
const cargo = describe(pvp('pvp.zone.created', { event: 'CargoShip', grid: 'Q9', durationSec: -1 }))
assert.equal(cargo.verb, 'A PvP zone opened around the cargo ship')
assert.equal(cargo.detail, 'Q9 · while the ship is here')
assert.equal(describe(pvp('pvp.zone.created', { event: 'Bradley', durationSec: 90 })).detail, 'for 90 s')
})
test('a zone the public are not told the event of is just a zone; staff read a monument’s name as written', () => {
assert.equal(describe(pvp('pvp.zone.created', { grid: 'C3' })).verb, 'A PvP zone opened')
assert.equal(describe(pvp('pvp.zone.created', { event: 'Airfield' })).verb, 'A PvP zone opened (Airfield)')
})
test('a zone closing, for a staff reader, says which one closed', () => {
assert.equal(describe(pvp('pvp.zone.deleted', { event: 'Helicopter' })).verb, 'The PvP zone around the downed patrol helicopter has closed')
assert.equal(describe(pvp('pvp.zone.deleted', {})).verb, 'The PvP zone has closed')
})
test('every one of Dynamic PVP’s own events has words, and the server and client agree on which they are', () => {
const { GENERAL_EVENTS } = createRequire(import.meta.url)('../../server/dynamicPvp.js')
for (const event of GENERAL_EVENTS) {
assert.ok(!describe(pvp('pvp.zone.created', { event })).verb.includes(`(${event})`), `${event} has no words of its own`)
}
})
// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4) ─────────────
const treasure = (kind, frame) => row(kind, { kind, ...frame })
test('a treasure event starting says where, and names nobody', () => {
const line = describe(treasure('dt.started', { grid: 'K12' }))
assert.equal(line.verb, 'A Dangerous Treasures event has started')
assert.equal(line.detail, 'K12')
assert.equal(line.actor, null)
})
test('a win names its winner only when the server sent the name, and says how many fought', () => {
const named = describe(treasure('dt.won', { grid: 'K12', name: 'Alice', invaderCount: 3 }))
assert.equal(named.actor, 'Alice')
assert.equal(named.verb, 'won the treasure')
assert.equal(named.detail, 'K12 · 3 players fought for it')
const unnamed = describe(treasure('dt.won', { grid: 'K12', invaderCount: 1 }))
assert.equal(unnamed.actor, null)
assert.equal(unnamed.verb, 'The treasure was won')
assert.equal(unnamed.detail, 'K12')
})
test('a treasure event ending, for a staff reader, is its own line', () => {
assert.equal(describe(treasure('dt.ended', { grid: 'K12' })).verb, 'A Dangerous Treasures event has ended')
})
// ── Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5) ───────────────────
const guarded = (kind, frame) => row(kind, { kind, ...frame })
test('a guarded crate landing says where, and names nobody', () => {
const line = describe(guarded('gc.started', { grid: 'M4' }))
assert.equal(line.verb, 'A guarded crate landed')
assert.equal(line.detail, 'M4')
assert.equal(line.actor, null)
})
test('a clearing names its clearer only when the server sent the name', () => {
const named = describe(guarded('gc.ended', { grid: 'M4', won: true, name: 'Alice' }))
assert.equal(named.actor, 'Alice')
assert.equal(named.verb, 'cleared the guarded crate')
assert.equal(named.detail, 'M4')
const unnamed = describe(guarded('gc.ended', { grid: 'M4', won: true }))
assert.equal(unnamed.actor, null)
assert.equal(unnamed.verb, 'The guarded crate was cleared')
})
test('an ending with nobody clearing it, and a timeout for a staff reader, are their own lines', () => {
assert.equal(describe(guarded('gc.ended', { grid: 'M4', won: false })).verb, 'A guarded crate ended with nobody clearing it')
assert.equal(describe(guarded('gc.failed', { grid: 'M4' })).verb, 'A guarded crate timed out with its guards standing')
})

View File

@@ -11,7 +11,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { ago, attacker, clock, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
import { ago, attacker, clock, coins, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
const NOW = Date.parse('2026-09-16T12:00:00Z')
@@ -70,6 +70,13 @@ test('a count that is not a number is zero, never NaN on the page', () => {
assert.equal(count(null), '0')
})
test('a balance never reported is a dash, not zero coins (PLAN_REDESIGNS §11.2.2)', () => {
assert.equal(coins(null), '—')
assert.equal(coins(undefined), '—')
assert.equal(coins(0), (0).toLocaleString())
assert.equal(coins(1500.456), (1500.46).toLocaleString(undefined, { maximumFractionDigits: 2 }))
})
test("a feed row from another day carries its date, not just a time", () => {
// Found by the page walk: with the feed filtered to the previous wipe, three
// events from six weeks ago rendered as `02:03 PM` and read as this afternoon.

View File

@@ -6,7 +6,9 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { eventTip, isBoss, titlesFrom, unknownRuns } from '../src/lib/mapEvents.js'
import { createRequire } from 'node:module'
import { eventTip, isBoss, pvpTip, PVP_NAMES, titlesFrom, unknownRuns } from '../src/lib/mapEvents.js'
const CALENDAR = {
entries: [
@@ -47,3 +49,13 @@ test('a boss is an event boss or a world one', () => {
test('the run ids still to look up', () => {
assert.deepEqual(unknownRuns([{ runId: '41' }, { runId: '7' }, { runId: '7' }, {}], { 41: 'x' }), ['7'])
})
// Dynamic PVP's rings (PLAN_REDESIGNS §14.3.3): its own events by a plain name,
// a monument's or an operator's as written, and nothing at all as just a zone.
test('a Dynamic PVP ring says what it is around', () => {
assert.equal(pvpTip({ event: 'Helicopter' }), 'PvP zone · Patrol helicopter')
assert.equal(pvpTip({ event: 'Airfield' }), 'PvP zone · Airfield')
assert.equal(pvpTip({}), 'PvP zone')
const { GENERAL_EVENTS } = createRequire(import.meta.url)('../../server/dynamicPvp.js')
assert.deepEqual(Object.keys(PVP_NAMES).sort(), [...GENERAL_EVENTS].sort())
})

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"id": "rust",
"name": "Rust",
"version": "0.1.0",
"coreApi": "^1.11.0",
"coreApi": "^1.15.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",

View File

@@ -126,6 +126,16 @@
"path": "/api/v1/admin/rust/zones",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/rust/backpacks",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/rust/economics",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/rust/links",
@@ -136,6 +146,16 @@
"path": "/api/v1/admin/users/:id/rust/permissions",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/rust/backpacks",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/rust/economics",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/rust/links",
@@ -351,6 +371,11 @@
"path": "/api/v1/admin/rust/zones/presets",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/users/:id/rust/economics",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/users/:id/rust/permissions/grants",

View File

@@ -59,6 +59,29 @@ const PUBLIC_KINDS = Object.freeze([
// the players near it (D286), so `npc.boss.phase` is staff's, below.
'npc.boss.spawned',
'npc.boss.died',
// Protocol 14: Rust's own world events (PLAN_REDESIGNS §14.3.1, D353, D376).
// Public as a KIND, and filtered per `event` inside it by `worldEvents.js`:
// an event type this build does not know is staff's alone, a player's name
// shows only to the presence audience, a Steam id only to staff, and a
// position to nobody. So it is not a presence kind — the event names nobody
// once `worldEvents.shapeFor` has read it for a viewer below that audience.
'world.event',
// Protocol 14: Dynamic PVP opened a PvP zone (PLAN_REDESIGNS §14.3.3). It
// names nobody, and `dynamicPvp.shapeFor` keeps its position for staff and an
// operator's own event names off public pages.
'pvp.zone.created',
// Protocol 14: a Dangerous Treasures event opened, and was won
// (PLAN_REDESIGNS §14.3.4). Public as kinds, read per viewer by
// `dangerousTreasures.shapeFor` as a world event is: the winner's name only
// for the presence audience, Steam ids and the position only for staff.
'dt.started',
'dt.won',
// Protocol 14: a guarded crate landed, and was cleared (PLAN_REDESIGNS
// §14.3.5). Public as kinds, read per viewer by `guardedCrate.shapeFor` as a
// treasure is: the clearer's name only for the presence audience, the Steam id
// and the position only for staff.
'gc.started',
'gc.ended',
])
/**
@@ -121,6 +144,15 @@ const STAFF_KINDS = Object.freeze([
'npc.placement.changed',
// Runicnpc stage 6: a boss's phase is said in game only to the players near it (D286).
'npc.boss.phase',
// Protocol 14 (§14.3.3): a Dynamic PVP zone closed. The plan keeps its close
// a staff line: the public learns a zone opened, and the map shows it going.
'pvp.zone.deleted',
// Protocol 14 (§14.3.4): a Dangerous Treasures event ended, won or not. The
// plan keeps it staff's; the map shows the chest go.
'dt.ended',
// Protocol 14 (§14.3.5): a guarded crate timed out with guards still standing.
// The plan keeps it staff's; the map shows the crate go.
'gc.failed',
])
/**
@@ -152,7 +184,7 @@ const PRESENCE_KINDS = Object.freeze([
'npc.boss.died',
])
/** Every event kind the protocol defines, through protocol 13. */
/** Every event kind the protocol defines, through protocol 14. */
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
const PUBLIC = new Set(PUBLIC_KINDS)

View File

@@ -0,0 +1,97 @@
// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4) ──────────────
//
// nivex's Dangerous Treasures opens a guarded, burning treasure chest somewhere
// on the map. Its events reach this module as three kinds: `dt.started`
// (public), `dt.won` (public) and `dt.ended` (staff's). The chest emptied is
// *Treasure Hunter*'s count, from the tally's `treasuresWon` (D375). A run's step
// opens one (`eventDangerousTreasures.js`).
//
// ── Who may read what ──────────────────────────────────────────────────────
//
// As a world event is read (`worldEvents.js`, D353, D376): the event and its
// grid are public; the winner's name shows only to the operator's presence
// audience, so with presence left at staff a signed-out visitor reads "The
// treasure was won at K12"; Steam ids, the invaders' among them, and the
// position are staff's. How many fought for it is a count, and names nobody.
// The step's key, when a step opened it, is staff's too.
const { gridOf } = require('./worldEvents')
const KINDS = Object.freeze(['dt.started', 'dt.won', 'dt.ended'])
/** DT's three shipped difficulties, as `dtevent <level>` takes them. */
const LEVELS = Object.freeze([
{ value: 0, label: 'Easy' },
{ value: 1, label: 'Medium' },
{ value: 2, label: 'Hard' },
])
const STEAM_ID = /^\d{17}$/
function isKind(kind) {
return KINDS.includes(kind)
}
/**
* One `dt.*` frame for one viewer: `{ staff, presence, geometry }`. Passes the
* envelope, adds `grid`; a win adds `invaderCount`, and `name` for the presence
* audience. Staff also get `x`, `z`, the step's `key`, the winner's `steamId` and
* the `invaders`. Any other frame is returned as is.
*/
function shapeFor(frame, { staff = false, presence = false, geometry = null } = {}) {
if (!frame || !isKind(frame.kind)) return frame
const out = {}
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
const grid = gridOf(geometry, frame.x, frame.z)
if (grid) out.grid = grid
if (frame.kind === 'dt.won') {
const invaders = Array.isArray(frame.invaders) ? frame.invaders : []
const count = Number(frame.invaderCount)
out.invaderCount = Number.isInteger(count) && count >= 0 ? count : invaders.length
if ((presence || staff) && typeof frame.name === 'string' && frame.name) out.name = frame.name
if (staff) {
if (STEAM_ID.test(String(frame.steamId || ''))) out.steamId = String(frame.steamId)
out.invaders = invaders.map(String).filter((id) => STEAM_ID.test(id))
}
}
if (staff) {
for (const key of ['x', 'z', 'key']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
}
return out
}
/**
* A server's last report about Dangerous Treasures and its console, from
* `servers.db.dtReportFor`: `{ dangerousTreasures, consolePlugins }`, each null
* where the plugin never said (no state yet, or a plugin before protocol 14).
* `dangerousTreasures` is `{ loaded, version }`.
*/
function reportOf(row) {
const parse = (value) => {
if (value === null || value === undefined) return null
if (typeof value !== 'string') return value
try {
return JSON.parse(value)
} catch {
return null
}
}
const dt = row ? parse(row.dangerousTreasures) : null
const probe = row ? parse(row.console) : null
return {
dangerousTreasures:
dt && typeof dt === 'object' ? { loaded: dt.loaded === true, version: typeof dt.version === 'string' ? dt.version : null } : null,
consolePlugins: probe && typeof probe === 'object' && typeof probe.plugins === 'boolean' ? probe.plugins : null,
}
}
module.exports = { KINDS, LEVELS, isKind, shapeFor, reportOf }

View File

@@ -857,6 +857,16 @@ CREATE TABLE IF NOT EXISTS rust_perm_run_grants (
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The reward's message to its player (protocol 14, PLAN_REDESIGNS.md §2.4,
-- D333): "You earned kit vip — type /kit vip", said once in game, at once or
-- when they next wake. NULL `notice` is a reward that says nothing, which is
-- every row written before this. `notice_told_at` is set when the plugin
-- reports the player was told; until then every sync carries it. A withdrawn
-- reward deletes its row, and its untold message goes with it.
ALTER TABLE rust_perm_run_grants ADD COLUMN IF NOT EXISTS notice VARCHAR(300) NULL;
ALTER TABLE rust_perm_run_grants ADD COLUMN IF NOT EXISTS notice_delivery VARCHAR(8) NULL;
ALTER TABLE rust_perm_run_grants ADD COLUMN IF NOT EXISTS notice_told_at DATETIME NULL;
-- The news switch (D104): whether a published news post is also said in this
-- server's chat. Off, because core enqueues every registered leg for every
-- post, and without it the day this module updates every post would start
@@ -1353,3 +1363,34 @@ CREATE TABLE IF NOT EXISTS rust_npc_factions (
-- stage 4, D250). Null for every other stat. No foreign key: a deleted profile
-- leaves a rule that ranks nobody, which the form shows and an admin removes.
ALTER TABLE rust_title_rules ADD COLUMN IF NOT EXISTS profile_id INT UNSIGNED NULL;
-- Economics (PLAN_REDESIGNS §11.2.2, D336): a player's last known coin balance
-- on the server, for the wipe it was reported in. A VALUE, written over, never
-- summed — the tally carries the balance itself, not a change. NULL until the
-- server has reported one, and again after `ecowipe` clears the wipe's. The
-- richest column and the Magnate title rank only balances above the server's
-- starting balance, so the untouched accounts of a fresh wipe rank nobody.
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS balance DECIMAL(14,2) NULL;
-- Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1). Two title
-- counts (D356): locked crates a player started hacking, once per crate
-- (Safecracker), and airdrops they looted first (Scavenger). Sums, from the
-- tally like the others. APC and helicopter kills keep their columns; under
-- protocol 14 the site writes them from `world.event`'s credit block instead.
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS crates_hacked INT UNSIGNED NOT NULL DEFAULT 0;
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS drops_looted INT UNSIGNED NOT NULL DEFAULT 0;
-- Who a downed helicopter, Chinook or Bradley credits on this server (D372,
-- D379): `blow` (the killing blow; for the patrol helicopter the crippling hit,
-- else the last hit), `last`, `most` (damage) or `all` (everyone who hurt it,
-- one each). The same rule writes the feed line and the count. A change applies
-- from then on; counts already written are not recounted.
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS credit_rule VARCHAR(8) NOT NULL DEFAULT 'blow';
-- Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4, D375): the treasure
-- chests a player emptied, from the tally like the others (Treasure Hunter).
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS treasures_won INT UNSIGNED NOT NULL DEFAULT 0;
-- Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5, D375): the guarded crates
-- a player cleared by killing the last guard, from the tally like the others (Warden).
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS guarded_won INT UNSIGNED NOT NULL DEFAULT 0;

71
server/dynamicPvp.js Normal file
View File

@@ -0,0 +1,71 @@
// ── Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3) ──────────────
//
// HunterZ's Dynamic PVP opens its own temporary PvP zones around a downed
// patrol helicopter, a Bradley, a locked crate, an airdrop, the cargo ship and
// the rest. They are the plugin's own: there is no step and no trigger here,
// only the feed's lines (`pvp.zone.created`, public; `pvp.zone.deleted`,
// staff's) and the map's `pvp` layer.
//
// ── Who may read what ──────────────────────────────────────────────────────
//
// As a world event is read (`worldEvents.js`, D353): the zone and its grid are
// public, and its position is staff's, because the map has its own audience for
// where things are (D111). A zone names nobody.
//
// **Only Dynamic PVP's own vanilla events carry their name to the public.** Its
// monument zones are named after the monument and an operator's own events by
// whatever the operator typed into its config; those reach staff as written and
// everyone else as "A PvP zone opened at G7".
const { gridOf } = require('./worldEvents')
/** Dynamic PVP 5.2.0's own events (its `GeneralEventType`), as its hooks name them. */
const GENERAL_EVENTS = Object.freeze([
'Bradley',
'Helicopter',
'Satellite',
'TimedSupply',
'SupplySignal',
'CargoShip',
'HackableCrate',
'ExcavatorIgnition',
'GhostShip',
'DeepSeaIsland',
'IslandCannon',
])
const KINDS = Object.freeze(['pvp.zone.created', 'pvp.zone.deleted'])
function isGeneral(event) {
return GENERAL_EVENTS.includes(event)
}
/**
* One `pvp.zone.*` frame for one viewer: `{ staff, geometry }`. Passes
* `kind`, `type`, `t`, `serverId`, `wipeId`, `radius`, `shape`, `durationSec`,
* adds `grid`, and `event` when it is one of Dynamic PVP's own or the viewer is
* staff. Staff also get `x`, `z` and `zoneId`. Any other frame is returned as is.
*/
function shapeFor(frame, { staff = false, geometry = null } = {}) {
if (!frame || !KINDS.includes(frame.kind)) return frame
const out = {}
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId', 'radius', 'shape', 'durationSec']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
const grid = gridOf(geometry, frame.x, frame.z)
if (grid) out.grid = grid
if (typeof frame.event === 'string' && frame.event && (staff || isGeneral(frame.event))) out.event = frame.event
if (staff) {
for (const key of ['x', 'z', 'zoneId']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
}
return out
}
module.exports = { GENERAL_EVENTS, KINDS, isGeneral, shapeFor }

View File

@@ -42,6 +42,8 @@ const clansDb = require('../model/clans/clans.db')
const eventsDb = require('../model/events/events.db')
const linksDb = require('../model/links/links.db')
const serversDb = require('../model/servers/servers.db')
const mapDb = require('../model/map/map.db')
const worldEvents = require('../worldEvents')
const { TRIGGER_IDS: T, PATHS, serverPath, leaderboardPath, clanPath } = require('./triggers')
const log = core.logger('engagement')
@@ -175,6 +177,25 @@ const HEADLINES = Object.freeze({
title: `${d.boss} is down to ${d.percent}% on ${d.server}`,
intro: `${d.boss} fell to ${d.percent}% of its health on ${d.server} and entered phase ${d.phase}.`,
}),
// Dangerous Treasures (protocol 14, §14.3.4). A missing grid drops out.
'rust.dt.started': (d) => ({
title: `A treasure event has started on ${d.server}`,
intro: `A Dangerous Treasures chest opened${d.grid ? ` at ${d.grid}` : ''} on ${d.server}. It is guarded, and it is burning.`,
}),
'rust.dt.won': (d) => ({
title: `The treasure was won on ${d.server}`,
intro: `${d.player || 'Somebody'} won the treasure${d.grid ? ` at ${d.grid}` : ''} on ${d.server}`
+ `${d.invaders > 1 ? `, with ${d.invaders} players fighting for it` : ''}.`,
}),
// Guarded Crate (protocol 14, §14.3.5). A missing grid drops out.
'rust.gc.started': (d) => ({
title: `A guarded crate landed on ${d.server}`,
intro: `A guarded crate landed${d.grid ? ` at ${d.grid}` : ''} on ${d.server}. Kill its guards to claim it.`,
}),
'rust.gc.ended': (d) => ({
title: `The guarded crate was cleared on ${d.server}`,
intro: `${d.player} cleared the guarded crate${d.grid ? ` at ${d.grid}` : ''} on ${d.server}.`,
}),
'rust.boss.killed': (d) => ({
title: `${d.boss} was killed on ${d.server}`,
intro: `${d.killer ? `${d.killer} landed the killing blow` : `${d.boss} fell`}`
@@ -186,9 +207,65 @@ const HEADLINES = Object.freeze({
}),
})
/** Where a crate is, as a sentence ends: "on Large Oil Rig", "dropped by a Chinook". */
const CRATE_PLACES = Object.freeze({
oilrig_large: 'on Large Oil Rig',
oilrig_small: 'on Small Oil Rig',
cargo: 'on the cargo ship',
ghostship: 'on a ghost ship',
chinook: 'dropped by a Chinook',
})
/**
* The headline of a world event (protocol 14), one sentence per type. Plain
* register like the rest; a part that is missing drops out rather than leaving
* a hole, so "at G7" disappears when the server has no map geometry yet.
*/
function worldHeadline(event, d) {
const at = d.grid ? ` at ${d.grid}` : ''
const on = ` on ${d.server}`
const by = d.player ? ` by ${d.player}` : ''
const place = CRATE_PLACES[d.where] || 'somewhere on the map'
const bradley = d.where === 'launch_site' ? "Launch Site's Bradley" : d.where === 'road' ? 'A road Bradley' : 'A Bradley'
const lines = {
'heli.arrived': ['A patrol helicopter is overhead', `A patrol helicopter flew onto the map${at}${on}.`],
'heli.left': ['The patrol helicopter has left', `The patrol helicopter left the map${on}.`],
'heli.downed': ['Patrol helicopter downed', `The patrol helicopter was downed${by}${at}${on}.`],
'bradley.spawned': [
d.where === 'road' ? (d.count > 1 ? `${d.count} road Bradleys are out` : 'A road Bradley is out') : 'Bradley is patrolling Launch Site',
d.where === 'road' ? `Road Bradleys took to the roads${on}.` : `Launch Site's Bradley is back${on}.`,
],
'bradley.destroyed': [`${bradley} was destroyed`, `${bradley} was destroyed${by}${at}${on}.`],
'cargo.arrived': ['The cargo ship has arrived', `The cargo ship sailed onto the map${at}${on}.`],
'cargo.docking': ['The cargo ship is docking', `The cargo ship is coming in to dock${d.harbour ? ` at ${d.harbour}` : ''}${on}.`],
'cargo.docked': ['The cargo ship has docked', `The cargo ship docked${d.harbour ? ` at ${d.harbour}` : ''}${on}, with fresh crates.`],
'cargo.undocked': ['The cargo ship cast off', `The cargo ship left${d.harbour ? ` ${d.harbour}` : ' the harbour'}${on}.`],
'cargo.left': ['The cargo ship is leaving', `The cargo ship turned for the edge of the map${on}.`],
'chinook.arrived': ['A Chinook is inbound', `A Chinook flew onto the map${at}${on} with a locked crate.`],
'chinook.dropped': ['A Chinook dropped a locked crate', `A Chinook dropped a locked crate${d.monument ? ` at ${d.monument}` : at}${on}.`],
'chinook.downed': ['A Chinook was shot down', `A Chinook was shot down${by}${at}${on}.`],
'airdrop.called': ['An airdrop is on its way', `An airdrop is on its way${at}${on}${d.player ? `, called by ${d.player}` : ''}.`],
'airdrop.landed': ['An airdrop has landed', `An airdrop landed${at}${on}.`],
'airdrop.looted': ['An airdrop was looted', `${d.player || 'Somebody'} looted an airdrop${at}${on}.`],
'crate.hack.started': ['A locked crate is being hacked', `${d.player || 'Somebody'} started hacking the locked crate ${place}${on}.`],
'crate.unlocked': ['A locked crate is open', `The locked crate ${place} finished unlocking${on}.`],
'crate.looted': ['A locked crate was looted', `${d.player || 'Somebody'} looted the locked crate ${place}${on}.`],
'vendor.arrived': ['The Travelling Vendor is here', `The Travelling Vendor drove onto the map${at}${on}.`],
'vendor.left': ['The Travelling Vendor has left', `The Travelling Vendor left the map${on}.`],
'satellite.crashed': ['A satellite has crashed', `A satellite came down${at}${on}. Its crates are burning.`],
'deepsea.opened': ['The Deep Sea is open', `The Deep Sea opened${on}.`],
'deepsea.closed': ['The Deep Sea has closed', `The Deep Sea closed${on}.`],
}
const line = lines[event]
return line ? { title: `${line[0]}${on}`, intro: line[1] } : {}
}
const WORLD_PREFIX = 'rust.world.'
function headline(triggerId, data) {
const make = HEADLINES[triggerId]
return make ? make(data || {}) : {}
if (make) return make(data || {})
return String(triggerId).startsWith(WORLD_PREFIX) ? worldHeadline(triggerId.slice(WORLD_PREFIX.length), data || {}) : {}
}
/**
@@ -460,7 +537,119 @@ async function onBoss(server, item, frame) {
}) ? 1 : 0
}
/**
* Rust's own world events (protocol 14, D355): one trigger per type. Public
* news, so a replay older than a broadcast's window tells nobody (D63). The
* grid comes from the map's geometry, as the feed's does; a downed helicopter,
* Chinook or Bradley names whom the server's credit rule names, as its feed
* line and its count do (D372). Names only, never a Steam id.
*/
async function onWorld(server, item, frame) {
const t = frameTime(item, frame)
const event = str(frame.event)
if (!event || !worldEvents.isKnownType(event) || !stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
const type = worldEvents.TYPES[event]
const crate = event.startsWith('crate.')
const data = {
...serverVars(server),
subject: crate ? `${server.id}:${event}:${frame.where || ''}` : `${server.id}:${event}`,
...(grid ? { grid } : {}),
...(frame.where !== undefined ? { where: str(frame.where) } : {}),
...(event === 'bradley.spawned' ? { count: Math.max(1, Math.floor(Number(frame.count) || 1)) } : {}),
...(frame.harbourName || frame.harbour ? { harbour: str(frame.harbourName) || str(frame.harbour) } : {}),
...(frame.monumentName || frame.monument ? { monument: str(frame.monumentName) || str(frame.monument) } : {}),
}
if (type.player && str(frame.name)) data.player = str(frame.name)
if (type.credit) {
const ids = worldEvents.credited(frame, worldEvents.creditRuleOf(server.creditRule))
const phrase = worldEvents.namesPhrase(ids.map((id) => worldEvents.nameOf(frame, id)), ids.length)
data.credited = ids.length
if (phrase) data.player = phrase
}
return fire(`${WORLD_PREFIX}${event}`, {
data,
dedupeKey: dedupeKey('world', server.id, event, t, frame.x, frame.z),
occurredAt: t,
}) ? 1 : 0
}
/**
* Dangerous Treasures (protocol 14, §14.3.4): a chest opened, and one won.
* Public news, so a replay older than a broadcast's window tells nobody (D63).
* The grid comes from the map's geometry, as the feed's does; the winner by
* in-game name only, never a Steam id. An ending raises nothing.
*/
async function onDangerousTreasures(server, item, frame) {
const t = frameTime(item, frame)
if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
const data = { ...serverVars(server), subject: `${server.id}:${frame.kind}`, ...(grid ? { grid } : {}) }
if (frame.kind === 'dt.started') {
return fire(T['rust.dt.started'], {
data: { ...data, byEvent: Boolean(str(frame.key)) },
dedupeKey: dedupeKey('dt.started', server.id, t, frame.x, frame.z),
occurredAt: t,
}) ? 1 : 0
}
const invaders = Number(frame.invaderCount)
return fire(T['rust.dt.won'], {
data: {
...data,
...(str(frame.name) ? { player: str(frame.name) } : {}),
invaders: Number.isInteger(invaders) && invaders >= 0 ? invaders : Array.isArray(frame.invaders) ? frame.invaders.length : 0,
},
dedupeKey: dedupeKey('dt.won', server.id, t, frame.steamId, frame.x, frame.z),
occurredAt: t,
}) ? 1 : 0
}
/**
* Guarded Crate (protocol 14, §14.3.5): a crate landed, and one cleared. As a
* treasure's are: public news, a replay past a broadcast's window tells nobody
* (D63), the clearer by in-game name only. An ending with nobody named, and a
* timeout, raise nothing.
*/
async function onGuardedCrate(server, item, frame) {
const t = frameTime(item, frame)
if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
if (frame.kind === 'gc.ended' && !str(frame.name)) return 0
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
const data = { ...serverVars(server), subject: `${server.id}:${frame.kind}`, ...(grid ? { grid } : {}) }
if (frame.kind === 'gc.started') {
return fire(T['rust.gc.started'], {
data: { ...data, byEvent: Boolean(str(frame.key)) },
dedupeKey: dedupeKey('gc.started', server.id, t, frame.x, frame.z),
occurredAt: t,
}) ? 1 : 0
}
return fire(T['rust.gc.ended'], {
data: { ...data, player: str(frame.name) },
dedupeKey: dedupeKey('gc.ended', server.id, t, frame.steamId, frame.x, frame.z),
occurredAt: t,
}) ? 1 : 0
}
const HANDLERS = Object.freeze({
'dt.started': onDangerousTreasures,
'dt.won': onDangerousTreasures,
'gc.started': onGuardedCrate,
'gc.ended': onGuardedCrate,
'world.event': onWorld,
'npc.boss.spawned': onBoss,
'npc.boss.phase': onBoss,
'npc.boss.died': onBoss,

View File

@@ -548,7 +548,171 @@ const BOSSES = [
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES])
// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1, D355) ─────
//
// One trigger per `world.event` type, `rust.world.<type>`. Public news, as a
// boss is: `everyone` is the ceiling and `subscribers` the default. A phase
// gate reads the same catalogue, so "advance when Bradley is destroyed" is
// `{ on: 'rust.world.bradley.destroyed' }` with no further code.
//
// The cooldown subject is the server and the type, and the crate types add
// where the crate is — one line per oil rig per cooldown, not one per hack on
// the map. `player` is the in-game name only, as a boss's killer is; no Steam
// id is declared.
const WORLD_SUBJECT = { name: 'subject', type: 'string', required: true, example: 'main:heli.downed',
description: 'The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display.' }
const WORLD_GRID = { name: 'grid', type: 'string', required: false, example: 'G7',
description: 'The map grid square. Absent when the server has no map geometry yet, or the event is off the map.' }
const WORLD_PLAYER = { name: 'player', type: 'string', required: false, example: 'Marisol',
description: 'The player it names, by in-game name. Absent when nobody is named.' }
const WORLD_CREDITED = [
{ name: 'player', type: 'string', required: false, example: 'Marisol and Darrow',
description: 'Who is credited, by in-game name and by the server\'s credit rule: "Alice", "Alice and Bob", "Alice, Bob and 2 others". Absent when nobody is.' },
{ name: 'credited', type: 'int', required: true, example: 2,
description: 'How many players the credit rule names.' },
]
const WORLD_WHERE = (example, list) => ({ name: 'where', type: 'string', required: true, example,
description: `Where it is: ${list}.` })
const CRATE_WHERE = WORLD_WHERE('oilrig_large', 'oilrig_large, oilrig_small, cargo, ghostship or chinook (a crate a Chinook dropped)')
const WORLD_HARBOUR = { name: 'harbour', type: 'string', required: false, example: 'Harbor',
description: 'The harbour, by the game\'s own name for it.' }
const WORLD_MONUMENT = { name: 'monument', type: 'string', required: false, example: 'Water Treatment Plant',
description: 'The monument it dropped at, by the game\'s own name for it.' }
const WORLD_COUNT = { name: 'count', type: 'int', required: true, example: 12,
description: 'How many: road Bradleys come several at once.' }
/** Each world event's label, description and the variables it carries beyond the server's. */
const WORLD_EVENTS = [
['heli.arrived', 'A patrol helicopter arrived', 'A patrol helicopter flew onto the map.', [WORLD_GRID]],
['heli.left', 'The patrol helicopter left', 'A patrol helicopter left the map without being downed.', [WORLD_GRID]],
['heli.downed', 'A patrol helicopter was downed', 'Players brought down a patrol helicopter.', [WORLD_GRID, ...WORLD_CREDITED]],
['bradley.spawned', 'Bradley is out', 'Launch Site\'s Bradley spawned, or road Bradleys took to the roads.', [WORLD_WHERE('road', 'launch_site or road'), WORLD_COUNT]],
['bradley.destroyed', 'A Bradley was destroyed', 'Players destroyed a Bradley APC: Launch Site\'s, a road one, or another.', [WORLD_WHERE('launch_site', 'launch_site, road or other'), WORLD_GRID, ...WORLD_CREDITED]],
['cargo.arrived', 'The cargo ship arrived', 'The cargo ship sailed onto the map.', [WORLD_GRID]],
['cargo.docking', 'The cargo ship is docking', 'The cargo ship turned in to dock at a harbour.', [WORLD_GRID, WORLD_HARBOUR]],
['cargo.docked', 'The cargo ship docked', 'The cargo ship docked at a harbour, with fresh crates.', [WORLD_GRID, WORLD_HARBOUR]],
['cargo.undocked', 'The cargo ship cast off', 'The cargo ship left the harbour it was docked at.', [WORLD_GRID, WORLD_HARBOUR]],
['cargo.left', 'The cargo ship is leaving', 'The cargo ship turned for the edge of the map.', [WORLD_GRID]],
['chinook.arrived', 'A Chinook arrived', 'A Chinook flew onto the map with a locked crate.', [WORLD_GRID]],
['chinook.dropped', 'A Chinook dropped its crate', 'A Chinook dropped a locked crate at a monument.', [WORLD_GRID, WORLD_MONUMENT]],
['chinook.downed', 'A Chinook was shot down', 'Players shot down a Chinook.', [WORLD_GRID, ...WORLD_CREDITED]],
['airdrop.called', 'An airdrop is on its way', 'A cargo plane is bringing an airdrop: called by a supply signal, or by the schedule.', [WORLD_GRID, WORLD_PLAYER]],
['airdrop.landed', 'An airdrop landed', 'An airdrop reached the ground.', [WORLD_GRID, WORLD_PLAYER]],
['airdrop.looted', 'An airdrop was looted', 'Somebody opened an airdrop first.', [WORLD_GRID, WORLD_PLAYER]],
['crate.hack.started', 'A locked crate is being hacked', 'Somebody started hacking a locked crate. On an oil rig this also calls its scientists.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
['crate.unlocked', 'A locked crate unlocked', 'A hacked locked crate finished unlocking.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
['crate.looted', 'A locked crate was looted', 'Somebody opened an unlocked locked crate first.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
['vendor.arrived', 'The Travelling Vendor arrived', 'The Travelling Vendor drove onto the map.', [WORLD_GRID]],
['vendor.left', 'The Travelling Vendor left', 'The Travelling Vendor left the map.', [WORLD_GRID]],
['satellite.crashed', 'A satellite crashed', 'A satellite came down, and its crates are burning.', [WORLD_GRID]],
['deepsea.opened', 'The Deep Sea opened', 'The Deep Sea is open: its ghost ships and islands can be reached.', []],
['deepsea.closed', 'The Deep Sea closed', 'The Deep Sea closed.', []],
]
const WORLD = WORLD_EVENTS.map(([event, label, description, vars]) => ({
id: `rust.world.${event}`,
label,
description,
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE, WORLD_SUBJECT, ...vars],
}))
// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4, §14.3.9) ──────
//
// A treasure event opening and being won are triggers, as a world event is
// (D355): public news, `everyone` the ceiling and `subscribers` the default.
// The cooldown subject is the server, so a busy hour of treasures is one line
// per cooldown. `player` is the winner's in-game name only; no Steam id is
// declared. Its ending is staff's on the feed and raises nothing.
const DT_SUBJECT = { name: 'subject', type: 'string', required: true, example: 'main:dt.won',
description: 'The server and the event. The cooldown subject; not meant for display.' }
const DT_BY_EVENT = { name: 'byEvent', type: 'boolean', required: true, example: false,
description: 'Whether one of this site\'s events started it (a Start a Dangerous Treasures event step).' }
const DANGEROUS_TREASURES = [
{
id: 'rust.dt.started',
label: 'A treasure event started',
description: 'Dangerous Treasures opened a guarded, burning treasure chest on a server: on its own schedule, by an admin, or by an event.',
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE, DT_SUBJECT, WORLD_GRID, DT_BY_EVENT],
},
{
id: 'rust.dt.won',
label: 'A treasure was won',
description: 'A player emptied a Dangerous Treasures chest on a server.',
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [
...SERVER,
...HEADLINE,
DT_SUBJECT,
WORLD_GRID,
{ name: 'player', type: 'string', required: false, example: 'Marisol',
description: 'Who emptied the chest, by in-game name.' },
{ name: 'invaders', type: 'int', required: true, example: 3,
description: 'How many players fought for it, the winner included.' },
],
},
]
// ── Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5, §14.3.9) ────────────
//
// A guarded crate landing and being cleared are triggers, as Dangerous
// Treasures' are: public news, the server the cooldown subject. `player` is the
// clearer's in-game name only. A crate whose guards went with nobody killing
// the last (NpcSpawn reloaded under it) and one that timed out raise nothing:
// neither is news.
const GC_BY_EVENT = { name: 'byEvent', type: 'boolean', required: true, example: false,
description: 'Whether one of this site\'s events placed it (a Place a guarded crate step).' }
const GUARDED_CRATE = [
{
id: 'rust.gc.started',
label: 'A guarded crate landed',
description: 'Guarded Crate dropped a locked crate ringed by guards on a server: on its own schedule, by an admin, or by an event.',
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE, { ...DT_SUBJECT, example: 'main:gc.started' }, WORLD_GRID, GC_BY_EVENT],
},
{
id: 'rust.gc.ended',
label: 'A guarded crate was cleared',
description: 'A player killed the last guard of a guarded crate on a server.',
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ ...DT_SUBJECT, example: 'main:gc.ended' },
WORLD_GRID,
{ name: 'player', type: 'string', required: true, example: 'Marisol',
description: 'Who killed the last guard, by in-game name.' },
],
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES, ...WORLD, ...DANGEROUS_TREASURES, ...GUARDED_CRATE])
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))

View File

@@ -0,0 +1,172 @@
// ── Starting a Dangerous Treasures event (protocol 14, PLAN_REDESIGNS §14.3.4) ──
//
// `rust.dt.start`, "Start a Dangerous Treasures event": one of nivex's treasure
// events, at the difficulty the step picks, where DT picks. The plugin runs DT's
// own console command, `dtevent <level>`, and joins the start DT raises to the
// step's key, so a retry is answered with the first start and never opens a
// second chest.
//
// **Kept (`reversible: 'none'`).** DT guards, unlocks and despawns its own chest
// (900 s after it unlocks, if nobody empties it); a run ending takes nothing back.
// `risk: 'change'`, so an operator switches it on deliberately.
//
// **Refused at verify** where the server said DT is not loaded, or that its
// framework cannot run another plugin's console command (hello's
// `console.plugins`, D363, Rust-Plugins#34). A server that has not said (a plugin
// before protocol 14, or never reached) is left to the plugin, as a zone's
// TruePVE is (§14.3.16).
//
// ── D365: DT's warm-up ─────────────────────────────────────────────────────
//
// For about ten minutes after it loads DT is still finding where events may go,
// and every start fails. The plugin calls that `dt-not-ready`; the step reports
// "Dangerous Treasures is not ready yet" and asks core to try once more in ten
// minutes (`retryAfterMs`, MODULE_API 1.15.0, D371). `dt-no-spot` retries on
// core's ordinary delay. Past core's attempts the run pauses on its own
// `on_failure`, and staff press Retry on the paused step (EVENTS.md §I), which
// sends the same key.
const client = require('./sidecarClient')
const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
const { gridOf } = require('./worldEvents')
const { LEVELS, reportOf } = require('./dangerousTreasures')
const serversDb = require('./model/servers/servers.db')
const mapDb = require('./model/map/map.db')
/** How long core waits before trying a start DT was not ready for (D365). */
const NOT_READY_RETRY_MS = 10 * 60 * 1000
/** What the step says DT is not ready, in the plan's words (D365). */
const NOT_READY = 'Dangerous Treasures is not ready yet'
/** The plugin's refusals a second attempt would repeat. */
const PERMANENT = new Set([
'events-disabled',
'malformed',
'bad-level',
'dt-missing',
'console-unavailable',
// DT's own refusal in its own words: a difficulty its config no longer has, or
// its console command renamed. Neither is fixed by waiting.
'refused',
])
const BUDGETS = [
{
id: 'rust.dt.events',
label: 'Dangerous Treasures events',
unit: 'events',
description: 'Dangerous Treasures events an event starts, each one counted. Dangerous Treasures despawns its own chest; nothing is taken back when the run ends.',
},
]
const OPTION_SOURCES = [
{
id: 'rust.options.dt_levels',
label: 'Dangerous Treasures difficulty',
description: "Dangerous Treasures' three difficulties, as it ships them.",
async resolve() {
return LEVELS.map((l) => ({ value: l.value, label: l.label }))
},
},
]
/** A level param: 0, 1 or 2, or null for anything else. Blank is Easy. */
function levelOf(raw) {
if (raw === undefined || raw === null || raw === '') return 0
const n = Number(raw)
return LEVELS.some((l) => l.value === n) ? n : null
}
/**
* Why a server cannot take the step, from what it last said, or null. Only a
* report that SAYS no refuses; one that said nothing is the plugin's to answer.
*/
function missingOn(report, name) {
if (report.dangerousTreasures && !report.dangerousTreasures.loaded) {
return `Dangerous Treasures is not installed on ${name}`
}
if (report.consolePlugins === false) {
return `${name}'s modding framework cannot run Dangerous Treasures' console command from a plugin, so a treasure event cannot be started there yet (Rust-Plugins#34)`
}
return null
}
/** The grid an opened chest is in, or null without the map's geometry. */
async function gridAt(serverId, x, z) {
const meta = await mapDb.getMeta(serverId).catch(() => null)
if (!meta) return null
return gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, x, z)
}
const dtStart = {
id: 'rust.dt.start',
label: 'Start a Dangerous Treasures event',
description:
'Starts one Dangerous Treasures event: a guarded, burning treasure chest that unlocks after a few minutes. Dangerous Treasures picks the spot, and despawns the chest itself if nobody empties it. Needs Dangerous Treasures on the server. In the first ten minutes after Dangerous Treasures loads it is not ready, and the step tries again ten minutes later.',
risk: 'change',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'Which server. It needs Dangerous Treasures installed.' },
{ name: 'level', type: 'int', required: false, example: 1, source: 'rust.options.dt_levels',
description: 'The difficulty: 0 Easy, 1 Medium, 2 Hard. Left blank, Easy.' },
],
cost: () => ({ 'rust.dt.events': 1 }),
async perform({ idempotencyKey, params, verify }) {
const level = levelOf(params.level)
if (level === null) {
return { ok: false, retry: false, error: `a Dangerous Treasures event is 0 (Easy), 1 (Medium) or 2 (Hard), not "${params.level}"` }
}
const serverId = String(params.server || '').trim()
if (!serverId) return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
const found = await serverFor(serverId)
if (!found.ok) return found
const name = found.server.name || found.server.id
const missing = missingOn(reportOf(await serversDb.dtReportFor(found.server.id)), name)
if (missing) return { ok: false, retry: false, error: missing }
if (verify) return { ok: true }
const result = await client.dtStart(found.server, { key: idempotencyKey, level })
if (!result.ok) {
const permanent = result.status === 'not-configured' || result.status === 'no-token'
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'Dangerous Treasures start') }
}
const data = result.data || {}
if (data.kind !== 'dt.opened') {
if (data.reason === 'dt-not-ready') {
return { ok: false, retry: true, retryAfterMs: NOT_READY_RETRY_MS, error: `${NOT_READY} on ${name}` }
}
return {
ok: false,
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
error: pluginError(data, `${name} did not start the Dangerous Treasures event`),
}
}
const grid = await gridAt(found.server.id, data.x, data.z)
return {
ok: true,
detail: {
level: LEVELS.find((l) => l.value === Number(data.level))?.label || String(data.level),
...(grid ? { grid } : {}),
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing new was started' } : {}),
},
}
},
}
const ACTIONS = [dtStart]
module.exports = { BUDGETS, ACTIONS, OPTION_SOURCES, PERMANENT, NOT_READY, NOT_READY_RETRY_MS, levelOf, missingOn }

193
server/eventGuardedCrate.js Normal file
View File

@@ -0,0 +1,193 @@
// ── Placing a guarded crate (protocol 14, PLAN_REDESIGNS §14.3.5) ────────────
//
// `rust.gc.place`, "Place a guarded crate": one of Guarded Crate's events (Easy,
// Medium, Hard and Elite as it ships; an operator may add or rename them), at a
// monument or a point. The plugin runs Guarded Crate's own console command,
// `gcrate position <event> x,y,z`, and joins the start Guarded Crate raises to
// the step's key, so a retry is answered with the first placement and never
// places a second crate.
//
// **Kept (`reversible: 'none'`, D373).** Guarded Crate has no way to end one
// event: `gcrate stop all` ends every one on the server, other people's
// included, and raises no hook. So a crate a run placed stays until its last
// guard dies or its own timer runs out, and the step's line in the run log says
// so when it is placed (D380): core gives a module nothing at a run's end for a
// step that is kept. `risk: 'change'`, so an operator switches it on deliberately.
//
// **Refused at verify (D364)** where the server said Guarded Crate is not
// loaded, that NpcSpawn is missing (Guarded Crate's `position` does not check,
// and the crate would land unguarded), that its framework cannot run another
// plugin's console command (hello's `console.plugins`, D363, Rust-Plugins#34),
// or that it has no event of the name asked for. A server that has not said is
// left to the plugin, as a Dangerous Treasures step is (§14.3.18).
const client = require('./sidecarClient')
const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS, location } = require('./eventWorld')
const { gridOf } = require('./worldEvents')
const { reportOf } = require('./guardedCrate')
const serversDb = require('./model/servers/servers.db')
const mapDb = require('./model/map/map.db')
/** Guarded Crate's events as it ships them, offered when no server has said its own. */
const DEFAULT_EVENTS = Object.freeze(['Easy', 'Medium', 'Hard', 'Elite'])
/** What the run log says of a crate the run leaves behind (D373, D380). */
const KEPT = 'kept when the run ends: it ends when its last guard dies, or when nobody has killed one for its time limit (30 minutes as Guarded Crate ships)'
/** The plugin's refusals a second attempt would repeat. `gc-too-close` is not one: the other crate may be cleared. */
const PERMANENT = new Set([
'events-disabled',
'malformed',
'bad-event',
'bad-position',
'gc-missing',
'npcspawn-missing',
'console-unavailable',
'no-monument',
'out-of-range',
'off-map',
// Guarded Crate's own refusal in its own words, or its command switched off.
'refused',
])
const BUDGETS = [
{
id: 'rust.gc.crates',
label: 'Guarded crates',
unit: 'crates',
description: 'Guarded crates an event places, each one counted. A guarded crate is kept when the run ends: it ends when its guards are cleared or after its own time.',
},
]
/** One event name as `events` lists it, ignoring case as Guarded Crate does, or null. */
function eventIn(events, wanted) {
const lower = String(wanted || '').trim().toLowerCase()
return (events || []).find((e) => e.toLowerCase() === lower) || null
}
const OPTION_SOURCES = [
{
id: 'rust.options.gc_events',
label: 'Guarded Crate events',
description: "The events each server's Guarded Crate has, by name; Guarded Crate's four defaults where no server has said.",
async resolve() {
const rows = await serversDb.gcReports().catch(() => [])
const byName = new Map()
for (const row of rows) {
const report = reportOf(row).guardedCrate
if (!report || !report.loaded || !report.events) continue
for (const name of report.events) {
const key = name.toLowerCase()
if (!byName.has(key)) byName.set(key, { value: name, servers: [] })
byName.get(key).servers.push(row.name || row.serverId)
}
}
if (byName.size === 0) {
return DEFAULT_EVENTS.map((name) => ({ value: name, label: `${name} (as Guarded Crate ships)` }))
}
return [...byName.values()].map((e) => ({ value: e.value, label: `${e.value} — ${e.servers.join(', ')}` }))
},
},
]
/**
* Why a server cannot take the step, from what it last said, or null. Only a
* report that SAYS no refuses; one that said nothing is the plugin's to answer.
*/
function missingOn(report, name, wanted) {
const gc = report.guardedCrate
if (gc && !gc.loaded) return `Guarded Crate is not installed on ${name}`
if (gc && gc.npcSpawn && !gc.npcSpawn.loaded) {
return `Guarded Crate is loaded on ${name} but NpcSpawn is missing: its events cannot start`
}
if (report.consolePlugins === false) {
return `${name}'s modding framework cannot run Guarded Crate's console command from a plugin, so a guarded crate cannot be placed there yet (Rust-Plugins#34)`
}
if (gc && gc.events && !eventIn(gc.events, wanted)) {
return `"${wanted}" is not one of Guarded Crate's events on ${name} (${gc.events.join(', ')})`
}
return null
}
/** The grid a placed crate is in, or null without the map's geometry. */
async function gridAt(serverId, x, z) {
const meta = await mapDb.getMeta(serverId).catch(() => null)
if (!meta) return null
return gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, x, z)
}
const gcPlace = {
id: 'rust.gc.place',
label: 'Place a guarded crate',
description:
"Places one Guarded Crate event at a monument or a point: a cargo plane drops a locked crate there, ringed by guards, and whoever kills the last guard has cleared it. Needs Guarded Crate and NpcSpawn on the server. The crate is kept when the run ends — Guarded Crate cannot end one alone — and ends when its guards are cleared or after its own time.",
risk: 'change',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'event', type: 'string', required: true, example: 'Elite', source: 'rust.options.gc_events',
description: "Which of Guarded Crate's events: how many guards, and how hard. Easy, Medium, Hard or Elite as it ships." },
{ name: 'server', type: 'string', required: false, example: 'main', source: 'rust.options.servers',
description: 'Which server. A monument already says; for x and z, required.' },
{ name: 'monument', type: 'string', required: false, example: 'main/powerplant_1', source: 'rust.options.monuments',
description: 'Where, by monument. Give this OR x and z.' },
{ name: 'offsetX', type: 'float', required: false, example: 0, description: "Metres east of the monument's centre (negative is west)." },
{ name: 'offsetZ', type: 'float', required: false, example: 0, description: "Metres north of the monument's centre (negative is south)." },
{ name: 'x', type: 'float', required: false, example: -604, description: 'World x, instead of a monument.' },
{ name: 'z', type: 'float', required: false, example: -342, description: 'World z, instead of a monument.' },
],
cost: () => ({ 'rust.gc.crates': 1 }),
async perform({ idempotencyKey, params, verify }) {
const wanted = String(params.event || '').trim()
if (!wanted) return { ok: false, retry: false, error: "the step does not say which of Guarded Crate's events — pick one" }
const where = location(params)
if (!where.ok) return { ok: false, retry: false, error: where.error }
// A height means nothing here: the crate falls to the ground, and its guards stand on it.
const { y, ...wire } = where.wire
const found = await serverFor(where.serverId)
if (!found.ok) return found
const name = found.server.name || found.server.id
const missing = missingOn(reportOf(await serversDb.gcReportFor(found.server.id)), name, wanted)
if (missing) return { ok: false, retry: false, error: missing }
if (verify) return { ok: true }
const result = await client.gcPlace(found.server, { key: idempotencyKey, event: wanted, ...wire })
if (!result.ok) {
const permanent = result.status === 'not-configured' || result.status === 'no-token'
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'guarded crate placement') }
}
const data = result.data || {}
if (data.kind !== 'gc.placed') {
return {
ok: false,
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
error: pluginError(data, `${name} did not place the guarded crate`),
}
}
const grid = await gridAt(found.server.id, data.x, data.z)
return {
ok: true,
detail: {
event: String(data.event || wanted),
...(grid ? { grid } : {}),
note: data.repeat ? `answered from the first attempt; nothing new was placed. The crate is ${KEPT}` : `The crate is ${KEPT}`,
...(data.repeat ? { repeat: true } : {}),
},
}
},
}
const ACTIONS = [gcPlace]
module.exports = { BUDGETS, ACTIONS, OPTION_SOURCES, PERMANENT, DEFAULT_EVENTS, KEPT, eventIn, missingOn }

View File

@@ -100,7 +100,7 @@ function pluginError(data, fallback) {
* of them to forget the drift check, which is the one thing §F says a lease must
* not be allowed to skip.
*/
function lease({ id, label, description, type, min, max, family, targetLabel, source, example, wire }) {
function lease({ id, label, description, type, min, max, family, targetLabel, source, example, valueExample, wire }) {
async function resolve(target) {
const { serverId, rest } = splitTarget(target)
const found = await serverFor(serverId)
@@ -130,6 +130,8 @@ function lease({ id, label, description, type, min, max, family, targetLabel, so
type,
...(min === undefined ? {} : { min }),
...(max === undefined ? {} : { max }),
// The VALUE's example (core MODULE_API 1.13.0), beside the target's below.
...(valueExample === undefined ? {} : { example: valueExample }),
maxDurationMs: MAX_LEASE_MS,
target: { label: targetLabel, source, example },
@@ -322,6 +324,7 @@ const LEASES = [
targetLabel: 'Which server, group and permission',
source: 'rust.options.grouppermissions',
example: 'main/default/kits.vip',
valueExample: true,
wire: (rest) => {
const slash = rest.lastIndexOf('/')
if (slash <= 0 || slash >= rest.length - 1) {
@@ -371,6 +374,80 @@ async function familyOptions(family, sourceId) {
return bounded(rows, sourceId)
}
// ── The kit warning (PLAN_REDESIGNS.md §2.6, protocol 14) ────────────────────
//
// "Kit vip: 2 uses, 24 h cooldown — 9 of 41 linked players have used it up."
// Shown under a group-permission target that is a kit's permission, which is
// what a kit weekend borrows: it tells the author that the weekend's extra use
// (step 2) is what lets those players take the kit again.
/** How long one server's kit counts are reused. The editor asks while typing. */
const KIT_NOTE_TTL_MS = 60_000
/** The most kits one server's notes ask about. One `kits.uses` each. */
const KIT_NOTE_MAX_KITS = 25
const kitNoteCache = new Map()
/** "24 h", "90 min", "45 s". */
function durationWords(seconds) {
const s = Math.max(0, Math.round(Number(seconds) || 0))
if (s >= 3600 && s % 3600 === 0) return `${s / 3600} h`
if (s >= 60) return `${Math.round(s / 60)} min`
return `${s} s`
}
/** One kit's note, from its `kits.list` row and its `kits.uses` answer. */
function kitNote(kit, uses) {
const limit = Number(kit.max) > 0 ? `${kit.max} use${Number(kit.max) === 1 ? '' : 's'}` : 'no use limit'
const cooldown = Number(kit.cooldown) > 0 ? `, ${durationWords(kit.cooldown)} cooldown` : ''
const asked = Number(uses.asked) || 0
if (!asked) return `Kit ${kit.name}: ${limit}${cooldown} — no linked player has played on this server yet.`
const used = Number(kit.max) > 0
? `${Number(uses.exhausted) || 0} of ${asked} linked players have used it up`
: `${asked} linked players`
const waiting = Number(uses.onCooldown) > 0 ? `, ${uses.onCooldown} are on its cooldown` : ''
return `Kit ${kit.name}: ${limit}${cooldown} — ${used}${waiting}. A kit weekend's extra use is what lets them take it again.`
}
/**
* Permission → note, for the kits on one server that a permission gates.
* Cached a minute per server; anything that fails is simply no note — a warning
* is advice, and its absence must never break the field it advises.
*/
async function kitNotes(server) {
const cached = kitNoteCache.get(server.id)
if (cached && Date.now() - cached.at < KIT_NOTE_TTL_MS) return cached.notes
const notes = new Map()
try {
const listed = await client.kits(server)
const gated = ((listed.ok && listed.data && listed.data.kits) || [])
.filter((k) => k && k.permission)
.slice(0, KIT_NOTE_MAX_KITS)
if (gated.length) {
// eslint-disable-next-line global-require
const linksDb = require('./model/links/links.db')
const steamIds = (await linksDb.linkedOnServer(server.id)).map((r) => String(r.steamId))
for (const kit of gated) {
const uses = await client.kitsUses(server, kit.name, steamIds)
if (!uses.ok || !uses.data || uses.data.kind !== 'kits.uses') continue
const permission = String(kit.permission).toLowerCase()
// Two kits behind one permission: both are named, one after the other.
const note = kitNote(kit, uses.data)
notes.set(permission, notes.has(permission) ? `${notes.get(permission)} ${note}` : note)
}
}
} catch (err) {
log.warn('kit notes could not be read', { server: server.id, message: err.message })
}
kitNoteCache.set(server.id, { at: Date.now(), notes })
return notes
}
const OPTION_SOURCES = [
{
id: 'rust.options.servers',
@@ -411,13 +488,19 @@ const OPTION_SOURCES = [
for (const { server, result } of answers) {
const data = result.data || {}
const perms = (data.permissions || []).map((p) => String(p).toLowerCase())
// The kit warning (PLAN_REDESIGNS.md §2.6): a row that grants a kit's
// permission says how many of the server's linked players have used it
// up. A kit's permission is whatever its operator typed into Kits, not
// always `kits.*`, so every server is asked — once a minute (cached).
const notes = await kitNotes(server)
for (const g of data.groups || []) {
const group = g && g.name ? String(g.name).toLowerCase() : null
if (!group) continue
for (const perm of perms) {
const value = `${server.id}/${group}/${perm}`
if (term && !value.includes(term)) continue
rows.push({ value, label: `${group} · ${perm}`, group: server.name || server.id })
const note = notes.get(perm)
rows.push({ value, label: `${group} · ${perm}`, group: server.name || server.id, ...(note ? { note } : {}) })
}
}
}
@@ -437,4 +520,7 @@ module.exports = {
pluginError,
perServer,
bounded,
kitNote,
kitNotes,
kitNoteCache,
}

524
server/eventPermRewards.js Normal file
View File

@@ -0,0 +1,524 @@
// ── Permissions as event rewards (PLAN_REDESIGNS.md §12, D342–D346) ───────
//
// Any permission a server's plugins registered can be a reward — night vision,
// a chat colour, a `/remove` command — with no code written for the perk. Two
// verbs, because core gives an action one risk class (D346):
//
// rust.perm.lend until the run ends: a kit reward's lease, one
// `rust_perm_run_grants` row per recipient per permission,
// withdrawn by the step's revert (`change` / `ledger`)
// rust.perm.give for good: a standing `rust_perm_grants` row per website
// user per permission, scoped to the step's server, source
// `event` (`irreversible` / `none`); staff take it back in
// the permission manager
//
// …and the picker both use, `rust.options.permissions`: the enabled servers'
// catalogues, grouped by the plugin that registered each permission.
//
// Nothing here is new machinery. Both write rows the permission mirror already
// pushes, so there is no plugin code, no sidecar code and no protocol change
// (§12.1). The message is D333's notice and rides `perm.sync` like a kit
// reward's.
//
// ── The safety is the author's own "I am sure" (D343) ───────────────────────
//
// No blocklist and no allowlist: the bridge's own `runicgateway.*` permissions
// are in the picker like any other. A required toggle in the step says the
// author meant it, and unticked the dry run and the run refuse, naming what the
// step would have given.
const permDb = require('./model/permissions/permissions.db')
const linksDb = require('./model/links/links.db')
const servers = require('./model/servers/servers.model')
const core = require('./core')
const { serverFor, bounded } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
const rewards = require('./eventRewards')
const log = core.logger('perm-rewards')
const { MAX_RECIPIENTS, MAX_CHAT, DELIVERIES, MODES, oneOf, checkCount, pickRecipients, peopleOf, usersFor, readTally, entitlementRef } = rewards
/** At most this many permissions in one step (§12.6): a perk bundle, with a price that stays readable. */
const MAX_PERMISSIONS = 10
/** A permission name as Oxide and Carbon store it: lower case, and at most the column's width. */
const PERMISSION_MAX = 128
/** What a for-good grant says it came from, in the permission manager (§12.5). */
const SOURCE = 'event'
const BUDGETS = [
{
id: 'rust.permissions',
label: 'Permissions lent',
unit: 'permissions',
description:
'Permissions an event lends until it ends: one per recipient per permission. A mode that is not a count is priced at the most it could lend.',
},
{
id: 'rust.permissions.kept',
label: 'Permissions given for good',
unit: 'permissions',
description:
'Permissions an event gives for good: one per recipient per permission. Ending the event does not take them back; staff remove them in the permission manager. A mode that is not a count is priced at the most it could give.',
},
]
// ── The params both steps check ──────────────────────────────────────────────
/**
* The picked permissions as a list, checked: lower case, trimmed, each once, at
* least one and at most MAX_PERMISSIONS. Core hands a `multiple` param over as
* an array (MODULE_API 1.14.0); a bare value is a list of one.
*/
function checkPermissions(raw) {
const items = raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw]
const out = []
for (const item of items) {
const name = String(item === undefined || item === null ? '' : item).trim().toLowerCase()
if (!name) continue
if (name.length > PERMISSION_MAX || /\s/.test(name)) return { ok: false, error: `"${item}" is not a permission name` }
if (!out.includes(name)) out.push(name)
}
if (!out.length) return { ok: false, error: 'pick at least one permission' }
if (out.length > MAX_PERMISSIONS) return { ok: false, error: `a step gives at most ${MAX_PERMISSIONS} permissions, not ${out.length}` }
return { ok: true, value: out }
}
/** The "I am sure" toggle (D343). Only a real `true` passes; a string is not a tick. */
function checkConfirm(params, permissions) {
if (params.confirm === true) return { ok: true }
return { ok: false, error: `Tick "I am sure" to make ${permissions.join(', ')} a reward` }
}
/**
* The step's message, checked like a kit reward's (D333): `{ ok, text,
* delivery }`, text null for a step that tells nobody — the default (§12.6),
* because a permission's name means nothing to a player and only the author can
* say what it does. `{permissions}` is the names; measured with them in place.
*/
function permNotice(params, permissions) {
const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
if (!text) return { ok: true, text: null, delivery: null }
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
const delivery = oneOf(rawDelivery, DELIVERIES)
if (!delivery) return { ok: false, error: `a reward's message goes to chat or to a popup, not to "${rawDelivery}"` }
const said = sayPermissions(text, permissions)
if (said.length > MAX_CHAT) {
return { ok: false, error: `a reward's message is at most ${MAX_CHAT} characters with the permissions in it, not ${said.length}` }
}
return { ok: true, text, delivery }
}
const sayPermissions = (text, permissions) => text.replace(/\{permissions\}/g, permissions.join(', '))
/**
* Every picked permission must be in the server's catalogue (§12.2), and one
* that is not is refused by name rather than skipped (§12.6): the plugin may be
* unloaded for the wipe, and a reward that silently gives less than it says is
* the worse failure. Runs in the dry run too.
*/
async function checkCatalogue(server, permissions) {
const name = server.name || server.id
const registered = new Set(
(await permDb.listCatalogue())
.filter((row) => row.serverId === server.id)
.map((row) => String(row.permission).toLowerCase()),
)
if (!registered.size) {
return {
ok: false,
error: `${name} has reported no permissions yet — its plugins' permissions arrive with its next permission sync`,
}
}
const missing = permissions.filter((p) => !registered.has(p))
if (missing.length) {
return {
ok: false,
retry: false,
error: `${name} has no permission ${missing.join(', ')} — its plugins did not register ${missing.length === 1 ? 'it' : 'them'}`,
}
}
return { ok: true }
}
/**
* Everything both steps check before touching anything, in the order an author
* fixes them: the permissions, the toggle, the mode and its count, the message,
* the server, its catalogue, the bound. `{ ok, permissions, mode, count, notice,
* server }` or a refusal core reads as permanent.
*/
async function checkStep(params, verb) {
const permissions = checkPermissions(params.permissions)
if (!permissions.ok) return { ok: false, retry: false, error: permissions.error }
const confirm = checkConfirm(params, permissions.value)
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
const mode = oneOf(params.recipients, MODES)
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
const count = checkCount(mode, params.count)
if (!count.ok) return { ok: false, retry: false, error: count.error }
const notice = permNotice(params, permissions.value)
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
const found = await serverFor(String(params.server || '').trim())
if (!found.ok) return found
const server = found.server
const listed = await checkCatalogue(server, permissions.value)
if (!listed.ok) return listed
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
return { ok: false, retry: false, error: `${server.name || server.id} ${verb} permissions to at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
}
return { ok: true, permissions: permissions.value, mode, count, notice, server }
}
/**
* The people a mode picks, as website users (D101, D103): `{ ok, people,
* missed }`, each person `{ steamId, name, userId }` and each website user once —
* of two linked accounts that both took part, the earlier in the list, the
* higher score, is the one. `linked` reads no tally (§2.5).
*/
async function recipientsOf({ server, mode, count, runId, idempotencyKey, verb }) {
const name = server.name || server.id
let picked
let bound = MAX_RECIPIENTS
let users
if (mode === 'linked') {
// One past the bound, so "too many" is a sentence and not a silent cut.
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
if (picked.length > bound) {
return { ok: false, retry: false, error: `${name} has more than ${bound} linked players, and ${verb} permissions to at most ${bound} in one step — reward from a tally instead` }
}
} else {
const tally = await readTally(server, runId)
if (!tally.ok) return tally
picked = pickRecipients(peopleOf(tally.data), mode, count, idempotencyKey)
bound = Number(tally.data.maxRecipients) || bound
if (picked.length > bound) {
return { ok: false, retry: false, error: `${picked.length} people qualify, and ${name} ${verb} permissions to at most ${bound} in one step — pick a count-based mode or a higher bar` }
}
users = await usersFor(picked.map((p) => p.steamId))
}
const people = []
const byUser = new Set()
const missed = []
for (const p of picked) {
const userId = users.get(p.steamId)
if (!userId) {
missed.push(p.name)
continue
}
if (byUser.has(userId)) continue
byUser.add(userId)
people.push({ ...p, userId })
}
return { ok: true, people, missed }
}
const missedDetail = (missed) =>
missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}
/** People priced before the tally is read: the count for a count, the bound for every other mode. */
function pricedPeople(p) {
const mode = oneOf(p.recipients, MODES)
const n = Math.round(Number(p.count) || 0)
return mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
}
/** How many permissions a step names, as its price counts them: at least one, at most the bound. */
function pricedPermissions(p) {
const checked = checkPermissions(p.permissions)
return checked.ok ? checked.value.length : 1
}
// ── The params, declared once for both steps (§12.5: "the same params") ─────
const paramsFor = (messageExample) => [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'The server the permissions are given on. Each one must be registered there by one of its plugins.' },
{ name: 'permissions', type: 'string', required: true, example: 'nightvision.allowed', source: 'rust.options.permissions',
multiple: true, max: MAX_PERMISSIONS,
description: `The permissions, by the plugin that registered them — at most ${MAX_PERMISSIONS}.` },
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
description: 'Who gets them: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
{ name: 'count', type: 'float', required: false, example: 3,
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
{ name: 'message', type: 'string', required: false, example: messageExample,
description: 'Said once to each player, in game: at once if they are up, otherwise when they next wake on the server. {permissions} is the names. Leave it empty to say nothing — a permission\'s name means little to a player, so say what it does.' },
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
{ name: 'confirm', type: 'boolean', required: true, example: false,
description: 'I am sure I want to give these permissions as a reward. Unticked, the step refuses in the dry run and when it runs.' },
]
// ── Until the run ends (§12.4) ───────────────────────────────────────────────
const kitEntitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle')
const permLend = {
id: 'rust.perm.lend',
label: 'Permissions until the event ends',
description:
'Lends the permissions you pick to the people a mode picks from this run\'s tally — or every linked player of the server — until the run ends. Waits for them if they are offline. Someone who already held one keeps it when the event ends.',
// A kit reward's lease with other permissions (D346): the same rows, the same
// revert, so the same risk. Core reverts it at the end of every run.
risk: 'change',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: paramsFor('You can use /nv until the event ends'),
cost: (p) => ({ 'rust.permissions': pricedPeople(p) * pricedPermissions(p) }),
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const step = await checkStep(params, 'lends')
if (!step.ok) return step
if (verify) return { ok: true }
const { permissions, mode, count, notice, server } = step
const name = server.name || server.id
const ref = entitlementRef(server.id, runId, stepId)
const resource = { kind: 'permission-lease', ref, payload: { serverId: server.id, permissions } }
// As a kit reward's: the rows ARE the lease, and a repeated key finds them.
const existing = await permDb.listRunGrantsForStep(runId, stepId)
if (existing.length) {
return {
ok: true,
resources: [resource],
detail: { repeat: true, lent: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was lent' },
}
}
const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey, verb: 'lends' })
if (!picked.ok) return picked
// One row per recipient per permission; the message on the first only, so
// each person is told once (the notice key is per run, step and user).
const said = notice.text ? sayPermissions(notice.text, permissions) : null
const rows = []
for (const p of picked.people) {
permissions.forEach((permission, i) => {
rows.push({
runId,
stepId,
idemKey: idempotencyKey,
userId: p.userId,
serverId: server.id,
steamId: p.steamId,
permission,
kit: '',
credit: false,
...(said && i === 0 ? { notice: said, noticeDelivery: notice.delivery } : {}),
})
})
}
await permDb.insertRunGrants(rows)
await permDb.markDirty(server.id)
log.info('permissions lent', { server: server.id, run: runId, step: stepId, permissions, mode, people: picked.people.length, missed: picked.missed.length })
return {
ok: true,
resources: [resource],
detail: {
server: name,
permissions,
mode,
...(count.value === null ? {} : { count: count.value }),
lent: picked.people.length,
...(said && picked.people.length ? { told: `${picked.people.length} will be told in game, by ${notice.delivery}` } : {}),
...missedDetail(picked.missed),
},
}
},
// The lease ends as a kit reward's does: the step's rows go and the next sync
// revokes what nobody else grants. A player who already held a permission
// keeps it, because the push reads the union (§12.1).
revert: (ctx) => kitEntitle.revert(ctx),
reconcile: (ctx) => kitEntitle.reconcile(ctx),
}
// ── For good (§12.5) ─────────────────────────────────────────────────────────
/** What a for-good grant's note says, in the permission manager. */
const grantNote = (runId, stepId) => `event run ${runId}, step ${stepId}`
const permGive = {
id: 'rust.perm.give',
label: 'Permissions for good',
description:
'Gives the permissions you pick to the people a mode picks, for good. Ending or cancelling the event does not take them back; staff remove them in the permission manager.',
// Irreversible, as `rust.coins.pay` is (D335, D346): core reverts a ledgered
// step at the end of EVERY run, so a ledger here would take the gift back.
risk: 'irreversible',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: paramsFor('You can use /nv from now on'),
cost: (p) => ({ 'rust.permissions.kept': pricedPeople(p) * pricedPermissions(p) }),
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const step = await checkStep(params, 'gives')
if (!step.ok) return step
if (verify) return { ok: true }
const { permissions, mode, count, notice, server } = step
const name = server.name || server.id
const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey, verb: 'gives' })
if (!picked.ok) return picked
// A grant already there is left alone (§12.5). One this step's own first
// attempt wrote carries its note, and is counted as given, not as held: a
// retry after a lost answer reads the same as the attempt that landed.
const note = grantNote(runId, stepId)
const held = await permDb.listGrantsForUsers(picked.people.map((p) => p.userId), server.id)
const heldBy = new Map(held.map((g) => [`${g.userId}:${String(g.permission).toLowerCase()}`, g]))
const rows = []
let alreadyHeld = 0
let repeated = 0
for (const p of picked.people) {
for (const permission of permissions) {
const there = heldBy.get(`${p.userId}:${permission}`)
if (there) {
if (there.source === SOURCE && there.note === note) repeated++
else alreadyHeld++
continue
}
rows.push({ userId: p.userId, permission, scope: server.id, source: SOURCE, note })
}
}
const inserted = await permDb.insertGrants(rows)
// The message rides a notice-only row (§12.5): no permission, no kit, no
// credit, so the push grants nothing for it and tells it once. Nothing
// reverts this step, so the row stays — a sentence that has been said.
const said = notice.text ? sayPermissions(notice.text, permissions) : null
if (said && picked.people.length) {
await permDb.insertRunGrants(picked.people.map((p) => ({
runId,
stepId,
idemKey: idempotencyKey,
userId: p.userId,
serverId: server.id,
steamId: p.steamId,
permission: '',
kit: '',
credit: false,
notice: said,
noticeDelivery: notice.delivery,
})))
}
if (inserted || said) await permDb.markDirty(server.id)
log.info('permissions given', { server: server.id, run: runId, step: stepId, permissions, mode, people: picked.people.length, given: inserted, alreadyHeld, repeated })
return {
ok: true,
detail: {
server: name,
permissions,
mode,
...(count.value === null ? {} : { count: count.value }),
people: picked.people.length,
given: inserted + repeated,
...(alreadyHeld ? { alreadyHeld, alreadyHeldNote: 'already held: the grant was there before, and is left as it was' } : {}),
...(repeated ? { repeat: true, repeatNote: 'answered from the first attempt; nothing was given twice' } : {}),
...(said && picked.people.length ? { told: `${picked.people.length} will be told in game, by ${notice.delivery}` } : {}),
...missedDetail(picked.missed),
},
}
},
}
// ── The picker (§12.2) ───────────────────────────────────────────────────────
/** A permission's group: the plugin that registered it, or its prefix, saying so (the permission manager's rule). */
function groupOf(permission, owner) {
if (owner) return owner
const prefix = permission.includes('.') ? permission.slice(0, permission.indexOf('.')) : permission
return `${prefix} (not registered by a plugin)`
}
/**
* The picker's rows from the catalogue and the enabled servers. Pure, so the
* rule is testable without a database: each permission once, grouped by its
* plugin, sorted by group and then permission without case, and a permission not
* on every enabled server says where it is (`note`, MODULE_API 1.13.0).
*/
function permissionRows(catalogue, enabled) {
const names = new Map(enabled.map((s) => [s.id, s.name || s.id]))
const byPermission = new Map()
for (const row of catalogue) {
if (!names.has(row.serverId)) continue
const permission = String(row.permission || '').trim()
if (!permission) continue
const key = permission.toLowerCase()
if (!byPermission.has(key)) byPermission.set(key, { permission, owner: null, on: new Set() })
const entry = byPermission.get(key)
if (!entry.owner && row.owner) entry.owner = String(row.owner)
entry.on.add(row.serverId)
}
const rows = [...byPermission.values()].map((e) => {
const group = groupOf(e.permission, e.owner)
const row = { value: e.permission, label: e.permission, group }
if (e.on.size < names.size) {
const where = enabled.filter((s) => e.on.has(s.id)).map((s) => names.get(s.id))
row.note = `Only on ${where.join(', ')}`
}
return row
})
const fold = (s) => s.toLowerCase()
rows.sort((a, b) => fold(a.group).localeCompare(fold(b.group)) || fold(a.value).localeCompare(fold(b.value)))
return rows
}
const OPTION_SOURCES = [
{
id: 'rust.options.permissions',
label: 'Permissions',
description: "Every permission the enabled servers' plugins registered, grouped by plugin; one missing from a server says where it is.",
async resolve() {
const [catalogue, enabled] = await Promise.all([permDb.listCatalogue(), servers.listForPolling()])
return bounded(permissionRows(catalogue, enabled), 'rust.options.permissions')
},
},
]
const ACTIONS = [permLend, permGive]
module.exports = {
MAX_PERMISSIONS,
SOURCE,
BUDGETS,
ACTIONS,
OPTION_SOURCES,
checkPermissions,
checkConfirm,
permNotice,
permissionRows,
grantNote,
}

View File

@@ -8,6 +8,11 @@
// rust.kit.entitle the right to redeem a kit, and one more use of
// it (R16, D103), for the people a mode picks
// rust.announce one line in a server's chat, or every server's
// rust.coins.pay Economics coins for the people a mode picks,
// kept once paid (protocol 14, D335)
// rust.backpack.size a bigger Backpacks backpack until the run ends:
// a kit reward with `backpacks.size.N` as its
// permission and no kit (protocol 14, D337)
//
// …and the announce leg, `rust.chat`, which says a published news post in the
// chat of every server whose switch is on (D104).
@@ -40,6 +45,8 @@ const servers = require('./model/servers/servers.model')
const permDb = require('./model/permissions/permissions.db')
const linksDb = require('./model/links/links.db')
const emit = require('./engagement/emit')
const economics = require('./model/economics/economics.model')
const backpacks = require('./model/backpacks/backpacks.model')
const voice = require('./model/permissions/voice')
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
@@ -57,7 +64,9 @@ const DEFAULT_KILL_WEIGHT = 5
const SCORES = ['seconds', 'kills', 'both']
const KILLS_OF = ['players', 'npcs', 'both']
const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent']
// `linked` (PLAN_REDESIGNS.md §2.5) reads no tally: every linked player of the
// kit's server, one use each — the kit weekend's second step.
const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent', 'linked']
/** The fleet, in `rust.announce`'s `server` param (D105). */
const EVERY_SERVER = '*'
@@ -79,6 +88,11 @@ const PERMANENT = new Set([
// Protocol 12: a popup on a server without PopupNotifications. Waiting does not
// install it.
'popup-unavailable',
// Protocol 14, Economics (§11.2.2). Nor does it install Economics, and a bad
// amount or too long a list is the step's, not the moment's.
'economics-missing',
'bad-amount',
'too-large',
])
const BUDGETS = [
@@ -95,6 +109,20 @@ const BUDGETS = [
unit: 'lines',
description: 'Lines an event says in a server\'s chat: one per server reached.',
},
{
id: 'rust.coins',
label: 'Coins paid',
unit: 'coins',
description:
'Economics coins an event pays: the amount times the people paid. A mode that is not a count is priced at the most it could pay. Coins paid are kept — nothing takes them back.',
},
{
id: 'rust.backpacks',
label: 'Backpack sizes',
unit: 'backpacks',
description:
'Bigger backpacks an event lends until it ends: one per recipient. A mode that is not a count is priced at the most it could lend.',
},
]
/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
@@ -135,7 +163,7 @@ function refParts(ref) {
* `everyone` takes none.
*/
function checkCount(mode, raw) {
if (mode === 'everyone') return { ok: true, value: null }
if (mode === 'everyone' || mode === 'linked') return { ok: true, value: null }
const value = Number(raw)
if (mode === 'top' || mode === 'random') {
@@ -287,6 +315,28 @@ async function readKit(server, kit) {
return { ok: true, kit: String(row.name), ...reward, maxRecipients: Number(data.maxRecipients) || MAX_RECIPIENTS }
}
/**
* A reward's message to its player, checked (protocol 14, PLAN_REDESIGNS.md
* §2.4, D333). `{ ok, text, delivery }`, with `text` null for a step that says
* nothing — the default, and every step saved before this. Measured with the
* kit's name in place, against the plugin's line bound, so a long kit name
* cannot push a message the form accepted past what the game will say.
*/
function rewardNotice(params, kitName) {
const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
if (!text) return { ok: true, text: null, delivery: null }
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
const delivery = oneOf(rawDelivery, DELIVERIES)
if (!delivery) return { ok: false, error: `a reward's message goes to chat or to a popup, not to "${rawDelivery}"` }
const said = text.replace(/\{kit\}/g, kitName)
if (said.length > MAX_CHAT) {
return { ok: false, error: `a reward's message is at most ${MAX_CHAT} characters with the kit's name in it, not ${said.length}` }
}
return { ok: true, text, delivery }
}
// ── The verbs ────────────────────────────────────────────────────────────────
const participationOpen = {
@@ -312,7 +362,7 @@ const participationOpen = {
description: 'Whose deaths count as a kill: players, NPCs (animals included), or both. The last hit gets it. Needed unless the score is seconds.' },
{ name: 'killWeight', type: 'float', required: false, example: DEFAULT_KILL_WEIGHT,
description: `For a score of both: how many minutes one kill is worth. Left blank, ${DEFAULT_KILL_WEIGHT}.` },
{ name: 'minutes', type: 'int', required: false, example: 60,
{ name: 'minutes', type: 'int', required: false, example: 60, duration: 'minutes',
description: `How long it counts, up to ${TALLY_MAX_MINUTES} (seven days). Left blank, seven days. The game forgets a tally seven days after it opened, however long it counted.` },
],
cost: () => ({}),
@@ -480,7 +530,7 @@ const kitEntitle = {
id: 'rust.kit.entitle',
label: 'Reward a kit',
description:
'Gives the people a mode picks from this run\'s tally the right to redeem a kit on its server, and one more use of it. Waits for them if they are offline. Teardown withdraws what is not yet redeemed.',
'Gives the people a mode picks from this run\'s tally — or every linked player of the server — the right to redeem a kit on its server, and one more use of it, and can tell each of them in game. Waits for them if they are offline. Teardown withdraws what is not yet redeemed.',
risk: 'change',
reversible: 'ledger',
version: 1,
@@ -489,9 +539,14 @@ const kitEntitle = {
{ name: 'kit', type: 'string', required: true, example: 'main/vip-starter', source: 'rust.options.kits',
description: 'The kit, as server/kit. The reward reaches only that server (D102).' },
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, or the top X per cent.' },
description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all (a kit weekend).' },
{ name: 'count', type: 'float', required: false, example: 3,
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone.' },
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
// Protocol 14 (PLAN_REDESIGNS.md §2.4, D333): the reward tells its player.
{ name: 'message', type: 'string', required: false, example: 'You earned kit {kit} — type /kit {kit}',
description: 'Said once to each player rewarded, in game: at once if they are up, otherwise when they next wake on the server. {kit} is the kit\'s name. Leave it empty to say nothing.' },
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
],
// Priced before the tally is read, so at the most it could grant: the count
@@ -513,6 +568,9 @@ const kitEntitle = {
const count = checkCount(mode, params.count)
if (!count.ok) return { ok: false, retry: false, error: count.error }
const notice = rewardNotice(params, parsed.kit)
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
const found = await serverFor(parsed.serverId)
if (!found.ok) return found
const server = found.server
@@ -540,20 +598,40 @@ const kitEntitle = {
}
}
const tally = await readTally(server, runId)
if (!tally.ok) return tally
let picked
let bound
let users
const picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
const bound = Number(tally.data.maxRecipients) || kit.maxRecipients
if (picked.length > bound) {
return {
ok: false,
retry: false,
error: `${picked.length} people qualify, and ${server.name || server.id} rewards at most ${bound} in one step — pick a count-based mode or a higher bar`,
if (mode === 'linked') {
// A kit weekend (§2.5): every linked player of the server, no tally.
// Read one past the bound so "too many" is a sentence, not a silent cut.
const linked = await linksDb.linkedOnServer(server.id, kit.maxRecipients + 1)
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
bound = kit.maxRecipients
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
if (picked.length > bound) {
return {
ok: false,
retry: false,
error: `${server.name || server.id} has more than ${bound} linked players, and rewards at most ${bound} in one step — raise the plugin's recipient bound (EventsMaxRecipients) or reward from a tally`,
}
}
}
} else {
const tally = await readTally(server, runId)
if (!tally.ok) return tally
const users = await usersFor(picked.map((p) => p.steamId))
picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
bound = Number(tally.data.maxRecipients) || kit.maxRecipients
if (picked.length > bound) {
return {
ok: false,
retry: false,
error: `${picked.length} people qualify, and ${server.name || server.id} rewards at most ${bound} in one step — pick a count-based mode or a higher bar`,
}
}
users = await usersFor(picked.map((p) => p.steamId))
}
const rows = []
const byUser = new Set()
const missed = []
@@ -579,6 +657,7 @@ const kitEntitle = {
permission: kit.permission,
kit: kit.kit,
credit: kit.max > 0,
...(notice.text ? { notice: notice.text.replace(/\{kit\}/g, kit.kit), noticeDelivery: notice.delivery } : {}),
})
}
@@ -598,6 +677,7 @@ const kitEntitle = {
mode,
...(count.value === null ? {} : { count: count.value }),
granted: rows.length,
...(notice.text && rows.length ? { told: `${rows.length} will be told in game, by ${notice.delivery}` } : {}),
...(missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}),
},
}
@@ -631,6 +711,412 @@ const kitEntitle = {
},
}
// ── Coins (protocol 14, PLAN_REDESIGNS.md §11.2.2) ──────────────────────────
/** The plugin's bounds on one payment's amount (PROTOCOL.md §20.3). */
const COINS_MIN = economics.AMOUNT_MIN
const COINS_MAX = economics.AMOUNT_MAX
/** The plugin keeps a payment's key in a file; core's key is used as it is unless it is long. */
const PAYMENT_KEY_MAX = 120
/**
* A payment's message, checked like a kit reward's (D333): `{ ok, text,
* delivery }`, text null for a step that says nothing. Measured with the amount
* in place of `{amount}`, against the plugin's line bound. The plugin fills in
* what each player RECEIVED, which is never more than the amount asked.
*/
function coinsNotice(params, amount) {
const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
if (!text) return { ok: true, text: null, delivery: null }
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
const delivery = oneOf(rawDelivery, DELIVERIES)
if (!delivery) return { ok: false, error: `a payment's message goes to chat or to a popup, not to "${rawDelivery}"` }
const said = text.replace(/\{amount\}/g, economics.coins(amount))
if (said.length > MAX_CHAT) {
return { ok: false, error: `a payment's message is at most ${MAX_CHAT} characters with the amount in it, not ${said.length}` }
}
return { ok: true, text, delivery }
}
/** The key the plugin remembers a payment by: core's, or a digest of it past the plugin's bound. */
function paymentKey(idempotencyKey, runId, stepId) {
const key = String(idempotencyKey || `coins:${runId}:${stepId}`)
return key.length <= PAYMENT_KEY_MAX ? key : `coins:${crypto.createHash('sha256').update(key).digest('hex').slice(0, 40)}`
}
const coinsPay = {
id: 'rust.coins.pay',
label: 'Pay coins',
description:
'Pays Economics coins to the people a mode picks from this run\'s tally — or to every linked player of the server — and can tell each of them in game. One payment per website account. Coins paid are kept: a run cancelled or aborted afterwards does not take them back.',
// Irreversible, like Module-uo's `uo.item.grant` (D335): coins in a player's
// balance may be spent the moment they land, and the platform reverts a
// ledgered step at the end of EVERY run, a normal finish included — so a
// ledger here would take a prize back. Staff take coins back by hand.
risk: 'irreversible',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'The server whose Economics pays. It needs Economics installed.' },
{ name: 'amount', type: 'float', required: true, example: 500,
description: `How many coins each person receives, ${economics.coins(COINS_MIN)} to ${economics.coins(COINS_MAX)}.` },
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
description: 'Who is paid: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
{ name: 'count', type: 'float', required: false, example: 3,
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
{ name: 'message', type: 'string', required: false, example: 'You earned {amount} coins',
description: 'Said once to each player paid, in game: at once if they are up, otherwise when they next wake on the server. {amount} is what they received. Leave it empty to say nothing.' },
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
],
// Priced before the tally is read, so at the most it could pay: the amount
// times the count for a count, times the recipient bound for every other mode.
cost: (p) => {
const mode = oneOf(p.recipients, MODES)
const n = Math.round(Number(p.count) || 0)
const people = mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
return { 'rust.coins': Math.ceil(Math.max(0, Number(p.amount) || 0) * people) }
},
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const amount = Math.round(Number(params.amount) * 100) / 100
if (!Number.isFinite(amount) || amount < COINS_MIN || amount > COINS_MAX) {
return { ok: false, retry: false, error: `a payment is ${economics.coins(COINS_MIN)} to ${economics.coins(COINS_MAX)} coins, not "${params.amount}"` }
}
const mode = oneOf(params.recipients, MODES)
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
const count = checkCount(mode, params.count)
if (!count.ok) return { ok: false, retry: false, error: count.error }
const notice = coinsNotice(params, amount)
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
const found = await serverFor(String(params.server || '').trim())
if (!found.ok) return found
const server = found.server
const name = server.name || server.id
// D199: refused with the reason where Economics is absent, in the dry run
// too — the author learns it on the form, not at the run's reward.
const econ = await economics.forServer(server.id)
if (!econ.loaded) return { ok: false, retry: false, error: `${economics.MISSING} (${name})` }
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
return { ok: false, retry: false, error: `${name} pays at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
}
if (verify) return { ok: true }
let picked
let bound = MAX_RECIPIENTS
let users
if (mode === 'linked') {
// Every linked player of the server, as a kit weekend's (§2.5). One past
// the bound, so "too many" is a sentence and not a silent cut.
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
if (picked.length > bound) {
return {
ok: false,
retry: false,
error: `${name} has more than ${bound} linked players, and pays at most ${bound} in one step — pay from a tally instead`,
}
}
} else {
const tally = await readTally(server, runId)
if (!tally.ok) return tally
picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
bound = Number(tally.data.maxRecipients) || bound
if (picked.length > bound) {
return {
ok: false,
retry: false,
error: `${picked.length} people qualify, and ${name} pays at most ${bound} in one step — pick a count-based mode or a higher bar`,
}
}
users = await usersFor(picked.map((p) => p.steamId))
}
// One payment per website user (D103's rule): of two linked accounts that
// both took part, the earlier in the list — the higher score — is paid.
const payees = []
const byUser = new Set()
const missed = []
for (const p of picked) {
const userId = users.get(p.steamId)
if (!userId) {
missed.push(p.name)
continue
}
if (byUser.has(userId)) continue
byUser.add(userId)
payees.push(p)
}
const missedDetail = missed.length
? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' }
: {}
if (!payees.length) {
return { ok: true, detail: { server: name, mode, amount, paid: 0, ...missedDetail } }
}
const result = await client.economicsPay(server, {
key: paymentKey(idempotencyKey, runId, stepId),
amount,
steamIds: payees.map((p) => p.steamId),
...(notice.text ? { message: notice.text, delivery: notice.delivery } : {}),
})
// A lost answer is retried with the same key, and the plugin answers it from
// its first reply: nobody is paid twice.
if (!result.ok) return transportFailure(server, result, 'Economics')
const data = result.data || {}
if (data.kind !== 'economics.paid') return refusal(server, data, 'payment')
const named = new Map(payees.map((p) => [p.steamId, p.name]))
const paid = (data.paid || []).map((row) => ({
name: named.get(String(row.steamId)) || String(row.steamId),
received: Math.round((Number(row.after) - Number(row.before)) * 100) / 100,
}))
const failed = (data.failed || []).map((row) => ({ name: named.get(String(row.steamId)) || String(row.steamId), reason: row.reason }))
const total = Math.round(paid.reduce((sum, row) => sum + row.received, 0) * 100) / 100
log.info('coins paid', { server: server.id, run: runId, step: stepId, amount, paid: paid.length, failed: failed.length, repeat: Boolean(data.repeat) })
return {
ok: true,
detail: {
server: name,
mode,
...(count.value === null ? {} : { count: count.value }),
amount,
paid: paid.length,
total,
received: paid.slice(0, 50),
...(failed.length ? { failed: failed.slice(0, 50), failedNote: 'at-limit: the balance is at Economics\' limit; refused or unmeasured: Economics did not take or report the deposit' } : {}),
...(notice.text && paid.length ? { told: `${paid.length} will be told in game, by ${notice.delivery}` } : {}),
...(data.repeat ? { repeat: true, repeatNote: 'answered from the first attempt; nobody was paid twice' } : {}),
...missedDetail,
},
}
},
}
// ── A bigger backpack (protocol 14, PLAN_REDESIGNS.md §11.3.2, D337, D338) ──
/** Said when the author writes nothing: the take-back surprises a player nobody warned (D338). */
const BACKPACK_NOTICE =
'Your backpack holds {size} until the event ends. Anything past your normal size then moves to your inventory, or drops at your feet if it is full.'
/**
* A bigger backpack's message, checked like a kit reward's (D333): `{ ok, text,
* delivery }`. Unlike a kit reward's it is never silent: an empty message is
* the default warning above. Measured with the size in place of `{size}`.
*/
function backpackNotice(params, size) {
const written = params.message === undefined || params.message === null ? '' : String(params.message).trim()
const text = written || BACKPACK_NOTICE
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
const delivery = oneOf(rawDelivery, DELIVERIES)
if (!delivery) return { ok: false, error: `a bigger backpack's message goes to chat or to a popup, not to "${rawDelivery}"` }
const said = text.replace(/\{size\}/g, String(size))
if (said.length > MAX_CHAT) {
return { ok: false, error: `a bigger backpack's message is at most ${MAX_CHAT} characters with the size in it, not ${said.length}` }
}
return { ok: true, text, delivery }
}
const backpackSize = {
id: 'rust.backpack.size',
label: 'Bigger backpack',
description:
'Lends the people a mode picks from this run\'s tally — or every linked player of the server — a bigger Backpacks backpack until the run ends, and tells each of them in game. Waits for them if they are offline. Someone who already holds a size at least as big gains nothing and loses nothing. At the end, whatever is past their normal size moves to their inventory the next time they open the backpack, or drops at their feet if the inventory is full.',
// A kit reward with a different permission (D337): the same lease, the same
// revert, so the same risk.
risk: 'change',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'The server whose backpacks grow. It needs Backpacks installed.' },
{ name: 'size', type: 'int', required: true, example: 48, source: 'rust.options.backpacksizes',
description: 'How many slots, one of the sizes the server\'s Backpacks offers (its backpacks.size.N permissions) above the smallest.' },
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
{ name: 'count', type: 'float', required: false, example: 3,
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
{ name: 'message', type: 'string', required: false, example: BACKPACK_NOTICE,
description: 'Said once to each player, in game: at once if they are up, otherwise when they next wake on the server. {size} is the size. Left empty, it is the example, which warns about the end.' },
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
],
// Priced as a kit reward is: the count for a count, the recipient bound for
// every other mode.
cost: (p) => {
const mode = oneOf(p.recipients, MODES)
const n = Math.round(Number(p.count) || 0)
return { 'rust.backpacks': mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS }
},
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const size = Number(params.size)
if (!Number.isInteger(size) || size < 1) return { ok: false, retry: false, error: `"${params.size}" is not a backpack size — pick one from the list` }
const mode = oneOf(params.recipients, MODES)
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
const count = checkCount(mode, params.count)
if (!count.ok) return { ok: false, retry: false, error: count.error }
const notice = backpackNotice(params, size)
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
const found = await serverFor(String(params.server || '').trim())
if (!found.ok) return found
const server = found.server
const name = server.name || server.id
// D199: refused with the reason where Backpacks is absent, in the dry run
// too, and a size the server does not offer is refused by name.
const report = await backpacks.forServer(server.id)
if (!report.loaded) return { ok: false, retry: false, error: `${backpacks.MISSING} (${name})` }
const offered = backpacks.leasable(report)
if (!offered.includes(size)) {
return {
ok: false,
retry: false,
error: offered.length
? `${name}'s Backpacks offers ${offered.join(', ')} slots above its smallest size, not ${size}`
: `${name}'s Backpacks offers no size above its smallest, so a bigger backpack gives nobody anything — add sizes to its "Permission sizes"`,
}
}
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
return { ok: false, retry: false, error: `${name} lends at most ${MAX_RECIPIENTS} backpacks in one step, not ${count.value}` }
}
if (verify) return { ok: true }
const permission = `backpacks.size.${size}`
const ref = entitlementRef(server.id, runId, stepId)
const resource = { kind: 'backpack-size', ref, payload: { serverId: server.id, permission } }
// As a kit reward's: the rows ARE the lease, and a repeated key finds them.
const existing = await permDb.listRunGrantsForStep(runId, stepId)
if (existing.length) {
return {
ok: true,
resources: [resource],
detail: { repeat: true, lent: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was lent' },
}
}
let picked
let bound = MAX_RECIPIENTS
let users
if (mode === 'linked') {
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
if (picked.length > bound) {
return {
ok: false,
retry: false,
error: `${name} has more than ${bound} linked players, and lends at most ${bound} backpacks in one step — lend from a tally instead`,
}
}
} else {
const tally = await readTally(server, runId)
if (!tally.ok) return tally
picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
bound = Number(tally.data.maxRecipients) || bound
if (picked.length > bound) {
return {
ok: false,
retry: false,
error: `${picked.length} people qualify, and ${name} lends at most ${bound} backpacks in one step — pick a count-based mode or a higher bar`,
}
}
users = await usersFor(picked.map((p) => p.steamId))
}
// One lease per website user (D103's rule). The permission reaches every
// account the user has linked, as a kit reward's does (D28).
const rows = []
const byUser = new Set()
const missed = []
for (const p of picked) {
const userId = users.get(p.steamId)
if (!userId) {
missed.push(p.name)
continue
}
if (byUser.has(userId)) continue
byUser.add(userId)
rows.push({
runId,
stepId,
idemKey: idempotencyKey,
userId,
serverId: server.id,
steamId: p.steamId,
permission,
kit: '',
credit: false,
notice: notice.text.replace(/\{size\}/g, String(size)),
noticeDelivery: notice.delivery,
})
}
await permDb.insertRunGrants(rows)
await permDb.markDirty(server.id)
log.info('backpacks lent', { server: server.id, run: runId, step: stepId, size, mode, lent: rows.length, missed: missed.length })
return {
ok: true,
resources: [resource],
detail: {
server: name,
size,
permission,
mode,
...(count.value === null ? {} : { count: count.value }),
lent: rows.length,
...(rows.length ? { told: `${rows.length} will be told in game, by ${notice.delivery}` } : {}),
...(missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}),
},
}
},
// The lease ends as a kit reward's does: the step's rows go and the next sync
// revokes the permission. Backpacks moves the overflow at the owner's next
// open (D338).
revert: (ctx) => kitEntitle.revert(ctx),
reconcile: (ctx) => kitEntitle.reconcile(ctx),
}
/**
* The body of a chat line: its delivery, and the voice's format when the line
* goes to chat and a voice is chosen (D140). A popup is not a chat line and
@@ -862,11 +1348,27 @@ const OPTION_SOURCES = [
{ value: 'minScore', label: 'A score of at least X' },
{ value: 'random', label: 'N drawn at random' },
{ value: 'topPercent', label: 'The top X per cent (ties in)' },
{ value: 'linked', label: 'Every linked player of the server (no tally)' },
]),
fixed('rust.options.delivery', 'Delivery', 'Where a line goes (D141).', [
{ value: 'chat', label: 'Chat' },
{ value: 'popup', label: 'A popup — needs PopupNotifications on the server' },
]),
{
// From each server's last hello: the sizes its Backpacks offers above the
// smallest, grouped by server (D337). The step checks the pair it is given.
id: 'rust.options.backpacksizes',
label: 'Backpack sizes',
description: "Each server's Backpacks sizes above its smallest — its backpacks.size.N permissions.",
async resolve() {
const [list, reports] = await Promise.all([servers.listForPolling(), backpacks.byServer()])
const rows = []
for (const s of list) {
for (const n of backpacks.leasable(reports.get(s.id))) rows.push({ value: n, label: `${n} slots`, group: s.name || s.id })
}
return rows
},
},
{
id: 'rust.options.chatservers',
label: 'Chat servers',
@@ -878,7 +1380,7 @@ const OPTION_SOURCES = [
},
]
const ACTIONS = [participationOpen, participationCollect, kitEntitle, announce]
const ACTIONS = [participationOpen, participationCollect, kitEntitle, coinsPay, backpackSize, announce]
module.exports = {
MAX_RECIPIENTS,
@@ -889,10 +1391,23 @@ module.exports = {
ACTIONS,
LEG,
OPTION_SOURCES,
MODES,
PERMANENT,
oneOf,
peopleOf,
usersFor,
readTally,
entitlementRef,
pickRecipients,
checkCount,
splitKit,
kitReward,
readKit,
rewardNotice,
coinsNotice,
paymentKey,
backpackNotice,
BACKPACK_NOTICE,
chatLine,
chatKey,
lineBody,

154
server/eventTemplates.js Normal file
View File

@@ -0,0 +1,154 @@
// ── Event templates (PLAN_REDESIGNS.md §2.5, D164, D192) ────────────────────
//
// A named starting point for a new event, through core's
// `api.registerEventTemplates` (MODULE_API 1.13.0). The author fills in a few
// inputs; `build()` turns them into an ordinary draft — phases, steps, params —
// which the editor opens unsaved. Core validates the plan as a spec and keeps
// nothing about the template, so this file can change without touching a
// single event it was ever used for.
//
// ── The kit weekend ──────────────────────────────────────────────────────────
//
// "Everybody gets kit vip this weekend." One phase that lasts the weekend, and
// two steps in it:
//
// 1. core.lease → rust.group.permission: the `default` group holds the kit's
// permission for the weekend, so every player can see and redeem it. The
// game holds the deadline and gives it back on time whether or not the site
// is up.
// 2. rust.kit.entitle → that kit, every linked player of the server (`linked`),
// one more use each — the use a player who already spent theirs needs to
// take the kit again. Withdrawn at teardown if not yet redeemed (D103).
//
// A kit with no permission is open to everyone already, so its weekend is step
// 2 alone; a kit with no use limit has no "one more use", so its weekend is step
// 1 alone. A kit with neither is refused by `readKit`, because a weekend of it
// gives nobody anything.
const { serverFor } = require('./eventLeases')
const { readKit, splitKit, rewardNotice, MAX_CHAT } = require('./eventRewards')
/** The lease's own ceiling (D77): a weekend longer than seven days is refused by the game. */
const MAX_WEEKEND_MINUTES = 7 * 24 * 60
/** "48 hours", "90 minutes". */
function minutesWords(n) {
if (n >= 120 && n % 60 === 0) return `${n / 60} hours`
return `${n} minute${n === 1 ? '' : 's'}`
}
const kitWeekend = {
id: 'rust.template.kit_weekend',
label: 'Kit weekend',
description:
'A kit for everybody, for a while: the default group holds the kit\'s permission for the length of the weekend, and every linked player of the server gets one more use of it. You can change every part of the plan before saving it.',
inputs: [
{
name: 'kit',
type: 'string',
required: true,
example: 'main/vip',
source: 'rust.options.kits',
description: 'The kit, as server/kit. The weekend happens on that server only.',
},
{
name: 'minutes',
type: 'int',
required: true,
example: 2880,
duration: 'minutes',
description: `How long the weekend lasts, up to ${MAX_WEEKEND_MINUTES} (seven days). 2880 is two days.`,
},
{
name: 'message',
type: 'string',
required: false,
example: 'Kit weekend! One more use of {kit} — type /kit {kit}',
description: `Told once to each linked player in game, at once or when they next wake. {kit} is the kit's name; at most ${MAX_CHAT} characters. Leave it empty to say nothing.`,
},
{
name: 'delivery',
type: 'string',
required: false,
example: 'popup',
source: 'rust.options.delivery',
description: 'Chat, or a popup where the server has PopupNotifications.',
},
],
async build({ inputs }) {
const parsed = splitKit(inputs.kit)
if (!parsed) return { ok: false, error: `"${inputs.kit}" is not a kit — pick one from the list, as server/kit` }
const minutes = Number(inputs.minutes)
if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_WEEKEND_MINUTES) {
return { ok: false, error: `a kit weekend lasts 1 to ${MAX_WEEKEND_MINUTES} minutes, not ${inputs.minutes}` }
}
const notice = rewardNotice({ message: inputs.message, delivery: inputs.delivery }, parsed.kit)
if (!notice.ok) return { ok: false, error: notice.error }
const found = await serverFor(parsed.serverId)
if (!found.ok) return { ok: false, error: found.error }
const server = found.server
const serverName = server.name || server.id
// The kit as its server describes it, now. A server that is down cannot say
// which permission gates the kit, and a plan guessed at would be wrong.
const kit = await readKit(server, parsed.kit)
if (!kit.ok) return { ok: false, error: kit.error }
const steps = []
if (kit.permission) {
steps.push({
actionId: 'core.lease',
label: `Open ${kit.kit} to everyone`,
params: {
lease: 'rust.group.permission',
target: `${server.id}/default/${kit.permission}`,
value: 'true',
minutes,
},
})
}
if (kit.max > 0) {
steps.push({
actionId: 'rust.kit.entitle',
label: 'One more use for every linked player',
params: {
kit: `${server.id}/${kit.kit}`,
recipients: 'linked',
...(notice.text ? { message: notice.text, delivery: notice.delivery } : {}),
},
})
}
const parts = [
kit.permission ? `everybody on ${serverName} may redeem ${kit.kit}` : null,
kit.max > 0 ? 'every linked player gets one more use of it' : null,
].filter(Boolean)
return {
title: `Kit weekend: ${kit.kit} on ${serverName}`,
summary: `For ${minutesWords(minutes)}, ${parts.join(', and ')}.`,
spec: {
schedule: { kind: 'manual' },
phases: [
{
key: 'weekend',
label: 'The weekend',
// The phase IS the weekend: it holds the run open for as long as the
// lease lasts, so teardown gives the permission back on time and not
// a second after the steps finish (§2.2, U-6).
advance: { after: `${minutes}m` },
steps,
},
],
},
}
},
}
const TEMPLATES = [kitWeekend]
module.exports = { TEMPLATES, MAX_WEEKEND_MINUTES, minutesWords }

View File

@@ -62,6 +62,7 @@ const ZONE_MESSAGE_MAX = 256
const DOME_STACK_MAX = 10
/** A blank stack: 3 spheres, chosen by eye at noon in RunicNPC's 9d player session (D326). */
const DOME_STACK_DEFAULT = 3
const TRUEPVE_RULESET_MAX = 64
/**
* ZoneDomes' own sphere types, by the number its API takes (D194). Only Standard
@@ -127,6 +128,10 @@ const PERMANENT = new Set([
// the server (`escort-offline`) is left to retry: they may join.
'no-zone',
'runicnpc-old',
// PLAN_REDESIGNS §14.3.2 (D368): no TruePVE on the server, or no ruleset of
// the name the step typed. Only the call can tell the second.
'truepve-missing',
'truepve-ruleset',
])
const BUDGETS = [
@@ -165,6 +170,28 @@ function domeMissing(mods) {
return null
}
/**
* The zone's TruePVE mapping (§14.3.2, D368): `{ exclude: true }` for "no PvP
* protection", `{ ruleset }` for a ruleset staff typed, or null for neither.
* Both is refused, and either is refused where the server said it has no
* TruePVE. A server that has not said is left to the plugin (`truepve-missing`),
* as a dome is. Whether the ruleset exists is the plugin's to answer.
*/
function truePveMapping(params, mods) {
const raw = params.noPvpProtection
const exclude = raw === true || raw === 'true'
const ruleset = params.pvpRuleset === undefined || params.pvpRuleset === null ? '' : String(params.pvpRuleset).trim()
if (exclude && ruleset) {
return { ok: false, error: 'a zone has no PvP protection or a TruePVE ruleset, not both' }
}
if (ruleset.length > TRUEPVE_RULESET_MAX) {
return { ok: false, error: `a TruePVE ruleset's name is at most ${TRUEPVE_RULESET_MAX} characters` }
}
if (!exclude && !ruleset) return { ok: true, wire: null }
if (mods && mods.truePve && !mods.truePve.loaded) return { ok: false, error: 'TruePVE is not installed on this server' }
return { ok: true, wire: exclude ? { exclude: true } : { ruleset } }
}
/** A number param, or undefined when left blank. */
function num(raw) {
if (raw === undefined || raw === null || raw === '') return undefined
@@ -696,6 +723,9 @@ const ACTIONS = [
type: 'int',
required: true,
example: 120,
// A timed step (core MODULE_API 1.13.0): the editor keeps the run open
// this long, because teardown erases the zone when the run ends.
duration: 'minutes',
description: `How long it stands, up to ${ZONE_MAX_MINUTES} (seven days). Counted against zone time.`,
},
{ name: 'name', type: 'string', required: false, example: 'Airfield brawl', description: 'What the zone is called.' },
@@ -728,6 +758,20 @@ const ACTIONS = [
'A ZoneDomes dome over the zone. "standard" is a full shaded dome; the colours show only where the sphere meets the ground or a building. Needs ZoneDomes and RunicGatewayDomes.cs on the server. Blank is no dome.',
},
{ name: 'domeStack', type: 'int', required: false, example: DOME_STACK_DEFAULT, description: `How many spheres the dome stacks, 1 to ${DOME_STACK_MAX}; more is darker. Left blank, ${DOME_STACK_DEFAULT}.` },
{
name: 'noPvpProtection',
type: 'boolean',
required: false,
example: true,
description: 'No PvP protection in this zone: TruePVE leaves it alone, so players can hurt each other inside. Needs TruePVE on the server. Not with a ruleset.',
},
{
name: 'pvpRuleset',
type: 'string',
required: false,
example: 'pvp-arena',
description: `Or this TruePVE ruleset in the zone, by its name in TruePVE's config (names are case-sensitive, up to ${TRUEPVE_RULESET_MAX} characters). A name TruePVE does not have fails the step when it runs. Needs TruePVE on the server.`,
},
],
async perform({ runId, idempotencyKey, params, verify }) {
@@ -782,6 +826,10 @@ const ACTIONS = [
dome = { type: kind.type, stack: stack === undefined ? DOME_STACK_DEFAULT : stack }
}
// §14.3.2: TruePVE's rules in the zone, where the server said it has TruePVE.
const truePve = truePveMapping(params, mods)
if (!truePve.ok) return { ok: false, retry: false, error: truePve.error }
// The dry run stops here, and has checked everything it can without the
// game. It does not ask whether the monument exists: a step authored for
// next wipe's map would fail every dry run until the wipe.
@@ -807,6 +855,7 @@ const ACTIONS = [
...(hasMessage ? { delivery } : {}),
...(format ? { format } : {}),
...(dome ? { dome } : {}),
...(truePve.wire ? { truePve: truePve.wire } : {}),
},
'zone',
)

180
server/eventWorldCall.js Normal file
View File

@@ -0,0 +1,180 @@
// ── Calling one of Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ──
//
// `rust.world.call`, "Call a world event": a patrol helicopter, a cargo ship, a
// Chinook, an airdrop, the Travelling Vendor, road Bradleys, the F-15 or a
// satellite crash, now, the way the game's own schedule runs them (D354, D367).
// The plugin runs the schedule's `TriggeredEvent` — no console command — so it
// works the same on Carbon.
//
// **Kept (`reversible: 'none'`).** Nothing a call starts is removed when the run
// ends: a cargo ship or a drop may already hold players or their loot (D354).
// `risk: 'change'` all the same, so an operator switches it on deliberately.
//
// **A position only where the game has a route to one** (stage 1's spike): the
// airdrop's drop point and the patrol helicopter's first destination. The
// Chinook drops only at the map's drop zones, and the one route to a chosen
// zone borrows the Airfield terminal's shared state, so it takes no position
// (D378); the rest go where the game puts them.
const client = require('./sidecarClient')
const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS, location } = require('./eventWorld')
/** The plugin's ceiling on calls per run (`WorldCallsHardMax`); an operator may set it lower. */
const MAX_CALLS_PER_RUN = 10
/**
* The events a call may run, mirroring the plugin's list. `max` is how many one
* step may call at once: the game runs the cargo ship, the vendor and road
* Bradleys (twelve at a time) one at a time.
*/
const EVENTS = Object.freeze([
{ value: 'patrol_heli', label: 'Patrol helicopter', max: 5, placeable: true },
{ value: 'airdrop', label: 'Airdrop', max: 5, placeable: true },
{ value: 'chinook', label: 'Chinook (drops a locked crate where the game chooses)', max: 5 },
{ value: 'cargo', label: 'Cargo ship', max: 1 },
{ value: 'bradley_road', label: 'Road Bradleys (several at once)', max: 1 },
{ value: 'travelling_vendor', label: 'Travelling Vendor', max: 1 },
{ value: 'satellite', label: 'Satellite crash', max: 5 },
{ value: 'f15', label: 'F-15 flyover (decoration only)', max: 5 },
])
/** The plugin's refusals a second attempt would repeat. */
const PERMANENT = new Set([
'events-disabled',
'malformed',
'bad-event',
'bad-count',
'not-placeable',
'too-many',
'out-of-range',
'no-monument',
'off-map',
])
const BUDGETS = [
{
id: 'rust.world.calls',
label: 'World events called',
unit: 'events',
description: "Rust's own world events an event calls — patrol helicopters, cargo ships, airdrops and the rest — each one counted. What a call starts is kept when the run ends.",
},
]
const OPTION_SOURCES = [
{
// The plugin's own list, mirrored, so the form fills with every server off.
id: 'rust.options.world_events',
label: 'World events',
description: "Rust's own world events a step can call. Only the airdrop and the patrol helicopter take a position; the rest go where the game puts them.",
async resolve() {
return EVENTS.map((e) => ({ value: e.value, label: e.label }))
},
},
]
/** Whether a step gives a position at all: a monument, or x or z. */
function hasPosition(params) {
const blank = (v) => v === undefined || v === null || v === ''
return !blank(params.monument) || !blank(params.x) || !blank(params.z)
}
const worldCall = {
id: 'rust.world.call',
label: 'Call a world event',
description:
"Calls one of Rust's own world events now — a patrol helicopter, an airdrop, a Chinook, the cargo ship, road Bradleys, the Travelling Vendor, a satellite crash or an F-15 flyover — as the game's own schedule would. An airdrop or a patrol helicopter can be sent to a monument or a point; the rest go where the game puts them. What it starts is kept when the run ends.",
risk: 'change',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'event', type: 'string', required: true, example: 'airdrop', source: 'rust.options.world_events',
description: 'Which world event to call.' },
{ name: 'count', type: 'int', required: false, example: 1,
description: 'How many at once: 1 to 5, and 1 for the cargo ship, road Bradleys and the vendor. Left blank, 1.' },
{ name: 'server', type: 'string', required: false, example: 'main', source: 'rust.options.servers',
description: 'Which server. A monument already says; otherwise required.' },
{ name: 'monument', type: 'string', required: false, example: 'main/powerplant_1', source: 'rust.options.monuments',
description: 'Airdrop and patrol helicopter only: where to send it, by monument. Give this OR x and z, or neither for where the game chooses.' },
{ name: 'offsetX', type: 'float', required: false, example: 0, description: "Metres east of the monument's centre (negative is west)." },
{ name: 'offsetZ', type: 'float', required: false, example: 0, description: "Metres north of the monument's centre (negative is south)." },
{ name: 'x', type: 'float', required: false, example: -604, description: 'Airdrop and patrol helicopter only: world x, instead of a monument.' },
{ name: 'z', type: 'float', required: false, example: -342, description: 'Airdrop and patrol helicopter only: world z, instead of a monument.' },
],
cost: (p) => ({ 'rust.world.calls': Math.max(1, Math.round(Number(p.count) || 1)) }),
async perform({ runId, idempotencyKey, params, verify }) {
const event = EVENTS.find((e) => e.value === String(params.event || '').trim())
if (!event) {
return { ok: false, retry: false, error: `"${params.event}" is not one of the world events a Rust server calls` }
}
const blank = params.count === undefined || params.count === null || params.count === ''
const count = blank ? 1 : Number(params.count)
if (!Number.isInteger(count) || count < 1 || count > event.max) {
return { ok: false, retry: false, error: `call 1 to ${event.max} of the ${event.label.toLowerCase()} at a time, and "${params.count}" is not that` }
}
let serverId = String(params.server || '').trim()
let wire = {}
if (hasPosition(params)) {
if (!event.placeable) {
return { ok: false, retry: false, error: `the ${event.label.toLowerCase()} goes where the game puts it; only an airdrop and a patrol helicopter take a position` }
}
const where = location(params)
if (!where.ok) return { ok: false, retry: false, error: where.error }
serverId = where.serverId
// A height means nothing to a call: the drop falls to the ground and the
// helicopter flies at its own.
const { y, ...flat } = where.wire
wire = flat
} else if (!serverId) {
return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
}
const found = await serverFor(serverId)
if (!found.ok) return found
if (verify) return { ok: true }
const result = await client.worldCall(found.server, {
runId: String(runId),
key: idempotencyKey,
event: event.value,
count,
...wire,
})
if (!result.ok) {
const permanent = result.status === 'not-configured' || result.status === 'no-token'
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'world event call') }
}
const data = result.data || {}
if (data.kind !== 'world.called') {
return {
ok: false,
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
error: pluginError(data, `${found.server.name || found.server.id} refused the ${event.label.toLowerCase()}`),
}
}
const called = Number(data.called) || 0
return {
ok: true,
detail: {
event: event.value,
called,
...(called < count ? { note: `the game started ${called} of the ${count} asked for` } : {}),
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing new was called' } : {}),
},
}
},
}
const ACTIONS = [worldCall]
module.exports = { EVENTS, MAX_CALLS_PER_RUN, PERMANENT, BUDGETS, ACTIONS, OPTION_SOURCES, hasPosition }

97
server/guardedCrate.js Normal file
View File

@@ -0,0 +1,97 @@
// ── Guarded Crate, with NpcSpawn (protocol 14, PLAN_REDESIGNS §14.3.5) ─────
//
// Bazz3l's Guarded Crate drops a locked crate from a cargo plane and rings it
// with NpcSpawn's scientists; whoever kills the last guard has cleared it. Its
// events reach this module as three kinds: `gc.started` (public), `gc.ended`
// (public) and `gc.failed` (staff's, the timeout). The crate cleared is
// *Warden*'s count, from the tally's `guardedWon` (D375). A run's step places
// one (`eventGuardedCrate.js`).
//
// ── Who may read what ──────────────────────────────────────────────────────
//
// As Dangerous Treasures' are (`dangerousTreasures.js`, D376): the event and its
// grid are public; the clearer's name shows only to the operator's presence
// audience, so with presence left at staff a signed-out visitor reads "The
// guarded crate was cleared · M4"; the Steam id, the position and a step's key
// are staff's. Whether a player cleared it at all is public (`won`), since an
// ending with nobody named (NpcSpawn reloaded under it) names nobody either.
const { gridOf } = require('./worldEvents')
const KINDS = Object.freeze(['gc.started', 'gc.ended', 'gc.failed'])
const STEAM_ID = /^\d{17}$/
function isKind(kind) {
return KINDS.includes(kind)
}
/**
* One `gc.*` frame for one viewer: `{ staff, presence, geometry }`. Passes the
* envelope, adds `grid`; an ending adds `won`, and `name` for the presence
* audience. Staff also get `x`, `z`, the step's `key` and the clearer's
* `steamId`. Any other frame is returned as is.
*/
function shapeFor(frame, { staff = false, presence = false, geometry = null } = {}) {
if (!frame || !isKind(frame.kind)) return frame
const out = {}
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
const grid = gridOf(geometry, frame.x, frame.z)
if (grid) out.grid = grid
if (frame.kind === 'gc.ended') {
out.won = STEAM_ID.test(String(frame.steamId || ''))
if (out.won && (presence || staff) && typeof frame.name === 'string' && frame.name) out.name = frame.name
if (out.won && staff) out.steamId = String(frame.steamId)
}
if (staff) {
for (const key of ['x', 'z', 'key']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
}
return out
}
/**
* A server's last report about Guarded Crate and its console, from
* `servers.db.gcReportFor`: `{ guardedCrate, consolePlugins }`, each null where
* the plugin never said (no state yet, or a plugin before protocol 14).
* `guardedCrate` is `{ loaded, version, npcSpawn: { loaded, version }, events }`;
* `npcSpawn` is null where it was not said, and `events` null where the
* plugin could not read Guarded Crate's data file.
*/
function reportOf(row) {
const parse = (value) => {
if (value === null || value === undefined) return null
if (typeof value !== 'string') return value
try {
return JSON.parse(value)
} catch {
return null
}
}
const integration = (body) =>
body && typeof body === 'object' ? { loaded: body.loaded === true, version: typeof body.version === 'string' ? body.version : null } : null
const gc = row ? parse(row.guardedCrate) : null
const probe = row ? parse(row.console) : null
return {
guardedCrate:
gc && typeof gc === 'object'
? {
...integration(gc),
npcSpawn: integration(gc.npcSpawn),
events: Array.isArray(gc.events) ? gc.events.filter((e) => typeof e === 'string' && e.trim()) : null,
}
: null,
consolePlugins: probe && typeof probe === 'object' && typeof probe.plugins === 'boolean' ? probe.plugins : null,
}
}
module.exports = { KINDS, isKind, shapeFor, reportOf }

View File

@@ -59,6 +59,11 @@ module.exports = function register(ctx, api) {
const eventLeases = require('./eventLeases')
const eventWorld = require('./eventWorld')
const eventRewards = require('./eventRewards')
const eventWorldCall = require('./eventWorldCall')
const eventDangerousTreasures = require('./eventDangerousTreasures')
const eventGuardedCrate = require('./eventGuardedCrate')
const eventPermRewards = require('./eventPermRewards')
const eventTemplates = require('./eventTemplates')
const boot = require('./boot')
const commands = require('./commands')
/* eslint-enable global-require */
@@ -168,8 +173,21 @@ module.exports = function register(ctx, api) {
// and the chat line, with the two budgets they spend. Registered in the same
// calls' neighbours, not merged into eventWorld's arrays, so each file keeps
// its own statement of what it declares.
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS])
//
// Permissions as rewards (PLAN_REDESIGNS.md §12) beside them: any registered
// permission, lent until the run ends or given for good, each on its own budget.
//
// Calling one of Rust's own world events (PLAN_REDESIGNS.md §14.3.1, protocol
// 14) beside them: kept when the run ends, on a budget of its own. Starting a
// Dangerous Treasures event (§14.3.4) and placing a guarded crate (§14.3.5)
// likewise, each on its own budget.
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS, ...eventDangerousTreasures.BUDGETS, ...eventGuardedCrate.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS, ...eventDangerousTreasures.ACTIONS, ...eventGuardedCrate.ACTIONS])
// The templates (PLAN_REDESIGNS.md §2.5, MODULE_API 1.13.0): a kit weekend
// as a starting point on core's New event screen. Core keeps nothing about a
// template; what it builds is an ordinary draft.
api.registerEventTemplates(eventTemplates.TEMPLATES)
// News in game chat (D104). Core enqueues every registered leg for every
// published post, so the leg itself sends only to the servers whose switch an
@@ -182,6 +200,10 @@ module.exports = function register(ctx, api) {
...eventLeases.OPTION_SOURCES,
...eventWorld.OPTION_SOURCES,
...eventRewards.OPTION_SOURCES,
...eventPermRewards.OPTION_SOURCES,
...eventWorldCall.OPTION_SOURCES,
...eventDangerousTreasures.OPTION_SOURCES,
...eventGuardedCrate.OPTION_SOURCES,
])
// The slash commands (phase 16, R11). The handlers run HERE, in the website
@@ -200,10 +222,10 @@ module.exports = function register(ctx, api) {
streams: STREAMS.length,
audiences: AUDIENCES.length,
leases: eventLeases.LEASES.length,
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length,
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length,
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length + eventDangerousTreasures.ACTIONS.length + eventGuardedCrate.ACTIONS.length,
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length + eventGuardedCrate.BUDGETS.length,
announceLeg: eventRewards.LEG.leg,
commands: commands.map((cmd) => cmd.name).join(' '),
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length,
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length + eventDangerousTreasures.OPTION_SOURCES.length + eventGuardedCrate.OPTION_SOURCES.length,
})
}

View File

@@ -44,6 +44,7 @@ const npcs = require('./model/npcs/npcs.model')
const npcsDb = require('./model/npcs/npcs.db')
const permissionsDb = require('./model/permissions/permissions.db')
const sidecar = require('./sidecarClient')
const worldEvents = require('./worldEvents')
const log = core.logger('ingest')
@@ -138,6 +139,17 @@ async function apply(serverId, item, server = null) {
}
case 'player.tally': {
// Protocol 14 (§11.2.2): the player's Economics balance, a VALUE written
// over rather than added — unlike everything below it.
const hasBalance = frame.balance !== undefined && frame.balance !== null
if (hasBalance) await db.setBalance(at, frame.balance)
// A balance with no name is an offline player's: a payment or a staff
// adjustment reaches somebody who is not there, and the plugin names only
// a connected player. It says nothing about play, so it must not move
// anybody's last-seen — which every writer below does.
if (hasBalance && !frame.name) break
await db.touchPlayer(frame.steamId, frame.name || null)
await db.addStats(at, {
npcKills: Number(frame.npcKills) || 0,
@@ -257,6 +269,33 @@ async function apply(serverId, item, server = null) {
break
}
// ── Protocol 14: Rust's own world events (§14.3.1) ──────────────────────
//
// Stored like everything else, and read per viewer by the feed. The one
// thing a world event COUNTS is a downed patrol helicopter, Chinook or
// Bradley: the plugin sends every candidate and the server's rule picks
// (D372, D379), so Rotorbane and Tankbreaker always agree with the line.
// A protocol-13 plugin still sends `heliKills` / `apcKills` on its tally
// instead, and the tally path above adds those as it always has.
case 'world.event': {
const rule = worldEvents.creditRuleOf(server && server.creditRule)
for (const kill of worldEvents.killsOf(frame, rule)) {
await db.touchPlayer(kill.steamId, kill.name || null)
await db.addTitleStats({ serverId, wipeId, steamId: kill.steamId }, { [kill.field]: 1 })
}
break
}
// ── Protocol 14: Economics wiped every balance (§11.2.2) ────────────────
//
// `ecowipe` puts everyone back at the start in the wipe in play, so none of
// its balances is known any more. A new save's wipe (`newSave`) opens a new
// map whose balances arrive as players connect; the last wipe's stay as the
// record of how it ended.
case 'economics.wiped':
if (!frame.newSave) await db.clearBalances(serverId, wipeId)
break
// ── Protocol 6: first-party clans ──────────────────────────────────────
//
// Each one is told to core as it happens (`ctx.teams.publish`) and written

View File

@@ -0,0 +1,36 @@
// ── SQL for Backpacks (PLAN_REDESIGNS §11.3.2) ────────────────────────────
//
// What each server's last hello said about Backpacks. The backpacks themselves
// are never stored (§11.3.2: live, never stored); this file only reads the
// report the plugin makes about the plugin.
const core = require('../../core')
const STATE = 'rust_server_state'
/**
* One server's report: `{ serverId, backpacks }`, with `backpacks` as the
* driver hands JSON_EXTRACT back — text, or null for a server whose plugin
* never said (no state yet, or a plugin before protocol 14).
*/
async function reportFor(serverId) {
const rows = await core.query(
`SELECT server_id AS serverId,
JSON_EXTRACT(raw, '$.integrations.backpacks') AS backpacks
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/** Every server's report, for the step's size list. */
async function reports() {
return core.query(
`SELECT server_id AS serverId,
JSON_EXTRACT(raw, '$.integrations.backpacks') AS backpacks
FROM ${STATE}`,
)
}
module.exports = { reportFor, reports }

View File

@@ -0,0 +1,129 @@
// ── Backpacks, as the site sees it (PLAN_REDESIGNS §11.3.2, D199, D337, D338) ─
//
// WhiteThunder's Backpacks is optional (D199): a server reports it in hello's
// `integrations.backpacks`, with the sizes its `backpacks.size.N` permissions
// offer, and every surface that needs it refuses with a reason where it is
// absent. Two readers share this file:
//
// • the step (`rust.backpack.size`, in eventRewards.js), which leases one of
// those sizes for a run (D337) and needs the list to check its param;
// • the staff view and the player's own page, which read one backpack live.
//
// **Live, never stored.** Each view is one `backpacks.read`, and nothing about
// a backpack is written to the database. The plugin only reads (R2).
const client = require('../../sidecarClient')
const db = require('./backpacks.db')
const servers = require('../servers/servers.model')
const { pluginError, transportError } = require('../../eventLeases')
/** The sentence every surface says where a server has no Backpacks (D199). */
const MISSING = 'Backpacks is not installed on this server'
/** JSON_EXTRACT comes back as text from the driver; an object passes through. */
function parse(value) {
if (value === null || value === undefined) return null
if (typeof value === 'object') return value
try {
return JSON.parse(value)
} catch {
return null
}
}
/**
* A server's Backpacks as its last hello said: `{ loaded: false }`, or loaded
* with its version and its sizes, ascending and whole.
*/
function shape(raw) {
const b = parse(raw)
if (!b || b.loaded !== true) return { loaded: false, sizes: [] }
const sizes = [...new Set((Array.isArray(b.sizes) ? b.sizes : []).map(Number).filter((n) => Number.isInteger(n) && n > 0))]
sizes.sort((x, y) => x - y)
return { loaded: true, version: typeof b.version === 'string' ? b.version : null, sizes }
}
/** One server's report. `{ loaded: false }` for a server never heard from. */
async function forServer(serverId) {
const row = await db.reportFor(serverId)
return shape(row && row.backpacks)
}
/** Every server's report, keyed by id. */
async function byServer() {
const rows = await db.reports()
return new Map(rows.map((r) => [r.serverId, shape(r.backpacks)]))
}
/**
* The sizes a step may lease on a server: every size but the smallest, which
* is the default everybody holds and so gives nobody anything.
*/
function leasable(report) {
return report && report.loaded ? report.sizes.slice(1) : []
}
/** One stack as the page draws it; nested `contents` keep the shape one level deep. */
function stack(raw, nest) {
const row = {
slot: Number(raw.slot) || 0,
shortname: typeof raw.shortname === 'string' ? raw.shortname : null,
display: typeof raw.display === 'string' ? raw.display : null,
amount: Number(raw.amount) || 0,
}
if (raw.itemId !== undefined) row.itemId = Number(raw.itemId)
if (typeof raw.blueprint === 'string') row.blueprint = raw.blueprint
// A skin id is past 2^53 more often than not; it is a label here, never a number to do sums with.
if (raw.skin !== undefined && raw.skin !== null) row.skin = String(raw.skin)
if (typeof raw.name === 'string' && raw.name) row.name = raw.name
if (raw.overflow === true) row.overflow = true
if (nest && Array.isArray(raw.contents) && raw.contents.length) row.contents = raw.contents.filter(Boolean).map((c) => stack(c, false))
return row
}
/**
* One player's backpack on one server, read live.
*
* Resolves `{ ok: true, backpack }` — `{ serverId, server, steamId, exists,
* capacity, pageSize, items }`, the last three only when it exists — or
* `{ ok: false, status, message, reason? }`: 404 for no such server, 409 for a
* server without Backpacks, 503 for one that could not be asked.
*/
async function read({ serverId, steamId }) {
const server = await servers.getForCalling(serverId)
if (!server) return { ok: false, status: 404, message: 'No such server' }
const name = server.name || server.id
const result = await client.backpacksRead(server, String(steamId))
if (!result.ok) return { ok: false, status: 503, message: transportError(server, result, 'backpacks') }
const data = result.data || {}
if (data.kind === 'backpacks.contents') {
const base = { serverId: server.id, server: name, steamId: String(steamId) }
if (data.exists !== true) return { ok: true, backpack: { ...base, exists: false } }
return {
ok: true,
backpack: {
...base,
exists: true,
capacity: Number(data.capacity) || 0,
pageSize: Number(data.pageSize) || 48,
items: (Array.isArray(data.items) ? data.items : []).filter(Boolean).map((s) => stack(s, true)),
},
}
}
if (data.reason === 'backpacks-missing') return { ok: false, status: 409, reason: data.reason, message: `${MISSING} (${name})` }
return {
ok: false,
status: data.reason === 'bad-id' ? 400 : 502,
reason: data.reason || null,
message: pluginError(data, `${name} did not answer with a backpack`),
}
}
module.exports = { MISSING, shape, forServer, byServer, leasable, stack, read }

View File

@@ -0,0 +1,36 @@
// ── SQL for Economics (PLAN_REDESIGNS §11.2.2) ────────────────────────────
//
// What each server's last hello said about Economics. The balances themselves
// are rows of `rust_player_wipe_stats` and are written by ingest through
// `events.db.js`; this file only reads the report the plugin makes about the
// plugin.
const core = require('../../core')
const STATE = 'rust_server_state'
/**
* Every server's report: `{ serverId, wipeId, economics }`, with `economics`
* as the driver hands JSON_EXTRACT back — text, or null for a server whose
* plugin never said (no state yet, or a plugin before protocol 14).
*/
async function reports() {
return core.query(
`SELECT server_id AS serverId, wipe_id AS wipeId,
JSON_EXTRACT(raw, '$.integrations.economics') AS economics
FROM ${STATE}`,
)
}
async function reportFor(serverId) {
const rows = await core.query(
`SELECT server_id AS serverId, wipe_id AS wipeId,
JSON_EXTRACT(raw, '$.integrations.economics') AS economics
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
module.exports = { reports, reportFor }

View File

@@ -0,0 +1,218 @@
// ── Economics, as the site sees it (PLAN_REDESIGNS §11.2.2, D199, D335, D336) ─
//
// Wulf's Economics is optional (D199): a server reports it in hello's
// `integrations.economics`, and every surface that needs it refuses with a
// reason where it is absent, never quietly. Three readers share this file:
//
// • the step (`rust.coins.pay`, in eventRewards.js), which asks whether the
// server has Economics before it pays anybody;
// • the player's own page and the app, which read each linked account's
// balance live and fall back to the last one the tally carried;
// • staff, who deposit, withdraw or set one player's balance.
//
// The starting balance matters to D336: the richest column and the Magnate
// title rank only balances above it, because Economics answers it for an
// account it has never seen (§11.2.1). The plugin reads it from Economics'
// config; when that file cannot be read the site assumes Economics' default,
// 1000, and says so (`startingKnown: false`).
const core = require('../../core')
const client = require('../../sidecarClient')
const db = require('./economics.db')
const eventsDb = require('../events/events.db')
const servers = require('../servers/servers.model')
const { pluginError, transportError } = require('../../eventLeases')
const log = core.logger('economics')
/** Economics' own default starting balance, assumed when its config cannot be read. */
const DEFAULT_STARTING_BALANCE = 1000
/** The sentence every surface says where a server has no Economics (D199). */
const MISSING = 'Economics is not installed on this server'
const OPS = ['deposit', 'withdraw', 'set']
/** The plugin's bounds (PROTOCOL.md §20.3), refused here first so a form says why. */
const AMOUNT_MIN = 0.01
const AMOUNT_MAX = 1000000
/** JSON_EXTRACT comes back as text from the driver; an object passes through. */
function parse(value) {
if (value === null || value === undefined) return null
if (typeof value === 'object') return value
try {
return JSON.parse(value)
} catch {
return null
}
}
/**
* A server's Economics as its last hello said: `{ loaded: false }`, or loaded
* with its version, its starting balance (assumed where unread) and whether
* balances wipe with the map.
*/
function shape(raw) {
const e = parse(raw)
if (!e || e.loaded !== true) return { loaded: false }
const start = Number(e.startingBalance)
const known = e.startingBalance !== undefined && e.startingBalance !== null && Number.isFinite(start)
return {
loaded: true,
version: typeof e.version === 'string' ? e.version : null,
startingBalance: known ? start : DEFAULT_STARTING_BALANCE,
startingKnown: known,
wipesWithMap: typeof e.wipesWithMap === 'boolean' ? e.wipesWithMap : null,
}
}
/** One server's report, with the wipe it is on. `{ loaded: false }` for a server never heard from. */
async function forServer(serverId) {
const row = await db.reportFor(serverId)
if (!row) return { loaded: false, wipeId: null }
return { ...shape(row.economics), wipeId: row.wipeId || null }
}
/** Every server's report, keyed by id. */
async function byServer() {
const rows = await db.reports()
return new Map(rows.map((r) => [r.serverId, { ...shape(r.economics), wipeId: r.wipeId || null }]))
}
/** An amount as a sentence says it: thousands separated, at most two places. */
function coins(n) {
return Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })
}
/**
* The balances of some Steam accounts on every enabled server: live where the
* server answers, the last balance the tally carried where it does not, and
* null where neither knows. A server without Economics is listed with the reason,
* so a page can say why there is nothing rather than show nothing.
*
* @param {Array<{steamId, name}>} accounts the accounts to read, a user's links
*/
async function balancesOf(accounts) {
const ids = [...new Set(accounts.map((a) => String(a.steamId)).filter(Boolean))]
const [list, reports] = await Promise.all([servers.listForPolling(), byServer()])
return Promise.all(
list.map(async (server) => {
const econ = reports.get(server.id) || { loaded: false }
const base = { serverId: server.id, server: server.name || server.id }
if (!econ.loaded) return { ...base, economics: false, reason: MISSING, accounts: [] }
const known = await eventsDb.balancesFor(server.id, econ.wipeId, ids)
const live = new Map()
let answered = false
if (ids.length) {
const result = await client.economicsBalances(server, ids)
const data = (result.ok && result.data) || {}
if (data.kind === 'economics.balances') {
answered = true
for (const row of data.balances || []) {
if (row && row.steamId && Number.isFinite(Number(row.balance))) live.set(String(row.steamId), Number(row.balance))
}
}
}
return {
...base,
economics: true,
startingBalance: econ.startingBalance,
startingKnown: econ.startingKnown,
live: answered,
accounts: accounts.map((a) => {
const id = String(a.steamId)
const source = live.has(id) ? 'live' : known.has(id) ? 'last-known' : null
return {
steamId: id,
name: a.name || null,
balance: source === 'live' ? live.get(id) : source ? known.get(id) : null,
source,
}
}),
}
}),
)
}
/** Checks a staff adjustment's op and amount. `{ ok }` or `{ ok: false, message }`. */
function checkAdjustment(op, amount) {
if (!OPS.includes(op)) return { ok: false, message: `op is one of ${OPS.join(', ')}` }
const value = Number(amount)
const min = op === 'set' ? 0 : AMOUNT_MIN
if (!Number.isFinite(value) || value < min || value > AMOUNT_MAX) {
return { ok: false, message: `an amount is ${op === 'set' ? '0' : coins(AMOUNT_MIN)} to ${coins(AMOUNT_MAX)} coins` }
}
return { ok: true, value: Math.round(value * 100) / 100 }
}
/**
* Staff deposit, withdraw or set one player's balance on one server.
*
* Resolves `{ ok: true, before, after, repeat, server }` or `{ ok: false,
* status, message, reason? }`. The key makes a resent form harmless: the plugin
* answers a key it has seen from the first reply, and moves nothing.
*/
async function adjust({ serverId, steamId, op, amount, key }) {
const checked = checkAdjustment(op, amount)
if (!checked.ok) return { ok: false, status: 400, message: checked.message }
const server = await servers.getForCalling(serverId)
if (!server) return { ok: false, status: 404, message: 'No such server' }
const econ = await forServer(server.id)
if (!econ.loaded) return { ok: false, status: 409, reason: 'economics-missing', message: MISSING }
const result = await client.economicsAdjust(server, { key, steamId, op, amount: checked.value })
if (!result.ok) return { ok: false, status: 503, message: transportError(server, result, 'Economics') }
const data = result.data || {}
const name = server.name || server.id
if (data.kind === 'economics.adjusted') {
return { ok: true, before: Number(data.before), after: Number(data.after), repeat: Boolean(data.repeat), server: name, amount: checked.value }
}
if (data.reason === 'insufficient') {
// Economics takes all or nothing (§11.2.1), so the sentence is the balance.
return { ok: false, status: 409, reason: 'insufficient', message: `They have only ${coins(data.balance)} coins on ${name}` }
}
log.warn('Economics refused an adjustment', { server: server.id, op, reason: data.reason })
return {
ok: false,
status: data.reason === 'economics-missing' ? 409 : 400,
reason: data.reason || null,
message: pluginError(data, `${name} refused the adjustment`),
}
}
/** The activity row's sentence: "Deposited 500 coins to Wanderer on Main: 1,000 → 1,500". */
function adjustmentSummary({ op, amount, name, server, before, after }) {
const verb = { deposit: `Deposited ${coins(amount)} coins to`, withdraw: `Withdrew ${coins(amount)} coins from`, set: `Set the balance of` }[op]
return `${verb} ${name} on ${server}${op === 'set' ? ` to ${coins(amount)}` : ''}: ${coins(before)} → ${coins(after)}`
}
module.exports = {
DEFAULT_STARTING_BALANCE,
MISSING,
OPS,
AMOUNT_MIN,
AMOUNT_MAX,
shape,
forServer,
byServer,
coins,
balancesOf,
checkAdjustment,
adjust,
adjustmentSummary,
}

View File

@@ -148,6 +148,13 @@ const TITLE_SUMS = {
rockets: 'rockets',
explosives: 'explosives',
missions: 'missions',
// Protocol 14 (D356): Safecracker and Scavenger.
cratesHacked: 'crates_hacked',
dropsLooted: 'drops_looted',
// Protocol 14 (D375): Treasure Hunter.
treasuresWon: 'treasures_won',
// Protocol 14 (D375): Warden.
guardedWon: 'guarded_won',
}
/** The two *best* fields and their columns: the longest single kill, never a sum. */
@@ -193,6 +200,45 @@ async function addTitleStats({ serverId, wipeId, steamId }, frame = {}) {
)
}
/** The largest balance a row holds: DECIMAL(14,2). Economics' own limit is far below it. */
const BALANCE_MAX = 999999999999.99
/**
* One player's Economics balance for the wipe (protocol 14, §11.2.2): a VALUE,
* written over and never added. `last_seen` is left alone on purpose — a payment
* or a staff adjustment reaches an offline player, and a balance is not play.
*/
async function setBalance({ serverId, wipeId, steamId }, balance) {
const value = Number(balance)
if (!serverId || !steamId || !Number.isFinite(value)) return
await core.query(
`INSERT INTO ${STATS} (server_id, wipe_id, steam_id, balance)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE balance = VALUES(balance)`,
[serverId, wipeId || '', steamId, Math.max(-BALANCE_MAX, Math.min(Math.round(value * 100) / 100, BALANCE_MAX))],
)
}
/** `ecowipe`: every balance of the wipe is back at the start, so none is known. */
async function clearBalances(serverId, wipeId) {
if (!serverId) return
await core.query(`UPDATE ${STATS} SET balance = NULL WHERE server_id = ? AND wipe_id = ?`, [serverId, wipeId || ''])
}
/** The last known balances of some players in one wipe, as Steam id → number. */
async function balancesFor(serverId, wipeId, steamIds) {
if (!serverId || !steamIds.length) return new Map()
const rows = await core.query(
`SELECT steam_id AS steamId, balance
FROM ${STATS}
WHERE server_id = ? AND wipe_id = ? AND balance IS NOT NULL
AND steam_id IN (${steamIds.map(() => '?').join(', ')})`,
[serverId, wipeId || '', ...steamIds],
)
return new Map(rows.map((r) => [String(r.steamId), Number(r.balance)]))
}
/** One weapon's credited kills (§5.3), added as a delta like a gathered resource. */
async function addWeaponKills({ serverId, wipeId, steamId }, weapon, kills) {
if (!serverId || !steamId || !weapon || !(kills > 0)) return
@@ -291,9 +337,28 @@ async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
* All-time is a SUM over the per-wipe rows rather than a separate set of
* counters, which is what makes it impossible for the two to disagree — there
* is only ever one number, added up differently.
*
* The balance is the exception (§11.2.2): a value per wipe, so it is read only
* for one wipe, as that wipe's MAX — there is one row per player, so the max is
* the value. `balanceFloor` is the server's starting balance, and only set when
* the caller ranks by balance: then a row at or under it is not on the board
* (D336), because "1,000" cannot tell a new account from one spent back to it.
*/
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 }) {
const column = { kills: 'kills', deaths: 'deaths', npcKills: 'npc_kills', playtime: 'playtime_sec' }[sort] || 'kills'
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25, balanceFloor = null }) {
const byBalance = sort === 'balance' && wipeId && Number.isFinite(balanceFloor)
const column = {
kills: 'kills',
deaths: 'deaths',
npcKills: 'npc_kills',
playtime: 'playtime_sec',
// Protocol 14 (D356, D377): the columns Safecracker and Scavenger bring.
cratesHacked: 'crates_hacked',
dropsLooted: 'drops_looted',
// Protocol 14 (D375): Dangerous Treasures won, Treasure Hunter's column.
treasuresWon: 'treasures_won',
// Protocol 14 (D375): guarded crates cleared, Warden's column.
guardedWon: 'guarded_won',
}[sort] || 'kills'
const params = [serverId]
let where = 's.server_id = ?'
@@ -303,6 +368,10 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
params.push(wipeId)
}
const having = byBalance ? 'HAVING MAX(s.balance) > ?' : ''
if (byBalance) params.push(balanceFloor)
const order = byBalance ? 'MAX(s.balance)' : `SUM(s.${column})`
params.push(limit)
return core.query(
@@ -313,12 +382,18 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
SUM(s.npc_kills) AS npcKills,
SUM(s.structures) AS structures,
SUM(s.playtime_sec) AS playtimeSec,
SUM(s.crates_hacked) AS cratesHacked,
SUM(s.drops_looted) AS dropsLooted,
SUM(s.treasures_won) AS treasuresWon,
SUM(s.guarded_won) AS guardedWon,
MAX(s.balance) AS balance,
MAX(s.last_seen) AS lastSeen
FROM ${STATS} s
LEFT JOIN ${PLAYERS} p ON p.steam_id = s.steam_id
WHERE ${where}
GROUP BY s.steam_id, p.name
ORDER BY SUM(s.${column}) DESC, MAX(s.last_seen) DESC
${having}
ORDER BY ${order} DESC, MAX(s.last_seen) DESC
LIMIT ?`,
params,
)
@@ -387,6 +462,9 @@ module.exports = {
addGathered,
addTitleStats,
addWeaponKills,
setBalance,
clearBalances,
balancesFor,
replacePresence,
pruneEvents,
recentEvents,

View File

@@ -13,7 +13,13 @@
// type.
const catalogue = require('../../catalogue')
const worldEvents = require('../../worldEvents')
const dynamicPvp = require('../../dynamicPvp')
const dangerousTreasures = require('../../dangerousTreasures')
const guardedCrate = require('../../guardedCrate')
const db = require('./events.db')
const mapDb = require('../map/map.db')
const serversDb = require('../servers/servers.db')
/** Hard ceiling on a page, whatever a caller asks for. */
const MAX_LIMIT = 200
@@ -51,7 +57,7 @@ function parseKinds(raw) {
* refused: naming it in an error would confirm the kind exists, which is a small
* thing to leak and a free one to avoid.
*/
async function recent({ serverId, admin = false, presence = false, kind = null, wipeId = null, limit }) {
async function recent({ serverId, admin = false, staff = false, presence = false, kind = null, wipeId = null, limit }) {
const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
// Every requested kind was refused. Answering with an empty list is right —
@@ -65,7 +71,64 @@ async function recent({ serverId, admin = false, presence = false, kind = null,
limit: boundedLimit(limit),
})
return rows.map(shape)
const shaped = rows.map(shape)
if (!shaped.some((r) => r.kind === 'world.event' || dynamicPvp.KINDS.includes(r.kind) || dangerousTreasures.isKind(r.kind) || guardedCrate.isKind(r.kind))) return shaped
// Protocol 14 (§14.3.1): each world event is read for THIS viewer — its type
// allowed, its names to the presence audience, its Steam ids to staff, its
// credit by the server's rule — and given a grid from the map's geometry.
// Dynamic PVP's zone lines (§14.3.3) are read the same way for their grid and
// position, and Dangerous Treasures' (§14.3.4) for their grid, the winner's
// name and the Steam ids, and Guarded Crate's (§14.3.5) alike. Both reads only
// when the page holds one of them at all.
const view = await worldView(serverId, { staff: staff || admin, presence })
return shaped
.map((row) => {
if (row.kind === 'world.event') return worldRow(row, view)
if (dynamicPvp.KINDS.includes(row.kind)) return pvpRow(row, view)
if (dangerousTreasures.isKind(row.kind)) return dtRow(row, view)
if (guardedCrate.isKind(row.kind)) return gcRow(row, view)
return row
})
.filter(Boolean)
}
/** What reading a world event for one viewer needs: the server's credit rule and map geometry. */
async function worldView(serverId, { staff, presence }) {
const [server, meta] = await Promise.all([
serversDb.getServer(serverId).catch(() => null),
mapDb.getMeta(serverId).catch(() => null),
])
return {
staff,
presence,
rule: worldEvents.creditRuleOf(server && server.creditRule),
geometry: meta
? { worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }
: null,
}
}
/** One Dynamic PVP row for a viewer (§14.3.3): its grid for everyone, its position for staff. */
function pvpRow(row, view) {
return { ...row, frame: dynamicPvp.shapeFor(row.frame, view) }
}
/** One Dangerous Treasures row for a viewer (§14.3.4). The row's own Steam id, the winner's, is staff's. */
function dtRow(row, view) {
return { ...row, steamId: view.staff ? row.steamId : null, frame: dangerousTreasures.shapeFor(row.frame, view) }
}
/** One Guarded Crate row for a viewer (§14.3.5). The row's own Steam id, the clearer's, is staff's. */
function gcRow(row, view) {
return { ...row, steamId: view.staff ? row.steamId : null, frame: guardedCrate.shapeFor(row.frame, view) }
}
/** One world-event row for a viewer, or null when they may not see it. The row's own Steam id is staff's too. */
function worldRow(row, view) {
const frame = worldEvents.shapeFor(row.frame, view)
if (!frame) return null
return { ...row, steamId: view.staff ? row.steamId : null, frame }
}
/**
@@ -102,15 +165,20 @@ function shape(row) {
* counters, so the two can never disagree — which is the whole reason R12's
* "per-wipe detail plus all-time rollups" is one table and not two.
*/
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false }) {
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false, economics = null }) {
// The richest column (§11.2.2): only for one wipe — a balance is a value, and
// there is no all-time one — and only where the server reports Economics.
const withBalance = Boolean(economics && wipeId)
const rows = await db.leaderboard({
serverId,
wipeId,
sort,
sort: sort === 'balance' && !withBalance ? 'kills' : sort,
limit: boundedLimit(limit, 25),
balanceFloor: withBalance && sort === 'balance' ? economics.startingBalance : null,
})
return rows.map((r) => ({
...(withBalance ? { balance: r.balance === null || r.balance === undefined ? null : Number(r.balance) } : {}),
steamId: r.steamId,
name: r.name || null,
kills: Number(r.kills) || 0,
@@ -118,6 +186,13 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, pre
npcKills: Number(r.npcKills) || 0,
structures: Number(r.structures) || 0,
playtimeSec: Number(r.playtimeSec) || 0,
// Protocol 14 (D356): Safecracker's and Scavenger's counts.
cratesHacked: Number(r.cratesHacked) || 0,
dropsLooted: Number(r.dropsLooted) || 0,
// Protocol 14 (D375): Treasure Hunter's count.
treasuresWon: Number(r.treasuresWon) || 0,
// Protocol 14 (D375): Warden's count.
guardedWon: Number(r.guardedWon) || 0,
// Withheld below the presence audience. A tally refreshes it every minute a
// player is on, so a `lastSeen` of forty seconds ago is the Online tab by
// another name. The ORDER still uses it as a tie-break — that says who was

View File

@@ -166,6 +166,28 @@ async function userIdsForSteamIds(steamIds) {
)
}
/**
* The linked players of one server: every linked Steam account that has played
* there, in any wipe (PLAN_REDESIGNS.md §2.5, the kit weekend's `linked`).
*
* "Has played there" is a stats row on that server, which every player who ever
* connected has. A link is fleet-wide, so the server is what narrows it: a kit
* weekend on one server rewards that server's players, not every account on the
* site. Bounded at `limit`, ordered by most recently seen, so a caller with a
* cap keeps the people who are actually around.
*/
async function linkedOnServer(serverId, limit = 5000) {
return core.query(
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name AS name, MAX(s.last_seen) AS lastSeen
FROM ${LINKS} l
JOIN rust_player_wipe_stats s ON s.steam_id = l.steam_id AND s.server_id = ?
GROUP BY l.steam_id, l.user_id, l.name
ORDER BY lastSeen DESC, l.steam_id
LIMIT ?`,
[serverId, Number(limit) || 5000],
)
}
/**
* The servers that handed out a link code in the last `windowSec` (PLAN_FIXES F5).
*
@@ -195,4 +217,5 @@ module.exports = {
removeBySteamId,
statsForSteamId,
userIdsForSteamIds,
linkedOnServer,
}

View File

@@ -7,15 +7,16 @@
// viewer is SENT only the layers they may see — a hidden layer is absent from the
// answer, never present and hidden by the page (§30.2).
//
// ── The four layers ───────────────────────────────────────────────────────
// ── The five layers ───────────────────────────────────────────────────────
//
// world monuments, and the world's own events: cargo, the patrol
// helicopter, the Chinook, Bradley, supply drops, locked crates
// events what this site's events placed: zones, crates, NPCs (phase 13a)
// players who is on the server and where, and the sleepers
// bases tool cupboards and player vending machines, as positions only
// pvp Dynamic PVP's own PvP zones, where they are now (protocol 14)
//
// Defaults: the first two public, the last two staff. The audiences are the
// Defaults: world, events and pvp public; players and bases staff. The audiences are the
// presence rungs (`staff` · `signed_in` · `public`) and follow its asymmetric
// fallbacks: an unknown stored word narrows to staff, an unknown viewer is
// public (`model/visibility`).
@@ -42,9 +43,11 @@ const visibilityDb = require('../visibility/visibility.db')
const log = core.logger('map')
const LAYERS = Object.freeze(['world', 'events', 'players', 'bases'])
// `pvp` (protocol 14, PLAN_REDESIGNS §14.3.3) is Dynamic PVP's own zones, public by
// default: the game draws them for everyone near one, and they name nobody.
const LAYERS = Object.freeze(['world', 'events', 'players', 'bases', 'pvp'])
const DEFAULTS = Object.freeze({ world: 'public', events: 'public', players: 'staff', bases: 'staff' })
const DEFAULTS = Object.freeze({ world: 'public', events: 'public', players: 'staff', bases: 'staff', pvp: 'public' })
/** D118: on, because it shows a member nothing the game does not already show them. */
const DEFAULT_MATES = true
@@ -329,6 +332,8 @@ function project(live, acc, ids = { own: new Set(), mates: new Set() }) {
out.players = Array.isArray(live.players) ? live.players : []
if (live.playersTruncated) out.playersTruncated = true
}
// Dynamic PVP's zones (§14.3.3): an older plugin's answer has no `pvp`, and it is empty.
if (acc.layers.pvp.visible) out.pvp = Array.isArray(live.pvp) ? live.pvp : []
if (acc.layers.bases.visible) {
out.bases = Array.isArray(live.bases) ? live.bases : []
if (live.basesTruncated) out.basesTruncated = true

View File

@@ -322,6 +322,33 @@ async function insertGrant({ userId, permission, scope, source, note, grantedBy
return { inserted: affected(result) > 0, id: result.insertId }
}
/**
* The grants some users already hold at one scope, with their source and note
* (PLAN_REDESIGNS §12.5): `rust.perm.give` leaves a grant that is there alone,
* and tells its own first attempt's grants from a staff member's by the note.
*/
async function listGrantsForUsers(userIds, scope) {
const ids = [...new Set((userIds || []).map(Number).filter(Boolean))]
if (!ids.length) return []
return core.query(
`SELECT user_id AS userId, permission, source, note
FROM ${GRANTS}
WHERE scope = ? AND user_id IN (${ids.map(() => '?').join(', ')})`,
[scope, ...ids],
)
}
/** Add many grants at once, leaving any already there (§12.5). Returns how many were added. */
async function insertGrants(rows) {
if (!rows.length) return 0
const result = await core.query(
`INSERT IGNORE INTO ${GRANTS} (user_id, permission, scope, source, note, granted_by)
VALUES ${placeholders(rows, 6)}`,
rows.flatMap((r) => [r.userId, r.permission, r.scope, r.source, r.note || null, r.grantedBy || null]),
)
return affected(result)
}
/** Delete a grant and the exceptions it carried, which no foreign key can reach. */
async function deleteGrant(id) {
const removed = affected(await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id])) > 0
@@ -398,7 +425,8 @@ async function deleteExceptionsFor(holder, grantId) {
async function listRunGrants() {
return core.query(
`SELECT run_id AS runId, step_id AS stepId, user_id AS userId, server_id AS serverId,
steam_id AS steamId, permission, kit, credit
steam_id AS steamId, permission, kit, credit,
notice, notice_delivery AS noticeDelivery, notice_told_at AS noticeToldAt
FROM ${RUN_GRANTS}`,
)
}
@@ -416,8 +444,8 @@ async function insertRunGrants(rows) {
if (!rows.length) return 0
const result = await core.query(
`INSERT IGNORE INTO ${RUN_GRANTS} (run_id, step_id, idem_key, user_id, server_id, steam_id, permission, kit, credit)
VALUES ${placeholders(rows, 9)}`,
`INSERT IGNORE INTO ${RUN_GRANTS} (run_id, step_id, idem_key, user_id, server_id, steam_id, permission, kit, credit, notice, notice_delivery)
VALUES ${placeholders(rows, 11)}`,
rows.flatMap((r) => [
String(r.runId),
String(r.stepId),
@@ -428,12 +456,36 @@ async function insertRunGrants(rows) {
r.permission || '',
r.kit,
r.credit ? 1 : 0,
r.notice || null,
r.notice ? r.noticeDelivery || 'chat' : null,
]),
)
return affected(result)
}
/**
* Mark reward messages told (protocol 14, D333): the plugin listed their keys in
* a sync report. A key is `<runId>:<stepId>:<userId>` (`noticeKey` in the
* model); one that names no untold row — told already, or withdrawn — changes
* nothing.
*/
async function markNoticesTold(serverId, keys) {
let marked = 0
for (const key of keys || []) {
const [runId, stepId, userId] = String(key).split(':')
if (!runId || !stepId || !Number(userId)) continue
const result = await core.query(
`UPDATE ${RUN_GRANTS} SET notice_told_at = NOW()
WHERE server_id = ? AND run_id = ? AND step_id = ? AND user_id = ?
AND notice IS NOT NULL AND notice_told_at IS NULL`,
[serverId, runId, stepId, Number(userId)],
)
marked += affected(result)
}
return marked
}
async function deleteRunGrantsForStep(runId, stepId) {
return deleteRunGrantsWhere('run_id = ? AND step_id = ?', [String(runId), String(stepId)])
}
@@ -848,6 +900,8 @@ module.exports = {
listGrants,
getGrant,
insertGrant,
insertGrants,
listGrantsForUsers,
deleteGrant,
listSteamGrants,
getSteamGrant,
@@ -858,6 +912,7 @@ module.exports = {
deleteException,
deleteExceptionsFor,
listRunGrants,
markNoticesTold,
listRunGrantsForStep,
insertRunGrants,
deleteRunGrantsForStep,

View File

@@ -342,8 +342,11 @@ function buildDesired(serverId, authored) {
const permission = normaliseName(row.permission)
if (!permission) continue
// A grant an event gave for good (PLAN_REDESIGNS.md §12.5) says so, with its
// note, so the screen can show where it came from; staff delete it like any.
const given = row.source === 'event' ? { origin: 'event', note: row.note || '' } : {}
for (const steamId of steamIdsByUser.get(row.userId) || []) {
addGrant(steamId, permission, { type: 'userGrant', id: row.id, userId: row.userId, scope: row.scope })
addGrant(steamId, permission, { type: 'userGrant', id: row.id, userId: row.userId, scope: row.scope, ...given })
}
}
@@ -365,6 +368,7 @@ function buildDesired(serverId, authored) {
// every account the user has linked (D28). The CREDIT is one extra use on the
// account that took part, while it is still linked to the winner.
const credits = new Map()
const notices = []
for (const row of runGrants) {
if (row.serverId !== serverId) continue
@@ -380,6 +384,19 @@ function buildDesired(serverId, authored) {
const key = `${row.steamId}|${row.kit}`
credits.set(key, (credits.get(key) || 0) + 1)
}
// The reward's message (protocol 14, D333), to the account the reward
// picked, while it is still linked to the winner — and only until the
// plugin reports it said it. A withdrawn reward has no row, so nobody is
// told about a reward that is no longer theirs.
if (row.notice && !row.noticeToldAt && linked.includes(row.steamId)) {
notices.push({
key: noticeKey(row),
steamId: row.steamId,
message: String(row.notice),
delivery: row.noticeDelivery === 'popup' ? 'popup' : 'chat',
})
}
}
// ── A group's BetterChat style (phase 17, D138) ───────────────────────
@@ -415,11 +432,15 @@ function buildDesired(serverId, authored) {
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({ steamId, permissions })),
// Always sent, even empty (D103).
credits: creditRows,
// Always sent, even empty (D333): the whole untold set, so a notice the
// site stops sending is one the plugin may forget.
notices: notices.sort((a, b) => a.key.localeCompare(b.key)),
}
const hashed = [
...rows,
...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
...notices.map((n) => ({ kind: 'notice', subject: n.steamId, object: n.key, value: `${n.delivery}:${n.message}` })),
]
return { payload, rows, hash: hashRows(hashed), sources }
@@ -441,6 +462,15 @@ function isShared(group, serverRows) {
* A digest of the desired set. Sorted before hashing, and a row's VALUE is in
* it (a style field, a group's title, rank and parent): a change to one must push.
*/
/**
* The key of one reward message (protocol 14, D333): `<runId>:<stepId>:<userId>`.
* One reward step tells one person once, whichever of their linked accounts it
* picked; `permissions.db.markNoticesTold` reads it back.
*/
function noticeKey(row) {
return `${row.runId}:${row.stepId}:${row.userId}`
}
function hashRows(rows) {
const canonical = rows
.map((row) => `${row.kind} ${row.subject} ${row.object}${row.value === undefined || row.value === null ? '' : `=${row.value}`}`)
@@ -561,6 +591,7 @@ module.exports = {
buildDesired,
retirements,
hashRows,
noticeKey,
rowKey,
collapseGrants,
shapeSync,

View File

@@ -24,7 +24,7 @@ async function listServers({ enabledOnly = false } = {}) {
return core.query(
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
protocol, enabled, sort_order AS sortOrder, announce_news AS announceNews,
news_delivery AS newsDelivery,
news_delivery AS newsDelivery, credit_rule AS creditRule,
wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz,
DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt,
created_at AS createdAt, updated_at AS updatedAt
@@ -37,7 +37,7 @@ async function listServers({ enabledOnly = false } = {}) {
async function getServer(id) {
const rows = await core.query(
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
protocol, enabled, sort_order AS sortOrder,
protocol, enabled, sort_order AS sortOrder, credit_rule AS creditRule,
wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz,
DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt,
created_at AS createdAt, updated_at AS updatedAt
@@ -97,6 +97,15 @@ async function setSchedule(id, { wipeRule, wipeDay, wipeTime, wipeTz, wipeAnchor
)
}
/**
* One server's credit rule (D372). Its own statement, like the schedule, and for
* the same reason: written only when a save carries one, so a client that
* predates the rule cannot reset it to the default.
*/
async function setCreditRule(id, rule) {
await core.query(`UPDATE ${SERVERS} SET credit_rule = ? WHERE id = ?`, [rule, id])
}
async function deleteServer(id) {
await core.query(`DELETE FROM ${SERVERS} WHERE id = ?`, [id])
}
@@ -249,13 +258,64 @@ async function putState(state) {
)
}
/**
* What one server's last status said about Dangerous Treasures and its console
* (protocol 14, PLAN_REDESIGNS §14.3.4): `{ dangerousTreasures, console }`, each
* as the driver hands JSON_EXTRACT back (text, or null for a plugin that never
* said). Null for a server with no state yet.
*/
async function dtReportFor(serverId) {
const rows = await core.query(
`SELECT JSON_EXTRACT(raw, '$.integrations.dangerousTreasures') AS dangerousTreasures,
JSON_EXTRACT(raw, '$.console') AS console
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/**
* What one server's last status said about Guarded Crate (with NpcSpawn and its
* event names) and its console (protocol 14, PLAN_REDESIGNS §14.3.5):
* `{ guardedCrate, console }`, as `dtReportFor`'s are. Null for a server with no
* state yet.
*/
async function gcReportFor(serverId) {
const rows = await core.query(
`SELECT JSON_EXTRACT(raw, '$.integrations.guardedCrate') AS guardedCrate,
JSON_EXTRACT(raw, '$.console') AS console
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/** `gcReportFor` for every enabled server, with its id and name: the event list a step offers. */
async function gcReports() {
return core.query(
`SELECT s.id AS serverId, s.name AS name,
JSON_EXTRACT(st.raw, '$.integrations.guardedCrate') AS guardedCrate,
JSON_EXTRACT(st.raw, '$.console') AS console
FROM ${SERVERS} s
JOIN ${STATE} st ON st.server_id = s.id
WHERE s.enabled = 1
ORDER BY s.sort_order ASC, s.id ASC`,
)
}
module.exports = {
SERVERS,
STATE,
dtReportFor,
gcReportFor,
gcReports,
listServers,
getServer,
upsertServer,
setSchedule,
setCreditRule,
deleteServer,
listState,
getState,

View File

@@ -17,6 +17,7 @@
const core = require('../../core')
const db = require('./servers.db')
const { nextWipe } = require('./nextWipe')
const worldEvents = require('../../worldEvents')
const log = core.logger('servers')
@@ -59,6 +60,8 @@ function withToken(row) {
announceNews: Boolean(Number(row.announceNews)),
// D142: where that post goes — chat, or a popup.
newsDelivery: row.newsDelivery === 'popup' ? 'popup' : 'chat',
// D372: who a downed helicopter, Chinook or Bradley credits, for ingest.
creditRule: worldEvents.creditRuleOf(row.creditRule),
}
}
@@ -212,6 +215,8 @@ async function listForAdmin(now = Date.now()) {
// bridge makes. Anything but `patched` is said on the servers page.
zoneHelper: (state && state.zoneHelper) || null,
schedule: scheduleOf(row),
// D372: who a downed helicopter, Chinook or Bradley credits on this server.
creditRule: worldEvents.creditRuleOf(row.creditRule),
}
})
}

View File

@@ -76,9 +76,27 @@ const COLUMN = /^[a-z_]+$/
* @param {object} source a category's `source` from `titles.js`
* @param {object} lists list name → the short names it holds (`lists.js`)
*/
async function standings({ serverId, wipeId, source, lists = {}, limit }) {
async function standings({ serverId, wipeId, source, lists = {}, limit, floor = null }) {
const params = [serverId, wipeId]
// The richest (§11.2.2): the balance is a value, one per row, ranked only
// above `floor`, the server's starting balance (D336). No floor, no ranking —
// the caller passes one only for a server that reports Economics.
if (source.balance) {
if (!COLUMN.test(source.balance)) throw new Error('not a stats column')
if (!Number.isFinite(floor)) return []
return core.query(
`SELECT s.steam_id AS steamId, MAX(s.${source.balance}) AS value
FROM ${STATS} s
WHERE s.server_id = ? AND s.wipe_id = ? AND s.${source.balance} > ?
GROUP BY s.steam_id
ORDER BY value DESC, MAX(s.last_seen) DESC
LIMIT ?`,
[...params, floor, limit],
)
}
if (source.sum || source.diff || source.best) {
let value
if (source.sum) value = `SUM(s.${source.sum})`

View File

@@ -20,8 +20,12 @@ const crypto = require('node:crypto')
/**
* The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three
* conditions and their shipped titles, plus `playtime`, which predates them and
* has no default title, so a playtime rule always carries its own text.
* conditions and their shipped titles, the richest (§11.2.2, Magnate), the
* two world-event counts (§14.3.1, Safecracker and Scavenger), Dangerous
* Treasures won (§14.3.4, Treasure Hunter), guarded crates cleared (§14.3.5,
* Warden), plus
* `playtime`, which predates them and has no default title, so a playtime rule
* always carries its own text.
*
* `source` says where the standing is read from (`titles.db.js`):
* `sum` a column of `rust_player_wipe_stats`, summed
@@ -30,6 +34,9 @@ const crypto = require('node:crypto')
* `best` a column's MAX: the longest single kill (D174)
* `weapons` a named list's kills in `rust_weapon_kills` (§5.3)
* `gathered` a named list's amount in `rust_gather_totals` (D159)
* `balance` the Economics balance, a value per wipe (PLAN_REDESIGNS §11.2.2):
* ranked only above the server's starting balance (D336), and
* only on a server that reports Economics
* `npcProfile` one site NPC profile's kills, counted as the profile says
* (runicnpc stage 4, D247, D250). A rule on it names the profile,
* and carries its own text, like playtime.
@@ -61,6 +68,14 @@ const STATS = {
rockets: { label: 'Rockets fired', title: 'Artillerist', source: { sum: 'rockets' } },
explosives: { label: 'Explosives thrown', title: 'Demolitionist', source: { sum: 'explosives' } },
missions: { label: 'Missions completed', title: 'Wayfarer', source: { sum: 'missions' } },
// Protocol 14, Rust's own world events (D356): a hack counts for its starter, once per crate.
crateshacked: { label: 'Locked crates hacked', title: 'Safecracker', source: { sum: 'crates_hacked' } },
dropslooted: { label: 'Airdrops looted first', title: 'Scavenger', source: { sum: 'drops_looted' } },
// Protocol 14, Dangerous Treasures (D375): a chest counts for the player who emptied it.
treasureswon: { label: 'Dangerous Treasures won', title: 'Treasure Hunter', source: { sum: 'treasures_won' } },
// Protocol 14, Guarded Crate (D375): a crate counts for the player who killed its last guard.
guardedwon: { label: 'Guarded crates cleared', title: 'Warden', source: { sum: 'guarded_won' } },
richest: { label: 'Richest (Economics)', title: 'Magnate', source: { balance: 'balance' } },
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
profilekills: { label: 'Kills of an NPC profile', title: null, source: { npcProfile: true } },
}

View File

@@ -5,6 +5,7 @@
// the app's — so it is remembered briefly: the leaderboard is polled by every
// open page, and each rule is a leaderboard query of its own.
const economics = require('../economics/economics.model')
const serversDb = require('../servers/servers.db')
const db = require('./titles.db')
const lists = require('./lists')
@@ -118,12 +119,21 @@ async function heldFor(serverId, { wipeId, now = Date.now() } = {}) {
ruleBy.set(key, r)
}
// The richest ranks above the server's starting balance (D336), and only
// where the server reports Economics: a balance last known before it was
// removed is not anybody's standing now.
let floor = null
if (depth.has('richest')) {
const econ = await economics.forServer(serverId)
floor = econ.loaded ? econ.startingBalance : null
}
await Promise.all(
[...depth.entries()].map(async ([key, limit]) => {
const r = ruleBy.get(key)
const rows = r.stat === 'profilekills'
? await npcs.standing(serverId, r.profile, wipeId, limit)
: await db.standings({ serverId, wipeId, source: titles.STATS[r.stat].source, lists, limit })
: await db.standings({ serverId, wipeId, source: titles.STATS[r.stat].source, lists, limit, floor })
// SUM() and MAX() come back as strings; the rules compare numbers.
standings[key] = rows.map((row) => ({ steamId: row.steamId, value: Number(row.value) || 0 }))
}),

View File

@@ -99,15 +99,17 @@ async function deletePreset(id) {
/**
* Each configured server, in the operator's order, with what its last hello said
* about ZoneManager (`{ version, flags }`) and ZoneDomes (`{ loaded, version,
* helper, ready }`). Either is null for a server that never said: no ZoneManager,
* a plugin before protocol 13, or a server never reached.
* about ZoneManager (`{ version, flags }`), ZoneDomes (`{ loaded, version,
* helper, ready }`) and TruePVE (`{ loaded, version }`). Each is null for a server
* that never said: no ZoneManager, a plugin before protocol 13 (14 for TruePVE), or
* a server never reached.
*/
async function listZoneServers() {
const rows = await core.query(
`SELECT s.id, s.name, s.enabled,
JSON_EXTRACT(st.raw, '$.zoneManager') AS zoneManager,
JSON_EXTRACT(st.raw, '$.integrations.zoneDomes') AS zoneDomes
JSON_EXTRACT(st.raw, '$.integrations.zoneDomes') AS zoneDomes,
JSON_EXTRACT(st.raw, '$.integrations.truePve') AS truePve
FROM ${SERVERS} s
LEFT JOIN ${STATE} st ON st.server_id = s.id
ORDER BY s.sort_order ASC, s.id ASC`,
@@ -115,6 +117,7 @@ async function listZoneServers() {
return rows.map((r) => {
const zm = json(r.zoneManager, null)
const zd = json(r.zoneDomes, null)
const tp = json(r.truePve, null)
return {
id: r.id,
name: r.name,
@@ -135,6 +138,9 @@ async function listZoneServers() {
ready: zd.ready === true,
}
: null,
// Protocol 14 (PLAN_REDESIGNS §14.3.2): null from a plugin older than TruePVE's support.
truePve:
tp && typeof tp === 'object' ? { loaded: tp.loaded === true, version: typeof tp.version === 'string' ? tp.version : null } : null,
}
})
}

View File

@@ -129,7 +129,7 @@ async function optionRows() {
}))
}
/** What one server's last hello said about ZoneManager and ZoneDomes, or null. */
/** What one server's last hello said about ZoneManager, ZoneDomes and TruePVE, or null. */
async function serverZoneMods(serverId) {
return (await db.listZoneServers()).find((s) => s.id === serverId) || null
}

View File

@@ -458,6 +458,11 @@ async function applyReport(server, { desired, hash, retire, styleRetired = [], r
const revocations = await db.listRevocations(server.id)
await db.deleteRevocations(revocations.map((row) => row.id))
// Protocol 14 (D333). The reward messages the plugin has said; they leave the
// next sync's set, which is what lets the plugin forget them.
const noticesTold = Array.isArray(report.noticesTold) ? report.noticesTold : []
const marked = noticesTold.length ? await db.markNoticesTold(server.id, noticesTold) : 0
// What waits for a person: the reconcile's (D161's `adopt`, and an event's
// grant removed in the game), and a style field somebody changed by hand (D138).
await db.replaceDrift(server.id, [
@@ -505,6 +510,7 @@ async function applyReport(server, { desired, hash, retire, styleRetired = [], r
...(report.creditsApplied !== undefined
? { creditsApplied: report.creditsApplied, creditsWithdrawn: report.creditsWithdrawn }
: {}),
...(marked ? { noticesTold: marked } : {}),
})
}

View File

@@ -113,6 +113,11 @@ async function putServer(req, res) {
})
if (schedule) await db.setSchedule(id, schedule)
// D372: written only when the save carries one, like the schedule. The router
// has already refused a word this build does not know.
const creditRule = req.body.creditRule === undefined ? null : req.body.creditRule
if (creditRule) await db.setCreditRule(id, creditRule)
await core.activity.log({
req,
action: 'rust.server.save',
@@ -125,6 +130,8 @@ async function putServer(req, res) {
// The schedule as written, when the save carried one. Nothing in it is a
// secret, and "who moved the wipe" is a question players will ask.
...(schedule ? { schedule: { ...schedule, wipeOnceAt: schedule.wipeOnceAt ? schedule.wipeOnceAt.toISOString() : null } } : {}),
// The credit rule as written, when the save carried one (D372).
...(creditRule ? { creditRule } : {}),
},
})
@@ -340,6 +347,9 @@ async function integrations(req, res) {
ok: Boolean(data),
status: result.status,
integrations: data && data.integrations ? data.integrations : null,
// Protocol 14 (D363): whether the framework runs another plugin's console
// command from code, which a Dangerous Treasures step needs.
console: data && data.console ? data.console : null,
})
} catch (err) {
log.error('failed to read integrations', { server: id, error: err.message })

View File

@@ -22,6 +22,7 @@ const core = require('../../core')
const express = core.express
const admin = require('./rust.controller')
const worldEvents = require('../../worldEvents')
const { requireRole, validate } = core.middleware
const { body, param } = core.validator
@@ -63,7 +64,7 @@ adminRustRouter.put(
'/servers/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Create or update a Rust server'
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. A 400 carries one sentence per problem in `errors`.'
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. `creditRule` (protocol 14, D372) is who a downed patrol helicopter, Chinook or Bradley credits, for the feed line and for the Rotorbane and Tankbreaker counts alike: `blow` (the killing blow, the default; for the patrol helicopter the crippling hit, else the last hit), `last`, `most` (the most damage) or `all` (everyone who hurt it, one each). It is written only when present, and applies from then on. A 400 carries one sentence per problem in `errors`.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerSave" } } } } */
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Invalid body' } */
@@ -91,6 +92,8 @@ adminRustRouter.put(
body('wipeTz').optional({ values: 'null' }).isString().isLength({ max: 64 }),
body('wipeAnchor').optional({ values: 'falsy' }).isISO8601({ strict: true }).isLength({ max: 10 }),
body('wipeOnceAt').optional({ values: 'falsy' }).isISO8601({ strict: true }),
// Protocol 14 (D372): who a downed helicopter, Chinook or Bradley credits.
body('creditRule').optional().isIn(worldEvents.CREDIT_RULE_IDS),
validate,
admin.putServer,
)
@@ -206,7 +209,7 @@ adminRustRouter.get(
'/servers/:id/integrations',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Which optional mods a server has loaded'
// #swagger.description = 'Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why.'
// #swagger.description = 'Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why. `console` (protocol 14, D363) says whether the server’s framework runs another plugin’s console command from code, which a Start a Dangerous Treasures event step needs.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'What the server has loaded', content: { "application/json": { schema: { $ref: "#/components/schemas/RustIntegrations" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */

View File

@@ -5,8 +5,12 @@
// every statement here is scoped by it, so a panel opened on one user cannot
// read or write another's rows by editing a path segment.
const crypto = require('node:crypto')
const core = require('../../core')
const backpacks = require('../../model/backpacks/backpacks.model')
const economics = require('../../model/economics/economics.model')
const links = require('../../model/links/links.model')
const permissionsDb = require('../../model/permissions/permissions.db')
const permissions = require('../../model/permissions/permissions.model')
@@ -208,4 +212,90 @@ async function removeGrant(req, res) {
}
}
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant }
/** GET /admin/users/:id/rust/economics — this user's balances (§11.2.2), as the player sees them. */
async function listEconomics(req, res) {
try {
const held = await links.listForUser(Number(req.params.id))
res.json({ servers: await economics.balancesOf(held.map((l) => ({ steamId: l.steamId, name: l.name }))) })
} catch (err) {
log.error('failed to read a user’s balances', { error: err.message })
res.status(500).json({ message: 'Failed to read this user’s balances' })
}
}
/**
* POST /admin/users/:id/rust/economics — staff deposit, withdraw or set coins.
*
* **Scoped by the user**, like every write in this panel: the Steam id must be
* one this user has linked, so a wrong id on the form moves nobody's coins
* rather than a stranger's. The key is the form's, one per submission; a form
* that sent none gets one, and is then only as safe as a single click.
*/
async function adjustEconomics(req, res) {
const userId = Number(req.params.id)
const { serverId, steamId, op, amount } = req.body
const key = req.body.key ? `adj-${req.body.key}` : `adj-${crypto.randomUUID()}`
try {
const held = await links.listForUser(userId)
const link = held.find((l) => String(l.steamId) === String(steamId))
if (!link) return res.status(404).json({ message: 'That Steam account is not linked to this user' })
const result = await economics.adjust({ serverId, steamId: String(steamId), op, amount, key })
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
// A repeat moved nothing, so it is not a second row in the log.
if (!result.repeat) {
await core.activity.log({
req,
action: 'rust.economics.adjust',
detail: {
userId,
serverId,
steamId: String(steamId),
op,
amount: result.amount,
before: result.before,
after: result.after,
summary: economics.adjustmentSummary({
op,
amount: result.amount,
name: link.name || String(steamId),
server: result.server,
before: result.before,
after: result.after,
}),
},
})
}
return res.json({ before: result.before, after: result.after, repeat: result.repeat })
} catch (err) {
log.error('failed to adjust a balance', { userId, serverId, op, error: err.message })
return res.status(500).json({ message: 'Failed to adjust that balance' })
}
}
/**
* GET /admin/users/:id/rust/backpacks — one of this user's backpacks
* (§11.3.2), read live. Scoped by the user like every route in this panel: a
* Steam id they have not linked is a 404, never a stranger's backpack.
*/
async function readBackpack(req, res) {
const userId = Number(req.params.id)
const { serverId, steamId } = req.query
try {
const held = await links.listForUser(userId)
if (!held.some((l) => String(l.steamId) === String(steamId))) {
return res.status(404).json({ message: 'That Steam account is not linked to this user' })
}
const result = await backpacks.read({ serverId, steamId })
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
return res.json(result.backpack)
} catch (err) {
log.error('failed to read a backpack', { userId, serverId, error: err.message })
return res.status(500).json({ message: 'Failed to read that backpack' })
}
}
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant, listEconomics, adjustEconomics, readBackpack }

View File

@@ -30,7 +30,7 @@
const core = require('../../core')
const express = core.express
const { body, param } = core.validator
const { body, param, query } = core.validator
const usersRust = require('./usersRust.controller')
const { validate } = core.middleware
@@ -128,4 +128,72 @@ usersRustRouter.delete(
usersRust.removeGrant,
)
// ── Economics (PLAN_REDESIGNS §11.2.2): a person's coins, and staff's hand ──
//
// Beside their links, because "how many coins does this player have" is asked
// about a person. The write is the only way coins come back after an event
// paid them (D335): staff withdraw them by hand, and each adjustment is an
// activity row with the balance before and after.
usersRustRouter.get(
'/rust/economics',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A user’s coin balances (admin only)'
// #swagger.description = 'This user’s Economics balance on every server, per linked Steam account — live where the server answers, else the last balance it reported. A server without Economics is listed with the reason. The same shape as GET /player/rust/economics.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'The balances', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsBalances" } } } } */
param('id').isInt(),
validate,
usersRust.listEconomics,
)
usersRustRouter.post(
'/rust/economics',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Deposit, withdraw or set a user’s coins on one server (admin only)'
// #swagger.description = 'Through the server’s Economics, for one Steam account this user has linked. A withdrawal above the balance is refused with the balance, because Economics takes all or nothing. `key` makes a resent form harmless: the game answers a key it has seen from the first reply and moves nothing. Recorded in the activity log with the balance before and after.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjust" } } } } */
/* #swagger.responses[200] = { description: 'Adjusted', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjusted" } } } } */
/* #swagger.responses[400] = { description: 'A bad op or amount', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or that Steam account is not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Economics is not installed there, or the balance is too low to withdraw that much', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
body('serverId').isString().isLength({ min: 1, max: 64 }),
body('steamId').isString().matches(STEAM_ID_RE),
body('op').isIn(['deposit', 'withdraw', 'set']),
body('amount').isFloat({ min: 0, max: 1000000 }).toFloat(),
body('key').optional().isString().matches(/^[A-Za-z0-9-]{8,64}$/),
validate,
usersRust.adjustEconomics,
)
// ── Backpacks (PLAN_REDESIGNS §11.3.2): what a person carries, read live ──
//
// Read only (R2): there is no write beside it, here or in the game. Each open
// of the panel is one read; nothing is stored.
usersRustRouter.get(
'/rust/backpacks',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A user’s backpack on one server (admin only)'
// #swagger.description = 'One of this user’s linked Steam accounts’ Backpacks backpack on one server, read live from the game and never stored. The same shape as GET /player/rust/backpacks. Staff see the overflow too; only the owner’s own open of the backpack moves it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
// #swagger.parameters['serverId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'The server.' }
// #swagger.parameters['steamId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'One of this user’s linked Steam ids.' }
/* #swagger.responses[200] = { description: 'The backpack', content: { "application/json": { schema: { $ref: "#/components/schemas/RustBackpack" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or that Steam account is not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Backpacks is not installed on that server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
query('serverId').isString().isLength({ min: 1, max: 64 }),
query('steamId').isString().matches(STEAM_ID_RE),
validate,
usersRust.readBackpack,
)
module.exports = usersRustRouter

View File

@@ -27,7 +27,7 @@ visibilityRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Who may see who is online, and who may see a clan roster'
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world` and `events` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.'
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world`, `events` and `pvp` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.'
/* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
requireRole('admin'),
visibility.read,
@@ -37,7 +37,7 @@ visibilityRouter.put(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat'
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.'
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`, `pvp`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */

View File

@@ -21,6 +21,8 @@
const core = require('../../core')
const backpacks = require('../../model/backpacks/backpacks.model')
const economics = require('../../model/economics/economics.model')
const links = require('../../model/links/links.model')
const npcs = require('../../model/npcs/npcs.model')
const npcsDb = require('../../model/npcs/npcs.db')
@@ -55,6 +57,42 @@ async function ownNpcKills(req, res) {
}
}
/**
* GET /player/rust/economics — the caller's coin balances (§11.2.2): every
* server, each linked account, live where the server answers.
*/
async function ownEconomics(req, res) {
try {
const held = await links.listForUser(req.user.id)
res.json({ servers: await economics.balancesOf(held.map((l) => ({ steamId: l.steamId, name: l.name }))) })
} catch (err) {
log.error('failed to read a player’s balances', { error: err.message })
res.status(500).json({ message: 'Failed to read your balances' })
}
}
/**
* GET /player/rust/backpacks — one of the caller's own backpacks (§11.3.2),
* read live. Only an account the caller has linked: anybody else's is a 404,
* the same answer as an id nobody holds, so the route says nothing about
* whose ids exist.
*/
async function ownBackpack(req, res) {
const { serverId, steamId } = req.query
try {
const held = await links.listForUser(req.user.id)
if (!held.some((l) => String(l.steamId) === String(steamId))) {
return res.status(404).json({ message: 'That Steam account is not linked to you' })
}
const result = await backpacks.read({ serverId, steamId })
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
return res.json(result.backpack)
} catch (err) {
log.error('failed to read a player’s backpack', { serverId, error: err.message })
return res.status(500).json({ message: 'Failed to read your backpack' })
}
}
/** GET /player/rust/links — the Steam accounts the caller holds. */
async function listLinks(req, res) {
try {
@@ -187,4 +225,4 @@ async function listPermissions(req, res) {
}
}
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills }
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills, ownEconomics, ownBackpack }

View File

@@ -24,7 +24,7 @@
const core = require('../../core')
const express = core.express
const { body, param } = core.validator
const { body, param, query } = core.validator
const rust = require('./rust.controller')
const { validate, rateLimit } = core.middleware
@@ -67,6 +67,47 @@ playerRustRouter.get(
rust.ownNpcKills,
)
/**
* A backpack read runs on the game's main thread (55–117 ms the first time
* after a boot, §11.3.1), so a page held open on refresh is not allowed to
* become a load on the server. Generous for a person, tight for a loop.
*/
const backpackLimiter = rateLimit({
windowMs: 60 * 1000,
max: 20,
label: 'rust-backpack-read',
message: 'Too many backpack reads. Please wait a minute.',
})
playerRustRouter.get(
'/backpacks',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'Your backpack on one server'
// #swagger.description = 'One of the caller’s own linked Steam accounts’ Backpacks backpack on one server, read live from the game and never stored (PLAN_REDESIGNS §11.3.2). `exists: false` for an account that has never had one. Each stack has its slot, shortname, the game’s English name (`display`), amount, and skin, custom name, blueprint target and nested contents where set; `overflow` marks a stack past the capacity, which moves to the owner’s inventory the next time they open the backpack. 404 for a Steam account the caller has not linked; 409 where the server has no Backpacks. Requires a session; 20 reads a minute.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['serverId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'The server.' }
// #swagger.parameters['steamId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'One of the caller’s linked Steam ids.' }
/* #swagger.responses[200] = { description: 'The backpack', content: { "application/json": { schema: { $ref: "#/components/schemas/RustBackpack" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or not one of your linked Steam accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Backpacks is not installed on that server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
backpackLimiter,
query('serverId').isString().isLength({ min: 1, max: 64 }),
query('steamId').isString().matches(STEAM_ID_RE),
validate,
rust.ownBackpack,
)
playerRustRouter.get(
'/economics',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'Your coin balances'
// #swagger.description = 'The caller’s Economics balance on every server, per linked Steam account (PLAN_REDESIGNS §11.2.2). Read live from each server; where a server does not answer, the last balance it reported (`source: last-known`), and null where neither knows. A server without Economics is listed with `economics: false` and the reason. `startingKnown: false` means the server’s Economics config could not be read and the starting balance shown is Economics’ default. Empty `accounts` with no linked account. Requires a session.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The balances', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsBalances" } } } } */
rust.ownEconomics,
)
playerRustRouter.get(
'/servers',
// #swagger.tags = ['Player · Rust']

View File

@@ -12,6 +12,9 @@
const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const dangerousTreasures = require('../../dangerousTreasures')
const guardedCrate = require('../../guardedCrate')
const economics = require('../../model/economics/economics.model')
const events = require('../../model/events/events.model')
const map = require('../../model/map/map.model')
const mapDb = require('../../model/map/map.db')
@@ -83,6 +86,9 @@ async function getServer(req, res) {
* feed carries only what names nobody — a wipe, a start, a shutdown — and says
* so with `presenceHidden`, so a page can explain a quiet feed instead of
* implying a quiet server.
*
* `staff` is the same re-read rung, and reaches only what a world event carries
* (protocol 14, D353): a Steam id and a position. It never widens the kinds.
*/
async function listEvents(req, res) {
try {
@@ -92,6 +98,7 @@ async function listEvents(req, res) {
events: await events.recent({
serverId: req.params.id,
presence: presence.visible,
staff: presence.level === 'staff',
kind: req.query.kind,
wipeId: req.query.wipe || null,
limit: req.query.limit,
@@ -108,13 +115,27 @@ async function listEvents(req, res) {
async function listLeaderboard(req, res) {
try {
const presence = await visibility.canSeePresence(req, req.params.id)
const wipeId = req.query.wipe || null
// The richest column (§11.2.2): one wipe at a time, on a server that
// reports Economics. `economics` is null otherwise, and the rows carry no
// `balance`, so a page shows the column exactly when there is one.
const econ = await economics.forServer(req.params.id)
// Treasure Hunter's column (D375): on a server that reports Dangerous
// Treasures, and on any page whose rows already count a treasure won.
const dt = dangerousTreasures.reportOf(await serversDb.dtReportFor(req.params.id).catch(() => null))
// Warden's column (D375), likewise for Guarded Crate.
const gc = guardedCrate.reportOf(await serversDb.gcReportFor(req.params.id).catch(() => null))
const withBalance = econ.loaded && wipeId ? { startingBalance: econ.startingBalance } : null
const [rows, held] = await Promise.all([
events.leaderboard({
serverId: req.params.id,
wipeId: req.query.wipe || null,
wipeId,
sort: req.query.sort,
limit: req.query.limit,
presence: presence.visible,
economics: withBalance,
}),
titles.currentFor(req.params.id),
])
@@ -125,6 +146,9 @@ async function listLeaderboard(req, res) {
// this page is showing, because a title is what a player is, not what a
// past wipe was.
leaderboard: rows.map((row) => ({ ...row, titles: held.get(row.steamId) || [] })),
economics: withBalance,
dangerousTreasures: Boolean((dt.dangerousTreasures && dt.dangerousTreasures.loaded) || rows.some((r) => r.treasuresWon > 0)),
guardedCrate: Boolean((gc.guardedCrate && gc.guardedCrate.loaded) || rows.some((r) => r.guardedWon > 0)),
})
} catch (err) {
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })

View File

@@ -68,7 +68,7 @@ rustRouter.get(
'/servers/:id/events',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Recent events on one Rust server'
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld.'
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule`.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
@@ -82,10 +82,10 @@ rustRouter.get(
'/servers/:id/leaderboard',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The leaderboard for one Rust server'
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules.'
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336). Each row carries `treasuresWon` (Dangerous Treasures chests emptied, Treasure Hunter’s count, PLAN_REDESIGNS §14.3.4), and the answer carries `dangerousTreasures: true` where the server reports Dangerous Treasures or a row already counts one, which is when a page shows that column. Likewise `guardedWon` (guarded crates cleared, Warden’s count, PLAN_REDESIGNS §14.3.5) and `guardedCrate: true` where the server reports Guarded Crate or a row already counts one.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills or playtime', schema: { type: 'string' } }
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, guardedWon, or balance (one wipe on a server with Economics)', schema: { type: 'string' } }
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'The leaderboard' } */
siteMode,
@@ -191,7 +191,7 @@ rustRouter.get(
'/servers/:id/map',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'One Rust server’s map'
// #swagger.description = 'Where the picture of the current map is, the geometry to draw it with (world size, the picture’s size and ocean margin in pixels, and the game’s own grid), the monuments when the viewer may see the world layer — only the labels drawn on this server, so a hidden label such as Substation is not sent at all (D196) — and which of the four layers — `world`, `events`, `players`, `bases` — this viewer gets. A hidden layer says which audience can see it and never what it holds. The players layer can never be wider than who may see who is online (`cappedByPresence`). `mates` says whether this viewer gets their own position and their online clan mates’. `picture` is null when the game has no picture of its map; `geometry` is null when the server has never described its map.'
// #swagger.description = 'Where the picture of the current map is, the geometry to draw it with (world size, the picture’s size and ocean margin in pixels, and the game’s own grid), the monuments when the viewer may see the world layer — only the labels drawn on this server, so a hidden label such as Substation is not sent at all (D196) — and which of the five layers — `world`, `events`, `players`, `bases`, `pvp` — this viewer gets. A hidden layer says which audience can see it and never what it holds. The players layer can never be wider than who may see who is online (`cappedByPresence`). `mates` says whether this viewer gets their own position and their online clan mates’. `picture` is null when the game has no picture of its map; `geometry` is null when the server has never described its map.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The map, as this viewer may see it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMap" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
@@ -216,7 +216,7 @@ rustRouter.get(
'/servers/:id/map/live',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'What moves on one Rust server’s map'
// #swagger.description = 'The positions this viewer may see, asked of the game while somebody is looking and held for five seconds, so any number of viewers cost one ask. **A layer the viewer may not see is absent from the answer**, not empty: `world` (world events and locked crates), `events` (what the site’s events placed), `players` (online players and sleepers, with names) and `bases` (tool cupboards and vending machines, positions only). `mates` is the viewer’s own position and their online first-party clan mates’, for a linked viewer when the server allows it. `live: false` with a `reason` when the game did not answer. Positions are never stored.'
// #swagger.description = 'The positions this viewer may see, asked of the game while somebody is looking and held for five seconds, so any number of viewers cost one ask. **A layer the viewer may not see is absent from the answer**, not empty: `world` (world events and locked crates), `events` (what the site’s events placed), `players` (online players and sleepers, with names) and `bases` (tool cupboards and vending machines, positions only) and `pvp` (Dynamic PVP’s own PvP zones, where each is now; protocol 14). `mates` is the viewer’s own position and their online first-party clan mates’, for a linked viewer when the server allows it. `live: false` with a `reason` when the game did not answer. Positions are never stored.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The positions this viewer may see', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMapLive" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */

View File

@@ -69,7 +69,10 @@ const TIMEOUT_MS = 12000
* `/map/render` and `/map/live`, its picture and what moves on it (PLAN.md
* §30); **12** adds `/titles`, the chat titles BetterChat shows, and gives a
* permission sync a group's BetterChat style and a chat line a delivery and a
* voice (PLAN.md §33). The bump lands here in the same change as the emitters,
* voice (PLAN.md §33); **13** is the first player walk's fixes and redesigns and
* RunicNPC (PLAN_FIXES.md, PLAN_REDESIGNS.md §1–§6); **14** adds `/kits/uses`
* and a permission sync's `notices` — the step editor's kit warning and reward
* message (PLAN_REDESIGNS.md §2, D332, D333). The bump lands here in the same change as the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
@@ -79,7 +82,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 13
const PROTOCOL_VERSION = 14
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -425,6 +428,30 @@ const worldOwned = (server, { runId } = {}) =>
const worldZone = (server, body) => request(server, '/world/zone', { method: 'POST', body })
const worldPlace = (server, body) => request(server, '/world/place', { method: 'POST', body })
/**
* Run one of the game's own world events for a run (protocol 14, §20.5). `data.kind`
* is `world.called` (with `called`, and `repeat: true` for a key the plugin has
* seen) or `world.error` with a reason. What it starts is kept: there is no revert.
*/
const worldCall = (server, body) => request(server, '/world/call', { method: 'POST', body })
/**
* Open one Dangerous Treasures event for a run's step (protocol 14, §20.8):
* `{ key, level }`. `data.kind` is `dt.opened` (with `x`, `z`, `level`, and
* `repeat: true` for a key the plugin has seen) or `dt.error` with a reason,
* `dt-not-ready` among them (D365). DT despawns its own chest: there is no revert.
*/
const dtStart = (server, body) => request(server, '/dt/start', { method: 'POST', body })
/**
* Place one guarded crate for a run's step (protocol 14, §20.9): `{ key, event }`
* and a location as a world call takes one (`monument` and offsets, or `x` and
* `z`). `data.kind` is `gc.placed` (with `event`, `x`, `z`, and `repeat: true`
* for a key the plugin has seen) or `gc.error` with a reason, `npcspawn-missing`
* among them (D364). The crate is kept when the run ends (D373): there is no revert.
*/
const gcPlace = (server, body) => request(server, '/gc/place', { method: 'POST', body })
/**
* Give back what a run owns: named ids, else everything under a key, else the
* whole run. `data` lists `removed`, `gone` (already not there — a success)
@@ -448,6 +475,36 @@ const tallyClose = (server, body) => request(server, '/tally/close', { method: '
/** The kits this server's Kits plugin has, and the plugin's reward bounds. `kits.error` when Kits is not loaded. */
const kits = (server) => request(server, '/kits')
/**
* Protocol 14 (PLAN_REDESIGNS.md §2.6). Of these players, how many have used `kit`
* up and how many are on its cooldown: `kits.uses` with `{ kit, max, cooldown,
* asked, exhausted, onCooldown }`, counts only. `kits.error` when Kits is not
* loaded or has no such kit.
*/
const kitsUses = (server, kit, steamIds) =>
request(server, '/kits/uses', { method: 'POST', body: { kit, steamIds } })
/**
* Economics (protocol 14, PLAN_REDESIGNS.md §11.2.2). `economics.paid` lists each
* player's balance before and after; a key already paid answers `repeat: true`
* and pays nobody, so a retry is safe. `economics.error` has a reason.
*/
const economicsPay = (server, body) => request(server, '/economics/pay', { method: 'POST', body })
/** Each player's balance, and the server's starting balance when its config says. */
const economicsBalances = (server, steamIds) =>
request(server, '/economics/balances', { method: 'POST', body: { steamIds } })
/** Staff: `{ key, steamId, op, amount }`, op deposit · withdraw · set. `economics.adjusted` has before and after. */
const economicsAdjust = (server, body) => request(server, '/economics/adjust', { method: 'POST', body })
/**
* One player's backpack (protocol 14, PLAN_REDESIGNS.md §11.3.2), read live.
* `backpacks.contents` has `exists`, and with a backpack its capacity, page
* size and stacks; `backpacks.error` has a reason. Nothing is written.
*/
const backpacksRead = (server, steamId) => request(server, '/backpacks/read', { method: 'POST', body: { steamId } })
/**
* Say one line in the server's chat. `chat.ok` carries `said: false` when the
* key was said in the last ten minutes — a retry, answered and not repeated.
@@ -531,11 +588,19 @@ module.exports = {
worldOwned,
worldZone,
worldPlace,
worldCall,
dtStart,
gcPlace,
worldRevert,
tallyOpen,
tallySnapshot,
tallyClose,
kits,
kitsUses,
economicsPay,
economicsBalances,
economicsAdjust,
backpacksRead,
chat,
titles,
mapInfo,

View File

@@ -109,6 +109,12 @@ module.exports = {
wipeTz: { type: 'string', nullable: true, example: 'America/Chicago' },
wipeAnchor: { type: 'string', format: 'date', nullable: true, example: null },
wipeOnceAt: { type: 'string', format: 'date-time', nullable: true, example: null },
creditRule: {
type: 'string',
enum: ['blow', 'last', 'most', 'all'],
description: 'Who a downed patrol helicopter, Chinook or Bradley credits (D372): the killing blow, the last hit, the most damage, or everyone who hurt it. Written only when present.',
example: 'blow',
},
},
},
RustAdminServerList: {
@@ -139,6 +145,7 @@ module.exports = {
},
bootId: { type: 'string', nullable: true, example: 'boot-20260915T194502Z' },
sidecarProtocol: { type: 'integer', nullable: true, example: 1 },
creditRule: { type: 'string', enum: ['blow', 'last', 'most', 'all'], description: 'Who a downed patrol helicopter, Chinook or Bradley credits on this server (D372).', example: 'blow' },
zoneHelper: {
type: 'object',
nullable: true,
@@ -244,8 +251,52 @@ module.exports = {
type: 'object',
properties: { loaded: { type: 'boolean', example: false } },
},
backpacks: {
type: 'object',
description: 'Protocol 14 (PLAN_REDESIGNS §11.3.2): `sizes` is every registered backpacks.size.N, ascending; a Bigger backpack step may lease any above the first.',
properties: {
loaded: { type: 'boolean', example: true },
version: { type: 'string', example: '3.17.8' },
sizes: { type: 'array', items: { type: 'integer' }, example: [6, 12, 18, 24, 30, 36, 42, 48, 96, 144] },
},
},
truePve: {
type: 'object',
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.2): where loaded, a zone step may turn off PvP protection or name a TruePVE ruleset.',
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '2.4.6' } },
},
dynamicPvp: {
type: 'object',
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.3): where loaded, its PvP zones reach the feed and the map’s pvp layer.',
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '5.2.0' } },
},
dangerousTreasures: {
type: 'object',
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.4): where loaded, its treasure events reach the feed, the map and Treasure Hunter, and a step may start one (where `console.plugins` is true).',
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '3.0.0' } },
},
guardedCrate: {
type: 'object',
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.5): where loaded, its guarded crates reach the feed, the map and Warden, and a step may place one (where NpcSpawn is loaded and `console.plugins` is true). `events` are the event names in its data file, absent when the file could not be read.',
properties: {
loaded: { type: 'boolean', example: true },
version: { type: 'string', example: '2.0.17' },
npcSpawn: {
type: 'object',
description: 'NpcSpawn, which its guards need (D364).',
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '3.4.8' } },
},
events: { type: 'array', items: { type: 'string' }, example: ['Easy', 'Medium', 'Hard', 'Elite'] },
},
},
},
},
console: {
type: 'object',
nullable: true,
description: 'Protocol 14 (D363): what the plugin’s console probe found at its load. Null from an older plugin, or when the game could not be asked.',
properties: { plugins: { type: 'boolean', description: 'Whether another plugin’s console command runs from code here.', example: true } },
},
},
},
RustVoice: {
@@ -750,7 +801,7 @@ module.exports = {
type: 'object',
description: 'The live map’s switches and each server’s picture (phase 14).',
properties: {
layers: { type: 'array', items: { type: 'string' }, example: ['world', 'events', 'players', 'bases'] },
layers: { type: 'array', items: { type: 'string' }, example: ['world', 'events', 'players', 'bases', 'pvp'] },
minorLabels: {
type: 'array',
items: { type: 'string' },
@@ -764,6 +815,7 @@ module.exports = {
events: { $ref: '#/components/schemas/RustAudience' },
players: { $ref: '#/components/schemas/RustAudience' },
bases: { $ref: '#/components/schemas/RustAudience' },
pvp: { $ref: '#/components/schemas/RustAudience' },
mates: { type: 'boolean', example: true },
markers: { type: 'object', description: 'D196: a monument label key (lower case) to true (drawn) or false (hidden). Only the labels somebody switched; every other label follows the default.', additionalProperties: { type: 'boolean' }, example: { substation: true } },
},
@@ -901,6 +953,7 @@ module.exports = {
events: { $ref: '#/components/schemas/RustMapLayer' },
players: { $ref: '#/components/schemas/RustMapLayer' },
bases: { $ref: '#/components/schemas/RustMapLayer' },
pvp: { $ref: '#/components/schemas/RustMapLayer' },
},
},
mates: {
@@ -983,6 +1036,21 @@ module.exports = {
},
},
basesTruncated: { type: 'boolean', description: 'More than the plugin’s MapMaxBases.' },
pvp: {
type: 'array',
description: 'Protocol 14 (§14.3.3): Dynamic PVP’s own PvP zones, where each is now (cargo’s moves with the ship). A cube zone’s radius is half its longer side.',
items: {
type: 'object',
properties: {
zoneId: { type: 'string', example: '1412203045' },
event: { type: 'string', description: 'Dynamic PVP’s event name: one of its own (Helicopter, Bradley, CargoShip …), a monument’s, or an operator’s.', example: 'Helicopter' },
radius: { type: 'number', example: 100 },
shape: { type: 'string', enum: ['sphere', 'cube'], example: 'sphere' },
x: { type: 'number' },
z: { type: 'number' },
},
},
},
mates: {
type: 'array',
description: 'The viewer’s own positions (their sleeper too) and their online first-party clan mates on this server.',
@@ -1286,6 +1354,80 @@ module.exports = {
kills: { type: 'array', items: { type: 'object' }, example: [{ serverId: 'main', profile: 'warden', label: 'Warden', kills: 3 }] },
},
},
RustEconomicsBalances: {
type: 'object',
description: 'Coin balances per server and linked Steam account (PLAN_REDESIGNS §11.2.2). `source` is `live`, `last-known` or null.',
properties: {
servers: {
type: 'array',
items: {
type: 'object',
properties: {
serverId: { type: 'string', example: 'main' },
server: { type: 'string', example: 'Main' },
economics: { type: 'boolean', example: true },
reason: { type: 'string', example: 'Economics is not installed on this server' },
startingBalance: { type: 'number', example: 1000 },
startingKnown: { type: 'boolean', example: true },
live: { type: 'boolean', example: true },
accounts: {
type: 'array',
items: { type: 'object' },
example: [{ steamId: '76561198000000001', name: 'Wanderer', balance: 1500, source: 'live' }],
},
},
},
},
},
},
RustEconomicsAdjust: {
type: 'object',
required: ['serverId', 'steamId', 'op', 'amount'],
properties: {
serverId: { type: 'string', example: 'main' },
steamId: { type: 'string', example: '76561198000000001' },
op: { type: 'string', enum: ['deposit', 'withdraw', 'set'], example: 'deposit' },
amount: { type: 'number', example: 500, description: '0.01 to 1,000,000; 0 is allowed for set.' },
key: { type: 'string', example: '3f2a9c1e-7b4d-4e8a-9f10-2c6d5e8b7a41', description: 'One per submission, so a resend moves nothing.' },
},
},
RustEconomicsAdjusted: {
type: 'object',
properties: {
before: { type: 'number', example: 1000 },
after: { type: 'number', example: 1500 },
repeat: { type: 'boolean', example: false },
},
},
RustBackpackStack: {
type: 'object',
description: 'One stack in a backpack (PLAN_REDESIGNS §11.3.2). `display` is the game’s English name (D340); for a blueprint it is the name of what it makes, and `blueprint` that item’s shortname (D341). `shortname` is null, with `itemId`, for an item the game does not know.',
properties: {
slot: { type: 'integer', example: 2, description: 'Runs across pages: the first slot of page two is pageSize.' },
shortname: { type: 'string', nullable: true, example: 'rifle.ak' },
display: { type: 'string', nullable: true, example: 'Assault Rifle' },
amount: { type: 'integer', example: 1 },
itemId: { type: 'integer', example: 1545779598 },
blueprint: { type: 'string', example: 'rifle.ak' },
skin: { type: 'string', example: '10129', description: 'A workshop skin id, as text.' },
name: { type: 'string', example: 'spike-a', description: 'A renamed item’s name.' },
overflow: { type: 'boolean', example: true, description: 'Past the capacity: it moves to the owner’s inventory, or the ground, at their next open (D338).' },
contents: { type: 'array', description: 'A weapon’s attachments or a container’s items, one level deep, without `contents` or `overflow` of their own.', items: { type: 'object' } },
},
},
RustBackpack: {
type: 'object',
description: 'One Steam account’s backpack on one server, read live and never stored. `capacity`, `pageSize` and `items` are present only when `exists`.',
properties: {
serverId: { type: 'string', example: 'main' },
server: { type: 'string', example: 'Main' },
steamId: { type: 'string', example: '76561198000000001' },
exists: { type: 'boolean', example: true, description: 'False for an account that has never had a backpack.' },
capacity: { type: 'integer', example: 12 },
pageSize: { type: 'integer', example: 48 },
items: { type: 'array', items: { $ref: '#/components/schemas/RustBackpackStack' } },
},
},
RustVisibilityUpdate: {
type: 'object',
description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',

View File

@@ -160,6 +160,8 @@ function fakeApi() {
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
// MODULE_API 1.13.0 (PLAN_REDESIGNS.md §2.5).
registerEventTemplates(templates) { once('registerEventTemplates'); record.eventTemplates = templates },
// Core's `once()` holds here too: a module's commands are one batch.
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },

View File

@@ -0,0 +1,325 @@
// ── Backpacks (PLAN_REDESIGNS §11.3.2, protocol 14, D337, D338, D340, D341) ─
//
// Each test is one way the feature could look right and be wrong:
//
// the sizes a step may lease skip the smallest, which everybody holds
// a server without Backpacks is refused with the reason, in the dry run too (D199)
// a size the server does not offer is refused by name
// the lease is a kit reward's row: the size's permission, no kit, no credit (D337)
// an empty message is the take-back warning, never silence (D338)
// the revert deletes the step's rows and marks the server for a push
// a read answers what the game said, live; a stranger's id never reaches it
// a stack keeps its name, blueprint, skin as text, and one level of contents
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withRecorder(rows = []) {
const statements = []
const ctx = fakeCtx({
db: {
query: (sql, params = []) => {
statements.push({ sql, params })
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return { statements }
}
withRecorder()
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
const linksDb = require('../model/links/links.db')
const permDb = require('../model/permissions/permissions.db')
const backpacksDb = require('../model/backpacks/backpacks.db')
const backpacks = require('../model/backpacks/backpacks.model')
const rewards = require('../eventRewards')
const ok = (data) => ({ ok: true, status: 'ok', data })
const A = '76561198000000001'
const B = '76561198000000002'
const C = '76561198000000003'
const SIZES = [6, 12, 18, 24, 30, 36, 42, 48, 96, 144]
// ── What a server reports ──────────────────────────────────────────────────
test('a server’s sizes are whole, ascending and unique; the smallest is never leased', () => {
assert.deepStrictEqual(backpacks.shape(null), { loaded: false, sizes: [] })
assert.deepStrictEqual(backpacks.shape(JSON.stringify({ loaded: false })), { loaded: false, sizes: [] })
const report = backpacks.shape(JSON.stringify({ loaded: true, version: '3.17.8', sizes: [48, 6, 12, 12, 'x', -3, 2.5] }))
assert.deepStrictEqual(report, { loaded: true, version: '3.17.8', sizes: [6, 12, 48] })
assert.deepStrictEqual(backpacks.leasable(report), [12, 48])
assert.deepStrictEqual(backpacks.leasable({ loaded: false, sizes: [] }), [])
})
// ── The step ───────────────────────────────────────────────────────────────
const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
const lend = rewards.ACTIONS.find((a) => a.id === 'rust.backpack.size')
function stub(t, { loaded = true, sizes = SIZES, people = [], links = {}, existing = [] } = {}) {
const calls = { inserted: [], dirty: [], deleted: [] }
const saved = {
getServer: serversDb.getServer,
reportFor: backpacksDb.reportFor,
tallySnapshot: client.tallySnapshot,
userIdsForSteamIds: linksDb.userIdsForSteamIds,
listRunGrantsForStep: permDb.listRunGrantsForStep,
insertRunGrants: permDb.insertRunGrants,
markDirty: permDb.markDirty,
deleteRunGrantsForStep: permDb.deleteRunGrantsForStep,
}
serversDb.getServer = async (id) => (id === 'main' ? ROW : null)
backpacksDb.reportFor = async () => ({ serverId: 'main', backpacks: loaded ? JSON.stringify({ loaded: true, version: '3.17.8', sizes }) : null })
client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 })
linksDb.userIdsForSteamIds = async (ids) => ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
permDb.listRunGrantsForStep = async () => existing
permDb.insertRunGrants = async (rows) => {
calls.inserted.push(...rows)
return rows.length
}
permDb.markDirty = async (serverId) => calls.dirty.push(serverId)
permDb.deleteRunGrantsForStep = async (runId, stepId) => {
calls.deleted.push(`${runId}:${stepId}`)
return ['main']
}
t.after(() => Object.assign(serversDb, { getServer: saved.getServer }))
t.after(() => Object.assign(backpacksDb, { reportFor: saved.reportFor }))
t.after(() => Object.assign(client, { tallySnapshot: saved.tallySnapshot }))
t.after(() => Object.assign(linksDb, { userIdsForSteamIds: saved.userIdsForSteamIds }))
t.after(() =>
Object.assign(permDb, {
listRunGrantsForStep: saved.listRunGrantsForStep,
insertRunGrants: saved.insertRunGrants,
markDirty: saved.markDirty,
deleteRunGrantsForStep: saved.deleteRunGrantsForStep,
}),
)
return calls
}
const PARAMS = { server: 'main', size: 48, recipients: 'everyone' }
const person = (steamId, score) => ({ steamId, name: `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 })
test('a bigger backpack is a ledgered change, priced per recipient on its own budget', () => {
assert.strictEqual(lend.risk, 'change')
assert.strictEqual(lend.reversible, 'ledger')
assert.deepStrictEqual(lend.cost({ recipients: 'top', count: 3 }), { 'rust.backpacks': 3 })
assert.deepStrictEqual(lend.cost({ recipients: 'linked' }), { 'rust.backpacks': rewards.MAX_RECIPIENTS })
})
test('a server without Backpacks is refused in the dry run, with the reason (D199)', async (t) => {
stub(t, { loaded: false })
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: true })
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, false)
assert.match(result.error, /Backpacks is not installed on this server \(Main\)/)
})
test('a size the server does not offer, or its smallest, is refused by name', async (t) => {
stub(t, { sizes: [6, 12, 48] })
for (const size of [6, 96, 0, 'big']) {
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, size }, verify: true })
assert.strictEqual(result.ok, false, String(size))
assert.strictEqual(result.retry, false)
}
const named = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, size: 96 }, verify: true })
assert.match(named.error, /offers 12, 48 slots/)
const ok48 = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: PARAMS, verify: true })
assert.deepStrictEqual(ok48, { ok: true })
})
test('the lease is one row per website user: the size’s permission, no kit, no credit (D337)', async (t) => {
const calls = stub(t, {
// A and B are one person (user 1); C has linked nothing.
people: [person(A, 9), person(B, 5), person(C, 3)],
links: { [A]: 1, [B]: 1 },
})
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
assert.strictEqual(result.ok, true)
assert.strictEqual(calls.inserted.length, 1)
const [row] = calls.inserted
assert.strictEqual(row.permission, 'backpacks.size.48')
assert.strictEqual(row.kit, '')
assert.strictEqual(row.credit, false)
assert.strictEqual(row.steamId, A)
assert.strictEqual(row.userId, 1)
assert.deepStrictEqual(calls.dirty, ['main'])
assert.deepStrictEqual(result.resources, [{ kind: 'backpack-size', ref: 'main:7:4', payload: { serverId: 'main', permission: 'backpacks.size.48' } }])
assert.strictEqual(result.detail.lent, 1)
assert.deepStrictEqual(result.detail.missed, ['p3'])
})
test('an empty message is the take-back warning, with the size in it (D338)', async (t) => {
const calls = stub(t, { people: [person(A, 1)], links: { [A]: 1 } })
await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: { ...PARAMS, message: ' ' }, verify: false })
assert.match(calls.inserted[0].notice, /^Your backpack holds 48 until the event ends\./)
assert.strictEqual(calls.inserted[0].noticeDelivery, 'chat')
assert.strictEqual(rewards.backpackNotice({}, 48).text, rewards.BACKPACK_NOTICE)
assert.strictEqual(rewards.backpackNotice({ message: 'Big bag: {size}', delivery: 'popup' }, 96).delivery, 'popup')
assert.strictEqual(rewards.backpackNotice({ message: 'x', delivery: 'carrier pigeon' }, 96).ok, false)
assert.strictEqual(rewards.backpackNotice({ message: `${'x'.repeat(254)}{size}` }, 144).ok, false)
})
test('a repeated key finds its rows and lends nothing new', async (t) => {
const calls = stub(t, { existing: [{ userId: 1 }, { userId: 2 }], people: [person(A, 1)], links: { [A]: 1 } })
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
assert.strictEqual(result.detail.repeat, true)
assert.strictEqual(result.detail.lent, 2)
assert.strictEqual(calls.inserted.length, 0)
})
test('the revert deletes the step’s rows, and the next sync takes the size back', async (t) => {
const calls = stub(t)
const result = await lend.revert({ runId: 7, resources: [{ kind: 'backpack-size', ref: 'main:7:4' }], idempotencyKey: 'k-1' })
assert.deepStrictEqual(result, { ok: true })
assert.deepStrictEqual(calls.deleted, ['7:4'])
assert.deepStrictEqual(calls.dirty, ['main'])
})
test('the size list offers each server’s sizes above its smallest, grouped by server', async (t) => {
const saved = { listForPolling: servers.listForPolling, reports: backpacksDb.reports }
t.after(() => Object.assign(servers, { listForPolling: saved.listForPolling }) && Object.assign(backpacksDb, { reports: saved.reports }))
servers.listForPolling = async () => [{ id: 'main', name: 'Main' }, { id: 'pve', name: 'PvE' }]
backpacksDb.reports = async () => [
{ serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48] }) },
{ serverId: 'pve', backpacks: null },
]
const source = rewards.OPTION_SOURCES.find((s) => s.id === 'rust.options.backpacksizes')
assert.deepStrictEqual(await source.resolve(), [{ value: 48, label: '48 slots', group: 'Main' }])
})
// ── Reading one ────────────────────────────────────────────────────────────
function stubRead(t, answer) {
const sent = []
const saved = { getForCalling: servers.getForCalling, backpacksRead: client.backpacksRead }
t.after(() => Object.assign(servers, { getForCalling: saved.getForCalling }) && Object.assign(client, { backpacksRead: saved.backpacksRead }))
servers.getForCalling = async (id) => (id === 'main' ? { id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' } : null)
client.backpacksRead = async (server, steamId) => {
sent.push(steamId)
return typeof answer === 'function' ? answer(steamId) : answer
}
return sent
}
test('a backpack is read live and reduced to what the grid draws', async (t) => {
const sent = stubRead(
t,
ok({
kind: 'backpacks.contents',
steamId: A,
exists: true,
capacity: 12,
pageSize: 48,
items: [
{ slot: 0, shortname: 'wood', display: 'Wood', amount: 1000 },
{ slot: 2, shortname: 'rifle.ak', display: 'Assault Rifle', amount: 1, contents: [{ slot: 0, shortname: 'weapon.mod.lasersight', display: 'Weapon Lasersight', amount: 1, contents: [{ slot: 0 }] }] },
{ slot: 3, shortname: 'hoodie', display: 'Hoodie', amount: 1, skin: 10129 },
{ slot: 5, shortname: 'blueprintbase', display: 'Assault Rifle', blueprint: 'rifle.ak', amount: 1 },
{ slot: 14, shortname: 'note', display: 'Note', amount: 1, name: 'spike-a', overflow: true },
{ slot: 15, shortname: null, itemId: 999, amount: 1, overflow: true },
],
}),
)
const result = await backpacks.read({ serverId: 'main', steamId: A })
assert.deepStrictEqual(sent, [A])
assert.strictEqual(result.ok, true)
const bp = result.backpack
assert.strictEqual(bp.server, 'Main')
assert.strictEqual(bp.capacity, 12)
assert.strictEqual(bp.pageSize, 48)
assert.strictEqual(bp.items.length, 6)
assert.strictEqual(bp.items[1].contents.length, 1)
assert.strictEqual(bp.items[1].contents[0].contents, undefined, 'one level of contents, no deeper')
assert.strictEqual(bp.items[2].skin, '10129', 'a skin id is text')
assert.strictEqual(bp.items[3].blueprint, 'rifle.ak')
assert.strictEqual(bp.items[4].name, 'spike-a')
assert.strictEqual(bp.items[4].overflow, true)
assert.strictEqual(bp.items[5].itemId, 999)
})
test('a player who never had a backpack is exists: false, with no capacity', async (t) => {
stubRead(t, ok({ kind: 'backpacks.contents', steamId: A, exists: false }))
const result = await backpacks.read({ serverId: 'main', steamId: A })
assert.deepStrictEqual(result.backpack, { serverId: 'main', server: 'Main', steamId: A, exists: false })
})
test('a read says why it has nothing: no server, no Backpacks, or no answer', async (t) => {
stubRead(t, ok({ kind: 'backpacks.error', reason: 'backpacks-missing', message: 'Backpacks is not installed on this server' }))
const missing = await backpacks.read({ serverId: 'main', steamId: A })
assert.strictEqual(missing.status, 409)
assert.strictEqual(missing.message, 'Backpacks is not installed on this server (Main)')
assert.strictEqual((await backpacks.read({ serverId: 'nope', steamId: A })).status, 404)
client.backpacksRead = async () => ({ ok: false, status: 'http-503' })
const down = await backpacks.read({ serverId: 'main', steamId: A })
assert.strictEqual(down.status, 503)
assert.match(down.message, /Main has no game connected/)
})
// ── The routes ─────────────────────────────────────────────────────────────
function res() {
return {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code
return this
},
json(body) {
this.body = body
return this
},
}
}
test('a backpack is read only for an account its user has linked', async (t) => {
const links = require('../model/links/links.model')
const saved = { listForUser: links.listForUser }
t.after(() => Object.assign(links, saved))
links.listForUser = async (userId) => (userId === 1 ? [{ steamId: A, name: 'pa' }] : [])
const sent = stubRead(t, ok({ kind: 'backpacks.contents', steamId: A, exists: false }))
const player = require('../router/player/rust.controller')
const staff = require('../router/admin/usersRust.controller')
const own = res()
await player.ownBackpack({ user: { id: 1 }, query: { serverId: 'main', steamId: A } }, own)
assert.strictEqual(own.statusCode, 200)
assert.strictEqual(own.body.exists, false)
const stranger = res()
await player.ownBackpack({ user: { id: 1 }, query: { serverId: 'main', steamId: B } }, stranger)
assert.strictEqual(stranger.statusCode, 404)
const wrongUser = res()
await staff.readBackpack({ params: { id: '2' }, query: { serverId: 'main', steamId: A } }, wrongUser)
assert.strictEqual(wrongUser.statusCode, 404)
const viaStaff = res()
await staff.readBackpack({ params: { id: '1' }, query: { serverId: 'main', steamId: A } }, viaStaff)
assert.strictEqual(viaStaff.statusCode, 200)
assert.deepStrictEqual(sent, [A, A], 'a stranger’s id never reaches the game')
})

View File

@@ -95,7 +95,7 @@ test('every kind is classified exactly once', () => {
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
})
test('the classification covers exactly the event kinds the protocol defines, through protocol 13', () => {
test('the classification covers exactly the event kinds the protocol defines, through protocol 14', () => {
// The spec lives in another repository, so the list is restated here rather
// than parsed — and restating it is the point: adding a kind to the protocol
// without deciding who may see it has to fail somewhere, and this is where.
@@ -150,6 +150,25 @@ test('the classification covers exactly the event kinds the protocol defines, th
'npc.boss.spawned',
'npc.boss.phase',
'npc.boss.died',
// Protocol 14 (§20.5): Rust's own world events, one kind told apart by
// `event`. Public as a kind; `worldEvents.js` filters per type and per viewer.
'world.event',
// Protocol 14 (§20.7): Dynamic PVP's zones. The opening is public and names
// nobody; the closing is a staff line (PLAN_REDESIGNS §14.3.3).
'pvp.zone.created',
'pvp.zone.deleted',
// Protocol 14 (§20.8): Dangerous Treasures. The opening and the win are
// public, read per viewer by `dangerousTreasures.js`; the ending is staff's
// (PLAN_REDESIGNS §14.3.4).
'dt.started',
'dt.won',
'dt.ended',
// Protocol 14 (§20.9): Guarded Crate. The landing and the clearing are
// public, read per viewer by `guardedCrate.js`; the timeout is staff's
// (PLAN_REDESIGNS §14.3.5).
'gc.started',
'gc.ended',
'gc.failed',
]
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
@@ -165,6 +184,25 @@ test('the classification covers exactly the event kinds the protocol defines, th
for (const kind of ['npc.boss.spawned', 'npc.boss.died']) {
assert.equal(catalogue.isPublic(kind), true, `${kind} is said to the whole server, so it is public (D286)`)
}
// D353, D376: the world event is public and names nobody below the presence
// audience — the names come off per viewer, so the kind is not a presence kind.
assert.equal(catalogue.isPublic('world.event'), true)
assert.equal(catalogue.isPresence('world.event'), false)
assert.equal(catalogue.isPublic('pvp.zone.created'), true)
assert.equal(catalogue.isPresence('pvp.zone.created'), false)
assert.equal(catalogue.isPublic('pvp.zone.deleted'), false, 'a zone closing is a staff line')
// D376 for Dangerous Treasures: the win names its winner only to the presence
// audience, per viewer, so neither public kind is a presence kind.
for (const kind of ['dt.started', 'dt.won']) {
assert.equal(catalogue.isPublic(kind), true)
assert.equal(catalogue.isPresence(kind), false)
}
assert.equal(catalogue.isPublic('dt.ended'), false, 'a treasure event ending is a staff line')
for (const kind of ['gc.started', 'gc.ended']) {
assert.equal(catalogue.isPublic(kind), true)
assert.equal(catalogue.isPresence(kind), false)
}
assert.equal(catalogue.isPublic('gc.failed'), false, 'a guarded crate timing out is a staff line')
})
test('every kind that names a player who was on is behind the presence setting', () => {
@@ -187,6 +225,10 @@ test('every kind that names a player who was on is behind the presence setting',
// A presence kind is a subset of the public ones, never a staff kind widened.
for (const kind of catalogue.PRESENCE_KINDS) assert.ok(catalogue.PUBLIC_KINDS.includes(kind))
// And what is left names nobody.
assert.deepEqual(catalogue.kindsFor({}).sort(), ['npc.boss.spawned', 'server.initialized', 'server.shutdown', 'server.wipe'])
// And what is left names nobody — a world event included, once `worldEvents.shapeFor`
// has taken its names off for a viewer below the presence audience (D376).
// Dynamic PVP's opening names nobody at all, and a Dangerous Treasures win names
// nobody once `dangerousTreasures.shapeFor` has read it for such a viewer, nor
// a guarded crate cleared once `guardedCrate.shapeFor` has.
assert.deepEqual(catalogue.kindsFor({}).sort(), ['dt.started', 'dt.won', 'gc.ended', 'gc.started', 'npc.boss.spawned', 'pvp.zone.created', 'server.initialized', 'server.shutdown', 'server.wipe', 'world.event'])
})

View File

@@ -0,0 +1,316 @@
// ── Dangerous Treasures (PLAN_REDESIGNS §14.3.4, protocol 14) ──────────────
//
// Each test is one way the feature could look right and be wrong:
//
// the winner's name reaches a viewer below the presence audience (D376), or a
// Steam id, an invader, a position or a step's key reaches anybody but staff
// the step runs on a server that said it has no DT, or whose framework cannot
// run DT's console command (D363), or a server that has not said is refused
// DT's warm-up is not answered with core's ten-minute retry (D365, D371), or
// a refusal DT would repeat is retried
// a retry of a key opens a second chest
// the tally's treasures won is dropped on the floor, or Treasure Hunter's
// column and sort are missing
// a trigger declares a variable its emitter never fills, or names a Steam id
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/** Installs a core whose `db.query` records every statement and answers `rows`. */
function withRecorder(rows = []) {
const statements = []
const ctx = fakeCtx({
db: {
query: (sql, params = []) => {
statements.push({ sql, params })
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return { ctx, statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) }
}
withRecorder()
const dt = require('../dangerousTreasures')
const { gridOf } = require('../worldEvents')
const { TRIGGERS } = require('../engagement/triggers')
const A = '76561198000000001'
const B = '76561198000000002'
const NOW = Date.now()
const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 }
const frame = (kind, over = {}) => ({ kind, type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', x: 812, z: -340.5, ...over })
const won = (over = {}) => frame('dt.won', { steamId: A, name: 'Alice', invaders: [A, B], invaderCount: 2, ...over })
// ── Who may read what (D353, D376) ─────────────────────────────────────────
test('the public reads a treasure event’s grid, never its position or the step’s key', () => {
const out = dt.shapeFor(frame('dt.started', { key: '7:1' }), { geometry: GEOMETRY })
assert.strictEqual(out.grid, gridOf(GEOMETRY, 812, -340.5))
assert.strictEqual(out.x, undefined)
assert.strictEqual(out.z, undefined)
assert.strictEqual(out.key, undefined)
assert.strictEqual(out.kind, 'dt.started')
})
test('a win names its winner only to the presence audience, and its Steam ids only to staff', () => {
const signedOut = dt.shapeFor(won(), { geometry: GEOMETRY })
assert.strictEqual(signedOut.name, undefined)
assert.strictEqual(signedOut.steamId, undefined)
assert.strictEqual(signedOut.invaders, undefined)
assert.strictEqual(signedOut.invaderCount, 2, 'a count names nobody')
const member = dt.shapeFor(won(), { presence: true, geometry: GEOMETRY })
assert.strictEqual(member.name, 'Alice')
assert.strictEqual(member.steamId, undefined)
assert.strictEqual(member.invaders, undefined)
const staff = dt.shapeFor(won({ invaders: [A, B, 'not-an-id'] }), { staff: true, geometry: GEOMETRY })
assert.strictEqual(staff.name, 'Alice')
assert.strictEqual(staff.steamId, A)
assert.deepStrictEqual(staff.invaders, [A, B])
assert.strictEqual(staff.x, 812)
})
test('an older or partial win still counts its invaders, and something off the map has no grid', () => {
assert.strictEqual(dt.shapeFor(won({ invaderCount: undefined }), {}).invaderCount, 2)
assert.strictEqual(dt.shapeFor(frame('dt.started', { x: 9000 }), { geometry: GEOMETRY }).grid, undefined)
assert.strictEqual(dt.shapeFor(frame('dt.started'), {}).grid, undefined, 'no geometry, no grid')
const other = { kind: 'player.death', x: 1 }
assert.strictEqual(dt.shapeFor(other, {}), other)
})
test('the feed reads a stored win for its viewer, the row’s own Steam id staff’s', async () => {
withRecorder((sql) => {
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'dt.won', t: NOW, wipeId: 'w1', steamId: A, raw: JSON.stringify(won()) }]
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
return []
})
const events = require('../model/events/events.model')
const [signedOut] = await events.recent({ serverId: 'main', presence: false })
assert.strictEqual(signedOut.steamId, null)
assert.strictEqual(signedOut.frame.name, undefined)
assert.ok(signedOut.frame.grid)
const [staff] = await events.recent({ serverId: 'main', admin: true })
assert.strictEqual(staff.steamId, A)
assert.deepStrictEqual(staff.frame.invaders, [A, B])
})
test('a server’s report is read leniently: what it never said is null', () => {
assert.deepStrictEqual(dt.reportOf(null), { dangerousTreasures: null, consolePlugins: null })
assert.deepStrictEqual(dt.reportOf({ dangerousTreasures: '{"loaded":true,"version":"3.0.0"}', console: '{"plugins":false}' }), {
dangerousTreasures: { loaded: true, version: '3.0.0' },
consolePlugins: false,
})
assert.deepStrictEqual(dt.reportOf({ dangerousTreasures: 'not json', console: '{}' }), { dangerousTreasures: null, consolePlugins: null })
})
// ── The step ───────────────────────────────────────────────────────────────
/** The step against one server `main`, whose last report is `report`, and a sidecar answering `reply`. */
function stubStart(t, { report = { dangerousTreasures: { loaded: true, version: '3.0.0' }, console: { plugins: true } }, reply } = {}) {
withRecorder((sql) => {
if (sql.includes('FROM rust_server_state')) {
return report === null ? [] : [{ dangerousTreasures: JSON.stringify(report.dangerousTreasures ?? null), console: JSON.stringify(report.console ?? null) }]
}
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
return []
})
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const saved = { dtStart: client.dtStart, getServer: serversDb.getServer }
const calls = []
serversDb.getServer = async (id) => (id === 'main' ? { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' } : null)
client.dtStart = async (server, body) => {
calls.push({ server: server.id, body })
return typeof reply === 'function' ? reply(body) : reply
}
t.after(() => {
client.dtStart = saved.dtStart
serversDb.getServer = saved.getServer
})
return calls
}
const startStep = () => require('../eventDangerousTreasures').ACTIONS[0]
const ok = (data) => ({ ok: true, status: 'ok', data })
const opened = (over = {}) => ok({ kind: 'dt.opened', key: '7:1', level: 1, repeat: false, x: 812, z: -340.5, ...over })
test('the step is kept, priced one event each, and offers DT’s three difficulties', async () => {
const step = startStep()
assert.strictEqual(step.id, 'rust.dt.start')
assert.strictEqual(step.reversible, 'none')
assert.strictEqual(step.risk, 'change')
assert.strictEqual(step.revert, undefined)
assert.deepStrictEqual(step.cost({ server: 'main', level: 2 }), { 'rust.dt.events': 1 })
const [levels] = require('../eventDangerousTreasures').OPTION_SOURCES
assert.deepStrictEqual(await levels.resolve(), [
{ value: 0, label: 'Easy' },
{ value: 1, label: 'Medium' },
{ value: 2, label: 'Hard' },
])
})
test('the dry run refuses a server without DT, or whose framework cannot run its command, and nothing else', async (t) => {
const run = (params) => startStep().perform({ runId: 7, idempotencyKey: '7:1', params, verify: true })
stubStart(t, { report: { dangerousTreasures: { loaded: false }, console: { plugins: true } } })
const absent = await run({ server: 'main', level: 1 })
assert.strictEqual(absent.retry, false)
assert.match(absent.error, /Dangerous Treasures is not installed on Main/)
stubStart(t, { report: { dangerousTreasures: { loaded: true }, console: { plugins: false } } })
const carbon = await run({ server: 'main', level: 1 })
assert.strictEqual(carbon.retry, false)
assert.match(carbon.error, /cannot run Dangerous Treasures' console command/)
assert.match(carbon.error, /Rust-Plugins#34/)
// A plugin before protocol 14, or a server never reached, has not said: the plugin answers.
stubStart(t, { report: null })
assert.ok((await run({ server: 'main', level: 1 })).ok)
const calls = stubStart(t)
assert.ok((await run({ server: 'main' })).ok, 'blank is Easy')
assert.match((await run({ server: 'main', level: 3 })).error, /0 \(Easy\), 1 \(Medium\) or 2 \(Hard\)/)
assert.match((await run({ server: 'main', level: 'hard' })).error, /not "hard"/)
assert.match((await run({ level: 1 })).error, /which server/)
assert.strictEqual(calls.length, 0, 'the dry run starts nothing')
})
test('a start sends the key and the level, and says where it opened', async (t) => {
const calls = stubStart(t, { reply: opened() })
const result = await startStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', level: '1' } })
assert.ok(result.ok)
assert.deepStrictEqual(calls, [{ server: 'main', body: { key: '7:1', level: 1 } }])
assert.deepStrictEqual(result.detail, { level: 'Medium', grid: gridOf(GEOMETRY, 812, -340.5) })
})
test('DT not ready is said in the plan’s words and retried once in ten minutes (D365, D371)', async (t) => {
stubStart(t, { reply: ok({ kind: 'dt.error', reason: 'dt-not-ready', message: 'Dangerous Treasures is not ready yet: …' }) })
const result = await startStep().perform({ runId: 7, idempotencyKey: '7:2', params: { server: 'main', level: 0 } })
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, true)
assert.strictEqual(result.retryAfterMs, 600000)
assert.strictEqual(result.error, 'Dangerous Treasures is not ready yet on Main')
})
test('no spot retries on core’s ordinary delay; a refusal DT would repeat is not retried', async (t) => {
stubStart(t, {
reply: (body) => ok(body.level === 2
? { kind: 'dt.error', reason: 'dt-no-spot', message: 'Dangerous Treasures found no valid spot for an event' }
: { kind: 'dt.error', reason: 'refused', message: 'Dangerous Treasures did not start: Invalid difficulty' }),
})
const step = startStep()
const noSpot = await step.perform({ runId: 7, idempotencyKey: '7:3', params: { server: 'main', level: 2 } })
assert.strictEqual(noSpot.ok, false)
assert.strictEqual(noSpot.retry, undefined)
assert.strictEqual(noSpot.retryAfterMs, undefined)
assert.match(noSpot.error, /no valid spot/)
const refused = await step.perform({ runId: 7, idempotencyKey: '7:4', params: { server: 'main', level: 1 } })
assert.strictEqual(refused.retry, false)
assert.match(refused.error, /Invalid difficulty/)
for (const reason of ['dt-missing', 'console-unavailable', 'bad-level', 'malformed', 'events-disabled']) {
assert.ok(require('../eventDangerousTreasures').PERMANENT.has(reason), reason)
}
})
test('a game that is down is retried, and a repeat of a key opens nothing new', async (t) => {
stubStart(t, { reply: { ok: false, status: 'http-503' } })
const down = await startStep().perform({ runId: 7, idempotencyKey: '7:5', params: { server: 'main', level: 1 } })
assert.strictEqual(down.retry, undefined)
assert.match(down.error, /has no game connected/)
stubStart(t, { reply: opened({ repeat: true }) })
const again = await startStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', level: 1 } })
assert.ok(again.ok)
assert.strictEqual(again.detail.repeat, true)
})
// ── Treasure Hunter (D375) ─────────────────────────────────────────────────
test('the tally’s treasures won is added up', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', { id: 1, kind: 'player.tally', frame: { kind: 'player.tally', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', steamId: A, name: 'Alice', treasuresWon: 3 } })
const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('treasures_won'))
assert.ok(titleAdd, 'the tally reached the title columns')
const cols = titleAdd.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+)\)/)[1].split(', ')
assert.strictEqual(titleAdd.params[3 + cols.indexOf('treasures_won')], 3)
})
test('the leaderboard sorts by treasures won and carries the count on every row', async () => {
const rec = withRecorder((sql) => (sql.includes('FROM rust_player_wipe_stats') ? [{ steamId: A, name: 'Alice', kills: 1, treasuresWon: '4' }] : []))
const events = require('../model/events/events.model')
const [row] = await events.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'treasuresWon' })
assert.strictEqual(row.treasuresWon, 4)
const board = rec.touching('rust_player_wipe_stats').at(-1)
assert.match(board.sql, /SUM\(s\.treasures_won\) AS treasuresWon/)
assert.match(board.sql, /ORDER BY[^]*treasures_won/)
})
// ── Triggers (D355, §14.3.9) ───────────────────────────────────────────────
test('a start and a win are public triggers, neither declaring a Steam id', () => {
const found = TRIGGERS.filter((t) => t.id.startsWith('rust.dt.'))
assert.deepStrictEqual(found.map((t) => t.id).sort(), ['rust.dt.started', 'rust.dt.won'])
for (const t of found) {
assert.strictEqual(t.ceiling, 'everyone', t.id)
assert.strictEqual(t.subjectKey, 'subject', t.id)
assert.ok(!t.variables.some((v) => /steam/i.test(v.name)), `${t.id} declares a Steam id`)
}
})
test('a start and a win fire their triggers with what they declare; an ending fires nothing', async () => {
const { ctx } = withRecorder((sql) => (sql.includes('FROM rust_map_images') ? [{ serverId: 'main', ...GEOMETRY }] : []))
const emit = require('../engagement/emit')
emit.reset()
const server = { id: 'main', name: 'Main' }
await emit.onEvent(server, { id: 1, kind: 'dt.started', frame: frame('dt.started', { key: '7:1' }) })
await emit.onEvent(server, { id: 2, kind: 'dt.won', frame: won({ invaderCount: 3 }) })
await emit.onEvent(server, { id: 3, kind: 'dt.ended', frame: frame('dt.ended') })
const calls = ctx.events.emit.calls
assert.strictEqual(calls.length, 2)
const [startedId, started] = calls[0]
assert.strictEqual(startedId, 'rust.dt.started')
assert.strictEqual(started.data.byEvent, true)
assert.strictEqual(started.data.subject, 'main:dt.started')
assert.match(started.data.intro, /A Dangerous Treasures chest opened at [A-Z]+\d+ on Main/)
const [wonId, winner] = calls[1]
assert.strictEqual(wonId, 'rust.dt.won')
assert.strictEqual(winner.data.player, 'Alice')
assert.strictEqual(winner.data.invaders, 3)
assert.match(winner.data.intro, /Alice won the treasure at [A-Z]+\d+ on Main, with 3 players fighting for it\./)
for (const [id, envelope] of calls) {
const declared = new Set(TRIGGERS.find((x) => x.id === id).variables.map((v) => v.name))
for (const name of Object.keys(envelope.data)) assert.ok(declared.has(name), `${id} fills ${name}, which it does not declare`)
}
})
test('an old treasure tells nobody (D63)', async () => {
const { ctx } = withRecorder()
const emit = require('../engagement/emit')
emit.reset()
await emit.onEvent({ id: 'main', name: 'Main' }, { id: 1, kind: 'dt.won', frame: won({ t: NOW - 3 * 3600 * 1000 }) })
assert.strictEqual(ctx.events.emit.calls.length, 0)
})

View File

@@ -0,0 +1,102 @@
// ── Dynamic PVP's zones (PLAN_REDESIGNS §14.3.3, protocol 14) ──────────────
//
// Each test is one way the feature could look right and be wrong:
//
// a zone's position reaches a viewer below staff through the feed, round the
// map's own audience (D111)
// an operator's own event name, typed into Dynamic PVP's config, reaches a
// public page
// the feed shapes a zone line without its grid
// a frame of another kind is changed by the zone reader
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/** Installs a core whose `db.query` answers `rows`. */
function withRows(rows = []) {
const ctx = fakeCtx({
db: { query: (sql, params = []) => Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows), pool: {} },
})
require('../core')._reset()
require('../core').init(ctx)
}
withRows()
const dynamicPvp = require('../dynamicPvp')
const { gridOf } = require('../worldEvents')
const NOW = Date.now()
const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 }
const created = (over = {}) => ({
kind: 'pvp.zone.created',
type: 'event',
t: NOW,
serverId: 'main',
wipeId: 'w1',
zoneId: '1412203045',
event: 'Helicopter',
x: 812,
z: -340.5,
radius: 100,
shape: 'sphere',
durationSec: 600,
...over,
})
test('the public read the zone, its grid and how long it lasts, and never where it is', () => {
const out = dynamicPvp.shapeFor(created(), { staff: false, geometry: GEOMETRY })
assert.strictEqual(out.event, 'Helicopter')
assert.strictEqual(out.grid, gridOf(GEOMETRY, 812, -340.5))
assert.strictEqual(out.radius, 100)
assert.strictEqual(out.durationSec, 600)
for (const hidden of ['x', 'z', 'zoneId']) assert.strictEqual(hidden in out, false, `${hidden} is staff's`)
})
test('staff read the position and the zone id too', () => {
const out = dynamicPvp.shapeFor(created(), { staff: true, geometry: GEOMETRY })
assert.strictEqual(out.x, 812)
assert.strictEqual(out.z, -340.5)
assert.strictEqual(out.zoneId, '1412203045')
})
test("a monument's or an operator's own event name is staff's alone", () => {
for (const event of ['Airfield', 'My secret arena']) {
assert.strictEqual('event' in dynamicPvp.shapeFor(created({ event }), { staff: false, geometry: GEOMETRY }), false)
assert.strictEqual(dynamicPvp.shapeFor(created({ event }), { staff: true, geometry: GEOMETRY }).event, event)
}
for (const event of dynamicPvp.GENERAL_EVENTS) assert.ok(dynamicPvp.isGeneral(event))
})
test('without a map geometry a line has no grid, and off the map neither', () => {
assert.strictEqual('grid' in dynamicPvp.shapeFor(created(), { geometry: null }), false)
assert.strictEqual('grid' in dynamicPvp.shapeFor(created({ x: 9000 }), { geometry: GEOMETRY }), false)
})
test('a frame of another kind is passed back untouched', () => {
const other = { kind: 'world.event', event: 'heli.downed', x: 1, z: 2 }
assert.strictEqual(dynamicPvp.shapeFor(other, { staff: false }), other)
})
test('the feed reads a zone line for its viewer, with the map’s grid', async () => {
const stored = created({ event: 'Airfield' })
withRows((sql) => {
if (sql.includes('FROM rust_events')) return [{ id: 11, kind: 'pvp.zone.created', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stored) }]
if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', enabled: 1 }]
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
return []
})
const events = require('../model/events/events.model')
const [signedOut] = await events.recent({ serverId: 'main' })
assert.strictEqual(signedOut.frame.grid, gridOf(GEOMETRY, 812, -340.5))
assert.strictEqual('x' in signedOut.frame, false)
assert.strictEqual('event' in signedOut.frame, false)
const [staff] = await events.recent({ serverId: 'main', staff: true })
assert.strictEqual(staff.frame.x, 812)
assert.strictEqual(staff.frame.event, 'Airfield')
})

View File

@@ -0,0 +1,309 @@
// ── Economics (PLAN_REDESIGNS §11.2.2, protocol 14, D335, D336) ───────────
//
// Each test is one way the feature could look right and be wrong:
//
// a balance is a VALUE, written over — summed, it would grow every minute
// an offline player's balance moves nobody's last-seen
// `ecowipe` clears the wipe's balances; a new save's wipe keeps the last one's
// the richest ranks only above the starting balance, and only one wipe (D336)
// a server without Economics is refused with the reason, in the dry run too (D199)
// one payment per website user, with the key, so a retry never pays twice
// a withdrawal above the balance is a sentence with the balance in it
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/** Installs a core whose `db.query` records every statement and answers `rows`. */
function withRecorder(rows = []) {
const statements = []
const ctx = fakeCtx({
db: {
query: (sql, params = []) => {
statements.push({ sql, params })
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return { statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) }
}
withRecorder()
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
const linksDb = require('../model/links/links.db')
const economicsDb = require('../model/economics/economics.db')
const economics = require('../model/economics/economics.model')
const eventsDb = require('../model/events/events.db')
const eventsModel = require('../model/events/events.model')
const titlesDb = require('../model/titles/titles.db')
const rewards = require('../eventRewards')
const ok = (data) => ({ ok: true, status: 'ok', data })
const frame = (over = {}) => ({ type: 'event', t: 1, serverId: 'main', wipeId: 'w1', ...over })
const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...over }) })
const A = '76561198000000001'
const B = '76561198000000002'
const C = '76561198000000003'
const D = '76561198000000004'
// ── What a server reports ──────────────────────────────────────────────────
test('a server whose Economics config cannot be read is assumed at 1000, and says so', () => {
assert.deepStrictEqual(economics.shape(null), { loaded: false })
assert.deepStrictEqual(economics.shape({ loaded: false }), { loaded: false })
const unread = economics.shape(JSON.stringify({ loaded: true, version: '3.9.2' }))
assert.strictEqual(unread.startingBalance, 1000)
assert.strictEqual(unread.startingKnown, false)
const read = economics.shape({ loaded: true, version: '3.9.2', startingBalance: 0, wipesWithMap: true })
assert.strictEqual(read.startingBalance, 0)
assert.strictEqual(read.startingKnown, true)
assert.strictEqual(read.wipesWithMap, true)
})
// ── Ingest ─────────────────────────────────────────────────────────────────
test('a tally’s balance is written over, and an offline one moves nobody’s last-seen', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
// No name: the plugin names only a connected player.
await apply('main', item('player.tally', { steamId: A, balance: 1500.456 }))
const stats = rec.touching('rust_player_wipe_stats')
assert.strictEqual(stats.length, 1)
assert.match(stats[0].sql, /balance = VALUES\(balance\)/)
assert.doesNotMatch(stats[0].sql, /balance \+/)
assert.doesNotMatch(stats[0].sql, /last_seen/)
assert.deepStrictEqual(stats[0].params, ['main', 'w1', A, 1500.46])
assert.strictEqual(rec.touching('rust_players ').length, 0, 'no player row is touched')
// A connected player's tally carries the balance AND its play.
const live = withRecorder()
await apply('main', item('player.tally', { steamId: A, name: 'Wanderer', balance: 900, npcKills: 2 }))
assert.ok(live.touching('rust_player_wipe_stats').some((s) => /balance = VALUES\(balance\)/.test(s.sql)))
assert.ok(live.touching('rust_player_wipe_stats').some((s) => /npc_kills = npc_kills \+/.test(s.sql)))
})
test('ecowipe clears the wipe’s balances; a new save keeps the last wipe’s as its record', async () => {
const { apply } = require('../ingest')
const rec = withRecorder()
await apply('main', item('economics.wiped', { newSave: false }))
const cleared = rec.touching('rust_player_wipe_stats')
assert.strictEqual(cleared.length, 1)
assert.match(cleared[0].sql, /SET balance = NULL WHERE server_id = \? AND wipe_id = \?/)
assert.deepStrictEqual(cleared[0].params, ['main', 'w1'])
const kept = withRecorder()
await apply('main', item('economics.wiped', { newSave: true }))
assert.strictEqual(kept.touching('rust_player_wipe_stats').length, 0)
})
// ── The richest (D336) ─────────────────────────────────────────────────────
test('the richest column ranks one wipe, above the starting balance', async () => {
const rec = withRecorder()
await eventsDb.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'balance', limit: 25, balanceFloor: 1000 })
const [q] = rec.statements
assert.match(q.sql, /HAVING MAX\(s\.balance\) > \?/)
assert.match(q.sql, /ORDER BY MAX\(s\.balance\) DESC/)
assert.deepStrictEqual(q.params, ['main', 'w1', 1000, 25])
// All time has no balance: the sort falls back, and no row carries one.
const all = withRecorder([{ steamId: A, kills: 3, balance: 5000 }])
const rows = await eventsModel.leaderboard({ serverId: 'main', wipeId: null, sort: 'balance', economics: { startingBalance: 1000 } })
assert.doesNotMatch(all.statements[0].sql, /HAVING/)
assert.match(all.statements[0].sql, /ORDER BY SUM\(s\.kills\) DESC/)
assert.ok(!('balance' in rows[0]))
// One wipe on a server with Economics: every row carries its balance, null when none.
withRecorder([{ steamId: A, balance: '2500.00' }, { steamId: B, balance: null }])
const wipe = await eventsModel.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'kills', economics: { startingBalance: 1000 } })
assert.strictEqual(wipe[0].balance, 2500)
assert.strictEqual(wipe[1].balance, null)
})
test('Magnate ranks nobody without a floor, and only balances above it with one', async () => {
const none = withRecorder()
assert.deepStrictEqual(await titlesDb.standings({ serverId: 'main', wipeId: 'w1', source: { balance: 'balance' }, limit: 3 }), [])
assert.strictEqual(none.statements.length, 0)
const rec = withRecorder()
await titlesDb.standings({ serverId: 'main', wipeId: 'w1', source: { balance: 'balance' }, limit: 3, floor: 1000 })
assert.match(rec.statements[0].sql, /s\.balance > \?/)
assert.deepStrictEqual(rec.statements[0].params, ['main', 'w1', 1000, 3])
})
// ── The step ───────────────────────────────────────────────────────────────
const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
const pay = rewards.ACTIONS.find((a) => a.id === 'rust.coins.pay')
function stub(t, { loaded = true, people = [], links = {}, payAnswer } = {}) {
const calls = { pay: [] }
const saved = {
getServer: serversDb.getServer,
reportFor: economicsDb.reportFor,
tallySnapshot: client.tallySnapshot,
economicsPay: client.economicsPay,
userIdsForSteamIds: linksDb.userIdsForSteamIds,
}
serversDb.getServer = async (id) => (id === 'main' ? ROW : null)
economicsDb.reportFor = async () => ({ serverId: 'main', wipeId: 'w1', economics: loaded ? JSON.stringify({ loaded: true, startingBalance: 1000 }) : null })
client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 })
client.economicsPay = async (server, body) => {
calls.pay.push(body)
return payAnswer ? payAnswer(body) : ok({ kind: 'economics.paid', key: body.key, repeat: false, paid: [], failed: [] })
}
linksDb.userIdsForSteamIds = async (ids) => ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
t.after(() => {
serversDb.getServer = saved.getServer
economicsDb.reportFor = saved.reportFor
client.tallySnapshot = saved.tallySnapshot
client.economicsPay = saved.economicsPay
linksDb.userIdsForSteamIds = saved.userIdsForSteamIds
})
return calls
}
const PARAMS = { server: 'main', amount: 500, recipients: 'everyone', message: 'You earned {amount} coins', delivery: 'popup' }
test('pay coins is irreversible and kept: no ledger, no revert (D335)', () => {
assert.strictEqual(pay.risk, 'irreversible')
assert.strictEqual(pay.reversible, 'none')
assert.strictEqual(pay.revert, undefined)
})
test('a server without Economics is refused in the dry run, with the reason (D199)', async (t) => {
stub(t, { loaded: false })
const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: true })
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, false)
assert.match(result.error, /Economics is not installed on this server/)
})
test('coins go once to each website user, with the key, the amount and the message', async (t) => {
const person = (steamId, score) => ({ steamId, name: `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 })
const calls = stub(t, {
// A and B are one person (user 1); C has linked nothing; D is user 2.
people: [person(A, 9), person(B, 5), person(C, 3), person(D, 1)],
links: { [A]: 1, [B]: 1, [D]: 2 },
payAnswer: (body) =>
ok({
kind: 'economics.paid',
key: body.key,
repeat: false,
paid: [{ steamId: A, before: 1000, after: 1500 }],
failed: [{ steamId: D, reason: 'at-limit' }],
}),
})
const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
assert.strictEqual(result.ok, true)
assert.deepStrictEqual(calls.pay, [
{ key: 'k-1', amount: 500, steamIds: [A, D], message: 'You earned {amount} coins', delivery: 'popup' },
])
assert.strictEqual(result.detail.paid, 1)
assert.strictEqual(result.detail.total, 500)
assert.deepStrictEqual(result.detail.received, [{ name: 'p1', received: 500 }])
assert.deepStrictEqual(result.detail.failed, [{ name: 'p4', reason: 'at-limit' }])
assert.deepStrictEqual(result.detail.missed, ['p3'])
assert.strictEqual(result.resources, undefined, 'nothing is ledgered')
})
test('a repeated key is answered from the first payment, and a lost answer is retried', async (t) => {
const person = { steamId: A, name: 'pa', seconds: 60, kills: 0, score: 1, joinedAt: 1 }
stub(t, { people: [person], links: { [A]: 1 }, payAnswer: () => ok({ kind: 'economics.paid', repeat: true, paid: [{ steamId: A, before: 1000, after: 1500 }], failed: [] }) })
const repeat = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
assert.strictEqual(repeat.ok, true)
assert.strictEqual(repeat.detail.repeat, true)
client.economicsPay = async () => ({ ok: false, status: 'timeout' })
const lost = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
assert.strictEqual(lost.ok, false)
assert.notStrictEqual(lost.retry, false, 'a timeout is retried, and the key keeps it to one payment')
client.economicsPay = async () => ok({ kind: 'economics.error', reason: 'economics-missing', message: 'Economics is not installed on this server' })
const missing = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
assert.strictEqual(missing.retry, false)
})
test('a payment is priced at the most it could pay, and its message is measured with the amount in it', () => {
assert.deepStrictEqual(pay.cost({ amount: 500, recipients: 'top', count: 3 }), { 'rust.coins': 1500 })
assert.deepStrictEqual(pay.cost({ amount: 2.5, recipients: 'everyone' }), { 'rust.coins': 250 })
assert.strictEqual(rewards.coinsNotice({ message: '' }, 500).text, null)
assert.strictEqual(rewards.coinsNotice({ message: `${'x'.repeat(250)}{amount}` }, 1000000).ok, false)
assert.strictEqual(rewards.coinsNotice({ message: 'Got {amount}', delivery: 'carrier pigeon' }, 5).ok, false)
const key = rewards.paymentKey('k'.repeat(300), 7, 4)
assert.ok(key.startsWith('coins:') && key.length <= 120)
assert.strictEqual(rewards.paymentKey('k-1', 7, 4), 'k-1')
})
test('an amount outside 0.01 to 1,000,000 is refused before anything is asked', async (t) => {
stub(t)
for (const amount of [0, -5, 1000001, 'lots']) {
const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, amount }, verify: true })
assert.strictEqual(result.ok, false, String(amount))
assert.strictEqual(result.retry, false)
}
})
// ── Staff ──────────────────────────────────────────────────────────────────
test('an adjustment checks its op and amount; only a set may be 0', () => {
assert.strictEqual(economics.checkAdjustment('steal', 5).ok, false)
assert.strictEqual(economics.checkAdjustment('deposit', 0).ok, false)
assert.strictEqual(economics.checkAdjustment('set', 0).ok, true)
assert.strictEqual(economics.checkAdjustment('withdraw', 1000001).ok, false)
assert.strictEqual(economics.checkAdjustment('deposit', 12.345).value, 12.35)
})
test('a withdrawal above the balance is refused with the balance', async (t) => {
const saved = { getForCalling: servers.getForCalling, reportFor: economicsDb.reportFor, economicsAdjust: client.economicsAdjust }
t.after(() => Object.assign(servers, { getForCalling: saved.getForCalling }) && Object.assign(economicsDb, { reportFor: saved.reportFor }) && Object.assign(client, { economicsAdjust: saved.economicsAdjust }))
const sent = []
servers.getForCalling = async () => ({ id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' })
economicsDb.reportFor = async () => ({ serverId: 'main', wipeId: 'w1', economics: { loaded: true } })
client.economicsAdjust = async (server, body) => {
sent.push(body)
return ok({ kind: 'economics.error', reason: 'insufficient', balance: 340 })
}
const result = await economics.adjust({ serverId: 'main', steamId: A, op: 'withdraw', amount: 500, key: 'adj-1' })
assert.strictEqual(result.ok, false)
assert.strictEqual(result.status, 409)
assert.match(result.message, /only 340 coins on Main/)
assert.deepStrictEqual(sent, [{ key: 'adj-1', steamId: A, op: 'withdraw', amount: 500 }])
economicsDb.reportFor = async () => null
const missing = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 5, key: 'adj-2' })
assert.strictEqual(missing.status, 409)
assert.strictEqual(missing.message, economics.MISSING)
})
test('the activity row says what moved', () => {
assert.strictEqual(
economics.adjustmentSummary({ op: 'deposit', amount: 500, name: 'Wanderer', server: 'Main', before: 1000, after: 1500 }),
'Deposited 500 coins to Wanderer on Main: 1,000 → 1,500',
)
assert.strictEqual(
economics.adjustmentSummary({ op: 'set', amount: 0, name: 'Wanderer', server: 'Main', before: 1000, after: 0 }),
'Set the balance of Wanderer on Main to 0: 1,000 → 0',
)
})

View File

@@ -156,18 +156,32 @@ test('the event verbs are registered, and every budget has a verb that spends it
const budgets = api.record.eventBudgets
assert.deepStrictEqual(actions.map((a) => a.id).sort(), [
'rust.announce',
'rust.backpack.size',
'rust.coins.pay',
'rust.crate.place',
'rust.dt.start',
'rust.gc.place',
'rust.kit.entitle',
'rust.npc.place',
'rust.participation.collect',
'rust.participation.open',
'rust.perm.give',
'rust.perm.lend',
'rust.world.call',
'rust.zone.open',
])
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
'rust.announcements',
'rust.backpacks',
'rust.coins',
'rust.dt.events',
'rust.gc.crates',
'rust.grants',
'rust.npcs',
'rust.permissions',
'rust.permissions.kept',
'rust.prefabs',
'rust.world.calls',
'rust.zone.minutes',
])
@@ -178,7 +192,8 @@ test('the event verbs are registered, and every budget has a verb that spends it
// operator. The two tally verbs spend nothing: counting people costs no loot.
const spent = new Set()
for (const a of actions) {
const example = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
// A list param (MODULE_API 1.14.0) is priced as core prices it: its one example as a list of one.
const example = Object.fromEntries(a.params.map((p) => [p.name, p.multiple ? [p.example] : p.example]))
const dims = Object.keys(a.cost(example)).filter((id) => a.cost(example)[id] > 0)
if (a.id.startsWith('rust.participation.')) {
assert.strictEqual(dims.length, 0, `${a.id} prices ${dims.join(', ')}`)
@@ -189,17 +204,21 @@ test('the event verbs are registered, and every budget has a verb that spends it
}
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
// What a teardown can give back is declared honestly: a line said and a
// collect filed are not undone.
const once = new Set(['rust.announce', 'rust.participation.collect'])
// What a teardown can give back is declared honestly: a line said, a
// collect filed, coins paid (D335) and permissions given for good (D346) are
// not undone, and nor is a world event called (D354): a cargo ship or a drop
// may already hold players or their loot; nor a Dangerous Treasures event
// started, which DT despawns itself (§14.3.4); nor a guarded crate placed,
// which Guarded Crate cannot end alone (§14.3.5, D373).
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give', 'rust.world.call', 'rust.dt.start', 'rust.gc.place'])
for (const a of actions) {
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
if (once.has(a.id)) continue
assert.strictEqual(typeof a.revert, 'function')
assert.strictEqual(typeof a.reconcile, 'function')
// Every param source is one this module registers.
const sources = new Set(api.record.eventOptionSources.map((s) => s.id))
for (const p of a.params) if (p.source) assert.ok(sources.has(p.source), `${a.id}.${p.name} names ${p.source}`)
if (once.has(a.id)) continue
assert.strictEqual(typeof a.revert, 'function')
assert.strictEqual(typeof a.reconcile, 'function')
}
})

View File

@@ -0,0 +1,368 @@
// ── Guarded Crate, with NpcSpawn (PLAN_REDESIGNS §14.3.5, protocol 14) ──────
//
// Each test is one way the feature could look right and be wrong:
//
// the clearer's name reaches a viewer below the presence audience (D376), or
// a Steam id, a position or a step's key reaches anybody but staff
// an ending with nobody clearing it reads as a win
// the step runs on a server that said it has no Guarded Crate, no NpcSpawn
// (D364), a framework that cannot run its command (D363), or no event of that
// name, or a server that has not said is refused
// the step's location is not a world call's, or the run log is not told the
// crate is kept (D373, D380)
// a refusal Guarded Crate would repeat is retried, or a crowded spot is not
// a retry of a key places a second crate
// the tally's crates cleared is dropped, or Warden's column and sort are missing
// a trigger declares a variable its emitter never fills, names a Steam id, or
// fires for a crate nobody cleared
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/** Installs a core whose `db.query` records every statement and answers `rows`. */
function withRecorder(rows = []) {
const statements = []
const ctx = fakeCtx({
db: {
query: (sql, params = []) => {
statements.push({ sql, params })
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return { ctx, statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) }
}
withRecorder()
const gc = require('../guardedCrate')
const { gridOf } = require('../worldEvents')
const { TRIGGERS } = require('../engagement/triggers')
const A = '76561198000000001'
const NOW = Date.now()
const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 }
const frame = (kind, over = {}) => ({ kind, type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', x: -604, z: -342, ...over })
const cleared = (over = {}) => frame('gc.ended', { steamId: A, name: 'Alice', ...over })
const REPORT = { loaded: true, version: '2.0.17', npcSpawn: { loaded: true, version: '3.4.8' }, events: ['Easy', 'Medium', 'Hard', 'Elite'] }
// ── Who may read what (D353, D376) ─────────────────────────────────────────
test('the public reads a guarded crate’s grid, never its position or the step’s key', () => {
const out = gc.shapeFor(frame('gc.started', { key: '7:1' }), { geometry: GEOMETRY })
assert.strictEqual(out.grid, gridOf(GEOMETRY, -604, -342))
assert.strictEqual(out.x, undefined)
assert.strictEqual(out.z, undefined)
assert.strictEqual(out.key, undefined)
assert.strictEqual(out.kind, 'gc.started')
})
test('a clearing names its clearer only to the presence audience, and the Steam id only to staff', () => {
const signedOut = gc.shapeFor(cleared(), { geometry: GEOMETRY })
assert.strictEqual(signedOut.won, true, 'that somebody cleared it names nobody')
assert.strictEqual(signedOut.name, undefined)
assert.strictEqual(signedOut.steamId, undefined)
const member = gc.shapeFor(cleared(), { presence: true, geometry: GEOMETRY })
assert.strictEqual(member.name, 'Alice')
assert.strictEqual(member.steamId, undefined)
const staff = gc.shapeFor(cleared({ key: '7:1' }), { staff: true, geometry: GEOMETRY })
assert.strictEqual(staff.name, 'Alice')
assert.strictEqual(staff.steamId, A)
assert.strictEqual(staff.x, -604)
assert.strictEqual(staff.key, '7:1')
})
test('an ending with nobody clearing it is not a win, for anybody', () => {
for (const view of [{}, { presence: true }, { staff: true }]) {
const out = gc.shapeFor(frame('gc.ended'), { ...view, geometry: GEOMETRY })
assert.strictEqual(out.won, false)
assert.strictEqual(out.name, undefined)
assert.strictEqual(out.steamId, undefined)
}
// A name without a Steam id is not a clearer either.
assert.strictEqual(gc.shapeFor(frame('gc.ended', { name: 'Alice' }), { staff: true }).name, undefined)
const other = { kind: 'player.death', x: 1 }
assert.strictEqual(gc.shapeFor(other, {}), other)
})
test('the feed reads a stored clearing for its viewer, the row’s own Steam id staff’s', async () => {
withRecorder((sql) => {
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'gc.ended', t: NOW, wipeId: 'w1', steamId: A, raw: JSON.stringify(cleared()) }]
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
return []
})
const events = require('../model/events/events.model')
const [signedOut] = await events.recent({ serverId: 'main', presence: false })
assert.strictEqual(signedOut.steamId, null)
assert.strictEqual(signedOut.frame.name, undefined)
assert.strictEqual(signedOut.frame.won, true)
assert.ok(signedOut.frame.grid)
const [staff] = await events.recent({ serverId: 'main', admin: true })
assert.strictEqual(staff.steamId, A)
assert.strictEqual(staff.frame.name, 'Alice')
})
test('a server’s report is read leniently: what it never said is null', () => {
assert.deepStrictEqual(gc.reportOf(null), { guardedCrate: null, consolePlugins: null })
assert.deepStrictEqual(gc.reportOf({ guardedCrate: JSON.stringify(REPORT), console: '{"plugins":false}' }), {
guardedCrate: REPORT,
consolePlugins: false,
})
// A plugin that could not read the data file sends no events; NpcSpawn unsaid is null.
assert.deepStrictEqual(gc.reportOf({ guardedCrate: '{"loaded":true,"version":"2.0.17"}', console: null }).guardedCrate, {
loaded: true,
version: '2.0.17',
npcSpawn: null,
events: null,
})
assert.deepStrictEqual(gc.reportOf({ guardedCrate: 'not json', console: '{}' }), { guardedCrate: null, consolePlugins: null })
})
// ── The step ───────────────────────────────────────────────────────────────
const SERVERS = {
main: { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' },
pve: { id: 'pve', name: 'PvE', enabled: 1, sidecarBaseUrl: 'http://pve:1' },
}
/** The step against servers `main` and `pve`, whose last reports are `reports`, and a sidecar answering `reply`. */
function stubPlace(t, { reports = { main: { guardedCrate: REPORT, console: { plugins: true } } }, reply } = {}) {
const row = (id) => {
const r = reports[id]
return r ? { serverId: id, name: SERVERS[id].name, guardedCrate: JSON.stringify(r.guardedCrate ?? null), console: JSON.stringify(r.console ?? null) } : null
}
withRecorder((sql, params) => {
if (sql.includes('JOIN rust_server_state')) return Object.keys(reports).map(row)
if (sql.includes('FROM rust_server_state')) return row(params[0]) ? [row(params[0])] : []
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
return []
})
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const saved = { gcPlace: client.gcPlace, getServer: serversDb.getServer }
const calls = []
serversDb.getServer = async (id) => SERVERS[id] || null
client.gcPlace = async (server, body) => {
calls.push({ server: server.id, body })
return typeof reply === 'function' ? reply(body) : reply
}
t.after(() => {
client.gcPlace = saved.gcPlace
serversDb.getServer = saved.getServer
})
return calls
}
const module_ = () => require('../eventGuardedCrate')
const placeStep = () => module_().ACTIONS[0]
const ok = (data) => ({ ok: true, status: 'ok', data })
const placed = (over = {}) => ok({ kind: 'gc.placed', key: '7:1', event: 'Elite', repeat: false, x: -604, z: -342, ...over })
test('the step is kept, priced one crate each, and has no revert (D373)', () => {
const step = placeStep()
assert.strictEqual(step.id, 'rust.gc.place')
assert.strictEqual(step.reversible, 'none')
assert.strictEqual(step.risk, 'change')
assert.strictEqual(step.revert, undefined)
assert.deepStrictEqual(step.cost({ event: 'Elite', monument: 'main/powerplant_1' }), { 'rust.gc.crates': 1 })
assert.match(step.description, /kept when the run ends/)
})
test('the event list is every server’s own, once each, and Guarded Crate’s defaults where none has said', async (t) => {
const [events] = module_().OPTION_SOURCES
stubPlace(t, {
reports: {
main: { guardedCrate: REPORT, console: { plugins: true } },
pve: { guardedCrate: { ...REPORT, events: ['easy', 'Boss Drop'] }, console: { plugins: true } },
},
})
assert.deepStrictEqual(await events.resolve(), [
{ value: 'Easy', label: 'Easy — Main, PvE' },
{ value: 'Medium', label: 'Medium — Main' },
{ value: 'Hard', label: 'Hard — Main' },
{ value: 'Elite', label: 'Elite — Main' },
{ value: 'Boss Drop', label: 'Boss Drop — PvE' },
])
stubPlace(t, { reports: { main: { guardedCrate: { loaded: false }, console: null } } })
assert.deepStrictEqual((await events.resolve()).map((e) => e.value), ['Easy', 'Medium', 'Hard', 'Elite'])
})
test('the dry run refuses what the server said it cannot do, and nothing else (D364)', async (t) => {
const run = (params) => placeStep().perform({ runId: 7, idempotencyKey: '7:1', params, verify: true })
const at = { event: 'Elite', monument: 'main/powerplant_1' }
stubPlace(t, { reports: { main: { guardedCrate: { loaded: false }, console: { plugins: true } } } })
const absent = await run(at)
assert.strictEqual(absent.retry, false)
assert.match(absent.error, /Guarded Crate is not installed on Main/)
stubPlace(t, { reports: { main: { guardedCrate: { ...REPORT, npcSpawn: { loaded: false } }, console: { plugins: true } } } })
const noGuards = await run(at)
assert.strictEqual(noGuards.retry, false)
assert.strictEqual(noGuards.error, 'Guarded Crate is loaded on Main but NpcSpawn is missing: its events cannot start')
stubPlace(t, { reports: { main: { guardedCrate: REPORT, console: { plugins: false } } } })
const carbon = await run(at)
assert.match(carbon.error, /cannot run Guarded Crate's console command/)
assert.match(carbon.error, /Rust-Plugins#34/)
const calls = stubPlace(t)
const unknown = await run({ ...at, event: 'Legendary' })
assert.strictEqual(unknown.retry, false)
assert.match(unknown.error, /"Legendary" is not one of Guarded Crate's events on Main \(Easy, Medium, Hard, Elite\)/)
assert.ok((await run({ ...at, event: 'elite' })).ok, 'a name is found ignoring case, as Guarded Crate finds it')
assert.match((await run({ monument: 'main/powerplant_1' })).error, /which of Guarded Crate's events/)
assert.match((await run({ event: 'Elite', x: 1, z: 2 })).error, /which server/)
assert.match((await run({ event: 'Elite', monument: 'main/powerplant_1', x: 1, z: 2 })).error, /exactly one of them/)
assert.strictEqual(calls.length, 0, 'the dry run places nothing')
// A plugin before protocol 14, or a server never reached, has not said: the plugin answers.
stubPlace(t, { reports: {} })
assert.ok((await run(at)).ok)
// Nor does a report that could not read the event names refuse a name.
stubPlace(t, { reports: { main: { guardedCrate: { ...REPORT, events: undefined }, console: { plugins: true } } } })
assert.ok((await run({ ...at, event: 'Boss Drop' })).ok)
})
test('a placement sends the key, the event and a world call’s location, and tells the run log the crate is kept', async (t) => {
const calls = stubPlace(t, { reply: placed() })
const result = await placeStep().perform({
runId: 7,
idempotencyKey: '7:1',
params: { event: 'Elite', monument: 'main/powerplant_1', offsetX: 10, offsetZ: -5, y: 40 },
})
assert.ok(result.ok)
assert.deepStrictEqual(calls, [{ server: 'main', body: { key: '7:1', event: 'Elite', monument: 'powerplant_1', offsetX: 10, offsetZ: -5 } }])
assert.strictEqual(result.detail.event, 'Elite')
assert.strictEqual(result.detail.grid, gridOf(GEOMETRY, -604, -342))
assert.strictEqual(result.detail.note, `The crate is ${module_().KEPT}`)
const byPoint = stubPlace(t, { reply: placed() })
await placeStep().perform({ runId: 7, idempotencyKey: '7:2', params: { event: 'Easy', server: 'main', x: -604, z: -342 } })
assert.deepStrictEqual(byPoint[0].body, { key: '7:2', event: 'Easy', x: -604, z: -342 })
})
test('a crowded spot and a game that is down are retried; a refusal Guarded Crate would repeat is not', async (t) => {
stubPlace(t, {
reply: (body) => ok(body.event === 'Hard'
? { kind: 'gc.error', reason: 'gc-too-close', message: 'another guarded crate is running within 80 m of that spot' }
: { kind: 'gc.error', reason: 'npcspawn-missing', message: 'Guarded Crate is loaded but NpcSpawn is missing: its events cannot start' }),
})
const step = placeStep()
const params = (event) => ({ event, monument: 'main/powerplant_1' })
const crowded = await step.perform({ runId: 7, idempotencyKey: '7:3', params: params('Hard') })
assert.strictEqual(crowded.ok, false)
assert.strictEqual(crowded.retry, undefined)
assert.match(crowded.error, /within 80 m/)
const noGuards = await step.perform({ runId: 7, idempotencyKey: '7:4', params: params('Easy') })
assert.strictEqual(noGuards.retry, false)
assert.match(noGuards.error, /NpcSpawn is missing/)
for (const reason of ['gc-missing', 'npcspawn-missing', 'console-unavailable', 'bad-event', 'bad-position', 'refused', 'no-monument', 'off-map', 'malformed', 'events-disabled']) {
assert.ok(module_().PERMANENT.has(reason), reason)
}
assert.ok(!module_().PERMANENT.has('gc-too-close'))
stubPlace(t, { reply: { ok: false, status: 'http-503' } })
const down = await step.perform({ runId: 7, idempotencyKey: '7:5', params: params('Elite') })
assert.strictEqual(down.retry, undefined)
assert.match(down.error, /has no game connected/)
})
test('a repeat of a key places nothing new, and says so beside the crate being kept', async (t) => {
stubPlace(t, { reply: placed({ repeat: true }) })
const again = await placeStep().perform({ runId: 7, idempotencyKey: '7:1', params: { event: 'Elite', monument: 'main/powerplant_1' } })
assert.ok(again.ok)
assert.strictEqual(again.detail.repeat, true)
assert.match(again.detail.note, /^answered from the first attempt; nothing new was placed\. The crate is kept/)
})
// ── Warden (D375) ──────────────────────────────────────────────────────────
test('the tally’s crates cleared is added up', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', { id: 1, kind: 'player.tally', frame: { kind: 'player.tally', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', steamId: A, name: 'Alice', guardedWon: 2 } })
const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('guarded_won'))
assert.ok(titleAdd, 'the tally reached the title columns')
const cols = titleAdd.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+)\)/)[1].split(', ')
assert.strictEqual(titleAdd.params[3 + cols.indexOf('guarded_won')], 2)
})
test('the leaderboard sorts by crates cleared and carries the count on every row', async () => {
const rec = withRecorder((sql) => (sql.includes('FROM rust_player_wipe_stats') ? [{ steamId: A, name: 'Alice', kills: 1, guardedWon: '3' }] : []))
const events = require('../model/events/events.model')
const [row] = await events.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'guardedWon' })
assert.strictEqual(row.guardedWon, 3)
const board = rec.touching('rust_player_wipe_stats').at(-1)
assert.match(board.sql, /SUM\(s\.guarded_won\) AS guardedWon/)
assert.match(board.sql, /ORDER BY[^]*guarded_won/)
})
// ── Triggers (D355, §14.3.9) ───────────────────────────────────────────────
test('a landing and a clearing are public triggers, neither declaring a Steam id', () => {
const found = TRIGGERS.filter((t) => t.id.startsWith('rust.gc.'))
assert.deepStrictEqual(found.map((t) => t.id).sort(), ['rust.gc.ended', 'rust.gc.started'])
for (const t of found) {
assert.strictEqual(t.ceiling, 'everyone', t.id)
assert.strictEqual(t.subjectKey, 'subject', t.id)
assert.ok(!t.variables.some((v) => /steam/i.test(v.name)), `${t.id} declares a Steam id`)
}
})
test('a landing and a clearing fire with what they declare; nobody clearing it and a timeout fire nothing', async () => {
const { ctx } = withRecorder((sql) => (sql.includes('FROM rust_map_images') ? [{ serverId: 'main', ...GEOMETRY }] : []))
const emit = require('../engagement/emit')
emit.reset()
const server = { id: 'main', name: 'Main' }
await emit.onEvent(server, { id: 1, kind: 'gc.started', frame: frame('gc.started', { key: '7:1' }) })
await emit.onEvent(server, { id: 2, kind: 'gc.ended', frame: cleared() })
await emit.onEvent(server, { id: 3, kind: 'gc.ended', frame: frame('gc.ended') })
await emit.onEvent(server, { id: 4, kind: 'gc.failed', frame: frame('gc.failed') })
const calls = ctx.events.emit.calls
assert.strictEqual(calls.length, 2)
const [startedId, started] = calls[0]
assert.strictEqual(startedId, 'rust.gc.started')
assert.strictEqual(started.data.byEvent, true)
assert.strictEqual(started.data.subject, 'main:gc.started')
assert.match(started.data.intro, /A guarded crate landed at [A-Z]+\d+ on Main\./)
const [endedId, ended] = calls[1]
assert.strictEqual(endedId, 'rust.gc.ended')
assert.strictEqual(ended.data.player, 'Alice')
assert.match(ended.data.intro, /Alice cleared the guarded crate at [A-Z]+\d+ on Main\./)
for (const [id, envelope] of calls) {
const declared = new Set(TRIGGERS.find((x) => x.id === id).variables.map((v) => v.name))
for (const name of Object.keys(envelope.data)) assert.ok(declared.has(name), `${id} fills ${name}, which it does not declare`)
}
})
test('an old crate tells nobody (D63)', async () => {
const { ctx } = withRecorder()
const emit = require('../engagement/emit')
emit.reset()
await emit.onEvent({ id: 'main', name: 'Main' }, { id: 1, kind: 'gc.ended', frame: cleared({ t: NOW - 3 * 3600 * 1000 }) })
assert.strictEqual(ctx.events.emit.calls.length, 0)
})

View File

@@ -45,6 +45,10 @@ test('the player tier serves the identity routes, the entitlement read, and noth
routes.map((r) => `${r.method} ${r.path}`).sort(),
[
'DELETE /links/:steamId',
// Protocol 14 (§11.3.2): the caller's own backpack, read live. Read-only.
'GET /backpacks',
// Protocol 14 (§11.2.2): the caller's own coin balances. Read-only.
'GET /economics',
'GET /links',
// Phase 8: what the site has given the caller in game. Read-only on this
// tier by construction — the authoring routes are all admin.
@@ -88,8 +92,13 @@ test('the admin.users.detail router merges the parent’s params and keeps its o
'DELETE /rust/links/:steamId',
// Phase 7 filled the same panel with what this person may do in game.
'DELETE /rust/permissions/grants/:grantId',
// Protocol 14 (§11.3.2): what they carry, read live. There is no write.
'GET /rust/backpacks',
// Protocol 14 (§11.2.2): their coins, and staff's hand on them.
'GET /rust/economics',
'GET /rust/links',
'GET /rust/permissions',
'POST /rust/economics',
'POST /rust/permissions/grants',
])

View File

@@ -84,17 +84,19 @@ const LIVE = {
{ steamId: '7005', name: 'My alt, asleep', x: 5, z: 5, sleeping: true, online: false },
],
bases: [{ kind: 'tc', x: 9, z: 9 }],
pvp: [{ zoneId: '1412203045', event: 'Helicopter', x: 30, z: 40, radius: 100, shape: 'sphere' }],
}
test('a fresh install shows the world and events to anybody, and positions to staff only', async (t) => {
settings(t)
assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', mates: true, markers: {} })
assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', pvp: 'public', mates: true, markers: {} })
const acc = await map.access({}, 'main')
assert.equal(acc.layers.world.visible, true)
assert.equal(acc.layers.events.visible, true)
assert.equal(acc.layers.players.visible, false)
assert.equal(acc.layers.bases.visible, false)
assert.equal(acc.layers.pvp.visible, true, 'Dynamic PVP zones name nobody (§14.3.3)')
assert.equal(acc.mates.visible, false, 'an anonymous viewer has no accounts to be shown')
})
@@ -158,6 +160,19 @@ test('a hidden layer is absent from the answer, not an empty array', async (t) =
}
})
test('§14.3.3: the pvp layer passes Dynamic PVP zones through, is empty from an older plugin, and can be hidden', async (t) => {
settings(t)
assert.deepStrictEqual(map.project(LIVE, await map.access({}, 'main')).pvp, LIVE.pvp)
const { pvp, ...older } = LIVE
assert.deepStrictEqual(map.project(older, await map.access({}, 'main')).pvp, [])
})
test('§14.3.3: a pvp layer set to staff is absent for the public', async (t) => {
settings(t, { stored: { 'map.layer.pvp.audience': 'staff' } })
const out = map.project(LIVE, await map.access({}, 'main'))
assert.equal(Object.prototype.hasOwnProperty.call(out, 'pvp'), false)
})
test('staff get every layer', async (t) => {
settings(t, { viewer: { level: 'staff', userId: 1 } })
const out = map.project({ ...LIVE, basesTruncated: true }, await map.access({}, 'main'))

View File

@@ -0,0 +1,345 @@
// ── Permissions as event rewards (PLAN_REDESIGNS §12, D342–D346) ──────────
//
// Each test is one way the feature could look right and be wrong:
//
// the picker groups by the plugin that registered a permission, by prefix
// when none did, sorts both levels without case, and says where one is missing
// several permissions arrive as core's list (MODULE_API 1.14.0); a bare value
// is a list of one; more than ten is refused
// unticked, both steps refuse in the dry run, naming what they would give (D343)
// a permission the server's catalogue lacks is refused by name, never skipped
// lend writes one row per recipient per permission, the message on one row
// give writes standing grants, source `event`, scoped to the server, leaves one
// already there alone, and tells a retry from a staff member's grant by note
// give's message rides a notice-only row the push grants nothing for
// a for-good grant carries its note to the permission manager
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withRecorder(rows = []) {
const statements = []
const ctx = fakeCtx({
db: {
query: (sql, params = []) => {
statements.push({ sql, params })
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return { statements }
}
withRecorder()
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const linksDb = require('../model/links/links.db')
const permDb = require('../model/permissions/permissions.db')
const model = require('../model/permissions/permissions.model')
const rewards = require('../eventRewards')
const perms = require('../eventPermRewards')
const ok = (data) => ({ ok: true, status: 'ok', data })
const A = '76561198000000001'
const B = '76561198000000002'
const C = '76561198000000003'
const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
const lend = perms.ACTIONS.find((a) => a.id === 'rust.perm.lend')
const give = perms.ACTIONS.find((a) => a.id === 'rust.perm.give')
const CATALOGUE = [
{ serverId: 'main', permission: 'nightvision.allowed', owner: 'NightVision' },
{ serverId: 'main', permission: 'betterchat.color', owner: 'BetterChat' },
{ serverId: 'main', permission: 'kits.vip', owner: 'Kits' },
]
function stub(t, { catalogue = CATALOGUE, people = [], links = {}, existing = [], held = [] } = {}) {
const calls = { inserted: [], grants: [], dirty: [], deleted: [], heldAsked: [] }
const saved = {
getServer: serversDb.getServer,
tallySnapshot: client.tallySnapshot,
userIdsForSteamIds: linksDb.userIdsForSteamIds,
listCatalogue: permDb.listCatalogue,
listRunGrantsForStep: permDb.listRunGrantsForStep,
insertRunGrants: permDb.insertRunGrants,
insertGrants: permDb.insertGrants,
listGrantsForUsers: permDb.listGrantsForUsers,
markDirty: permDb.markDirty,
deleteRunGrantsForStep: permDb.deleteRunGrantsForStep,
}
serversDb.getServer = async (id) => (id === 'main' ? ROW : null)
client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 })
linksDb.userIdsForSteamIds = async (ids) => ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
permDb.listCatalogue = async () => catalogue
permDb.listRunGrantsForStep = async () => existing
permDb.insertRunGrants = async (rows) => {
calls.inserted.push(...rows)
return rows.length
}
permDb.insertGrants = async (rows) => {
calls.grants.push(...rows)
return rows.length
}
permDb.listGrantsForUsers = async (userIds, scope) => {
calls.heldAsked.push({ userIds, scope })
return held
}
permDb.markDirty = async (serverId) => calls.dirty.push(serverId)
permDb.deleteRunGrantsForStep = async (runId, stepId) => {
calls.deleted.push(`${runId}:${stepId}`)
return ['main']
}
t.after(() => {
Object.assign(serversDb, { getServer: saved.getServer })
Object.assign(client, { tallySnapshot: saved.tallySnapshot })
Object.assign(linksDb, { userIdsForSteamIds: saved.userIdsForSteamIds })
Object.assign(permDb, {
listCatalogue: saved.listCatalogue,
listRunGrantsForStep: saved.listRunGrantsForStep,
insertRunGrants: saved.insertRunGrants,
insertGrants: saved.insertGrants,
listGrantsForUsers: saved.listGrantsForUsers,
markDirty: saved.markDirty,
deleteRunGrantsForStep: saved.deleteRunGrantsForStep,
})
})
return calls
}
const PARAMS = { server: 'main', permissions: ['nightvision.allowed', 'betterchat.color'], recipients: 'everyone', confirm: true }
const person = (steamId, score) => ({ steamId, name: `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 })
// ── The declarations ───────────────────────────────────────────────────────
test('lend is a ledgered change and give is irreversible, each on its own budget (D346)', () => {
assert.strictEqual(lend.risk, 'change')
assert.strictEqual(lend.reversible, 'ledger')
assert.strictEqual(typeof lend.revert, 'function')
assert.strictEqual(give.risk, 'irreversible')
assert.strictEqual(give.reversible, 'none')
assert.strictEqual(give.revert, undefined)
// People times permissions: the count for a count, the bound for every other mode.
assert.deepStrictEqual(lend.cost({ recipients: 'top', count: 3, permissions: ['a.x', 'b.y'] }), { 'rust.permissions': 6 })
assert.deepStrictEqual(give.cost({ recipients: 'linked', permissions: ['a.x'] }), { 'rust.permissions.kept': rewards.MAX_RECIPIENTS })
// The switchboard prices the example as a list of one.
assert.deepStrictEqual(lend.cost({ recipients: 'top', count: 3, permissions: ['nightvision.allowed'] }), { 'rust.permissions': 3 })
assert.deepStrictEqual(perms.BUDGETS.map((b) => b.id), ['rust.permissions', 'rust.permissions.kept'])
})
test('the permissions param is core\'s list, at most ten, and confirm is a required toggle', () => {
for (const action of [lend, give]) {
const list = action.params.find((p) => p.name === 'permissions')
assert.strictEqual(list.multiple, true)
assert.strictEqual(list.max, perms.MAX_PERMISSIONS)
assert.strictEqual(list.source, 'rust.options.permissions')
assert.strictEqual(Array.isArray(list.example), false)
const confirm = action.params.find((p) => p.name === 'confirm')
assert.deepStrictEqual([confirm.type, confirm.required, confirm.example], ['boolean', true, false])
}
// Each step's own message example says how long the perk lasts.
assert.match(lend.params.find((p) => p.name === 'message').example, /until the event ends/)
assert.doesNotMatch(give.params.find((p) => p.name === 'message').example, /until the event ends/)
})
test('a list is lower case, once each, a bare value is a list of one, and eleven is refused', () => {
assert.deepStrictEqual(perms.checkPermissions(['NightVision.Allowed', ' kits.vip ', 'nightvision.allowed']).value, ['nightvision.allowed', 'kits.vip'])
assert.deepStrictEqual(perms.checkPermissions('kits.vip').value, ['kits.vip'])
assert.match(perms.checkPermissions([]).error, /at least one/)
assert.match(perms.checkPermissions(['two words']).error, /not a permission name/)
const eleven = Array.from({ length: 11 }, (_, i) => `p.n${i}`)
assert.match(perms.checkPermissions(eleven).error, /at most 10 permissions, not 11/)
})
// ── The picker (§12.2) ─────────────────────────────────────────────────────
test('the picker groups by plugin, by prefix when none registered it, sorted both levels without case', () => {
const enabled = [{ id: 'main', name: 'Main' }, { id: 'pvp', name: 'PvP' }]
const rows = perms.permissionRows(
[
{ serverId: 'main', permission: 'zonemanager.ignoreflag.nokits', owner: 'ZoneManager' },
{ serverId: 'pvp', permission: 'zonemanager.ignoreflag.nokits', owner: 'ZoneManager' },
{ serverId: 'main', permission: 'Betterchat.Color', owner: 'BetterChat' },
{ serverId: 'pvp', permission: 'betterchat.color', owner: 'BetterChat' },
{ serverId: 'main', permission: 'nightvision.allowed', owner: 'NightVision' },
{ serverId: 'main', permission: 'admin.teleport', owner: null },
{ serverId: 'pvp', permission: 'admin.teleport', owner: null },
{ serverId: 'main', permission: 'betterchat.admin', owner: 'BetterChat' },
{ serverId: 'pvp', permission: 'betterchat.admin', owner: 'BetterChat' },
// A disabled server's catalogue is not offered.
{ serverId: 'off', permission: 'secret.thing', owner: 'Secret' },
],
enabled,
)
assert.deepStrictEqual(rows.map((r) => [r.group, r.value]), [
['admin (not registered by a plugin)', 'admin.teleport'],
['BetterChat', 'betterchat.admin'],
['BetterChat', 'Betterchat.Color'],
['NightVision', 'nightvision.allowed'],
['ZoneManager', 'zonemanager.ignoreflag.nokits'],
])
// One server only: the row says which.
assert.strictEqual(rows.find((r) => r.value === 'nightvision.allowed').note, 'Only on Main')
assert.strictEqual('note' in rows.find((r) => r.value === 'admin.teleport'), false)
})
// ── What both steps refuse ─────────────────────────────────────────────────
test('unticked, both steps refuse in the dry run, naming what they would give (D343)', async (t) => {
stub(t)
for (const action of [lend, give]) {
for (const confirm of [false, undefined, 'true']) {
const result = await action.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, confirm }, verify: true })
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, false)
assert.strictEqual(result.error, 'Tick "I am sure" to make nightvision.allowed, betterchat.color a reward')
}
}
})
test('a permission the server did not register is refused by name, in the dry run too', async (t) => {
stub(t)
const result = await give.perform({ runId: 7, stepId: 4, params: { ...PARAMS, permissions: ['nightvision.allowed', 'removertool.remove'] }, verify: true })
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, false)
assert.strictEqual(result.error, 'Main has no permission removertool.remove — its plugins did not register it')
})
test('a server that has reported no catalogue yet is a wait, not a refusal', async (t) => {
stub(t, { catalogue: [] })
const result = await lend.perform({ runId: 7, stepId: 4, params: PARAMS, verify: true })
assert.strictEqual(result.ok, false)
assert.notStrictEqual(result.retry, false)
assert.match(result.error, /Main has reported no permissions yet/)
})
test('a dry run that passes writes nothing', async (t) => {
const calls = stub(t)
assert.deepStrictEqual(await lend.perform({ runId: 7, stepId: 4, params: PARAMS, verify: true }), { ok: true })
assert.deepStrictEqual(await give.perform({ runId: 7, stepId: 4, params: PARAMS, verify: true }), { ok: true })
assert.deepStrictEqual([calls.inserted, calls.grants, calls.dirty], [[], [], []])
})
// ── Until the run ends (§12.4) ─────────────────────────────────────────────
test('lend writes a row per recipient per permission, the message on one row each', async (t) => {
const calls = stub(t, { people: [person(A, 10), person(B, 5), person(C, 0)], links: { [A]: 1, [B]: 2, [C]: 3 } })
const result = await lend.perform({
runId: 7, stepId: 4, idempotencyKey: 'k-1',
params: { ...PARAMS, message: 'You have {permissions} until the end', delivery: 'popup' },
})
assert.strictEqual(result.ok, true)
assert.strictEqual(result.detail.lent, 2)
assert.deepStrictEqual(result.resources, [{ kind: 'permission-lease', ref: 'main:7:4', payload: { serverId: 'main', permissions: ['nightvision.allowed', 'betterchat.color'] } }])
assert.deepStrictEqual(calls.inserted.map((r) => [r.userId, r.permission, r.kit, r.credit, r.notice || null]), [
[1, 'nightvision.allowed', '', false, 'You have nightvision.allowed, betterchat.color until the end'],
[1, 'betterchat.color', '', false, null],
[2, 'nightvision.allowed', '', false, 'You have nightvision.allowed, betterchat.color until the end'],
[2, 'betterchat.color', '', false, null],
])
assert.strictEqual(calls.inserted[0].noticeDelivery, 'popup')
assert.deepStrictEqual(calls.dirty, ['main'])
})
test('a blank message tells nobody (§12.6)', async (t) => {
const calls = stub(t, { people: [person(A, 10)], links: { [A]: 1 } })
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: { ...PARAMS, message: ' ' } })
assert.strictEqual(result.ok, true)
assert.strictEqual(calls.inserted.some((r) => r.notice), false)
assert.strictEqual('told' in result.detail, false)
})
test('a repeated key finds the rows and lends nothing new; the revert is a kit reward\'s', async (t) => {
const calls = stub(t, { existing: [{ userId: 1 }, { userId: 1 }, { userId: 2 }] })
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS })
assert.strictEqual(result.detail.repeat, true)
assert.strictEqual(result.detail.lent, 2)
assert.deepStrictEqual(calls.inserted, [])
assert.deepStrictEqual(await lend.revert({ runId: 7, resources: result.resources, idempotencyKey: 'k-1' }), { ok: true })
assert.deepStrictEqual(calls.deleted, ['7:4'])
assert.deepStrictEqual(calls.dirty, ['main'])
})
// ── For good (§12.5) ───────────────────────────────────────────────────────
test('give writes standing grants scoped to the server, source event, with the step in the note', async (t) => {
const calls = stub(t, { people: [person(A, 10), person(B, 5)], links: { [A]: 1, [B]: 2 } })
const result = await give.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS })
assert.strictEqual(result.ok, true)
assert.strictEqual(result.resources, undefined)
assert.deepStrictEqual(calls.heldAsked, [{ userIds: [1, 2], scope: 'main' }])
assert.deepStrictEqual(calls.grants, [
{ userId: 1, permission: 'nightvision.allowed', scope: 'main', source: 'event', note: 'event run 7, step 4' },
{ userId: 1, permission: 'betterchat.color', scope: 'main', source: 'event', note: 'event run 7, step 4' },
{ userId: 2, permission: 'nightvision.allowed', scope: 'main', source: 'event', note: 'event run 7, step 4' },
{ userId: 2, permission: 'betterchat.color', scope: 'main', source: 'event', note: 'event run 7, step 4' },
])
assert.strictEqual(result.detail.given, 4)
// No message: no notice row at all.
assert.deepStrictEqual(calls.inserted, [])
assert.deepStrictEqual(calls.dirty, ['main'])
})
test('a grant already there is left alone; this step\'s own is counted as given on a retry', async (t) => {
const calls = stub(t, {
people: [person(A, 10), person(B, 5)],
links: { [A]: 1, [B]: 2 },
held: [
{ userId: 1, permission: 'nightvision.allowed', source: 'admin', note: null },
{ userId: 2, permission: 'nightvision.allowed', source: 'event', note: 'event run 7, step 4' },
{ userId: 2, permission: 'betterchat.color', source: 'event', note: 'event run 7, step 4' },
],
})
const result = await give.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS })
assert.deepStrictEqual(calls.grants.map((g) => [g.userId, g.permission]), [[1, 'betterchat.color']])
assert.strictEqual(result.detail.given, 3)
assert.strictEqual(result.detail.alreadyHeld, 1)
assert.strictEqual(result.detail.repeat, true)
})
test('give\'s message rides a notice-only row the push grants nothing for', async (t) => {
const calls = stub(t, { people: [person(A, 10)], links: { [A]: 1 } })
await give.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: { ...PARAMS, message: 'You keep {permissions}' } })
assert.deepStrictEqual(calls.inserted.map((r) => [r.userId, r.permission, r.kit, r.credit, r.notice, r.noticeDelivery]), [
[1, '', '', false, 'You keep nightvision.allowed, betterchat.color', 'chat'],
])
const { payload } = model.buildDesired('main', {
groups: [], groupServers: [], groupPermissions: [], members: [], steamMembers: [],
grants: [], steamGrants: [], exceptions: [], groupChat: [],
steamIdsByUser: new Map([[1, [A]]]),
runGrants: calls.inserted.map((r) => ({ ...r, noticeToldAt: null })),
})
assert.deepStrictEqual(payload.grants, [])
assert.deepStrictEqual(payload.notices.map((n) => [n.key, n.steamId, n.message]), [['7:4:1', A, 'You keep nightvision.allowed, betterchat.color']])
})
test('a for-good grant carries its note to the permission manager; a staff grant does not grow one', () => {
const { sources } = model.buildDesired('main', {
groups: [], groupServers: [], groupPermissions: [], members: [], steamMembers: [],
grants: [
{ id: 9, userId: 1, permission: 'nightvision.allowed', scope: 'main', source: 'event', note: 'event run 7, step 4' },
{ id: 10, userId: 1, permission: 'kits.vip', scope: 'main', source: 'admin', note: null },
],
steamGrants: [], exceptions: [], groupChat: [], runGrants: [],
steamIdsByUser: new Map([[1, [A]]]),
})
assert.deepStrictEqual(sources.get(model.rowKey({ kind: 'grant', subject: A, object: 'nightvision.allowed' })), [
{ type: 'userGrant', id: 9, userId: 1, scope: 'main', origin: 'event', note: 'event run 7, step 4' },
])
assert.deepStrictEqual(sources.get(model.rowKey({ kind: 'grant', subject: A, object: 'kits.vip' })), [
{ type: 'userGrant', id: 10, userId: 1, scope: 'main' },
])
})

View File

@@ -0,0 +1,299 @@
// ── The step editor's Rust half (PLAN_REDESIGNS.md §2, protocol 14) ──────────
//
// `linked` rewards every linked player of the server, reads no tally, and is
// refused past the server's bound rather than trimmed (§2.5)
// a reward's message is checked with the kit's name in it, and a blank one
// says nothing — every step saved before protocol 14 (D333)
// the sync carries every untold message to the account the reward picked,
// and stops once it is told; the key reads back to its row
// the kit warning names how many linked players have used the kit up (§2.6)
// the kit weekend builds the plan the plan describes, and only the steps the
// kit can use (§2.5, D164)
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
require('../core')._reset()
require('../core').init(fakeCtx())
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
const permDb = require('../model/permissions/permissions.db')
const linksDb = require('../model/links/links.db')
const emit = require('../engagement/emit')
const rewards = require('../eventRewards')
const leases = require('../eventLeases')
const templates = require('../eventTemplates')
const model = require('../model/permissions/permissions.model')
const entitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle')
const ok = (data) => ({ ok: true, status: 'ok', data })
const MAIN = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
const KIT = { name: 'vip', permission: 'kits.vip', max: 2, cooldown: 86400 }
/** Swap one module's members for a test, and put them back after. */
function swap(t, mod, members) {
const saved = {}
for (const k of Object.keys(members)) saved[k] = mod[k]
Object.assign(mod, members)
t.after(() => Object.assign(mod, saved))
}
function rewardStubs(t, { linked = [], kits = [KIT], maxRecipients = 100 } = {}) {
const calls = { grants: [], tally: 0 }
swap(t, serversDb, { getServer: async (id) => (id === 'main' ? MAIN : null) })
swap(t, client, {
kits: async () => ok({ kind: 'kits.list', kits, maxRecipients }),
tallySnapshot: async () => {
calls.tally++
return ok({ kind: 'tally.snapshot', people: [], maxRecipients })
},
})
swap(t, permDb, {
insertRunGrants: async (rows) => calls.grants.push(...rows),
listRunGrantsForStep: async () => [],
markDirty: async () => {},
})
swap(t, linksDb, {
linkedOnServer: async (serverId, limit) => linked.slice(0, limit),
})
swap(t, emit, { entitled: () => 0 })
return calls
}
const envelope = (params) => ({ runId: '7', stepId: '3', idempotencyKey: 'k', params })
// ── linked ──────────────────────────────────────────────────────────────────
test('linked rewards every linked player of the server and reads no tally', async (t) => {
const calls = rewardStubs(t, {
linked: [
{ steamId: '76561190000000001', userId: 11, name: 'Ann' },
{ steamId: '76561190000000002', userId: 12, name: 'Bo' },
],
})
const answer = await entitle.perform(envelope({ kit: 'main/vip', recipients: 'linked' }))
assert.strictEqual(answer.ok, true, answer.error)
assert.strictEqual(calls.tally, 0)
assert.deepStrictEqual(calls.grants.map((r) => [r.userId, r.steamId, r.kit, r.credit]), [
[11, '76561190000000001', 'vip', true],
[12, '76561190000000002', 'vip', true],
])
// No message asked for, so none written: a step from before protocol 14.
assert.ok(calls.grants.every((r) => !('notice' in r)))
assert.strictEqual(answer.detail.mode, 'linked')
})
test('linked past the server\'s bound is refused, never trimmed', async (t) => {
const linked = Array.from({ length: 4 }, (_, i) => ({ steamId: `7656119000000000${i}`, userId: i + 1 }))
rewardStubs(t, { linked, maxRecipients: 3 })
const answer = await entitle.perform(envelope({ kit: 'main/vip', recipients: 'linked' }))
assert.strictEqual(answer.ok, false)
assert.strictEqual(answer.retry, false)
assert.match(answer.error, /more than 3 linked players/)
})
test('linked takes no count and is priced at the most a step can grant', () => {
assert.deepStrictEqual(rewards.checkCount('linked', 5), { ok: true, value: null })
assert.deepStrictEqual(entitle.cost({ recipients: 'linked' }), { 'rust.grants': rewards.MAX_RECIPIENTS })
})
// ── The reward's message (D333) ─────────────────────────────────────────────
test('a message is written per row with the kit\'s name in it, and says how it goes', async (t) => {
const calls = rewardStubs(t, { linked: [{ steamId: '76561190000000001', userId: 11 }] })
const answer = await entitle.perform(envelope({
kit: 'main/vip', recipients: 'linked', message: 'You earned kit {kit} — type /kit {kit}', delivery: 'Popup',
}))
assert.strictEqual(answer.ok, true, answer.error)
assert.strictEqual(calls.grants[0].notice, 'You earned kit vip — type /kit vip')
assert.strictEqual(calls.grants[0].noticeDelivery, 'popup')
assert.match(answer.detail.told, /1 will be told in game, by popup/)
})
test('a message is checked on the form: its length with the kit in it, and where it goes', () => {
assert.deepStrictEqual(rewards.rewardNotice({}, 'vip'), { ok: true, text: null, delivery: null })
assert.deepStrictEqual(rewards.rewardNotice({ message: ' ' }, 'vip'), { ok: true, text: null, delivery: null })
assert.strictEqual(rewards.rewardNotice({ message: 'hi' }, 'vip').delivery, 'chat')
assert.match(rewards.rewardNotice({ message: 'hi', delivery: 'shout' }, 'vip').error, /not to "shout"/)
// 250 characters fit; with a long kit name in place of {kit} they do not.
const text = `${'x'.repeat(245)}{kit}`
assert.strictEqual(rewards.rewardNotice({ message: text }, 'vip').ok, true)
assert.match(rewards.rewardNotice({ message: text }, 'a-very-long-kit-name').error, /at most 256 characters/)
})
// ── The sync's notices ──────────────────────────────────────────────────────
const authored = (runGrants) => ({
groups: [],
groupServers: [],
groupPermissions: [],
members: [],
steamMembers: [],
grants: [],
steamGrants: [],
exceptions: [],
groupChat: [],
steamIdsByUser: new Map([[11, ['76561190000000001']], [12, ['76561190000000002']]]),
runGrants,
})
const grantRow = (extra) => ({
runId: '7', stepId: '3', userId: 11, serverId: 'main', steamId: '76561190000000001',
permission: 'kits.vip', kit: 'vip', credit: 1, notice: null, noticeDelivery: null, noticeToldAt: null, ...extra,
})
test('the sync carries each untold message to the account the reward picked', () => {
const desired = model.buildDesired('main', authored([
grantRow({ notice: 'You earned kit vip', noticeDelivery: 'popup' }),
// Told already: it leaves the set, which is what lets the plugin forget it.
grantRow({ userId: 12, steamId: '76561190000000002', notice: 'x', noticeToldAt: new Date() }),
// No message: a reward that says nothing.
grantRow({ stepId: '4' }),
// Another server's reward is that server's to say.
grantRow({ serverId: 'alt', stepId: '5', notice: 'elsewhere' }),
// Unlinked since: nobody to tell.
grantRow({ stepId: '6', steamId: '76561190000000099', notice: 'gone' }),
]))
assert.deepStrictEqual(desired.payload.notices, [
{ key: '7:3:11', steamId: '76561190000000001', message: 'You earned kit vip', delivery: 'popup' },
])
})
test('a notice moves the digest, so a new one is pushed and a told one is dropped', () => {
const none = model.buildDesired('main', authored([grantRow({})]))
const one = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeDelivery: 'chat' })]))
const told = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeToldAt: new Date() })]))
assert.deepStrictEqual(none.payload.notices, [])
assert.notStrictEqual(none.hash, one.hash)
assert.strictEqual(none.hash, told.hash)
})
test('a told key reads back to its row; a malformed one changes nothing', async (t) => {
const core = require('../core')
const queries = []
swap(t, core, {
query: async (sql, params) => {
queries.push(params)
return { affectedRows: 1 }
},
})
const marked = await permDb.markNoticesTold('main', ['7:3:11', 'nonsense', '7:3:x'])
assert.strictEqual(marked, 1)
assert.deepStrictEqual(queries, [['main', '7', '3', 11]])
})
// ── The kit warning (§2.6) ──────────────────────────────────────────────────
test('the note says the limit, the cooldown, and how many have used the kit up', () => {
assert.strictEqual(
leases.kitNote(KIT, { asked: 41, exhausted: 9, onCooldown: 3 }),
'Kit vip: 2 uses, 24 h cooldown — 9 of 41 linked players have used it up, 3 are on its cooldown. A kit weekend\'s extra use is what lets them take it again.',
)
assert.match(leases.kitNote({ name: 'free', max: 0, cooldown: 0 }, { asked: 5 }), /^Kit free: no use limit — 5 linked players\./)
assert.match(leases.kitNote(KIT, { asked: 0 }), /no linked player has played on this server yet/)
})
test('the group-permission source notes the rows that grant a kit\'s permission', async (t) => {
leases.kitNoteCache.clear()
t.after(() => leases.kitNoteCache.clear())
const asked = []
swap(t, servers, { listForPolling: async () => [{ id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' }] })
swap(t, client, {
permCatalogue: async () => ok({ kind: 'perm.catalogue', permissions: ['kits.vip', 'other.thing'], groups: [{ name: 'default' }] }),
kits: async () => ok({ kind: 'kits.list', kits: [KIT, { name: 'open', permission: '', max: 1 }] }),
kitsUses: async (server, kit, steamIds) => {
asked.push({ kit, steamIds })
return ok({ kind: 'kits.uses', kit, max: 2, cooldown: 86400, asked: steamIds.length, exhausted: 1, onCooldown: 0 })
},
})
swap(t, linksDb, { linkedOnServer: async () => [{ steamId: '76561190000000001' }, { steamId: '76561190000000002' }] })
const source = leases.OPTION_SOURCES.find((s) => s.id === 'rust.options.grouppermissions')
const rows = await source.resolve({})
const vip = rows.find((r) => r.value === 'main/default/kits.vip')
const other = rows.find((r) => r.value === 'main/default/other.thing')
assert.match(vip.note, /^Kit vip: 2 uses, 24 h cooldown — 1 of 2 linked players have used it up\./)
assert.strictEqual('note' in other, false)
// Only the kit a permission gates is asked about, and with the server's linked players.
assert.deepStrictEqual(asked, [{ kit: 'vip', steamIds: ['76561190000000001', '76561190000000002'] }])
// Asked again within the minute: answered from the cache.
await source.resolve({})
assert.strictEqual(asked.length, 1)
})
test('a kit warning that cannot be read is no note, never a broken field', async (t) => {
leases.kitNoteCache.clear()
t.after(() => leases.kitNoteCache.clear())
swap(t, client, {
kits: async () => ok({ kind: 'kits.list', kits: [KIT] }),
kitsUses: async () => ({ ok: false, status: 'timeout', data: null }),
})
swap(t, linksDb, { linkedOnServer: async () => [] })
const notes = await leases.kitNotes({ id: 'main' })
assert.strictEqual(notes.size, 0)
})
// ── The kit weekend (§2.5) ──────────────────────────────────────────────────
const weekend = templates.TEMPLATES.find((x) => x.id === 'rust.template.kit_weekend')
function weekendStubs(t, kits = [KIT]) {
swap(t, serversDb, { getServer: async (id) => (id === 'main' ? MAIN : null) })
swap(t, client, { kits: async () => ok({ kind: 'kits.list', kits, maxRecipients: 100 }) })
}
test('the kit weekend is one phase as long as the weekend, holding both steps', async (t) => {
weekendStubs(t)
const draft = await weekend.build({ inputs: { kit: 'main/vip', minutes: 2880, message: 'Kit weekend! {kit}', delivery: 'popup' } })
assert.strictEqual(draft.title, 'Kit weekend: vip on Main')
assert.strictEqual(draft.summary, 'For 48 hours, everybody on Main may redeem vip, and every linked player gets one more use of it.')
const [phase] = draft.spec.phases
assert.deepStrictEqual(phase.advance, { after: '2880m' })
assert.deepStrictEqual(phase.steps.map((s) => s.actionId), ['core.lease', 'rust.kit.entitle'])
assert.deepStrictEqual(phase.steps[0].params, {
lease: 'rust.group.permission', target: 'main/default/kits.vip', value: 'true', minutes: 2880,
})
assert.deepStrictEqual(phase.steps[1].params, {
kit: 'main/vip', recipients: 'linked', message: 'Kit weekend! {kit}', delivery: 'popup',
})
})
test('a kit with no permission is step 2 alone; one with no use limit is step 1 alone', async (t) => {
weekendStubs(t, [{ name: 'open', permission: '', max: 1 }, { name: 'free', permission: 'kits.free', max: 0 }])
const open = await weekend.build({ inputs: { kit: 'main/open', minutes: 60 } })
assert.deepStrictEqual(open.spec.phases[0].steps.map((s) => s.actionId), ['rust.kit.entitle'])
assert.ok(!('message' in open.spec.phases[0].steps[0].params))
const free = await weekend.build({ inputs: { kit: 'main/free', minutes: 60 } })
assert.deepStrictEqual(free.spec.phases[0].steps.map((s) => s.actionId), ['core.lease'])
})
test('the kit weekend refuses what its steps would refuse, in the author\'s words', async (t) => {
weekendStubs(t)
assert.match((await weekend.build({ inputs: { kit: 'vip', minutes: 60 } })).error, /as server\/kit/)
assert.match((await weekend.build({ inputs: { kit: 'main/vip', minutes: 20000 } })).error, /1 to 10080 minutes/)
assert.match((await weekend.build({ inputs: { kit: 'main/vip', minutes: 60, delivery: 'shout', message: 'x' } })).error, /not to "shout"/)
assert.match((await weekend.build({ inputs: { kit: 'gone/vip', minutes: 60 } })).error, /gone/)
assert.match((await weekend.build({ inputs: { kit: 'main/nope', minutes: 60 } })).error, /no kit called "nope"/)
})
test('the template\'s inputs are params core will accept, and its minutes are a duration', () => {
for (const p of weekend.inputs) {
assert.ok(p.example !== undefined && p.example !== '', `${p.name} needs an example`)
}
assert.strictEqual(weekend.inputs.find((p) => p.name === 'minutes').duration, 'minutes')
assert.ok(/^rust\.[a-z0-9_]+\.[a-z0-9_]+$/.test(weekend.id))
})
test('the module\'s timed params say they are durations', () => {
const zone = require('../eventWorld').ACTIONS.find((a) => a.id === 'rust.zone.open')
const tally = rewards.ACTIONS.find((a) => a.id === 'rust.participation.open')
assert.strictEqual(zone.params.find((p) => p.name === 'minutes').duration, 'minutes')
assert.strictEqual(tally.params.find((p) => p.name === 'minutes').duration, 'minutes')
const groupLease = leases.LEASES.find((l) => l.id === 'rust.group.permission')
assert.strictEqual(groupLease.example, true)
})

View File

@@ -47,8 +47,13 @@ const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...
// ── The categories ────────────────────────────────────────────────────────
test('twenty-three categories carry a default title, and playtime carries none (D172)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 23)
test('twenty-eight categories carry a default title, and playtime carries none (D172, §11.2.2, D356, D375)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 28)
assert.strictEqual(titles.STATS.guardedwon.title, 'Warden')
assert.strictEqual(titles.STATS.treasureswon.title, 'Treasure Hunter')
assert.strictEqual(titles.STATS.richest.title, 'Magnate')
assert.strictEqual(titles.STATS.crateshacked.title, 'Safecracker')
assert.strictEqual(titles.STATS.dropslooted.title, 'Scavenger')
assert.ok(!titles.CATEGORY_IDS.includes('playtime'))
assert.strictEqual(titles.STATS.playtime.title, null)
assert.strictEqual(titles.STATS.animalkills.title, 'Beastbane')

View File

@@ -0,0 +1,395 @@
// ── Rust's own world events (PLAN_REDESIGNS §14.3.1, protocol 14) ──────────
//
// Each test is one way the feature could look right and be wrong:
//
// the catalogue drifts from the protocol's 24 types, and a new one is public
// the four credit rules (D372) disagree with their own table — or with the
// count, so the line says Alice and Rotorbane counts Bob
// a name reaches a viewer below the presence audience (D376), a Steam id or a
// position reaches anybody but staff (D353)
// a grid is clamped onto the edge of the map for something off it
// the tally's two new counts are dropped on the floor
// a trigger declares a variable its emitter never fills, or names a Steam id
// the call step takes a position for an event that has no route to one (D378)
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/** Installs a core whose `db.query` records every statement and answers `rows`. */
function withRecorder(rows = []) {
const statements = []
const ctx = fakeCtx({
db: {
query: (sql, params = []) => {
statements.push({ sql, params })
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return { ctx, statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) }
}
withRecorder()
const worldEvents = require('../worldEvents')
const catalogue = require('../catalogue')
const { TRIGGERS } = require('../engagement/triggers')
const A = '76561198000000001'
const B = '76561198000000002'
const C = '76561198000000003'
const NOW = Date.now()
/** A 6000 map of the game's own 41 cells of 146.3 m — the rigs' geometry. */
const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 }
const world = (event, over = {}) => ({ kind: 'world.event', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', event, ...over })
/** Bob did 61 %, Alice the crippling hit — D372's own example. */
const downed = (over = {}) => world('heli.downed', {
x: 812,
z: -340.5,
credit: { blow: A, last: B, most: B, damagers: [{ steamId: B, damage: 6120 }, { steamId: A, damage: 3880 }] },
names: { [A]: 'Alice', [B]: 'Bob' },
...over,
})
// ── The catalogue ──────────────────────────────────────────────────────────
test('the types are exactly the 24 the protocol defines, and an unknown one is staff only', () => {
// Sourced from docs/rust-link/PROTOCOL.md §20.5 (PLAN_REDESIGNS §14.3.1, D379).
const PROTOCOL_14 = [
'heli.arrived', 'heli.left', 'heli.downed',
'bradley.spawned', 'bradley.destroyed',
'cargo.arrived', 'cargo.docking', 'cargo.docked', 'cargo.undocked', 'cargo.left',
'chinook.arrived', 'chinook.dropped', 'chinook.downed',
'airdrop.called', 'airdrop.landed', 'airdrop.looted',
'crate.hack.started', 'crate.unlocked', 'crate.looted',
'vendor.arrived', 'vendor.left',
'satellite.crashed',
'deepsea.opened', 'deepsea.closed',
]
assert.deepStrictEqual([...worldEvents.TYPE_IDS].sort(), [...PROTOCOL_14].sort())
assert.ok(catalogue.isPublic('world.event'))
// Default deny, per type: a newer plugin's type is not served to the public.
const future = world('meteor.landed', { x: 1, z: 1, steamId: A, name: 'Alice' })
assert.strictEqual(worldEvents.shapeFor(future, { presence: true }), null)
assert.strictEqual(worldEvents.shapeFor(future, { staff: true }).event, 'meteor.landed')
})
// ── Credit (D372, D379) ────────────────────────────────────────────────────
test('each of the four credit rules picks what its table says', () => {
assert.deepStrictEqual(worldEvents.credited(downed(), 'blow'), [A])
assert.deepStrictEqual(worldEvents.credited(downed(), 'last'), [B])
assert.deepStrictEqual(worldEvents.credited(downed(), 'most'), [B])
assert.deepStrictEqual(worldEvents.credited(downed(), 'all'), [B, A])
// An unknown rule is the default, the killing blow.
assert.deepStrictEqual(worldEvents.credited(downed(), 'loudest'), [A])
})
test('with no killing blow, the killing-blow rule falls back to the last hit', () => {
const noBlow = downed({ credit: { last: B, most: A, damagers: [{ steamId: A, damage: 9 }, { steamId: B, damage: 1 }] } })
assert.deepStrictEqual(worldEvents.credited(noBlow, 'blow'), [B])
assert.deepStrictEqual(worldEvents.credited(noBlow, 'most'), [A])
})
test('a death nobody touched credits nobody under any rule, and counts nothing', () => {
const nobody = world('bradley.destroyed', { where: 'road', x: 0, z: 0, credit: { damagers: [] } })
for (const rule of worldEvents.CREDIT_RULE_IDS) {
assert.deepStrictEqual(worldEvents.credited(nobody, rule), [], rule)
assert.deepStrictEqual(worldEvents.killsOf(nobody, rule), [], rule)
}
})
test('the count and the line credit the same players, under every rule', () => {
for (const rule of worldEvents.CREDIT_RULE_IDS) {
const line = worldEvents.shapeFor(downed(), { presence: true, rule }).credited.map((c) => c.name)
const count = worldEvents.killsOf(downed(), rule).map((k) => k.name)
assert.deepStrictEqual(line, count, rule)
}
// Each kill is the column the type counts toward: Rotorbane for both helicopters, Tankbreaker for Bradley.
assert.strictEqual(worldEvents.killsOf(downed(), 'blow')[0].field, 'heliKills')
assert.strictEqual(worldEvents.killsOf(world('chinook.downed', { credit: { blow: A } }), 'blow')[0].field, 'heliKills')
assert.strictEqual(worldEvents.killsOf(world('bradley.destroyed', { where: 'launch_site', credit: { blow: A } }), 'blow')[0].field, 'apcKills')
// Nothing else counts.
assert.deepStrictEqual(worldEvents.killsOf(world('heli.arrived'), 'blow'), [])
})
test('a junk id in a credit block is never credited', () => {
const junk = downed({ credit: { blow: 'not-a-steam-id', last: '123', damagers: [{ steamId: '{}', damage: 1 }] } })
for (const rule of worldEvents.CREDIT_RULE_IDS) assert.deepStrictEqual(worldEvents.credited(junk, rule), [], rule)
})
// ── Who may read what (D353, D376) ─────────────────────────────────────────
test('below the presence audience a line names nobody, and carries no position', () => {
const signedOut = worldEvents.shapeFor(downed(), { presence: false, rule: 'blow', geometry: GEOMETRY })
assert.strictEqual(signedOut.event, 'heli.downed')
assert.strictEqual(signedOut.grid, worldEvents.gridOf(GEOMETRY, 812, -340.5))
assert.strictEqual(signedOut.creditedCount, 1)
assert.strictEqual(signedOut.credited, undefined)
for (const field of ['x', 'z', 'credit', 'names', 'steamId', 'name']) assert.strictEqual(signedOut[field], undefined, field)
const hack = worldEvents.shapeFor(world('crate.hack.started', { where: 'oilrig_large', steamId: A, name: 'Alice', x: 1, z: 1 }), { presence: false })
assert.strictEqual(hack.name, undefined)
assert.strictEqual(hack.steamId, undefined)
assert.strictEqual(hack.where, 'oilrig_large')
})
test('the presence audience reads names but no Steam id; staff read both, and the position', () => {
const member = worldEvents.shapeFor(downed(), { presence: true, rule: 'all' })
assert.deepStrictEqual(member.credited, [{ name: 'Bob' }, { name: 'Alice' }])
assert.strictEqual(member.x, undefined)
const staff = worldEvents.shapeFor(downed(), { staff: true, presence: true, rule: 'all' })
assert.deepStrictEqual(staff.credited, [{ name: 'Bob', steamId: B }, { name: 'Alice', steamId: A }])
assert.strictEqual(staff.x, 812)
assert.ok(staff.credit, 'staff see the whole credit block')
const loot = world('airdrop.looted', { steamId: A, name: 'Alice', x: 5, z: 5 })
assert.deepStrictEqual(
[worldEvents.shapeFor(loot, { presence: true }).name, worldEvents.shapeFor(loot, { presence: true }).steamId],
['Alice', undefined],
)
assert.strictEqual(worldEvents.shapeFor(loot, { staff: true, presence: true }).steamId, A)
})
test('a line with nobody in it reads the same for everybody', () => {
const docking = world('cargo.docking', { harbour: 'harbor_1', harbourName: 'Harbor', x: 100, z: 100 })
const a = worldEvents.shapeFor(docking, { presence: false, geometry: GEOMETRY })
const b = worldEvents.shapeFor(docking, { presence: true, geometry: GEOMETRY })
assert.deepStrictEqual(a, b)
assert.strictEqual(a.harbourName, 'Harbor')
})
// ── The grid ───────────────────────────────────────────────────────────────
test('the grid is the game’s, and nothing off the map gets one', () => {
// The north-west corner is A0; the 41st column is AO.
assert.strictEqual(worldEvents.gridOf(GEOMETRY, -2999, 2999), 'A0')
assert.strictEqual(worldEvents.gridOf(GEOMETRY, 2999, -2999), 'AO40')
// The Deep Sea and its ghost ships sit thousands of metres off the map.
assert.strictEqual(worldEvents.gridOf(GEOMETRY, -5200, 0), null)
// No geometry yet, or no position: no grid, rather than a guess.
assert.strictEqual(worldEvents.gridOf(null, 0, 0), null)
assert.strictEqual(worldEvents.gridOf(GEOMETRY, undefined, 0), null)
})
// ── Ingest ─────────────────────────────────────────────────────────────────
test('a downed helicopter counts toward Rotorbane for whom the server’s rule credits', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', { id: 1, kind: 'world.event', frame: downed() }, { id: 'main', creditRule: 'all' })
const adds = rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('heli_kills'))
assert.strictEqual(adds.length, 2)
assert.deepStrictEqual(adds.map((s) => s.params[2]).sort(), [A, B].sort())
// Under the default rule, only the killing blow.
const rec2 = withRecorder()
await apply('main', { id: 2, kind: 'world.event', frame: downed() }, { id: 'main' })
const once = rec2.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('heli_kills'))
assert.strictEqual(once.length, 1)
assert.strictEqual(once[0].params[2], A)
})
test('a Bradley counts toward Tankbreaker, and an arrival counts nothing', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', { id: 1, kind: 'world.event', frame: world('bradley.destroyed', { where: 'launch_site', credit: { blow: C }, names: { [C]: 'Cleo' } }) }, { id: 'main' })
const apc = rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('apc_kills'))
assert.strictEqual(apc.length, 1)
assert.strictEqual(apc[0].params[2], C)
const rec2 = withRecorder()
await apply('main', { id: 2, kind: 'world.event', frame: world('heli.arrived', { x: 1, z: 1 }) }, { id: 'main' })
assert.strictEqual(rec2.touching('rust_player_wipe_stats').length, 0)
})
test('the tally’s crates hacked and drops looted are added up (D356)', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', { id: 1, kind: 'player.tally', frame: { kind: 'player.tally', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', steamId: A, name: 'Alice', cratesHacked: 2, dropsLooted: 1 } })
const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('crates_hacked'))
assert.ok(titleAdd, 'the tally reached the title columns')
assert.ok(titleAdd.sql.includes('drops_looted'))
const cols = titleAdd.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+)\)/)[1].split(', ')
assert.strictEqual(titleAdd.params[3 + cols.indexOf('crates_hacked')], 2)
assert.strictEqual(titleAdd.params[3 + cols.indexOf('drops_looted')], 1)
})
// ── The feed ───────────────────────────────────────────────────────────────
test('the feed reads each world event for its viewer, with the server’s rule and grid', async () => {
const stored = downed()
withRecorder((sql) => {
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'world.event', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stored) }]
if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', creditRule: 'most', enabled: 1 }]
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
return []
})
const events = require('../model/events/events.model')
const [signedOut] = await events.recent({ serverId: 'main', presence: false })
assert.strictEqual(signedOut.frame.creditRule, 'most')
assert.strictEqual(signedOut.frame.credited, undefined)
assert.strictEqual(signedOut.frame.grid, worldEvents.gridOf(GEOMETRY, 812, -340.5))
const [member] = await events.recent({ serverId: 'main', presence: true })
assert.deepStrictEqual(member.frame.credited, [{ name: 'Bob' }])
})
// ── Triggers (D355) ────────────────────────────────────────────────────────
test('one public trigger per type, none of them declaring a Steam id', () => {
const world = TRIGGERS.filter((t) => t.id.startsWith('rust.world.'))
assert.deepStrictEqual(world.map((t) => t.id.slice('rust.world.'.length)).sort(), [...worldEvents.TYPE_IDS].sort())
for (const t of world) {
assert.strictEqual(t.ceiling, 'everyone', t.id)
assert.strictEqual(t.subjectKey, 'subject', t.id)
assert.ok(t.variables.some((v) => v.name === 'subject'), t.id)
assert.ok(!t.variables.some((v) => /steam/i.test(v.name)), `${t.id} declares a Steam id`)
}
})
test('a world event fires its trigger with the variables it declares, and a headline', async (t) => {
const { ctx } = withRecorder((sql) => (sql.includes('FROM rust_map_images') ? [{ serverId: 'main', ...GEOMETRY }] : []))
const emit = require('../engagement/emit')
emit.reset()
const server = { id: 'main', name: 'Main', creditRule: 'all' }
await emit.onEvent(server, { id: 1, kind: 'world.event', frame: downed() })
await emit.onEvent(server, { id: 2, kind: 'world.event', frame: world('crate.hack.started', { where: 'oilrig_small', steamId: A, name: 'Alice', x: 1, z: 1 }) })
await emit.onEvent(server, { id: 3, kind: 'world.event', frame: world('meteor.landed') })
const calls = ctx.events.emit.calls
assert.strictEqual(calls.length, 2, 'an unknown type fires nothing')
const [heliId, heli] = calls[0]
assert.strictEqual(heliId, 'rust.world.heli.downed')
assert.strictEqual(heli.data.player, 'Bob and Alice')
assert.strictEqual(heli.data.credited, 2)
assert.strictEqual(heli.data.subject, 'main:heli.downed')
assert.ok(heli.data.grid)
assert.match(heli.data.intro, /downed by Bob and Alice at /)
const [crateId, crate] = calls[1]
assert.strictEqual(crateId, 'rust.world.crate.hack.started')
assert.strictEqual(crate.data.subject, 'main:crate.hack.started:oilrig_small')
assert.match(crate.data.intro, /Alice started hacking the locked crate on Small Oil Rig on Main/)
// Every variable a trigger fills is one it declares.
for (const [id, envelope] of calls) {
const declared = new Set(TRIGGERS.find((x) => x.id === id).variables.map((v) => v.name))
for (const name of Object.keys(envelope.data)) assert.ok(declared.has(name), `${id} fills ${name}, which it does not declare`)
}
})
test('an old world event tells nobody (D63)', async () => {
const { ctx } = withRecorder()
const emit = require('../engagement/emit')
emit.reset()
await emit.onEvent({ id: 'main', name: 'Main' }, { id: 1, kind: 'world.event', frame: world('heli.arrived', { t: NOW - 3 * 3600 * 1000 }) })
assert.strictEqual(ctx.events.emit.calls.length, 0)
})
// ── The call step (D354, D367, D378) ───────────────────────────────────────
function stubCall(t, reply) {
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const saved = { worldCall: client.worldCall, getServer: serversDb.getServer }
const calls = []
serversDb.getServer = async (id) => (id === 'main' ? { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' } : null)
client.worldCall = async (server, body) => {
calls.push({ server: server.id, body })
return typeof reply === 'function' ? reply(body) : reply
}
t.after(() => {
client.worldCall = saved.worldCall
serversDb.getServer = saved.getServer
})
return calls
}
const callStep = () => require('../eventWorldCall').ACTIONS[0]
const ok = (data) => ({ ok: true, status: 'ok', data })
test('the call step is kept, priced per event, and mirrors the plugin’s list', () => {
const step = callStep()
assert.strictEqual(step.id, 'rust.world.call')
assert.strictEqual(step.reversible, 'none')
assert.strictEqual(step.risk, 'change')
assert.strictEqual(step.revert, undefined)
assert.deepStrictEqual(step.cost({ event: 'airdrop', count: 3 }), { 'rust.world.calls': 3 })
assert.deepStrictEqual(step.cost({ event: 'cargo' }), { 'rust.world.calls': 1 })
const { EVENTS } = require('../eventWorldCall')
assert.deepStrictEqual(EVENTS.map((e) => e.value).sort(), ['airdrop', 'bradley_road', 'cargo', 'chinook', 'f15', 'patrol_heli', 'satellite', 'travelling_vendor'])
assert.deepStrictEqual(EVENTS.filter((e) => e.placeable).map((e) => e.value).sort(), ['airdrop', 'patrol_heli'])
})
test('a call is refused in the dry run for a bad event, a bad count, a position it cannot take, or no server', async (t) => {
const calls = stubCall(t, ok({ kind: 'world.called', called: 1 }))
const step = callStep()
const run = (params) => step.perform({ runId: 7, idempotencyKey: '7:1', params, verify: true })
assert.match((await run({ server: 'main', event: 'meteor' })).error, /not one of the world events/)
assert.match((await run({ server: 'main', event: 'cargo', count: 2 })).error, /call 1 to 1/)
assert.match((await run({ server: 'main', event: 'airdrop', count: 6 })).error, /call 1 to 5/)
// D378: the Chinook goes where the game chooses.
assert.match((await run({ server: 'main', event: 'chinook', x: 10, z: 10 })).error, /only an airdrop and a patrol helicopter take a position/)
assert.match((await run({ event: 'airdrop' })).error, /which server/)
assert.ok((await run({ server: 'main', event: 'airdrop', x: 10, z: 10 })).ok)
for (const r of [await run({ server: 'main', event: 'meteor' })]) assert.strictEqual(r.retry, false)
assert.strictEqual(calls.length, 0, 'the dry run calls nothing')
})
test('a call sends the key, the run and the position, and reads the plugin’s answer', async (t) => {
const calls = stubCall(t, (body) => ok({ kind: 'world.called', key: body.key, event: body.event, called: body.count, repeat: false }))
const step = callStep()
const result = await step.perform({ runId: 7, idempotencyKey: '7:2', params: { event: 'airdrop', count: 2, monument: 'main/powerplant_1', offsetX: 10, y: 99 } })
assert.ok(result.ok)
assert.deepStrictEqual(result.detail, { event: 'airdrop', called: 2 })
assert.deepStrictEqual(calls[0], {
server: 'main',
body: { runId: '7', key: '7:2', event: 'airdrop', count: 2, monument: 'powerplant_1', offsetX: 10, offsetZ: 0 },
})
})
test('a refusal the plugin would repeat is not retried; a game that is down is', async (t) => {
stubCall(t, (body) => (body.event === 'cargo'
? ok({ kind: 'world.error', reason: 'too-many', message: 'a run calls at most 10 world events on this server' })
: { ok: false, status: 'http-503' }))
const step = callStep()
const refused = await step.perform({ runId: 7, idempotencyKey: '7:3', params: { server: 'main', event: 'cargo' } })
assert.strictEqual(refused.ok, false)
assert.strictEqual(refused.retry, false)
assert.match(refused.error, /at most 10/)
const down = await step.perform({ runId: 7, idempotencyKey: '7:4', params: { server: 'main', event: 'airdrop' } })
assert.strictEqual(down.ok, false)
assert.strictEqual(down.retry, undefined)
assert.match(down.error, /has no game connected/)
})
test('a repeat of a key says so and calls nothing new', async (t) => {
stubCall(t, ok({ kind: 'world.called', called: 1, repeat: true }))
const result = await callStep().perform({ runId: 7, idempotencyKey: '7:5', params: { server: 'main', event: 'patrol_heli' } })
assert.ok(result.ok)
assert.strictEqual(result.detail.repeat, true)
})

View File

@@ -12,6 +12,9 @@
// the step's dropdown VALUE is the line itself, so picking copies it (D210)
// the zone crosses with its flags, settings, messages and dome
// a dome on a server without ZoneDomes or its helper is refused with the reason
// TruePVE's rules cross as exclude or one ruleset, never both, and never to a
// server that said it has no TruePVE (§14.3.2, D368); a ruleset TruePVE does
// not have fails for good, with the plugin's words
const test = require('node:test')
const assert = require('node:assert')
@@ -34,13 +37,14 @@ const source = (id) => world.OPTION_SOURCES.find((s) => s.id === id)
const FLAGS = ['AutoLights', 'NoBuild', 'NoPlayerLoot', 'PvpGod', 'NoChat', 'SomethingNew']
function server(id, { flags = FLAGS, domes = null } = {}) {
function server(id, { flags = FLAGS, domes = null, truePve = null } = {}) {
return {
id,
name: id.toUpperCase(),
enabled: true,
zoneManager: flags ? { version: '3.1.14', flags } : null,
zoneDomes: domes,
truePve,
}
}
@@ -287,6 +291,84 @@ test('a dry run checks the options and the dome and sends nothing', async (t) =>
assert.strictEqual(calls.length, 0)
})
// ── TruePVE in the zone (§14.3.2, D368) ────────────────────────────────────
const TRUEPVE = { loaded: true, version: '2.4.6' }
const zoneRun = (params, extra = {}) =>
action('rust.zone.open').perform({ runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 5, ...params }, ...extra })
test('no PvP protection crosses as exclude, and a ruleset by its name, trimmed', async (t) => {
for (const [params, wire] of [
[{ noPvpProtection: true }, { exclude: true }],
[{ noPvpProtection: 'true' }, { exclude: true }],
[{ pvpRuleset: ' pvp-arena ' }, { ruleset: 'pvp-arena' }],
]) {
await t.test(JSON.stringify(params), async (tt) => {
stubZones(tt, { servers: [server('main', { truePve: TRUEPVE })] })
const calls = stubWorld(tt)
const result = await zoneRun(params)
assert.strictEqual(result.ok, true, result.error)
assert.deepStrictEqual(calls[0].body.truePve, wire)
})
}
})
test('left unset, switched off or blank, a zone sends no truePve at all', async (t) => {
stubZones(t, { servers: [server('main', { truePve: { loaded: false } })] })
const calls = stubWorld(t)
for (const params of [{}, { noPvpProtection: false, pvpRuleset: '' }, { noPvpProtection: null, pvpRuleset: ' ' }]) {
const result = await zoneRun(params)
assert.strictEqual(result.ok, true, result.error)
}
assert.ok(calls.every((c) => !('truePve' in c.body)))
})
test('both, a long name, or a server without TruePVE is refused for good, at verify too', async (t) => {
for (const [truePve, params, words] of [
[TRUEPVE, { noPvpProtection: true, pvpRuleset: 'pvp-arena' }, /not both/],
[TRUEPVE, { pvpRuleset: 'x'.repeat(65) }, /64 characters/],
[{ loaded: false }, { noPvpProtection: true }, /TruePVE is not installed on this server/],
[{ loaded: false }, { pvpRuleset: 'pvp-arena' }, /TruePVE is not installed on this server/],
]) {
for (const verify of [true, false]) {
await t.test(`${words} verify=${verify}`, async (tt) => {
stubZones(tt, { servers: [server('main', { truePve })] })
const calls = stubWorld(tt)
const result = await zoneRun(params, { verify })
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, false)
assert.match(result.error, words)
assert.strictEqual(calls.length, 0)
})
}
}
})
test('a server that has not said whether it has TruePVE is left to the plugin', async (t) => {
stubZones(t, { servers: [server('main', { truePve: null })] })
const calls = stubWorld(t)
const result = await zoneRun({ noPvpProtection: true })
assert.strictEqual(result.ok, true, result.error)
assert.deepStrictEqual(calls[0].body.truePve, { exclude: true })
})
test('a ruleset TruePVE does not have, or no TruePVE, fails for good with the plugin’s words', async (t) => {
for (const [reason, message] of [
['truepve-ruleset', "TruePVE on this server has no ruleset named 'pvp-arna' (names are case-sensitive), so the zone was not opened"],
['truepve-missing', 'TruePVE is not loaded on this server, so a zone cannot change its PvP rules'],
]) {
await t.test(reason, async (tt) => {
stubZones(tt, { servers: [server('main', { truePve: TRUEPVE })] })
stubWorld(tt)
client.worldZone = async () => ({ ok: true, status: 'ok', data: { kind: 'world.error', reason, message } })
const result = await zoneRun({ pvpRuleset: 'pvp-arna' })
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, false)
assert.strictEqual(result.error, message)
})
}
})
test('the dome dropdown offers ZoneDomes’ five colours', async () => {
assert.deepStrictEqual((await source('rust.options.domes').resolve()).map((r) => r.value), ['standard', 'red', 'blue', 'green', 'purple'])
})

278
server/worldEvents.js Normal file
View File

@@ -0,0 +1,278 @@
// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ──────────
//
// The patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked
// crates, the Travelling Vendor, the satellite and the Deep Sea reach this
// module as ONE frame kind, `world.event`, whose `event` says which (D349,
// D353). This file is the one place that knows the 24 of them, and the three
// readers — the feed, the ingest and the engagement triggers — all ask it.
//
// ── Why `event` and not `type` ─────────────────────────────────────────────
//
// The plan's example put the world event's type in `type`, which is the
// envelope's field: the sidecar files a frame on it (`event`, `snapshot`,
// `reply`), and it dropped every world event as untyped on the rig. Protocol
// 13's `world.expired` hit the same collision and settled on its own field; so
// does this one (stage 1, 2026-10-15).
//
// ── Who gets the credit (D372, D379) ───────────────────────────────────────
//
// For a patrol helicopter, a Chinook and a Bradley the plugin sends every
// candidate — the killing blow, the last hit, the most damage, everyone who
// hurt it — and decides nothing. The server's rule, chosen by its operator,
// picks from them HERE, for the feed line and for Rotorbane / Tankbreaker
// alike, so the two can never disagree. A rule changed applies from then on:
// the counts already written stay as they were.
//
// ── Who may read what (D353, D376) ─────────────────────────────────────────
//
// The event and its grid are public. A player's name shows only to the
// operator's presence audience — with presence left at staff, a signed-out
// visitor reads "Patrol helicopter downed at G8" — and a Steam id only to staff.
// A position never leaves this module: the map has its own audience for where
// things are (D111), and the feed must not be a way round it. An event type
// this build does not know is staff's alone (default deny, as `catalogue.js`).
/**
* Every type the protocol defines (PROTOCOL.md §20.5), and what each carries.
*
* `player` names one player (`steamId`, `name`)
* `credit` carries a credit block, and which tally column it counts toward
*/
const TYPES = Object.freeze({
'heli.arrived': {},
'heli.left': {},
'heli.downed': { credit: 'heliKills' },
'bradley.spawned': {},
'bradley.destroyed': { credit: 'apcKills' },
'cargo.arrived': {},
'cargo.docking': {},
'cargo.docked': {},
'cargo.undocked': {},
'cargo.left': {},
'chinook.arrived': {},
'chinook.dropped': {},
// D379: a Chinook shot down counted toward Rotorbane under D174, and still does.
'chinook.downed': { credit: 'heliKills' },
'airdrop.called': { player: true },
'airdrop.landed': { player: true },
'airdrop.looted': { player: true },
'crate.hack.started': { player: true },
'crate.unlocked': { player: true },
'crate.looted': { player: true },
'vendor.arrived': {},
'vendor.left': {},
'satellite.crashed': {},
'deepsea.opened': {},
'deepsea.closed': {},
})
const TYPE_IDS = Object.freeze(Object.keys(TYPES))
/** The operator's credit rules (D372), in the order the form offers them. `blow` is the default. */
const CREDIT_RULES = Object.freeze([
{ id: 'blow', label: 'Killing blow', description: 'The player whose hit destroyed it; for the patrol helicopter, the crippling hit, else the last hit.' },
{ id: 'last', label: 'Last hit', description: 'The last player to hurt it.' },
{ id: 'most', label: 'Most damage', description: 'The player who dealt it the most damage.' },
{ id: 'all', label: 'Everyone who hurt it', description: 'Every player who damaged it, one each.' },
])
const CREDIT_RULE_IDS = Object.freeze(CREDIT_RULES.map((r) => r.id))
const DEFAULT_CREDIT_RULE = 'blow'
/** A stored or posted rule, narrowed to one this build knows. */
function creditRuleOf(value) {
return CREDIT_RULE_IDS.includes(value) ? value : DEFAULT_CREDIT_RULE
}
const STEAM_ID = /^\d{17}$/
function steamId(value) {
const text = value === undefined || value === null ? '' : String(value)
return STEAM_ID.test(text) ? text : null
}
function isKnownType(event) {
return Object.prototype.hasOwnProperty.call(TYPES, event)
}
/**
* Who a downed helicopter, Chinook or Bradley credits under `rule`: a list of
* Steam ids, empty when nobody qualifies. D372's table, exactly:
*
* blow `blow`, else `last`
* last `last`
* most `most`
* all every `damagers` entry, one each (and the blow, if it dealt nothing)
*/
function credited(frame, rule) {
const credit = (frame && frame.credit) || {}
const one = (id) => (steamId(id) ? [steamId(id)] : [])
switch (creditRuleOf(rule)) {
case 'last':
return one(credit.last)
case 'most':
return one(credit.most)
case 'all': {
const ids = []
for (const row of Array.isArray(credit.damagers) ? credit.damagers : []) {
const id = steamId(row && row.steamId)
if (id && !ids.includes(id)) ids.push(id)
}
const blow = steamId(credit.blow)
if (blow && !ids.includes(blow)) ids.push(blow)
return ids
}
default:
return one(credit.blow).length ? one(credit.blow) : one(credit.last)
}
}
/** The name a frame carries for a credited Steam id, or null. */
function nameOf(frame, id) {
const names = (frame && frame.names) || {}
const name = names[id]
return typeof name === 'string' && name ? name : null
}
/**
* The grid label for a world position, as the in-game map writes it (D119): the
* game's own cell count and size, which the map stores from the plugin's
* `map.info`. Null without a geometry, and null OFF the map — the Deep Sea and
* its ghost ships are thousands of metres outside it, and a clamped edge cell
* there would be a confident wrong answer. The client's `mapGeometry.js` holds
* the same arithmetic for the map; this copy is for the lines the server writes.
*/
function gridOf(geometry, x, z) {
const g = geometry || {}
const xs = Number(x)
const zs = Number(z)
if (!(g.gridCells > 0) || !(g.gridCellSize > 0) || !(g.worldSize > 0)) return null
if (!Number.isFinite(xs) || !Number.isFinite(zs)) return null
const half = g.worldSize / 2
if (Math.abs(xs) > half || Math.abs(zs) > half) return null
const clamp = (v) => Math.max(0, Math.min(g.gridCells - 1, v))
const col = clamp(Math.floor((xs + half) / g.gridCellSize))
const row = clamp(Math.floor((half - zs) / g.gridCellSize))
return `${columnName(col)}${row}`
}
/** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */
function columnName(index) {
let name = ''
let n = index + 1
while (n > 0) {
const r = (n - 1) % 26
name = String.fromCharCode(65 + r) + name
n = Math.floor((n - 1) / 26)
}
return name
}
/**
* One stored `world.event` frame as a viewer may read it, or null when the
* viewer may not read it at all. **Pure.**
*
* `staff` the viewer is an admin or a moderator: Steam ids, and a type this
* build does not know
* `presence` the viewer is in the server's presence audience: names
* `rule` the server's credit rule
* `geometry` the server's map geometry, for the grid
*
* What comes back keeps the plugin's own fields for what it describes (`event`,
* `where`, `count`, `harbour`/`harbourName`, `monument`/`monumentName`) and
* adds `grid` and, for a downed one, `credited` — `[{ name?, steamId? }]` by
* the rule — with `creditRule` beside it. Positions, the raw credit block and
* the name table are not passed on: every one of them is either a position or
* a list of who was there.
*/
function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDIT_RULE, geometry = null } = {}) {
if (!frame || frame.kind !== 'world.event') return frame
const event = frame.event
const known = isKnownType(event)
if (!known && !staff) return null
const out = {}
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId', 'event', 'where', 'count', 'harbour', 'harbourName', 'monument', 'monumentName']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
const grid = gridOf(geometry, frame.x, frame.z)
if (grid) out.grid = grid
if (staff) {
if (frame.x !== undefined) out.x = frame.x
if (frame.z !== undefined) out.z = frame.z
}
// One player, named.
if (known && TYPES[event].player && steamId(frame.steamId)) {
if (presence && frame.name) out.name = frame.name
if (staff) out.steamId = frame.steamId
}
// Credit, by the server's rule.
if (known && TYPES[event].credit) {
out.creditRule = creditRuleOf(rule)
const ids = credited(frame, rule)
out.creditedCount = ids.length
if (presence || staff) {
out.credited = ids.map((id) => ({
...(nameOf(frame, id) ? { name: nameOf(frame, id) } : {}),
...(staff ? { steamId: id } : {}),
}))
}
if (staff && frame.credit) out.credit = frame.credit
}
if (!known) {
// Staff only, and everything, so a newer plugin's type is at least readable.
return { ...frame, ...out }
}
return out
}
/**
* What one `world.event` adds to the counts: a credited kill of a helicopter,
* a Chinook or a Bradley, for each player the server's rule names. `[{ steamId,
* name, field }]`, where `field` is the tally field the kill would have been
* (`heliKills`, `apcKills`), so ingest adds it the way it adds a tally's.
*/
function killsOf(frame, rule) {
if (!frame || frame.kind !== 'world.event' || !isKnownType(frame.event)) return []
const field = TYPES[frame.event].credit
if (!field) return []
return credited(frame, rule).map((id) => ({ steamId: id, name: nameOf(frame, id), field }))
}
/**
* The names a line credits, as a phrase: "Alice", "Alice and Bob", "Alice, Bob
* and 2 others". Used by the notifications' headlines; the client writes its own
* from the same list.
*/
function namesPhrase(names, total = names.length) {
const shown = names.filter(Boolean).slice(0, 2)
if (shown.length === 0) return null
const more = Math.max(0, total - shown.length)
if (more > 0) return `${shown.join(', ')} and ${more} other${more === 1 ? '' : 's'}`
return shown.length === 2 ? `${shown[0]} and ${shown[1]}` : shown[0]
}
module.exports = {
TYPES,
TYPE_IDS,
CREDIT_RULES,
CREDIT_RULE_IDS,
DEFAULT_CREDIT_RULE,
creditRuleOf,
isKnownType,
credited,
nameOf,
gridOf,
shapeFor,
killsOf,
namesPhrase,
}

File diff suppressed because it is too large Load Diff