38 Commits

Author SHA1 Message Date
3c3286357a Merge pull request 'chore(engagement): regenerate the manifest for coreApi ^1.21.0' (#74) from fix/engagement-manifest into edge
Reviewed-on: #74
2026-10-30 21:56:13 +00:00
68a8d637f5 chore(engagement): regenerate the manifest for coreApi ^1.21.0
All checks were successful
PR Checks / client-build (pull_request) Successful in 1m7s
PR Checks / server-tests (pull_request) Successful in 1m24s
PR Checks / frozen-manifest (pull_request) Successful in 2m25s
#72 raised coreApi to ^1.21.0 without regenerating
engagement-triggers.json, which embeds it, so check:engagement has
failed since. That went unseen on #72 (its server-tests job died on a
full runner disk before the check) and surfaced on the cutover (#73).
Only the coreApi line changes; no trigger, stream, audience or rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 16:55:00 -05:00
0c29dbf6eb Merge pull request 'feat(safety): report, block and hide chat lines (MODULE_API 1.21.0)' (#72) from feature/user-safety into edge
Some checks failed
PR Checks / client-build (pull_request) Successful in 49s
PR Checks / frozen-manifest (pull_request) Successful in 1m17s
PR Checks / server-tests (pull_request) Failing after 58s
Reviewed-on: #72
2026-10-30 14:20:39 +00:00
dd9b8b2711 feat(safety): report, block and hide chat lines (MODULE_API 1.21.0)
Some checks failed
PR Checks / client-build (pull_request) Successful in 51s
PR Checks / frozen-manifest (pull_request) Successful in 53s
PR Checks / server-tests (pull_request) Failing after -17s
USER_SAFETY.md step 3, the module half of report, block and hide.

- server/safety.js registers the rust.chat report target (snapshot for one
  reader, null when they could not have seen the line; staff's hide and
  unhide) and the steam game identity (ownersOf from rust_account_links).
- The events feed filters chat per reader, in the query: lines from a
  blocked account's Steam ids, the reader's unlinked Steam-ID blocks and the
  lines they hid. Signed-in readers get canBlock on each chat line (false for
  their own and for staff-linked players). Lines staff hid for everyone are
  left out for everyone but staff, who get them with hidden: true.
- rust_events gains hidden_at and hidden_by (ALTER ... IF NOT EXISTS).
- POST /admin/rust/chat/:id/hide and /unhide (admin, moderator), logged.
- The feed's chat lines get a menu: Report, Hide for me, Block player,
  and for staff Hide for everyone / Show to everyone again.
- coreApi ^1.21.0; ci/core-ref.json pinned to the website#233 merge;
  routes.manifest.json and swagger-fragment.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 09:04:08 -05:00
1fb685fa0f Merge pull request 'chore: remove a stray empty file committed by #70' (#71) from fix/stray-file into edge
Reviewed-on: #71
2026-10-30 09:30:40 +00:00
bd13f197c5 chore: remove a stray empty file committed by #70
All checks were successful
PR Checks / client-build (pull_request) Successful in 51s
PR Checks / frozen-manifest (pull_request) Successful in 1m19s
PR Checks / server-tests (pull_request) Successful in 35s
`Promise.reject(dup)` is a zero-byte file a tool hook wrote into the working
tree; #70 committed it by accident. Nothing reads it. Found in the account
deletion walk (docs/website/ACCOUNT_DELETION.md step 6, W1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 03:56:42 -05:00
e30c7d591d Merge pull request 'feat(links): unlink every Steam id before an account is deleted (MODULE_API 1.20.0)' (#70) from feature/account-deletion-hook into edge
Reviewed-on: #70
2026-10-30 04:48:54 +00:00
b5573d9ae3 feat(links): unlink every Steam id before an account is deleted (MODULE_API 1.20.0)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 1m1s
PR Checks / frozen-manifest (pull_request) Successful in 1m28s
PR Checks / client-build (pull_request) Successful in 43s
ACCOUNT_DELETION.md §4.4 (AD10). api.onUserDeleting calls links.unlinkForDeletion,
which runs the player's own unlinkOwned per link, logs rust.account.unlink with
reason 'account deleted', and marks every server's permission sync dirty so the
withdrawn grants reach the game on the next tick. coreApi ^1.20.0; core-ref pinned
to website#232's merge (7bd93b8); engagement manifest regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-29 20:38:30 -05:00
ec90d2d5ae Merge pull request 'fix(demo): lock removing a Steam link on the demo (DM27)' (#67) from fix/demo-lock-steam-unlink into edge
All checks were successful
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 58s
PR Checks / frozen-manifest (pull_request) Successful in 1m7s
Reviewed-on: #67
2026-10-28 09:47:44 +00:00
fb7511d700 fix(demo): lock removing a Steam link on the demo (DM27)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 54s
PR Checks / client-build (pull_request) Successful in 1m5s
PR Checks / frozen-manifest (pull_request) Successful in 1m36s
The step-5 walk found demo-player could remove its stand-in Steam link from
its Rust page, and staff could remove it from Admin -> Users. That link is
what the player tour's Rust pages, rewards and grants hang on.

- module.json demoLocks gains DELETE /player/rust/links/:steamId.
- The staff route, DELETE /admin/users/:id/rust/links/:steamId, serves under
  core's users mount, which a module's demoLocks may not name, so its handler
  refuses with DEMO_LOCKED behind ctx.demoMode before the link is touched.

Off the demo both behave as before. Checked on a real core with the demo
seed: both answer 403 DEMO_LOCKED and the link stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-28 04:44:28 -05:00
5c9cefdbc2 Merge pull request 'fix(demo): seed NPC profiles, factions and chat styles (DM26)' (#65) from feat/demo-seed-npcs-chat into edge
All checks were successful
PR Checks / server-tests (pull_request) Successful in 38s
PR Checks / client-build (pull_request) Successful in 55s
PR Checks / frozen-manifest (pull_request) Successful in 59s
Reviewed-on: #65
2026-10-28 01:22:44 +00:00
1f4ad25edb fix(demo): seed NPC profiles, factions and chat styles (DM26)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 55s
PR Checks / client-build (pull_request) Successful in 1m7s
PR Checks / frozen-manifest (pull_request) Successful in 1m36s
The demo's first restored wipes brought the default set's groups and
permissions back, but not its NPCs or chat titles. The restored rig's
RunicNPC says it is already managed (by the site before this one), so the
fresh site adopts nothing (D244) and its first push empties the profiles;
the placements then wait on missing profiles. The import never adopts a
group's chat style either.

onDemoSeed now also takes:
- npcs.factions: the faction table, through the NPC model's setFactions;
- npcs.profiles: each profile through the model's create/update, so it is
  checked exactly as the admin page checks it (an existing one is updated);
- chat: a BetterChat style per server and group, the defaults filled in. A
  group the rig's import has not brought in yet is created for that server,
  and the import then adopts the rig's title, rank and permissions into it.

The section's new parts are checked before anything is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-27 20:15:32 -05:00
228e178475 Merge pull request 'release: the public demo's module-side locks and seed hook (v0.6.0, MODULE_API 1.19.0)' (#64) from edge into main
All checks were successful
Release / release (push) Successful in 45s
Reviewed-on: #64
2026-10-27 17:32:01 +00:00
0cdb046d30 Merge pull request 'chore(ci): pin core-ref to website#224's merge on main' (#63) from chore/core-ref-224-merge into edge
All checks were successful
PR Checks / server-tests (pull_request) Successful in 41s
PR Checks / client-build (pull_request) Successful in 47s
PR Checks / frozen-manifest (pull_request) Successful in 58s
Reviewed-on: #63
2026-10-26 11:03:06 +00:00
791f13235e chore(ci): pin core-ref to website#224's merge on main
All checks were successful
PR Checks / client-build (pull_request) Successful in 1m2s
PR Checks / server-tests (pull_request) Successful in 1m8s
PR Checks / frozen-manifest (pull_request) Successful in 1m35s
website#224 (MODULE_API 1.19.0, demoRefuses) merged as 69998ec on main.
The merge tree equals the branch head the pin held (cee4ab8), so
routes.manifest.json does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 05:53:41 -05:00
c61f9f29ec Merge pull request 'feat(demo): the public demo's module-side locks and seed hook' (#62) from feat/demo-mode into edge
Reviewed-on: #62
2026-10-26 10:52:25 +00:00
7a0d0842ba feat(demo): the public demo's module-side locks and seed hook
All checks were successful
PR Checks / client-build (pull_request) Successful in 54s
PR Checks / server-tests (pull_request) Successful in 1m0s
PR Checks / frozen-manifest (pull_request) Successful in 1m19s
website/DEMO_MODE.md §3.2, §3.4, §3.5, §7.3 and §7.5 (step 2 of §10).
Nothing changes off the demo; everything below sits behind ctx.demoMode.

- demoLocks in module.json: PUT and DELETE /admin/rust/servers/:id.
- The console-command deny list (DM11): quit, restart, oxide.unload,
  oxide.reload, rcon.*, fps.limit and server.tickrate, by first word,
  with global. stripped and o. read as oxide. Refused at save through
  core's demoRefuses (MODULE_API 1.19.0, DM18) and again at run, with
  retry: false, before the rig is asked. Teardown commands too.
- The config editor (DM14): the bridge's files are left out of the
  list and refused for reading, writing and in the write history, by
  name (canonicalised) and by the catalogue's self. Other plugins'
  credential fields are redacted in the text and the form and made
  read-only; raw-JSON saves are refused; a file that does not parse is
  withheld. The admin server list leaves out the sidecar URL.
- onDemoSeed (DM15): upserts the rig's server row with an encrypted
  token and demo-player's stand-in Steam link.
- The release's install manifest carries protocol, read from
  PROTOCOL_VERSION, for the wipe's bundle match (DM12).

coreApi is ^1.19.0; ci/core-ref.json pins website#224's head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 03:40:30 -05:00
bc529962b4 Merge pull request 'release: weapon names and the admin copy fixes (v0.5.1)' (#61) from edge into main
All checks were successful
Release / release (push) Successful in -1m57s
Reviewed-on: #61
2026-10-23 07:07:21 +00:00
c4729315cd Merge pull request 'fix(feed): print weapons by name, not "ak47u entity"' (#60) from fix/weapon-names into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 50s
PR Checks / frozen-manifest (pull_request) Successful in 1m27s
PR Checks / server-tests (pull_request) Successful in 7m25s
Reviewed-on: #60
2026-10-23 06:26:39 +00:00
55a9ea4649 fix(feed): print weapons by name, not "ak47u entity"
All checks were successful
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 43s
PR Checks / frozen-manifest (pull_request) Successful in 1m5s
The bridge names a kill's weapon by the held entity's short prefab name
(ak47u.entity), and the feed printed it with the dots turned to spaces:
"with ak47u entity". Every event read now gives the frame a weaponName
(server/model/titles/weaponNames.js): the game's own English name for the
53 ranged weapons (guns, bows, crossbows, launchers, flamethrowers), read
off the Oxide rig's item list on Rust 26139947, and for anything else the
prefab name as words with its .entity/.weapon ending dropped.

The site prints weaponName and falls back to the same stripping for an
older server. The prefab name stays in the frame: the title lists count by
it. Added at read, so stored rows are renamed too. No protocol change; the
events route's Swagger description names the new field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 01:22:37 -05:00
2e80a21597 Merge pull request 'fix(admin): no decision numbers in admin copy, and "1 player holds one"' (#59) from fix/admin-copy into edge
Reviewed-on: #59
2026-10-22 20:32:52 +00:00
3741c954f5 fix(admin): no decision numbers in admin copy, and "1 player holds one"
All checks were successful
PR Checks / client-build (pull_request) Successful in 34s
PR Checks / frozen-manifest (pull_request) Successful in 1m3s
PR Checks / server-tests (pull_request) Successful in 7m12s
Twenty-four strings an administrator reads carried an internal decision
number - "(D189)", "(D198)", "(D257, D270)" and the like: the permission
screen's group Servers tab and a chip tooltip, the event editor's step
field and budget descriptions, two option sources, a command reward's
skipped note, and five NPC profile validation errors. A D-number means
something to the plan, never to an operator. Each is removed, and the
sentence around it left as it was. The developer-facing swagger
descriptions and code comments keep theirs.

The servers page's chat-title line read "Last pushed: 1 player hold
one." It now says "holds" for one.

Two tests pinned the old wording and now assert the text instead.

Found while writing the runicgateway.com Rust docs (its PLAN.md §16,
phase 15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 15:28:35 -05:00
3fac860949 Merge pull request 'chore(release): cut edge over to main — protocol 14 and Steam sign-in (MODULE_API 1.17.0)' (#58) from edge into main
All checks were successful
Release / release (push) Successful in 36s
Reviewed-on: #58
2026-10-22 11:16:11 +00:00
42bd6db6ad Merge pull request 'ci(rust): pin core to main at the cutover (website#220)' (#57) from chore/pin-core-main into edge
All checks were successful
PR Checks / server-tests (pull_request) Successful in 35s
PR Checks / frozen-manifest (pull_request) Successful in 1m9s
PR Checks / client-build (pull_request) Successful in 7m9s
Reviewed-on: #57
2026-10-22 11:13:51 +00:00
3453bc1867 ci(rust): pin core to main at the cutover (website#220)
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / frozen-manifest (pull_request) Successful in 59s
PR Checks / server-tests (pull_request) Successful in 7m15s
website#220 cut website edge over to main as 6890a74. The pin pointed at
9a1654f, the sha website#218 merged into edge as, which its own note said
moves to main when the cutover lands. The new tree adds the last fixes'
core half (website#219, no MODULE_API bump), whose route changes are only
core's own event routes, so routes.manifest.json does not change. The
frozen-manifest job, replayed locally at this ref, passes (89 routes, all
documented).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 05:45:25 -05:00
72726838b5 Merge pull request 'fix(rust): a zone resource carries its deadline as until (W1)' (#56) from fix/last-fixes-zone-until into edge
Reviewed-on: #56
2026-10-22 06:26:56 +00:00
9c35906d0e fix(rust): a zone resource carries its deadline as until (W1)
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / frozen-manifest (pull_request) Successful in 1m1s
PR Checks / server-tests (pull_request) Successful in 7m20s
Stage 4 of docs/modules/rust/PLAN_LAST_FIXES.md, the module's half of W1.
A placed zone now reports `until`, reckoned from the answer as now plus
holdMs, so it lands a round trip after the game's own deadline and never
before it. Core's reconcile then files a zone gone past that deadline as
expired, where a reconnect used to log resource.orphaned five seconds
before the game's world.expired arrived. Crates and NPCs send no
holdMs, so they carry no until.

No core pin change: `until` has been in the resource contract all along,
and a core without the reconcile half only shows it on the console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 00:38:49 -05:00
7304d68d0a Merge pull request 'fix(rust): the last fixes' small ones (W3 W6 W7/W11 W8 W10 W13-W18)' (#55) from fix/last-fixes-small into edge
Reviewed-on: #55
2026-10-21 18:45:37 +00:00
3aa142bf06 fix(rust): the last fixes' small ones (W3 W6 W7/W11 W8 W10 W13-W18)
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 32s
PR Checks / frozen-manifest (pull_request) Successful in 44s
Stage 2 of PLAN_LAST_FIXES (docs#366).

- W7/W11: an "is it loaded" refusal is confirmed live before it is given.
  New liveStatus.js asks the sidecar once (boot.refreshOne, as a poll tick
  would), at most once per server per 5 s and waiting at most 3 s, then
  reads the stored status again. Used by Economics (step and staff page),
  Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes,
  TruePVE and RunicNPC.
- W3: a world call answered `already-out` (the bridge half comes in stage
  3) succeeds as called 0, saying a Travelling Vendor is already out.
- W6: "A locked crate dropped by a Chinook is being hacked."
- W8: a RaidableBases profile's audit row names RaidableBases, not "@data".
- W10: the backpack sizes picker lists each size once, naming its servers.
- W13: a RaidableBases name both servers describe differently shows each
  server's own description.
- W14: each RaidableBases note after the first starts its own sentence.
- W15: a staff deposit refused at Economics' limit answers 409.
- W16: a payment's failedNote explains only the reasons that happened.
- W17: the sync state counts player grants that did not land
  (waitingToLand), and the Permissions page says "in sync · 1 waits for
  their player to join" instead of plain "in sync".
- W18: Guarded Crate's kept note drops the "30 minutes" it could not know.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-21 07:42:24 -05:00
d69c2108c6 Merge pull request 'fix(rust): send reward notices, and keep a downed line's credit rule (W21, W4)' (#54) from fix/last-fixes-bugs into edge
Reviewed-on: #54
2026-10-21 12:00:33 +00:00
8aff9b33a6 fix(rust): send reward notices, and keep a downed line's credit rule (W21, W4)
All checks were successful
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 48s
PR Checks / server-tests (pull_request) Successful in 7m16s
W21: the permission push named its fields one by one and left out
`notices`, so no kit reward, lend or give ever told its player
anything (D333). The body is now built by `pushBody`, which carries
every field the payload has; a test fails if a payload field is
dropped again.

W4: a downed patrol helicopter, Chinook or Bradley is stored with the
credit rule in force when it arrived, and the feed reads that stamp.
Before, the line re-credited past rows with the server's current rule
while the Rotorbane/Tankbreaker counts kept the old one. Rows stored
before this change keep today's reading.

PLAN_LAST_FIXES.md stage 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-21 06:16:32 -05:00
5decd5cc05 Merge pull request 'ci(rust): pin core to the sha website#218 merged as' (#53) from chore/core-ref-steam-merge into edge
Reviewed-on: #53
2026-10-21 05:38:58 +00:00
885485d153 ci(rust): pin core to the sha website#218 merged as
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / server-tests (pull_request) Successful in 7m14s
#218 (Steam sign-in, MODULE_API 1.17.0) merged into website edge as
9a1654f. The pin pointed at the PR's branch head, 5e398d8, while it was
open. The two trees differ by one core test file, so routes.manifest.json
does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-21 00:38:15 -05:00
47da3a2db8 Merge pull request 'feat(rust): a Steam account linked on the site is a Rust link (D392, D396)' (#52) from feat/steam-signin-link into edge
Reviewed-on: #52
2026-10-20 15:25:44 +00:00
8172454683 ci(rust): pin core to website feat/steam-signin (MODULE_API 1.17.0)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 48s
coreApi is ^1.17.0, which no core on main offers yet, so the frozen-manifest
job is proved against the head of the open website PR. Moves to the sha it
merges as.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-19 20:28:25 -05:00
b89039142e feat(rust): a Steam account linked on the site is a Rust link (D392, D396)
Registers core's MODULE_API 1.17.0 identity hooks (coreApi ^1.17.0):
onIdentityLinking refuses a Steam id that is another website account's Rust
link (D396); onIdentityLinked writes the Rust link with the PRIMARY KEY as the
race guard, reconciles Teams and raises the linked notice, as /link does.
Unlinking Steam leaves the Rust link (D393); the Rust account page says so.

ci/core-ref.json still pins the 1.15.0 core and must move to the website
branch head carrying 1.17.0 before CI can pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-19 14:02:53 -05:00
f93ba86883 Merge pull request 'feat(rust): RaidableBases — feed, raids map layer, Warlord, Spawn a raid base, profiles (PLAN_REDESIGNS §11.4)' (#51) from feat/raidable-bases into edge
Reviewed-on: #51
2026-10-17 06:33:08 +00:00
481837e937 feat(rust): RaidableBases — feed, raids map layer, Warlord, Spawn a raid base, profiles (PLAN_REDESIGNS §11.4)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 49s
PLAN_REDESIGNS §11.4 (D202-D208, D386-D391), protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.12.

- raid.started, raid.completed, raid.purchased and raid.locked public,
  raid.ended staff's; raidableBases.js reads each row per viewer (raiders'
  and a buyer's names only for the presence audience, D376/D390; Steam ids,
  position and a run's key only for staff). Feed lines in the World filter.
- The step rust.raidbase.spawn, "Spawn a raid base" (ledgered, budget
  rust.raids): difficulty, profile and base from option sources listing each
  server's own names (D386), a spot as a world call's location with a height
  adjustment or an exact height, and PvP/NPCs/lock as on/off/blank. A base
  the raids helper made is a world resource, so eventWorld's revert and
  reconcile serve it and world.expired marks it expired; one it did not make
  records nothing and the run log says it despawns on its own timer. Refused
  at verify for a server that said it cannot; a dry run with a spot returns
  the spot check as detail.warnings (MODULE_API 1.16.0, D391). rb-not-ready
  retries in ten minutes.
- Warlord: raids_completed, title category raidscompleted, the leaderboard's
  Raid bases column (sort=raidsCompleted).
- The map's raids layer (D114), public by default with its own audience; the
  run's key is dropped before any viewer gets it.
- Server settings' line for RaidableBases; its profiles in Mod configuration
  under a virtual path (D205).
- Swagger fragment, bundle and README.

Tests: server 684/684 (raidableBases.test.js new, every name in it invented);
client 99/99, build and externals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 15:18:52 -05:00
95 changed files with 5246 additions and 156 deletions

View File

@@ -384,6 +384,22 @@ jobs:
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
SIZE="$(stat -c%s "dist/module-rust-${VERSION}.tar.gz")"
# The wire protocol this release speaks, for the public demo's wipe
# (website/DEMO_MODE.md §7.5, DM12). The wipe picks the newest release
# whose `protocol` EQUALS the Rust bundle's. The Rust link has no 409,
# so a plugin on another protocol would mis-parse rather than be
# refused. Read from the one declaration (`server/sidecarClient.js`);
# a release that cannot find it fails here rather than ship a manifest
# the wipe would skip. Core's installer reads only the fields it names
# (modules/install.js), so the extra key changes nothing there.
PROTOCOL="$(node -e '
const src = require("fs").readFileSync("server/sidecarClient.js", "utf8");
const m = /^const PROTOCOL_VERSION = (\d+)$/m.exec(src);
if (!m) { console.error("no PROTOCOL_VERSION in server/sidecarClient.js"); process.exit(1); }
console.log(m[1]);
')"
echo "protocol ${PROTOCOL}"
# The install manifest. Same shape as the installer's bundle JSON — a
# per-asset sha256 fetched over HTTPS, no signatures — because that is
# the model this project already has and a second one would be a second
@@ -396,9 +412,10 @@ jobs:
--arg artifact "module-rust-${VERSION}.tar.gz" \
--arg sha256 "$SHA" \
--argjson size "$SIZE" \
--argjson protocol "$PROTOCOL" \
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-rust-${VERSION}.tar.gz" \
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
artifact:$artifact, url:$url, sha256:$sha256, size:$size, protocol:$protocol}' \
> "dist/module-rust-${VERSION}.json"
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS

View File

@@ -144,6 +144,19 @@ one now, kept when the run ends: an airdrop or a patrol helicopter can be sent t
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).
- **RaidableBases** (nivex, 3.2.2, free or paid), with **CopyPaste** to paste its bases, and the
**raids helper** `RunicGatewayRaids.cs` the plugin ships beside the bridge. With them: a base
appearing, raided, bought or locked to a player are public feed lines (*"Alice and Bob raided a Hard
raid base · G7"*, the names only for the presence audience), every live base is on the live map's own
**Raid bases** layer (public by default, set in Admin → Rust visibility), the **Raid bases**
leaderboard column and the *Warlord* title count the bases a player raided, and the step **Spawn a
raid base** spawns one: at a difficulty, a profile or a named base, where RaidableBases picks or at a
spot, with its own PvP, NPCs, despawn time and lock. That base is the event's, despawned when the run
ends. A dry run checks a spot and says what is in the way, as advice. Every difficulty, profile and
base name is the server's own. Without the helper the step can still ask RaidableBases for a base
where it picks, but it despawns on its own timer, and the step says so. RaidableBases' profiles are
editable from **Mod configuration**, reloaded and rolled back like any config
(`docs/modules/rust/PLAN_REDESIGNS.md` §11.4).
- **ZLevels Remastered** (nivex, 3.3.1). The servers page says whether it is loaded, and that is all:
its levels are never read, and the leaderboard has no column for them. Its XP boosts are its own
permissions (`zlevelsremastered.vip1`–`vip3`), so a *Double XP weekend* is a **Permissions until the event ends**

View File

@@ -34,6 +34,8 @@
"core.js",
"dangerousTreasures.js",
"db",
"demo.js",
"demoSeed.js",
"dynamicPvp.js",
"eventCommands.js",
"engagement",
@@ -41,6 +43,7 @@
"eventGuardedCrate.js",
"eventLeases.js",
"eventPermRewards.js",
"eventRaidableBases.js",
"eventProgress.js",
"eventRewards.js",
"eventTemplates.js",
@@ -49,13 +52,16 @@
"guardedCrate.js",
"index.js",
"ingest.js",
"liveStatus.js",
"mapImages.js",
"mapLive.js",
"model",
"npcSync.js",
"package.json",
"permSync.js",
"raidableBases.js",
"router",
"safety.js",
"sidecarClient.js",
"titleSync.js",
"worldEvents.js"

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. **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.",
"$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. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change. **2026-10-26, the public demo (DEMO_MODE.md §3.4, §3.5, DM18):** this module declares demoLocks, reads ctx.demoMode, registers onDemoSeed (MODULE_API 1.18.0) and gives its two console-command actions demoRefuses (1.19.0), so coreApi is ^1.19.0. It was pinned to the head of website feature/demo-step-refusal (website#224) while that PR was open — a 1.18.0 core never asks demoRefuses — and is back on `main` at the sha #224 merged as (the same tree, so routes.manifest.json does not change). **2026-10-30, account deletion (ACCOUNT_DELETION.md §4.4):** this module registers onUserDeleting (MODULE_API 1.20.0), so coreApi is ^1.20.0, pinned to `main` at the sha website#232 merged as. **2026-10-30, report, block and hide (USER_SAFETY.md §4.2):** this module registers a rust.chat report target and the steam game identity, and reads ctx.safety (MODULE_API 1.21.0), so coreApi is ^1.21.0, pinned to `main` at the sha website#233 merged as.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "eb773693d32781f2ec717f9be07816b6afefd162",
"refName": "main @ MODULE_API 1.15.0 (website#215 merged)"
"ref": "f13b8fe1fec9dc330a985430b8bd4f67c9ec26c5",
"refName": "website main @ website#233 merge, MODULE_API 1.21.0"
}

View File

@@ -350,6 +350,21 @@ export const adminUserBackpacks = {
req(`/admin/users/${encodeURIComponent(userId)}/rust/backpacks${query({ serverId, steamId })}`),
}
// ── report, block and hide (USER_SAFETY.md §4.2) ───────────────────────────
// Core's routes, not this module's: the Reports queue, a member's blocks and
// their "Hide for me" list are core's and hold for every module and the forum.
// Each names an ITEM — `{ targetType: 'rust.chat', targetId }` — never a person;
// core asks `server/safety.js` who said it. The last two are this module's own:
// staff's hide for everyone, set and cleared where the line lives.
export const safety = {
report: ({ targetType, targetId, reason, detail }) =>
req('/player/reports', { method: 'POST', body: { targetType, targetId, reason, detail } }),
block: ({ targetType, targetId }) => req('/auth/me/blocks', { method: 'POST', body: { targetType, targetId } }),
hide: ({ targetType, targetId }) => req('/auth/me/hidden', { method: 'POST', body: { targetType, targetId } }),
hideForEveryone: (lineId) => req(`/admin/rust/chat/${encodeURIComponent(lineId)}/hide`, { method: 'POST' }),
unhideForEveryone: (lineId) => req(`/admin/rust/chat/${encodeURIComponent(lineId)}/unhide`, { method: 'POST' }),
}
// ── 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
@@ -382,6 +397,7 @@ export default {
adminUserPermissions,
adminUserEconomics,
adminUserBackpacks,
safety,
coreEvents,
BASE,
}

View File

@@ -0,0 +1,198 @@
// ── A chat line's menu: report, hide, block (USER_SAFETY.md §4.2) ──────────
//
// One small button at the end of each chat line in the feed, opening a row of
// actions under it. What it offers is `lib/safety.js`'s `chatActions`; what it
// does is core's (report, block, Hide for me) or this module's admin route
// (staff's hide for everyone). After anything that changes what the reader
// sees, the feed is asked again: the server is what filters it (S11), so the
// honest way to show a block is to re-read the feed, never to drop the line here.
//
// The wording follows core's forum menu, so a member reads one set of sentences
// wherever they report or block from.
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../core.js'
import api from '../api.js'
import { CHAT_TARGET, REPORT_REASONS, chatActions } from '../lib/safety.js'
export default function ChatMenu({ line, onChanged }) {
const { user } = useAuth()
const actions = chatActions(line, user)
const [mode, setMode] = useState('closed') // closed | open | report | block | done
const [reason, setReason] = useState('abuse')
const [detail, setDetail] = useState('')
const [message, setMessage] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
if (!actions) return null
// Every move between the menu's states drops the last error: a refusal from
// one action ("you have already reported this") is not about the next.
const go = (next) => { setError(null); setMode(next) }
const target = { targetType: CHAT_TARGET, targetId: line.id }
const name = (line.frame && line.frame.name) || 'this player'
const run = async (call, { refetch = true, done = null } = {}) => {
setBusy(true)
setError(null)
try {
await call()
if (done) {
setMessage(done)
setMode('done')
} else {
setMode('closed')
}
if (refetch) await onChanged()
} catch (err) {
setError(err.message || 'Could not do that')
} finally {
setBusy(false)
}
}
const toggle = (
<button
type="button"
onClick={() => { setError(null); setMode(mode === 'closed' ? 'open' : 'closed') }}
aria-label={`Actions for ${name}’s line`}
aria-expanded={mode !== 'closed'}
title="Report, hide or block"
style={toggleStyle}
>
⋯
</button>
)
if (mode === 'closed') return toggle
return (
<>
{toggle}
<div className="sans" style={panelStyle}>
{mode === 'done' && <p style={noteStyle}>{message}</p>}
{mode === 'open' && actions.signIn && (
<p style={noteStyle}>
<Link to="/account/login" state={{ from: { pathname: window.location.pathname } }}>Sign in</Link>
{' '}to report or block.
</p>
)}
{mode === 'open' && !actions.signIn && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{actions.report && (
<button type="button" className="pill" onClick={() => go('report')}>Report</button>
)}
{actions.hide && (
<button type="button" className="pill" disabled={busy} onClick={() => run(() => api.safety.hide(target))}>
Hide for me
</button>
)}
{actions.block && (
<button type="button" className="pill" onClick={() => go('block')}>Block player</button>
)}
{actions.staff === 'hide' && (
<button type="button" className="pill" disabled={busy} onClick={() => run(() => api.safety.hideForEveryone(line.id))}>
Hide for everyone
</button>
)}
{actions.staff === 'unhide' && (
<button type="button" className="pill" disabled={busy} onClick={() => run(() => api.safety.unhideForEveryone(line.id))}>
Show to everyone again
</button>
)}
</div>
)}
{mode === 'report' && (
<form
onSubmit={(e) => {
e.preventDefault()
run(() => api.safety.report({ ...target, reason, detail: detail || undefined }), {
refetch: false,
done: 'Reported to site staff.',
})
}}
style={{ display: 'grid', gap: 8 }}
>
{/* A report changes nothing about the line, and says so: a member who
expects it to vanish and watches it stay will report it again. */}
<p style={noteStyle}>
This goes to <strong>site staff</strong>. Reporting does not hide or change anything — it asks a
staffer to look.
</p>
<label style={{ fontSize: '0.85rem' }}>
Reason
{' '}
<select className="input" value={reason} onChange={(e) => setReason(e.target.value)}>
{REPORT_REASONS.map(([value, text]) => (
<option key={value} value={value}>{text}</option>
))}
</select>
</label>
<textarea
className="textarea"
value={detail}
onChange={(e) => setDetail(e.target.value)}
placeholder="Anything a staffer should know (optional)"
maxLength={500}
rows={3}
/>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Send report</button>
<button type="button" className="pill" onClick={() => go('open')}>Cancel</button>
</div>
</form>
)}
{mode === 'block' && (
<div style={{ display: 'grid', gap: 8 }}>
{/* S9: the blocked player is not told, and a member deciding whether
to block is usually deciding whether it will start something. */}
<p style={noteStyle}>
Block <strong>{name}</strong>? You won&rsquo;t see anything they write, here or in the app. They
aren&rsquo;t told. You can undo it from <em>Account → Blocked</em>.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => run(() => api.safety.block(target))}>
Block
</button>
<button type="button" className="pill" onClick={() => go('open')}>Cancel</button>
</div>
</div>
)}
{error && <p style={{ ...noteStyle, color: 'var(--danger, crimson)' }}>{error}</p>}
</div>
</>
)
}
const toggleStyle = {
flex: 'none',
marginLeft: 'auto',
background: 'transparent',
border: 0,
color: 'var(--dim)',
cursor: 'pointer',
fontSize: '1rem',
lineHeight: 1,
padding: '0 4px',
}
const panelStyle = {
flexBasis: '100%',
display: 'grid',
gap: 8,
marginTop: 6,
padding: 10,
border: '1px solid var(--line)',
borderRadius: 6,
fontSize: '0.85rem',
}
const noteStyle = { margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }

View File

@@ -13,6 +13,7 @@
import { ErrorState, Loading } from '../core.js'
import Empty from './Empty.jsx'
import ChatMenu from './ChatMenu.jsx'
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
import { ago, clock } from '../lib/format.js'
import usePolled from '../hooks/usePolled.js'
@@ -32,7 +33,7 @@ const TONE = {
export default function Feed({ serverId, wipeId, filter, onFilter }) {
const kinds = kindsFor(filter)
const { data, error, loading, at } = usePolled(
const { data, error, loading, at, refresh } = usePolled(
() => api.servers.events(serverId, { kinds, wipe: wipeId, limit: 100 }),
// The key is the QUESTION. Changing server, wipe or filter blanks the rows,
// because what is on screen is an answer to a different one; a poll tick
@@ -111,10 +112,14 @@ export default function Feed({ serverId, wipeId, filter, onFilter }) {
key={event.id}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 12,
alignItems: 'baseline',
padding: '7px 0',
borderBottom: '1px solid var(--line-soft, var(--line))',
// Staff's hidden line (S10): only staff ever get one, dimmed
// and marked so the hide can be checked and undone here.
opacity: event.hidden ? 0.6 : 1,
}}
>
<time
@@ -137,7 +142,17 @@ export default function Feed({ serverId, wipeId, filter, onFilter }) {
{line.detail}
</span>
)}
{event.hidden && (
<span className="sans" style={{ color: 'var(--mode-maint, #e6c26a)', fontSize: '0.74rem' }}>
{' · '}
Hidden by staff
</span>
)}
</span>
{/* Report, Hide for me, Block player, and staff's Hide for
everyone (USER_SAFETY.md §4.2). The server filtered the feed
for this reader, so after any of them the feed is re-read. */}
<ChatMenu line={event} onChanged={refresh} />
</li>
)
})}

View File

@@ -50,6 +50,10 @@ const TREASURES = { key: 'treasuresWon', label: 'Treasures', sort: 'treasuresWon
// same rule from the server's `guardedCrate`.
const GUARDED = { key: 'guardedWon', label: 'Crates cleared', sort: 'guardedWon', value: (r) => count(r.guardedWon) }
// Warlord (PLAN_REDESIGNS §11.4, D202): RaidableBases bases completed, shown on
// the same rule from the server's `raidableBases`.
const RAIDS = { key: 'raidsCompleted', label: 'Raid bases', sort: 'raidsCompleted', value: (r) => count(r.raidsCompleted) }
const BALANCE = { key: 'balance', label: 'Coins', sort: 'balance', value: (r) => coins(r.balance) }
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
@@ -109,6 +113,7 @@ function Overall({ serverId, wipeId, sort, onSort }) {
...COLUMNS,
...(data && data.dangerousTreasures ? [TREASURES] : []),
...(data && data.guardedCrate ? [GUARDED] : []),
...(data && data.raidableBases ? [RAIDS] : []),
...(data && data.economics ? [BALANCE] : []),
]
// Present on every row or on none — the server decides per request.

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, pvpTip, titlesFrom, unknownRuns } from '../lib/mapEvents.js'
import { eventTip, isBoss, pvpTip, raidTip, titlesFrom, unknownRuns } from '../lib/mapEvents.js'
import api, { BASE } from '../api.js'
const STYLE_ID = 'rust-leaflet-css'
@@ -36,6 +36,7 @@ const LAYERS = [
{ id: 'players', label: 'Players' },
{ id: 'bases', label: 'Bases' },
{ id: 'pvp', label: 'PvP zones' },
{ id: 'raids', label: 'Raid bases' },
]
const COLOURS = {
@@ -57,6 +58,8 @@ const COLOURS = {
self: '#00e5ff',
mate: '#7cffb2',
pvp: '#ff1744',
raid: '#ff9100',
raidOurs: '#ffd600',
}
const WORLD_NAMES = {
@@ -87,7 +90,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, pvp: true, mates: true })
const [shown, setShown] = useState({ grid: true, world: true, events: true, players: true, bases: true, pvp: true, raids: true, mates: true })
useEffect(() => {
let alive = true
@@ -168,7 +171,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', 'pvp', 'mates', 'pins']) made[id] = L.layerGroup().addTo(map)
for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'pvp', 'raids', '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).
@@ -246,7 +249,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', 'pvp', 'mates']) made[id].clearLayers()
for (const id of ['world', 'events', 'players', 'bases', 'pvp', 'raids', 'mates']) made[id].clearLayers()
for (const w of answer.world || []) {
let tip = WORLD_NAMES[w.kind] || w.kind
@@ -289,6 +292,13 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
.addTo(made.pvp)
}
// RaidableBases' bases (§11.4): orange, and yellow for one this site's event spawned.
for (const r of answer.raids || []) {
dot(L, at(r), r.runId ? COLOURS.raidOurs : COLOURS.raid, 7, 2)
.bindTooltip(escape(raidTip(r, Date.now())))
.addTo(made.raids)
}
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'))
@@ -314,7 +324,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, pvp: shown.pvp, 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, raids: shown.raids, 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])
@@ -397,6 +407,7 @@ function Legend({ meta, live, shown, onToggle }) {
players: [COLOURS.online, COLOURS.sleeping],
bases: [COLOURS.tc, COLOURS.vending],
pvp: [COLOURS.pvp],
raids: [COLOURS.raid, COLOURS.raidOurs],
}
return (

View File

@@ -21,7 +21,7 @@
// here is presentation, and the honest presentation of a kind we have no words
// for is its own name.
import { attacker, duration, prefab } from './format.js'
import { attacker, duration, weapon } from './format.js'
/**
* Kinds this feed asks for.
@@ -59,6 +59,12 @@ export const FEED_KINDS = Object.freeze([
// like a treasure. A timeout (`gc.failed`) is staff's; the map shows it go.
'gc.started',
'gc.ended',
// RaidableBases (§11.4): a base appearing, raided, bought or locked to a
// player, read per viewer; its despawn (`raid.ended`) is staff's, and the map shows it go.
'raid.started',
'raid.completed',
'raid.purchased',
'raid.locked',
])
/** The filters the feed offers, and the kinds each one asks the API for. */
@@ -73,7 +79,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'] },
{ id: 'world', label: 'World events', kinds: ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended', 'raid.started', 'raid.completed', 'raid.purchased', 'raid.locked'] },
])
export function kindsFor(filterId) {
@@ -177,6 +183,13 @@ export function describe(row) {
case 'gc.failed':
return guardedLine(row.kind, frame)
case 'raid.started':
case 'raid.completed':
case 'raid.ended':
case 'raid.purchased':
case 'raid.locked':
return raidLine(row.kind, frame)
default:
return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' }
}
@@ -192,7 +205,7 @@ export function describe(row) {
*/
function death(frame, name) {
const where = [
frame.weapon ? `with ${prefab(frame.weapon)}` : null,
frame.weapon ? `with ${weapon(frame)}` : null,
frame.distance ? `${Math.round(frame.distance)}m` : null,
frame.grid || null,
frame.sleeping ? 'while sleeping' : null,
@@ -306,7 +319,7 @@ function worldLine(frame) {
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}`)
: say(`A locked crate ${place} is being hacked`)
case 'crate.unlocked':
return say(`The locked crate ${place} is open`)
case 'crate.looted':
@@ -407,4 +420,46 @@ function guardedLine(kind, frame) {
: { tone: 'kill', actor: null, verb: 'The guarded crate was cleared', subject: null, detail }
}
/** "Alice", "Alice and Bob", "Alice, Bob and 3 others". */
export function raiderNames(names, count) {
const shown = (names || []).slice(0, 2)
const rest = Math.max(0, (Number(count) || shown.length) - shown.length)
if (shown.length === 0) return null
if (rest === 0) return shown.length === 2 ? `${shown[0]} and ${shown[1]}` : shown[0]
return `${shown.join(', ')} and ${rest} other${rest === 1 ? '' : 's'}`
}
/**
* RaidableBases (protocol 14, PLAN_REDESIGNS §11.4). The server has read the
* frame for this viewer: `grid`, the plugin's own `difficulty` name (D386), `pvp`,
* and the raiders' `names` or a buyer's `name` only for the presence audience
* (D376, D390); `raiderCount` for everyone.
*/
function raidLine(kind, frame) {
const base = frame.difficulty ? `${frame.difficulty} raid base` : 'raid base'
const a = /^[aeiou]/i.test(base) ? 'An' : 'A'
const detail = [frame.grid || null, frame.pvp === true ? 'PvP' : null].filter(Boolean).join(' · ')
if (kind === 'raid.started') return { tone: 'server', actor: null, verb: `${a} ${base} appeared`, subject: null, detail }
if (kind === 'raid.ended') return { tone: 'server', actor: null, verb: `${a} ${base} despawned`, subject: null, detail }
if (kind === 'raid.completed') {
const who = raiderNames(frame.names, frame.raiderCount)
if (who) return { tone: 'kill', actor: who, verb: `raided ${a.toLowerCase()} ${base}`, subject: null, detail }
const count = Number(frame.raiderCount) || 0
const by = count > 0 ? `by ${count} player${count === 1 ? '' : 's'}` : null
return { tone: 'kill', actor: null, verb: `${a} ${base} was raided`, subject: null, detail: [detail, by].filter(Boolean).join(' · ') }
}
if (kind === 'raid.purchased') {
return frame.name
? { tone: 'server', actor: frame.name, verb: `bought ${a.toLowerCase()} ${base}`, subject: null, detail }
: { tone: 'server', actor: null, verb: `${a} ${base} was bought`, subject: null, detail }
}
return frame.name
? { tone: 'server', actor: null, verb: `${a} ${base} was locked to ${frame.name}`, subject: null, detail }
: { tone: 'server', actor: null, verb: `${a} ${base} was locked to a player`, subject: null, detail }
}
export default { describe, FEED_KINDS, FILTERS, kindsFor }

View File

@@ -130,6 +130,18 @@ export function prefab(name) {
return String(name).replace(/[_.]+/g, ' ').trim()
}
/**
* The weapon in a kill. The server sends `weaponName` — the game's own name for
* a gun, or the prefab name as words (`server/model/titles/weaponNames.js`) —
* and that is what prints. A server too old to send it gets the same fallback
* here: the `.entity` / `.weapon` ending dropped, so never "ak47u entity".
*/
export function weapon(frame) {
if (!frame || !frame.weapon) return ''
if (frame.weaponName) return String(frame.weaponName)
return prefab(String(frame.weapon).trim().replace(/[._](entity|weapon|deployed)$/, ''))
}
/**
* The NPC families whose prefab reads badly as words, by the prefix the game's
* short names share. First match wins, so a longer prefix sits above a shorter

View File

@@ -65,3 +65,21 @@ export function pvpTip(z) {
const name = PVP_NAMES[z.event] || z.event
return name ? `PvP zone · ${name}` : 'PvP zone'
}
// ── RaidableBases' bases (protocol 14, PLAN_REDESIGNS §11.4) ───────────────
/**
* A raid base's tooltip: "Raid base · Hard · raidhard2 · PvP · despawns in 42 min",
* and "· this site's event" for one a run spawned. The difficulty and base names are
* the plugin's own (D386).
*/
export function raidTip(r, now = Date.now()) {
const parts = ['Raid base']
if (r.difficulty) parts.push(r.difficulty)
if (r.base) parts.push(r.base)
if (r.pvp === true) parts.push('PvP')
const left = Number(r.despawnAt) - now
if (Number.isFinite(left) && left > 0) parts.push(`despawns in ${Math.max(1, Math.round(left / 60000))} min`)
if (r.runId) parts.push('this site’s event')
return parts.join(' · ')
}

56
client/src/lib/safety.js Normal file
View File

@@ -0,0 +1,56 @@
// ── Report, block and hide on a chat line: what the menu offers ────────────
//
// USER_SAFETY.md §4.2. Nothing here decides what a reader may SEE — the server
// already left out what they blocked or hid, and staff's hidden lines for
// everybody else (S11). This file only turns what the server said about a line
// into which buttons its menu shows, and it is pure so a DOM-less runner can ask
// all of it.
/** The report target a chat line is filed as. Registered by `server/safety.js`. */
export const CHAT_TARGET = 'rust.chat'
/**
* The reasons a report can give. Core's `content_reports.reason` enum, in the
* order and words core's own forum report uses, so a member reads one list
* wherever they report from.
*/
export const REPORT_REASONS = Object.freeze([
['abuse', 'Abusive or harassing'],
['spam', 'Spam'],
['sexual', 'Sexual content'],
['illegal', 'Illegal content'],
['impersonation', 'Impersonation'],
['other', 'Something else'],
])
/**
* The roles that hide a line for everyone: the two core's Reports queue is open
* to, and the two `POST /admin/rust/chat/:id/hide` admits. An editor writes
* content and moderates none.
*/
export const HIDE_ROLES = Object.freeze(['admin', 'moderator'])
/**
* What a chat line's menu offers this reader.
*
* signIn a signed-out reader gets one line telling them to sign in (S6)
* report any signed-in reader, on any line
* hide "Hide for me", likewise — a staff member's line included (S8)
* block only where the server said `canBlock` (S8, S13)
* staff 'hide' or 'unhide' for a reader who can hide for everyone (S10)
*
* @param {{ kind: string, canBlock?: boolean, hidden?: boolean }} line
* @param {{ role?: string } | null} user
*/
export function chatActions(line, user) {
if (!line || line.kind !== 'player.chat') return null
if (!user) return { signIn: true, report: false, hide: false, block: false, staff: null }
const canHideForAll = HIDE_ROLES.includes(user.role)
return {
signIn: false,
report: true,
hide: true,
block: line.canBlock === true,
staff: canHideForAll ? (line.hidden ? 'unhide' : 'hide') : null,
}
}

View File

@@ -49,7 +49,7 @@ export function titlesSummary(titles, push, categories) {
const rules = (titles && titles.rules) || []
if (!rules.length) return 'No chat titles.'
const list = rules.map((r) => `${ruleTitle(r, categories)} (top ${r.topN} ${statOf(categories, r.stat).label.toLowerCase()})`).join(', ')
const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? '' : 's'} hold one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? ' holds' : 's hold'} one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
return `Chat titles: ${list}.${shown}`
}
@@ -266,7 +266,42 @@ export function integrationsLine(r) {
const withDynamicPvp = r.integrations.dynamicPvp ? `${withTruePve} ${dynamicPvpLine(r.integrations.dynamicPvp)}` : withTruePve
const withDt = r.integrations.dangerousTreasures ? `${withDynamicPvp} ${dangerousTreasuresLine(r.integrations.dangerousTreasures, r.console)}` : withDynamicPvp
const withGc = r.integrations.guardedCrate ? `${withDt} ${guardedCrateLine(r.integrations.guardedCrate, r.console)}` : withDt
return r.integrations.zLevels ? `${withGc} ${zLevelsLine(r.integrations.zLevels)}` : withGc
const withZ = r.integrations.zLevels ? `${withGc} ${zLevelsLine(r.integrations.zLevels)}` : withGc
return r.integrations.raidableBases ? `${withZ} ${raidableBasesLine(r.integrations.raidableBases, r.console)}` : withZ
}
/**
* RaidableBases (PLAN_REDESIGNS §11.4), from protocol 14: its edition, its own
* difficulties (D386), CopyPaste, and the raids helper that makes a step's base the
* run's (D204) — and what a step may do without it (D348, D387).
*/
export function raidableBasesLine(rb, probe) {
if (!rb.loaded) {
return `RaidableBases is not loaded — no raid bases on the feed or the map, and a step cannot spawn one.${rb.helper && rb.helper.state !== 'missing' ? ' RunicGatewayRaids.cs is installed and waits for it.' : ''}`
}
const name = `RaidableBases ${rb.version || ''} (${rb.premium ? 'paid' : 'free'} edition)`.replace(' ()', '').trim()
const difficulties = Array.isArray(rb.difficulties) && rb.difficulties.length
? ` Its difficulties: ${rb.difficulties.map((d) => d.name).join(', ')}.`
: ''
if (rb.copyPaste && !rb.copyPaste.loaded) {
return `${name} is loaded but CopyPaste is missing: it cannot paste a base, and a step cannot spawn one.`
}
const state = rb.helper ? rb.helper.state : 'missing'
if (state === 'patched') {
const grid = rb.gridReady === false ? ' It is still building its spawn grid (up to ten minutes after it loads); a step waits for it.' : ''
const profiles = Array.isArray(rb.profiles) ? ` ${rb.profiles.length} profile${rb.profiles.length === 1 ? '' : 's'}.` : ''
return `${name} is loaded with RunicGatewayRaids.cs — its bases show on the feed and the map, and a step may spawn one that is the event’s: at a spot, with its own settings and lock, despawned when the event ends.${difficulties}${profiles}${grid}`
}
const helper = state === 'missing'
? 'RunicGatewayRaids.cs is not installed'
: `RunicGatewayRaids.cs could not patch it${rb.helper && rb.helper.reason ? ` (${rb.helper.reason})` : ''}`
if (rb.spawnCall) {
return `${name} is loaded — its bases show on the feed and the map. ${helper}, so a step may spawn one only where RaidableBases picks, at a difficulty, and it despawns on its own timer.${difficulties}`
}
if (probe && probe.plugins === false) {
return `${name} is loaded — its bases show on the feed and the map. ${helper}, and this framework cannot run its console command, so a step cannot spawn one (Rust-Plugins#34).`
}
return `${name} is loaded — its bases show on the feed and the map. ${helper}, so a step may spawn one only where RaidableBases picks, and it despawns on its own timer.${difficulties}`
}
/**

View File

@@ -444,7 +444,8 @@ export default function ModConfig() {
setTier('form')
}}
>
{entry.path}
{/* A RaidableBases profile (D205) travels under a virtual path; show its name. */}
{entry.profile ? `Profile · ${entry.path.split('/').pop()}` : entry.path}
</button>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{Math.round(entry.bytes / 102.4) / 10} KB

View File

@@ -542,7 +542,7 @@ function GroupPanel({ view, group, act, busy, chatFields }) {
{tab === 'servers' && (
<div className="sans" style={{ fontSize: '0.82rem' }}>
<p className="dim" style={{ margin: '0 0 8px' }}>
A group belongs to one server unless it is shared (D189). A shared group carries the same permissions and players on every server
A group belongs to one server unless it is shared. A shared group carries the same permissions and players on every server
it is on; a change made in one game gives that server its own copy.
</p>
<label style={{ display: 'block', marginBottom: 6 }}>
@@ -781,11 +781,17 @@ export default function Permissions() {
</select>
</label>
{sync && (
sync.inSync ? <Chip tone="good">in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''}</Chip>
sync.inSync && sync.waitingToLand > 0
? (
<Chip tone="warn" title="A grant to a Steam id this server has never seen lands when that player first joins: every sync pushes it again">
in sync · {sync.waitingToLand} {sync.waitingToLand === 1 ? 'waits' : 'wait'} for their player to join
</Chip>
)
: sync.inSync ? <Chip tone="good">in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''}</Chip>
: sync.state === 'failed' ? <Chip tone="bad" title={sync.error || ''}>last sync failed</Chip>
: <Chip tone="warn">waiting for a sync</Chip>
)}
{sync && !sync.importedAt && <Chip tone="warn" title="The first sync imports everything on the server (D198)">not imported yet</Chip>}
{sync && !sync.importedAt && <Chip tone="warn" title="The first sync imports everything on the server">not imported yet</Chip>}
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync(server.id))}>Sync now</button>
</>
)}

View File

@@ -130,7 +130,7 @@ function formFrom(server) {
isNew: false,
id: server.id,
name: server.name,
sidecarBaseUrl: server.sidecarBaseUrl,
sidecarBaseUrl: server.sidecarBaseUrl || '',
sidecarToken: '',
enabled: server.enabled,
sortOrder: server.sortOrder,
@@ -264,7 +264,7 @@ export default function ServerSettings() {
</span>
</div>
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
{s.sidecarBaseUrl} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
{s.sidecarBaseUrl || 'sidecar address hidden'} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
{s.sidecarProtocol != null && s.sidecarProtocol !== s.protocol ? ` (sidecar speaks ${s.sidecarProtocol})` : ''}
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
</div>

View File

@@ -385,6 +385,7 @@ const MAP_LAYERS = [
{ 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.' },
{ id: 'raids', label: 'Raid bases', hint: 'RaidableBases’ live bases, their difficulty and when they despawn, and which an event of this site spawned. Needs RaidableBases on the server.' },
]
const RANK = { public: 0, signed_in: 1, staff: 2 }

View File

@@ -377,6 +377,15 @@ export default function Account() {
<li>Type it below. It works once.</li>
</ol>
{/* D392: core's "Link Steam" is a second way to make this link. Unlinking
Steam there removes only the sign-in (D393), so this page says where a
Rust link is removed. */}
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.86rem', maxWidth: '60ch' }}>
If this site offers Steam sign-in, linking Steam on your <a href="/account" className="link-accent">account
page</a> links that Steam account here too. Unlinking it there only stops you signing in with it; remove a
Rust link here.
</p>
<LinkForm onLinked={reload} />
{loading && <Loading />}

View File

@@ -178,7 +178,7 @@ test('below the presence audience the same line names nobody (D376)', () => {
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')
assert.equal(hack.verb, 'A locked crate on Large Oil Rig is being hacked')
})
test('a named line reads as the plan writes it', () => {
@@ -203,8 +203,9 @@ test('every type the server knows has its own words, and an unknown one says its
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'])
test('the world filter asks for world events, Dynamic PVP zones opening, Dangerous Treasures, Guarded Crate and RaidableBases, which the server serves publicly', () => {
assert.deepEqual(kindsFor('world'), ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended', 'raid.started', 'raid.completed', 'raid.purchased', 'raid.locked'])
assert.ok(!FEED_KINDS.includes('raid.ended'), 'a raid base despawning is a staff line')
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'))
@@ -299,3 +300,56 @@ test('an ending with nobody clearing it, and a timeout for a staff reader, are t
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')
})
// ── RaidableBases (protocol 14, PLAN_REDESIGNS §11.4) ─────────────────────
//
// Every difficulty here is invented (D386): the line says the plugin's own name.
const raid = (kind, frame) => row(kind, { kind, ...frame })
test('a raid base appearing says its difficulty as the plugin named it, where, and whether it is PvP', () => {
const line = describe(raid('raid.started', { difficulty: 'Wobbly', grid: 'G7', pvp: true }))
assert.equal(line.verb, 'A Wobbly raid base appeared')
assert.equal(line.detail, 'G7 · PvP')
assert.equal(line.actor, null)
assert.equal(describe(raid('raid.started', { difficulty: 'Eerie', grid: 'G7' })).verb, 'An Eerie raid base appeared')
assert.equal(describe(raid('raid.started', { grid: 'G7' })).verb, 'A raid base appeared')
})
test('a raid names its raiders only when the server sent the names, and otherwise counts them', () => {
const named = describe(raid('raid.completed', { difficulty: 'Wobbly', grid: 'G7', names: ['Alice', 'Bob', 'Cy'], raiderCount: 4 }))
assert.equal(named.actor, 'Alice, Bob and 2 others')
assert.equal(named.verb, 'raided a Wobbly raid base')
const two = describe(raid('raid.completed', { difficulty: 'Wobbly', names: ['Alice', 'Bob'], raiderCount: 2 }))
assert.equal(two.actor, 'Alice and Bob')
const unnamed = describe(raid('raid.completed', { difficulty: 'Wobbly', grid: 'G7', raiderCount: 3 }))
assert.equal(unnamed.actor, null)
assert.equal(unnamed.verb, 'A Wobbly raid base was raided')
assert.equal(unnamed.detail, 'G7 · by 3 players')
})
test('a purchase and an admin lock name the player only when the server sent the name (D390), and a despawn is its own line', () => {
assert.equal(describe(raid('raid.purchased', { difficulty: 'Wobbly', name: 'Alice' })).actor, 'Alice')
assert.equal(describe(raid('raid.purchased', { difficulty: 'Wobbly', name: 'Alice' })).verb, 'bought a Wobbly raid base')
assert.equal(describe(raid('raid.purchased', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base was bought')
assert.equal(describe(raid('raid.locked', { difficulty: 'Wobbly', name: 'Alice' })).verb, 'A Wobbly raid base was locked to Alice')
assert.equal(describe(raid('raid.locked', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base was locked to a player')
assert.equal(describe(raid('raid.ended', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base despawned')
})
test('a kill line says the weapon by name', () => {
const named = describe(row('player.death', {
name: 'Bob', attackerType: 'player', attackerName: 'Alice',
weapon: 'ak47u.entity', weaponName: 'Assault Rifle', distance: 22,
}))
assert.match(named.detail, /with Assault Rifle/)
assert.doesNotMatch(named.detail, /entity/)
const older = describe(row('player.death', {
name: 'Bob', attackerType: 'player', attackerName: 'Alice', weapon: 'bolt_rifle.entity',
}))
assert.match(older.detail, /with bolt rifle/)
assert.doesNotMatch(older.detail, /entity/)
})

View File

@@ -11,7 +11,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { ago, attacker, clock, coins, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
import { ago, attacker, clock, coins, contrastInk, count, day, duration, nextWipe, prefab, shortId, weapon } from '../src/lib/format.js'
const NOW = Date.parse('2026-09-16T12:00:00Z')
@@ -147,3 +147,15 @@ test('an NPC attacker reads as a name: a family, or the prefab without its varia
test('a weapon keeps the light touch — attacker() is for what did the killing', () => {
assert.equal(prefab('rifle.ak'), 'rifle ak')
})
test('a kill prints the weapon the server named, and never an entity ending', () => {
// weaponName is the server's (model/titles/weaponNames.js): the game's own name for a gun.
assert.equal(weapon({ weapon: 'ak47u.entity', weaponName: 'Assault Rifle' }), 'Assault Rifle')
// A server too old to send it: the ending dropped, the rest as words.
assert.equal(weapon({ weapon: 'ak47u.entity' }), 'ak47u')
assert.equal(weapon({ weapon: 'bolt_rifle.entity' }), 'bolt rifle')
assert.equal(weapon({ weapon: 'machete.weapon' }), 'machete')
assert.equal(weapon({ weapon: 'rifle.ak' }), 'rifle ak')
assert.equal(weapon({}), '')
assert.equal(weapon(null), '')
})

View File

@@ -8,7 +8,7 @@ import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { eventTip, isBoss, pvpTip, PVP_NAMES, titlesFrom, unknownRuns } from '../src/lib/mapEvents.js'
import { eventTip, isBoss, pvpTip, PVP_NAMES, raidTip, titlesFrom, unknownRuns } from '../src/lib/mapEvents.js'
const CALENDAR = {
entries: [
@@ -59,3 +59,10 @@ test('a Dynamic PVP ring says what it is around', () => {
const { GENERAL_EVENTS } = createRequire(import.meta.url)('../../server/dynamicPvp.js')
assert.deepEqual(Object.keys(PVP_NAMES).sort(), [...GENERAL_EVENTS].sort())
})
test('a raid base’s tooltip says its own difficulty and base, PvP, when it despawns, and whether an event of this site spawned it', () => {
const now = 1_000_000
assert.equal(raidTip({ difficulty: 'Wobbly', base: 'wobble3', pvp: true, despawnAt: now + 42 * 60000 }, now), 'Raid base · Wobbly · wobble3 · PvP · despawns in 42 min')
assert.equal(raidTip({ difficulty: 'Wobbly', runId: '7' }, now), 'Raid base · Wobbly · this site’s event')
assert.equal(raidTip({ despawnAt: now - 1 }, now), 'Raid base')
})

View File

@@ -0,0 +1,45 @@
// ── A chat line's menu (USER_SAFETY.md §4.2) ───────────────────────────────
//
// The server decides what a reader sees and whether a line's speaker can be
// blocked; this asks that the menu shows exactly what it said, and nothing a
// signed-out reader could use.
import test from 'node:test'
import assert from 'node:assert/strict'
import { CHAT_TARGET, HIDE_ROLES, REPORT_REASONS, chatActions } from '../src/lib/safety.js'
const chat = (extra = {}) => ({ id: 1, kind: 'player.chat', steamId: '7656', frame: { name: 'Rusty', message: 'gg' }, ...extra })
test('the target is the one the server registers', () => {
assert.equal(CHAT_TARGET, 'rust.chat')
})
test('the six reasons are core’s report enum', () => {
assert.deepEqual(REPORT_REASONS.map(([value]) => value).sort(), ['abuse', 'illegal', 'impersonation', 'other', 'sexual', 'spam'])
assert.ok(REPORT_REASONS.every(([, label]) => typeof label === 'string' && label.length > 0))
})
test('a signed-out reader is told to sign in, and offered nothing else (S6)', () => {
assert.deepEqual(chatActions(chat({ canBlock: true }), null), { signIn: true, report: false, hide: false, block: false, staff: null })
})
test('a member can report and hide any line, and block only where the server said so', () => {
const member = { role: 'player' }
assert.deepEqual(chatActions(chat({ canBlock: true }), member), { signIn: false, report: true, hide: true, block: true, staff: null })
assert.equal(chatActions(chat({ canBlock: false }), member).block, false)
// A line with no word from the server is not blockable.
assert.equal(chatActions(chat(), member).block, false)
})
test('admins and moderators can hide for everyone, and undo it; editors cannot', () => {
assert.deepEqual([...HIDE_ROLES].sort(), ['admin', 'moderator'])
assert.equal(chatActions(chat(), { role: 'moderator' }).staff, 'hide')
assert.equal(chatActions(chat({ hidden: true }), { role: 'admin' }).staff, 'unhide')
assert.equal(chatActions(chat(), { role: 'editor' }).staff, null)
})
test('only chat lines have a menu', () => {
assert.equal(chatActions({ id: 2, kind: 'player.death' }, { role: 'player' }), null)
assert.equal(chatActions(null, { role: 'player' }), null)
})

View File

@@ -1,6 +1,6 @@
{
"_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.",
"coreApi": "^1.15.0",
"coreApi": "^1.21.0",
"triggers": [
{
"id": "rust.base.destroyed",

View File

@@ -2,7 +2,7 @@
"id": "rust",
"name": "Rust",
"version": "0.1.0",
"coreApi": "^1.15.0",
"coreApi": "^1.21.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
@@ -13,5 +13,10 @@
"player": ["/rust"]
},
"extensions": ["admin.users.detail"],
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"]
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"],
"demoLocks": [
{ "method": "PUT", "path": "/admin/rust/servers/:id" },
{ "method": "DELETE", "path": "/admin/rust/servers/:id" },
{ "method": "DELETE", "path": "/player/rust/links/:steamId" }
]
}

View File

@@ -251,6 +251,16 @@
"path": "/api/v1/admin/rust/permissions/groups/:id",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/chat/:id/hide",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/chat/:id/unhide",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/config/:serverId/file",

View File

@@ -82,6 +82,15 @@ const PUBLIC_KINDS = Object.freeze([
// and the position only for staff.
'gc.started',
'gc.ended',
// Protocol 14: a RaidableBases base appeared, was raided, was bought, or was
// locked to a player by an admin (PLAN_REDESIGNS §11.4, D390). Public as
// kinds, read per viewer by `raidableBases.shapeFor`: the raiders' and a
// buyer's names only for the presence audience, Steam ids, the position and a
// run's key only for staff.
'raid.started',
'raid.completed',
'raid.purchased',
'raid.locked',
])
/**
@@ -153,6 +162,9 @@ const STAFF_KINDS = Object.freeze([
// 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',
// Protocol 14 (§11.4): a RaidableBases base despawned. Staff's; the map shows
// the base go.
'raid.ended',
])
/**

View File

@@ -175,6 +175,18 @@ module.exports = {
pushActivity: (items) => need().teams.activity.push(items),
},
// Report, block and hide (MODULE_API.md §2.3, 1.21.0; USER_SAFETY.md S11, S13).
// Read only, both of them. `forViewer(userId)` is what one reader has blocked
// and hidden that touches this module — the accounts they block (already
// checked against staff, S8, and with linked Steam blocks already turned into
// account blocks, S14), this module's own unlinked Steam blocks, and their hides
// on `rust.chat`. `staffAmong(userIds)` is which accounts are staff, for a chat
// line's `canBlock`. Wrapped as calls, like `emit`. See `safety.js`.
safety: {
forViewer: (userId) => need().safety.forViewer(userId),
staffAmong: (userIds) => need().safety.staffAmong(userIds),
},
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
// only correct way to find a file you shipped, because the working directory is
// core's and the module's location is the loader's business.
@@ -186,4 +198,9 @@ module.exports = {
// origin to be relative to. A getter over core's getter, so an operator who
// changes `APP_BASE_URL` is read at the call, never captured at load.
get baseUrl() { return need().site.baseUrl },
// Whether this is the public demo (MODULE_API 1.18.0, DEMO_MODE.md §3.4, §3.5).
// Read through `demo.js`, which holds every lock it switches on. Only a real
// `true` counts, so a core that predates the member reads as "not the demo".
get demoMode() { return need().demoMode === true },
}

View File

@@ -246,6 +246,18 @@ CREATE TABLE IF NOT EXISTS rust_events (
KEY idx_rust_events_created (created_at)
);
-- Staff's "Hide for everyone" on a chat line (USER_SAFETY.md §4.2, S10). Set from
-- core's Reports queue or from the line's own menu, and cleared from that menu.
-- A hidden line is left out of the feed for everybody but staff, who still get it,
-- marked, so a hide can be checked and undone where it lives. The row is kept, and
-- the retention sweep prunes it like any other.
--
-- `hidden_by` is the staff account's id, with no foreign key: the hide stays a
-- hide when the account that made it is deleted, and nothing reads it back as a
-- person. ALTER rather than columns above, so an existing install gets them too.
ALTER TABLE rust_events ADD COLUMN IF NOT EXISTS hidden_at DATETIME NULL;
ALTER TABLE rust_events ADD COLUMN IF NOT EXISTS hidden_by INT NULL;
-- ── Who is on right now ───────────────────────────────────────────────────
--
@@ -1394,3 +1406,7 @@ ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS treasures_won INT UN
-- 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;
-- RaidableBases (protocol 14, PLAN_REDESIGNS §11.4, D202): the bases a player
-- completed as one of the plugin's own raiders, from the tally like the others (Warlord).
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS raids_completed INT UNSIGNED NOT NULL DEFAULT 0;

139
server/demo.js Normal file
View File

@@ -0,0 +1,139 @@
// ── The public demo's module-side locks (DEMO_MODE.md §3.4, §3.5) ──────────
//
// Core locks this module's two rig-link routes and the player's Steam unlink by
// `demoLocks` in module.json. What a route list cannot express lives here, behind
// `ctx.demoMode` (MODULE_API 1.18.0), and every handler that enforces one of these
// asks this file:
//
// the staff Steam unlink on Admin → Users (step-5 walk, W4): it serves under
// core's users mount, which a module's demoLocks may not name, so its handler
// refuses instead. The demo player's seeded link stays put either way;
// the console-command deny list (§3.4, DM11): the commands that would break
// the rig for every visitor until the next wipe. Asked at save, through
// core's `demoRefuses` hook (1.19.0, DM18), and again at run;
// the bridge's own config (§3.5, DM14): neither readable nor writable, because
// it holds the bridge's link to the sidecar and its credentials;
// secrets in every other config: redacted on the way out, read-only on the
// way in, and no raw-JSON saves.
//
// Off the demo nothing here refuses anything. The server is the lock (DM10):
// the UI may explain a refusal, but no screen is relied on to make one.
const core = require('./core')
const CODE = 'DEMO_LOCKED'
const MESSAGE = 'This is turned off on the demo site.'
/** Whether this is the public demo. */
function isDemo() {
return core.demoMode
}
/** The demo's refusal, in core's shape, so core's one toast shows it. */
function refuse(res, message = MESSAGE, extra = {}) {
return res.status(403).json({ message, code: CODE, ...extra })
}
// ── The console-command deny list (§3.4, DM11) ─────────────────────────────
/** Denied by first word, exactly. */
const DENIED = new Set(['quit', 'restart', 'oxide.unload', 'oxide.reload', 'fps.limit', 'server.tickrate'])
/** Denied by prefix: every RCON command. */
const DENIED_PREFIXES = ['rcon.']
/**
* The command a line runs, as the deny list compares it: the first word,
* lower-cased, with a leading `global.` stripped (Rust accepts `global.quit`)
* and the `o.` short form spelled `oxide.` (`o.unload`). Only the first word,
* so arguments cannot hide a denied command; `;` does not split a Rust console
* line (the §16 spike), so there is no second command to look for.
*/
function commandWord(line) {
let word = String(line === undefined || line === null ? '' : line).trim().split(/\s+/)[0].toLowerCase()
if (word.startsWith('global.')) word = word.slice('global.'.length)
if (word.startsWith('o.')) word = `oxide.${word.slice('o.'.length)}`
return word
}
/** Whether one console line is denied on the demo, by its first word. */
function isDeniedCommand(line) {
const word = commandWord(line)
if (!word) return false
return DENIED.has(word) || DENIED_PREFIXES.some((prefix) => word.startsWith(prefix))
}
/**
* The refusal sentence for a list of console lines, or null when none is
* denied — or when this is not the demo. Names the line as the author typed it.
*/
function deniedCommand(lines) {
if (!isDemo()) return null
const items = Array.isArray(lines) ? lines : lines === undefined || lines === null ? [] : [lines]
const hit = items.find((line) => isDeniedCommand(line))
return hit === undefined ? null : `"${String(hit).trim()}" is turned off on the demo site.`
}
// ── The bridge's own config (§3.5, DM14) ───────────────────────────────────
/**
* The bridge plugin's name. The catalogue's `self` is the plugin's own word for
* it and is used when it is there; this is the floor, so a catalogue that could
* not be read never unlocks the file (the lock fails closed).
*/
const BRIDGE = 'RunicGateway'
/**
* A config path in the form the host will resolve it to, for the bridge test
* only: `.` segments dropped, each segment trimmed of the spaces and trailing
* dots Windows ignores, and lower-cased. `./RunicGateway.json` and
* `RunicGateway /x.json` are the bridge's files on some host, so they are the
* bridge's files here.
*/
function canonicalSegments(path) {
return String(path || '')
.split(/[\\/]+/)
// A `.` segment trims to nothing and is dropped with the empty ones.
.map((segment) => segment.trim().replace(/[. ]+$/, ''))
.filter(Boolean)
.map((segment) => segment.toLowerCase())
}
/**
* Whether a config path belongs to the bridge plugin: its first segment, or its
* file name at the root, is the bridge's name. `self` is the catalogue's.
*/
function isBridgePath(path, self) {
const names = new Set([BRIDGE.toLowerCase()])
if (self) names.add(String(self).toLowerCase())
const parts = canonicalSegments(path)
if (!parts.length) return false
const first = parts.length > 1 ? parts[0] : parts[0].replace(/\.json$/, '')
return names.has(first)
}
/** Whether a plugin name (a catalogue group, an audit row's plugin) is the bridge. */
function isBridgePlugin(plugin, self) {
const name = String(plugin || '').toLowerCase()
return name === BRIDGE.toLowerCase() || (self ? name === String(self).toLowerCase() : false)
}
// ── Secrets in every other config (§3.5) ───────────────────────────────────
/** What a redacted value reads as, in the text and in the form. */
const REDACTED = '(hidden on the demo site)'
module.exports = {
CODE,
MESSAGE,
BRIDGE,
REDACTED,
isDemo,
refuse,
commandWord,
isDeniedCommand,
deniedCommand,
canonicalSegments,
isBridgePath,
isBridgePlugin,
}

190
server/demoSeed.js Normal file
View File

@@ -0,0 +1,190 @@
// ── The public demo's seed: this module's rows (MODULE_API 1.18.0, DM15) ────
//
// `npm run seed:demo` hands each module its `modules.<id>` section of the
// fixture, with every `"$env:NAME"` already replaced, and the demo accounts' ids
// (DEMO_MODE.md §7.3). This module writes two kinds of row from it, through its
// own models; core never writes them:
//
// servers the link to the rig: the sidecar URL and token come from the host's
// .env, so the token is never in the repo
// links demo-player's stand-in Steam id (DM6), so the player screens have
// somebody to show
// npcs the NPC faction table and profiles of the demo default set (DM26)
// chat a BetterChat style per group (DM26)
//
// { "servers": [{ "id": "main", "name": "Demo", "sidecarBaseUrl": "$env:RUST_SIDECAR_URL",
// "sidecarToken": "$env:RUST_SIDECAR_TOKEN" }],
// "links": [{ "username": "demo-player", "steamId": "7656119…", "serverId": "main" }],
// "npcs": { "factions": [{ "a": "bandits", "b": "guards", "relation": "allied" }],
// "profiles": [{ "name": "bandit", "servers": ["main"], "body": { … } }] },
// "chat": [{ "serverId": "main", "group": "vip", "style": { "Title": "[VIP]", … } }] }
//
// **Why NPCs and chat come from here, not the rig's baseline (DM26).** The rig is
// restored before the site is wiped, and a fresh site imports what it finds
// (§7.1). That holds for permissions, but not for these two. The restored rig's
// RunicNPC says it is already managed, by the site before this one, so the new
// site adopts nothing (D244) and its first push empties the profiles. The import
// never adopts a group's chat style. So the site holds them from the start and
// pushes them, and the placements in the baseline spawn again.
//
// A chat style names a group the rig's import has not brought in yet, because
// the seed runs first. The group is created for that server, and the import
// then adopts the rig's title, rank and permissions into it. A style lists only
// the fields that differ from BetterChat's defaults.
//
// **It runs on every seed, so it upserts.** Core seeds content on a database's
// first run only, but calls this every time. The section's shape is checked
// before anything is written, and a problem throws, which fails the seed. A
// profile is checked by the NPC model as it is saved, as the admin page saves
// one, so a profile the model refuses fails the seed too.
const linksDb = require('./model/links/links.db')
const servers = require('./model/servers/servers.model')
const serversDb = require('./model/servers/servers.db')
const npcs = require('./model/npcs/npcs.model')
const npcsDb = require('./model/npcs/npcs.db')
const npcShape = require('./model/npcs/npcProfile')
const permApply = require('./model/permissions/permissions.apply')
const permDb = require('./model/permissions/permissions.db')
const chatStyle = require('./model/permissions/chatStyle')
const sidecar = require('./sidecarClient')
const core = require('./core')
const log = core.logger('demo')
const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
const STEAM_ID = /^7656119\d{10}$/
// Oxide group names as the permission model keeps them: trimmed and lower-cased.
const GROUP_NAME = /^[a-z0-9_.-]{1,64}$/
/** BetterChat's defaults for the group, with the fixture's fields over them. */
function fullStyle(group, style) {
return { ...chatStyle.defaults(String(group || '')), ...(style && typeof style === 'object' ? style : {}) }
}
/** Every problem with the section, as sentences. Empty when it is good to write. */
function checkSection(data, accounts = {}) {
const problems = []
if (!data || typeof data !== 'object' || Array.isArray(data)) return ['modules.rust must be an object']
const list = (key) => (data[key] === undefined ? [] : data[key])
for (const key of ['servers', 'links', 'chat']) {
if (data[key] !== undefined && !Array.isArray(data[key])) problems.push(`modules.rust.${key} must be an array`)
}
const n = data.npcs
if (n !== undefined) {
if (!n || typeof n !== 'object' || Array.isArray(n)) problems.push('modules.rust.npcs must be an object')
else {
if (n.factions !== undefined && !Array.isArray(n.factions)) problems.push('modules.rust.npcs.factions must be an array')
if (n.profiles !== undefined && !Array.isArray(n.profiles)) problems.push('modules.rust.npcs.profiles must be an array')
}
}
if (problems.length) return problems
if (n && n.factions !== undefined) {
const checked = npcShape.checkFactions(n.factions)
if (!checked.ok) problems.push(`modules.rust.npcs.${checked.error}`)
}
;((n && n.profiles) || []).forEach((p, i) => {
const at = `modules.rust.npcs.profiles[${i}]`
if (!p || typeof p !== 'object') return problems.push(`${at} must be an object`)
if (!npcShape.NAME_RULE.test(String(p.name || ''))) problems.push(`${at}.name must be 1 to 40 of a-z, 0-9, _ and -`)
if (!p.body || typeof p.body !== 'object' || Array.isArray(p.body)) problems.push(`${at}.body must be an object`)
return undefined
})
list('chat').forEach((c, i) => {
const at = `modules.rust.chat[${i}]`
if (!c || typeof c !== 'object') return problems.push(`${at} must be an object`)
if (!SERVER_ID.test(String(c.serverId || ''))) problems.push(`${at}.serverId must be a server id`)
if (!GROUP_NAME.test(String(c.group || ''))) problems.push(`${at}.group must be a group name`)
const checked = chatStyle.validateStyle(fullStyle(c.group, c.style))
if (!checked.ok) problems.push(`${at}.style: ${checked.errors.join(' ')}`)
return undefined
})
list('servers').forEach((s, i) => {
const at = `modules.rust.servers[${i}]`
if (!s || typeof s !== 'object') return problems.push(`${at} must be an object`)
if (!SERVER_ID.test(String(s.id || ''))) problems.push(`${at}.id must be lowercase letters, digits and hyphens`)
if (typeof s.name !== 'string' || !s.name.trim()) problems.push(`${at}.name is required`)
if (!/^https?:\/\//.test(String(s.sidecarBaseUrl || ''))) problems.push(`${at}.sidecarBaseUrl must be an http(s) URL`)
if (typeof s.sidecarToken !== 'string' || !s.sidecarToken) problems.push(`${at}.sidecarToken is required`)
return undefined
})
list('links').forEach((l, i) => {
const at = `modules.rust.links[${i}]`
if (!l || typeof l !== 'object') return problems.push(`${at} must be an object`)
if (!accounts[l.username]) problems.push(`${at}.username must be one of the demo accounts`)
if (!STEAM_ID.test(String(l.steamId || ''))) problems.push(`${at}.steamId must be a 17-digit Steam id`)
if (l.serverId !== undefined && !SERVER_ID.test(String(l.serverId))) problems.push(`${at}.serverId must be a server id`)
return undefined
})
return problems
}
/** The hook core calls: `onDemoSeed(data, { accounts })`. */
async function onDemoSeed(data, { accounts = {} } = {}) {
const problems = checkSection(data, accounts)
if (problems.length) throw new Error(`the Rust demo seed has problems: ${problems.join('; ')}`)
for (const s of data.servers || []) {
await serversDb.upsertServer({
id: s.id,
name: s.name.trim(),
sidecarBaseUrl: s.sidecarBaseUrl,
sidecarTokenEnc: servers.encryptToken(s.sidecarToken),
protocol: s.protocol === undefined ? sidecar.PROTOCOL_VERSION : Number(s.protocol),
enabled: s.enabled === undefined ? true : Boolean(s.enabled),
sortOrder: s.sortOrder === undefined ? 0 : Number(s.sortOrder),
})
log.info('demo server ready', { server: s.id })
}
// A link is a plain insert by design (D23), so the upsert is spelled out here:
// the right owner already holds it, or it is moved to them. Only the seed may
// move a link, and only to a demo account.
for (const l of data.links || []) {
const userId = accounts[l.username]
const steamId = String(l.steamId)
const existing = await linksDb.getBySteamId(steamId)
if (existing && Number(existing.userId) === Number(userId)) continue
if (existing) await linksDb.removeBySteamId(steamId)
await linksDb.insert({ steamId, userId, name: l.name || null, serverId: l.serverId || null })
log.info('demo link ready', { username: l.username })
}
// After the servers: a profile is refused for a server the site does not have.
const n = data.npcs || {}
if (n.factions !== undefined) {
await npcs.setFactions(n.factions)
log.info('demo NPC factions ready', { pairs: n.factions.length })
}
if (n.profiles && n.profiles.length) {
const existing = await npcsDb.listProfiles()
for (const p of n.profiles) {
const input = { name: p.name, body: p.body, servers: p.servers, allServers: p.allServers, killsScope: p.killsScope }
const same = existing.find((e) => e.name === p.name && !e.replaced)
if (same) await npcs.update(same.id, input)
else await npcs.create(input)
log.info('demo NPC profile ready', { profile: p.name })
}
}
for (const c of data.chat || []) {
const name = String(c.group)
let group = await permApply.groupOn(name, c.serverId)
if (!group) {
const id = await permDb.insertGroup({ name, title: name })
await permDb.setGroupServers(id, { allServers: false, servers: [c.serverId] })
group = { id }
}
await permDb.setGroupChat(group.id, chatStyle.validateStyle(fullStyle(name, c.style)).fields)
await permDb.markDirty(c.serverId)
log.info('demo chat style ready', { server: c.serverId, group: name })
}
}
module.exports = { checkSection, onDemoSeed }

View File

@@ -41,6 +41,7 @@ const core = require('./core')
const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
const rewards = require('./eventRewards')
const demo = require('./demo')
const log = core.logger('commands')
@@ -115,6 +116,9 @@ function checkConfirm(params, sentence) {
/** The first command that names a placeholder, or null: only a reward has someone to fill one with. */
const withPlaceholder = (lines) => lines.find((line) => PLACEHOLDER.test(line)) || null
/** A `multiple` param as a list: a bare value is a list of one, nothing is none. */
const asList = (raw) => (raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw])
/** A command list's price: at least one, so the switchboard sees the dimension. */
function pricedCommands(raw) {
const checked = checkCommands(raw, { required: false })
@@ -169,7 +173,7 @@ function replyDetail(data) {
...(replies.length
? { replies: replies.map((r) => ({ command: String(r.command || ''), reply: String(r.reply || ''), ...(r.steamId ? { steamId: String(r.steamId) } : {}) })) }
: {}),
...(skippedCount ? { skipped, skippedCount, skippedNote: 'skipped: not on the server when the reward ran (D383)' } : {}),
...(skippedCount ? { skipped, skippedCount, skippedNote: 'skipped: not on the server when the reward ran' } : {}),
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing was run twice' } : {}),
}
}
@@ -190,6 +194,11 @@ async function checkRun(params) {
const teardown = checkCommands(params.teardown, { field: 'teardown commands', required: false })
if (!teardown.ok) return { ok: false, retry: false, error: teardown.error }
// The demo's deny list (DEMO_MODE.md §3.4), at run: core asked at save
// (demoRefuses), but a definition can predate the list.
const denied = demo.deniedCommand([...commands.value, ...teardown.value])
if (denied) return { ok: false, retry: false, error: denied }
const placeholder = withPlaceholder([...commands.value, ...teardown.value])
if (placeholder) {
return { ok: false, retry: false, error: `{steamid} and {name} are filled in only by Reward by console command, and "${placeholder}" has one` }
@@ -324,6 +333,10 @@ const commandRun = {
revert: revertRun,
// The demo's deny list at save (MODULE_API 1.19.0, DM18). Core asks only on the
// demo; null lets the step through.
demoRefuses: (p) => demo.deniedCommand([...asList(p.commands), ...asList(p.teardown)]),
// The site cannot ask a server whether a command's effect is still there, so
// a teardown not yet run is always in force: "I do not know" is never "gone".
async reconcile({ resources }) {
@@ -397,10 +410,15 @@ const commandReward = {
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) * pricedPeople(p) }),
demoRefuses: (p) => demo.deniedCommand(asList(p.commands)),
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const commands = checkCommands(params.commands)
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
const denied = demo.deniedCommand(commands.value)
if (denied) return { ok: false, retry: false, error: denied }
const confirm = checkConfirm(params, REWARD_SURE)
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }

View File

@@ -31,6 +31,7 @@ const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
const { gridOf } = require('./worldEvents')
const { LEVELS, reportOf } = require('./dangerousTreasures')
const live = require('./liveStatus')
const serversDb = require('./model/servers/servers.db')
const mapDb = require('./model/map/map.db')
@@ -131,7 +132,8 @@ const dtStart = {
if (!found.ok) return found
const name = found.server.name || found.server.id
const missing = missingOn(reportOf(await serversDb.dtReportFor(found.server.id)), name)
// Confirmed live before it refuses: the stored status lags a restart (W7).
const missing = await live.confirmed(found.server, async () => missingOn(reportOf(await serversDb.dtReportFor(found.server.id)), name), Boolean)
if (missing) return { ok: false, retry: false, error: missing }
if (verify) return { ok: true }

View File

@@ -26,14 +26,19 @@ const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS, location } = require('./eventWorld')
const { gridOf } = require('./worldEvents')
const { reportOf } = require('./guardedCrate')
const live = require('./liveStatus')
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)'
/**
* What the run log says of a crate the run leaves behind (D373, D380). No
* number: each of Guarded Crate's events has its own time limit, and the
* server's report names the events but not their limits (W18).
*/
const KEPT = "kept when the run ends: it ends when its last guard dies, or when nobody has killed one within its event's time limit in Guarded Crate"
/** 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([
@@ -154,7 +159,8 @@ const gcPlace = {
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)
// Confirmed live before it refuses: the stored status lags a restart (W7).
const missing = await live.confirmed(found.server, async () => missingOn(reportOf(await serversDb.gcReportFor(found.server.id)), name, wanted), Boolean)
if (missing) return { ok: false, retry: false, error: missing }
if (verify) return { ok: true }

View File

@@ -452,7 +452,7 @@ const OPTION_SOURCES = [
{
id: 'rust.options.servers',
label: 'Rust servers',
description: 'Every enabled server on this site. A lease holds a value on one of them (D73).',
description: 'Every enabled server on this site. A lease holds a value on one of them.',
async resolve() {
const list = await servers.listForPolling()
return list.map((s) => ({ value: s.id, label: s.name || s.id }))

View File

@@ -0,0 +1,471 @@
// ── Spawning a RaidableBases base (protocol 14, PLAN_REDESIGNS §11.4) ───────
//
// `rust.raidbase.spawn`, "Spawn a raid base": one of RaidableBases' bases for a
// run, at a difficulty, a profile or a named base, where RaidableBases picks
// (D203) or at a spot the admin gave (D207), with settings for this one spawn
// (D205) and the lock to the first attacker set for it (D206).
//
// ── Two routes, by what the server has (D348, D387) ─────────────────────────
//
// * **With the raids helper** (`RunicGatewayRaids.cs`, D204) the base is the
// RUN's: the plugin answers with an id, the base is a ledger resource like a
// zone (`reversible: 'ledger'`), teardown despawns that one base and no
// other, and if RaidableBases ends it first (its own timer, or raided) the
// row is marked `expired` (D170). A spot, a profile, a base and the settings
// all need it.
// * **Without it** the plugin can still be asked for a base at a difficulty
// (the paid edition's own call), or for any base (the free edition's console
// command, which says nothing back). That base is NOT the run's: it records
// no resource, despawns on RaidableBases' own timer, and the run log says so.
//
// ── Names are the plugin's (D386) ──────────────────────────────────────────
//
// Difficulty, profile and base pickers list what each server's installed copy
// reported at hello. Nothing here knows "Easy" or "Nightmare": an operator's
// own difficulty is offered as written, and a server that has said nothing
// offers nothing.
//
// ── The spot check (D208, D388, D389, D391) ─────────────────────────────────
//
// At run time the plugin refuses a spot with a player, a building, a deployable
// or a raid base on it, or water under it unless the profile is built for
// water; rocks and cliffs are warnings, carried into the run log. The dry run
// asks the same check and returns its findings as WARNINGS (MODULE_API 1.16.0):
// advice, because the world can change before the event runs.
//
// **RaidableBases' grid.** For up to ten minutes after it loads (666 s on the
// rigs' 6000 map, §11.4.2) the plugin refuses every spawn; the plugin calls that
// `rb-not-ready`, and the step asks core to try again in ten minutes
// (`retryAfterMs`, D371), as a Dangerous Treasures start does (D365).
const client = require('./sidecarClient')
const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS, location, revert, reconcile } = require('./eventWorld')
const { gridOf } = require('./worldEvents')
const { reportOf, helperPatched } = require('./raidableBases')
const live = require('./liveStatus')
const serversDb = require('./model/servers/servers.db')
const mapDb = require('./model/map/map.db')
/** The resource kind a run's base is ledgered under: the world registry's, so `eventWorld`'s revert and reconcile serve it. */
const OWNED_KIND = 'world'
/** How long core waits before trying a spawn RaidableBases was not ready for (its grid, D365's pattern). */
const NOT_READY_RETRY_MS = 10 * 60 * 1000
/** What the step says while the grid builds. */
const NOT_READY = 'RaidableBases is not ready yet: it is still building its spawn grid'
/** What the run log says of a base that is not the run's. */
const OWN_TIMER = 'not the run’s: RaidableBases despawns it on its own timer, and teardown leaves it'
/** The plugin's refusals a second attempt would repeat. `rb-busy` and `rb-not-ready` are not: they pass. */
const PERMANENT = new Set([
'events-disabled',
'malformed',
'rb-missing',
'copypaste-missing',
'helper-needed',
'helper-unsupported',
'bad-difficulty',
'bad-profile',
'bad-base',
'console-unavailable',
'no-monument',
'out-of-range',
'off-map',
'in-water',
// The spot is taken. A player may walk off, a deployable stays: a refusal on a
// spot is reported, and the run pauses on its own `on_failure` rather than
// spawning the base somewhere the admin did not choose (D208).
'spot-blocked',
'refused',
])
/** The on/off switches a step may set for one spawn; blank keeps the profile's own (or, for the lock, the config's). */
const SWITCHES = Object.freeze([
{ value: 'on', label: 'On' },
{ value: 'off', label: 'Off' },
])
const BUDGETS = [
{
id: 'rust.raids',
label: 'RaidableBases bases',
unit: 'bases',
description: 'RaidableBases bases an event spawns, each one counted. With the raids helper a base is the run’s and despawns at teardown; without it RaidableBases despawns it on its own timer.',
},
]
/** Every enabled server's report, with its name. */
async function reports() {
const rows = await serversDb.rbReports().catch(() => [])
return rows.map((row) => ({ serverId: row.serverId, name: row.name || row.serverId, ...reportOf(row) }))
}
/** Each value once, with the servers that have it, as `{ value, label }`. */
function byValue(entries) {
const out = new Map()
for (const { value, label, server } of entries) {
const key = value.toLowerCase()
if (!out.has(key)) out.set(key, { value, labels: new Map() })
// Labels compare ignoring case, as the names do.
const labels = out.get(key).labels
const said = label.toLowerCase()
if (!labels.has(said)) labels.set(said, { label, servers: [] })
if (!labels.get(said).servers.includes(server)) labels.get(said).servers.push(server)
}
// One name, one row. Where servers describe it differently (a profile Hard on
// one and Expert on another), each server's own words, not the first's (W13).
return [...out.values()].map((e) => ({
value: e.value,
label: [...e.labels.values()].map(({ label, servers }) => `${label} — ${servers.join(', ')}`).join('; '),
}))
}
const OPTION_SOURCES = [
{
id: 'rust.options.raid_difficulties',
label: 'RaidableBases difficulties',
description: "The difficulties each server's RaidableBases lists, by its own names and levels. Nothing is offered for a server that has not said.",
async resolve() {
const entries = []
for (const r of await reports()) {
const rb = r.raidableBases
if (!rb || !rb.loaded) continue
for (const d of rb.difficulties) entries.push({ value: d.name, label: `${d.name} (level ${d.level})`, server: r.name })
}
return byValue(entries)
},
},
{
id: 'rust.options.raid_profiles',
label: 'RaidableBases profiles',
description: "The profiles each server's RaidableBases loaded, with their difficulty. Needs the raids helper, which is what reads them.",
async resolve() {
const entries = []
for (const r of await reports()) {
const profiles = r.raidableBases && r.raidableBases.loaded ? r.raidableBases.profiles : null
for (const p of profiles || []) {
entries.push({ value: p.name, label: `${p.name}${p.difficulty ? ` · ${p.difficulty}` : ''}${p.enabled ? '' : ' (disabled)'}`, server: r.name })
}
}
return byValue(entries)
},
},
{
id: 'rust.options.raid_bases',
label: 'RaidableBases bases',
searchable: true,
description: "The bases each server's RaidableBases can paste (those with a CopyPaste file), by profile. Needs the raids helper.",
async resolve({ q } = {}) {
const term = String(q || '').trim().toLowerCase()
const entries = []
for (const r of await reports()) {
const profiles = r.raidableBases && r.raidableBases.loaded ? r.raidableBases.profiles : null
for (const p of profiles || []) {
for (const b of p.bases) {
if (term && !b.toLowerCase().includes(term) && !p.name.toLowerCase().includes(term)) continue
entries.push({ value: b, label: `${b} · ${p.name}${p.difficulty ? ` (${p.difficulty})` : ''}`, server: r.name })
}
}
}
return byValue(entries)
},
},
{
id: 'rust.options.raid_switch',
label: 'On or off',
description: 'On or off for this one base. Left blank, the profile’s own setting stands (the lock: RaidableBases’ config).',
async resolve() {
return SWITCHES.map((s) => ({ ...s }))
},
},
]
/** A switch param: true, false, or undefined for blank. Null for anything else. */
function switchOf(raw) {
if (raw === undefined || raw === null || raw === '') return undefined
const text = String(raw).trim().toLowerCase()
if (text === 'on' || text === 'true') return true
if (text === 'off' || text === 'false') return false
return null
}
/** A whole-number param in a range, undefined for blank, null when it is not one. */
function intOf(raw, min, max) {
if (raw === undefined || raw === null || raw === '') return undefined
const n = Number(raw)
return Number.isInteger(n) && n >= min && n <= max ? n : null
}
/** A number param, undefined for blank, null when it is not one. */
function floatOf(raw) {
if (raw === undefined || raw === null || raw === '') return undefined
const n = Number(raw)
return Number.isFinite(n) ? n : null
}
/**
* The step's params as the wire's, or `{ error }`. Validates shapes only; the
* names are checked against the server's report by `missingOn`.
*/
function wireOf(params) {
const wire = {}
const text = (v) => String(v === undefined || v === null ? '' : v).trim()
for (const field of ['difficulty', 'profile', 'base']) {
if (text(params[field])) wire[field] = text(params[field])
}
const spot = Boolean(text(params.monument)) || params.x !== undefined && params.x !== '' || params.z !== undefined && params.z !== ''
let serverId = text(params.server)
if (spot) {
const where = location(params)
if (!where.ok) return { error: where.error }
const { y, ...at } = where.wire
Object.assign(wire, at)
serverId = where.serverId
const adjust = floatOf(params.heightAdjust)
const height = floatOf(params.height)
if (adjust === null || height === null) return { error: 'a height is a number of metres' }
if (adjust !== undefined && height !== undefined) return { error: 'a spot takes a height adjustment or an exact height, not both' }
if (adjust !== undefined) wire.heightAdjust = adjust
if (height !== undefined) wire.height = height
} else if (floatOf(params.heightAdjust) !== undefined || floatOf(params.height) !== undefined) {
return { error: 'a height needs a spot: a monument, or x and z' }
}
if (!serverId) return { error: 'the step does not say which server — pick one' }
for (const flag of ['pvp', 'npcs', 'lock']) {
const value = switchOf(params[flag])
if (value === null) return { error: `${flag} is on, off or blank, not "${params[flag]}"` }
if (value !== undefined) wire[flag] = value
}
const npcCount = intOf(params.npcCount, 0, 20)
if (npcCount === null) return { error: `the NPC count is a whole number from 0 to 20, not "${params.npcCount}"` }
if (npcCount !== undefined) wire.npcCount = npcCount
const despawn = intOf(params.despawnMinutes, 1, 24 * 60)
if (despawn === null) return { error: `despawn minutes are a whole number from 1 to 1440, not "${params.despawnMinutes}"` }
if (despawn !== undefined) wire.despawnMinutes = despawn
return { serverId, wire, spot }
}
/** Whether the wire asks for anything only the raids helper can do. */
function needsHelper(wire, spot) {
return spot || ['profile', 'base', 'pvp', 'npcs', 'npcCount', 'despawnMinutes', 'lock'].some((k) => wire[k] !== undefined)
}
const eq = (a, b) => String(a).toLowerCase() === String(b).toLowerCase()
/**
* 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, wire, spot) {
const rb = report.raidableBases
if (!rb) return null
if (!rb.loaded) return `RaidableBases is not installed on ${name}`
if (rb.copyPaste && !rb.copyPaste.loaded) return `RaidableBases is loaded on ${name} but CopyPaste is missing: it pastes every base`
const patched = helperPatched(report)
if (needsHelper(wire, spot) && !patched) {
const state = rb.helper.state
const why = state === 'missing'
? `RunicGatewayRaids.cs is not installed on ${name}`
: `RunicGatewayRaids.cs could not patch ${name}'s RaidableBases (${rb.helper.reason || state})`
return `${why}, so a spot, a profile, a base and settings for one spawn cannot be used there; without them RaidableBases picks the spot and despawns the base on its own timer`
}
if (!patched && !rb.spawnCall && report.consolePlugins === false) {
return `${name}'s modding framework cannot run RaidableBases' console command from a plugin, and this edition has no spawn call another plugin can use; install RunicGatewayRaids.cs to spawn a base there (Rust-Plugins#34)`
}
if (wire.difficulty && rb.difficulties.length > 0 && !rb.difficulties.some((d) => eq(d.name, wire.difficulty))) {
return `"${wire.difficulty}" is not one of RaidableBases' difficulties on ${name} (${rb.difficulties.map((d) => d.name).join(', ')})`
}
if (rb.profiles) {
const profile = wire.profile ? rb.profiles.find((p) => eq(p.name, wire.profile)) : null
if (wire.profile && !profile) return `RaidableBases on ${name} has no profile "${wire.profile}"`
if (profile && wire.difficulty && profile.difficulty && !eq(profile.difficulty, wire.difficulty)) {
return `profile "${profile.name}" is ${profile.difficulty}, not ${wire.difficulty}`
}
if (wire.base) {
const owner = rb.profiles.find((p) => p.bases.some((b) => eq(b, wire.base)))
if (!owner) return `RaidableBases on ${name} has no base "${wire.base}" with a CopyPaste file`
if (profile && owner !== profile) return `base "${wire.base}" belongs to profile "${owner.name}", not "${profile.name}"`
if (wire.difficulty && owner.difficulty && !eq(owner.difficulty, wire.difficulty)) {
return `base "${wire.base}" is ${owner.difficulty}, not ${wire.difficulty}`
}
}
}
return null
}
/** The grid at a point, 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)
}
/** The dry run's advice on a spot (D208, D391): warnings, never a refusal. */
async function spotAdvice(server, name, wire) {
const { pvp, npcs, npcCount, despawnMinutes, lock, heightAdjust, height, ...ask } = wire
const result = await client.raidCheck(server, ask)
if (!result.ok) return [`the spot could not be checked now (${transportError(server, result, 'spot check')})`]
const data = result.data || {}
if (data.kind !== 'raid.checked') return [`the spot could not be checked now: ${pluginError(data, `${name} did not check the spot`)}`]
const advice = (Array.isArray(data.warnings) ? data.warnings : []).map((w) => `at the spot now: ${w}`)
if (!data.ok) {
advice.unshift(`the spot is refused now (${data.reason || 'refused'}): ${data.message || 'no reason given'}. It is checked again when the event runs`)
}
return advice
}
const raidSpawn = {
id: 'rust.raidbase.spawn',
label: 'Spawn a raid base',
description:
'Spawns one RaidableBases base: where RaidableBases picks, or at a spot you give. Needs RaidableBases and CopyPaste on the server. With RunicGatewayRaids.cs (installed with the plugin) the base is the event’s — it despawns when the event ends, and may have its own PvP, NPCs, despawn time and lock — and a spot, a profile or a named base may be chosen; without it RaidableBases picks everything and despawns the base on its own timer. For up to ten minutes after RaidableBases loads it is not ready, and the step tries again ten minutes later.',
risk: 'change',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: false, example: 'main', source: 'rust.options.servers',
description: 'Which server. A monument already says; otherwise required.' },
{ name: 'difficulty', type: 'string', required: false, example: 'Hard', source: 'rust.options.raid_difficulties',
description: 'One of the server’s own RaidableBases difficulties. Blank: any (or the profile’s or base’s own).' },
{ name: 'profile', type: 'string', required: false, example: 'Hard Bases', source: 'rust.options.raid_profiles',
description: 'A RaidableBases profile; a base is picked from it at random. Needs RunicGatewayRaids.cs.' },
{ name: 'base', type: 'string', required: false, example: 'raidhard2', source: 'rust.options.raid_bases',
description: 'One base by name (its CopyPaste file). Needs RunicGatewayRaids.cs.' },
{ name: 'monument', type: 'string', required: false, example: 'main/powerplant_1', source: 'rust.options.monuments',
description: 'A spot by monument (with the offsets), instead of where RaidableBases picks. Needs RunicGatewayRaids.cs.' },
{ 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: 650, description: 'A spot by world x and z, instead of where RaidableBases picks. Needs RunicGatewayRaids.cs.' },
{ name: 'z', type: 'float', required: false, example: 2037, description: 'World z, with x.' },
{ name: 'heightAdjust', type: 'float', required: false, example: 5,
description: 'At a spot: metres added to the profile’s own paste height. Give this or an exact height, not both.' },
{ name: 'height', type: 'float', required: false, example: 30,
description: 'At a spot: the exact height the base is pasted at (the profile’s forced height).' },
{ name: 'pvp', type: 'string', required: false, example: 'off', source: 'rust.options.raid_switch',
description: 'PvP at this base: on or off. Blank keeps the profile’s (and RaidableBases’ own convert switches).' },
{ name: 'npcs', type: 'string', required: false, example: 'on', source: 'rust.options.raid_switch',
description: 'NPCs at this base: on or off. Blank keeps the profile’s.' },
{ name: 'npcCount', type: 'int', required: false, example: 6,
description: 'How many NPCs, 0 to 20, in the profile’s own mix of scientists and murderers. 0 is none.' },
{ name: 'despawnMinutes', type: 'int', required: false, example: 90,
description: 'Minutes until RaidableBases despawns the base on its own, 1 to 1440. Teardown despawns it sooner.' },
{ name: 'lock', type: 'string', required: false, example: 'on', source: 'rust.options.raid_switch',
description: 'Lock the base to the first player or team to attack it: on or off. Blank follows RaidableBases’ config.' },
],
cost: () => ({ 'rust.raids': 1 }),
async perform({ runId, idempotencyKey, params, verify }) {
const parsed = wireOf(params || {})
if (parsed.error) return { ok: false, retry: false, error: parsed.error }
const found = await serverFor(parsed.serverId)
if (!found.ok) return found
const name = found.server.name || found.server.id
// Confirmed live before it refuses: the stored status lags a restart (W7).
const { report, missing } = await live.confirmed(
found.server,
async () => {
const read = reportOf(await serversDb.rbReportFor(found.server.id).catch(() => null))
return { report: read, missing: missingOn(read, name, parsed.wire, parsed.spot) }
},
(r) => Boolean(r.missing),
)
if (missing) return { ok: false, retry: false, error: missing }
if (verify) {
// D391: the spot's check is advice in a dry run. A server that has not
// said it has the helper is not asked.
if (parsed.spot && helperPatched(report)) {
const warnings = await spotAdvice(found.server, name, parsed.wire)
if (warnings.length) return { ok: true, detail: { warnings } }
}
return { ok: true }
}
const result = await client.raidSpawn(found.server, { key: idempotencyKey, runId: String(runId), ...parsed.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, 'raid base spawn') }
}
const data = result.data || {}
if (data.kind !== 'raid.queued') {
if (data.reason === 'rb-not-ready') {
return { ok: false, retry: true, retryAfterMs: NOT_READY_RETRY_MS, error: `${NOT_READY} on ${name}` }
}
const warned = Array.isArray(data.warnings) && data.warnings.length ? ` (also: ${data.warnings.join('; ')})` : ''
return {
ok: false,
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
error: `${pluginError(data, `${name} did not spawn the raid base`)}${warned}`,
}
}
const grid = data.x !== undefined && data.z !== undefined ? await gridAt(found.server.id, data.x, data.z) : null
const warnings = Array.isArray(data.warnings) ? data.warnings.map(String) : []
const detail = {
route: String(data.route || ''),
...(data.difficulty ? { difficulty: String(data.difficulty) } : {}),
...(data.profile ? { profile: String(data.profile) } : {}),
...(data.base ? { base: String(data.base) } : {}),
...(grid ? { grid } : {}),
...(warnings.length ? { warnings } : {}),
...(data.repeat ? { repeat: true } : {}),
}
const notes = []
if (data.repeat) notes.push('answered from the first attempt; nothing new was spawned')
if (data.x === undefined) notes.push('RaidableBases picks the spot; the map shows it once the base opens')
if (data.despawns !== 'teardown') notes.push(OWN_TIMER)
// Each note after the first starts a sentence (W14).
if (notes.length) detail.note = notes.map((n, i) => (i ? n[0].toUpperCase() + n.slice(1) : n)).join('. ')
// Only a base the helper made the run's is a resource teardown can take back.
if (!data.id) return { ok: true, detail }
return {
ok: true,
detail,
resources: [
{
kind: OWNED_KIND,
ref: `${found.server.id}:${data.id}`,
payload: {
serverId: found.server.id,
what: 'raidbase',
...(data.base ? { name: String(data.base) } : {}),
...(data.difficulty ? { difficulty: String(data.difficulty) } : {}),
},
},
],
}
},
revert,
reconcile,
}
const ACTIONS = [raidSpawn]
module.exports = { BUDGETS, ACTIONS, OPTION_SOURCES, PERMANENT, NOT_READY, NOT_READY_RETRY_MS, OWN_TIMER, wireOf, missingOn, switchOf, needsHelper }

View File

@@ -47,6 +47,7 @@ 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 live = require('./liveStatus')
const voice = require('./model/permissions/voice')
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
@@ -355,7 +356,7 @@ const participationOpen = {
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'Which server counts.' },
{ name: 'zone', type: 'string', required: false, example: 'Airfield brawl', source: 'rust.options.runzones',
description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts (D100).' },
description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts.' },
{ name: 'score', type: 'string', required: true, example: 'both', source: 'rust.options.scoremodes',
description: 'What earns a place: seconds present, kills, or both.' },
{ name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsof',
@@ -537,7 +538,7 @@ const kitEntitle = {
budgetMs: BUDGET_MS,
params: [
{ 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).' },
description: 'The kit, as server/kit. The reward reaches only that server.' },
{ 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 (a kit weekend).' },
{ name: 'count', type: 'float', required: false, example: 3,
@@ -741,6 +742,22 @@ function coinsNotice(params, amount) {
return { ok: true, text, delivery }
}
/** What each reason in a payment's `failed` list means, in the plugin's order. */
const FAILED_REASONS = Object.freeze({
'at-limit': "the balance is at Economics' limit",
refused: 'Economics did not take the deposit',
unmeasured: 'Economics did not report the deposit',
})
/** A payment's `failedNote`: only the reasons that happened, each explained (W16). */
function failedNote(failed) {
const seen = new Set(failed.map((row) => row.reason))
return [...Object.keys(FAILED_REASONS), ...[...seen].filter((r) => !FAILED_REASONS[r])]
.filter((reason) => seen.has(reason))
.map((reason) => (FAILED_REASONS[reason] ? `${reason}: ${FAILED_REASONS[reason]}` : `${reason}: Economics' own reason`))
.join('; ')
}
/** 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}`)
@@ -806,7 +823,8 @@ const coinsPay = {
// 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)
// Confirmed live before it refuses: the stored status lags a restart (W7).
const econ = await live.confirmed(server, () => economics.forServer(server.id), (e) => !e.loaded)
if (!econ.loaded) return { ok: false, retry: false, error: `${economics.MISSING} (${name})` }
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
@@ -906,7 +924,7 @@ const coinsPay = {
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' } : {}),
...(failed.length ? { failed: failed.slice(0, 50), failedNote: failedNote(failed) } : {}),
...(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,
@@ -995,7 +1013,8 @@ const backpackSize = {
// 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)
// Confirmed live before it refuses: the stored status lags a restart (W7).
const report = await live.confirmed(server, () => backpacks.forServer(server.id), (r) => !r.loaded)
if (!report.loaded) return { ok: false, retry: false, error: `${backpacks.MISSING} (${name})` }
const offered = backpacks.leasable(report)
if (!offered.includes(size)) {
@@ -1153,13 +1172,13 @@ const announce = {
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.chatservers',
description: 'Which server, or * for every server (D105).' },
description: 'Which server, or * for every server.' },
{ name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!',
description: `The line, up to ${MAX_CHAT} characters.` },
// Optional, and the action stays version 1: a bump would stop every step
// already written from dispatching until somebody re-saved it (§33.2).
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
description: 'chat (the default), or popup — which needs PopupNotifications on the server, and is refused with a reason where it is missing (D141).' },
description: 'chat (the default), or popup — which needs PopupNotifications on the server, and is refused with a reason where it is missing.' },
],
// One per server reached. `*` is priced at the enabled servers when core asks,
@@ -1342,7 +1361,7 @@ const OPTION_SOURCES = [
{ value: 'npcs', label: 'NPCs, animals included' },
{ value: 'both', label: 'Players and NPCs' },
]),
fixed('rust.options.recipientmodes', 'Recipients', 'Who a reward goes to (D101).', [
fixed('rust.options.recipientmodes', 'Recipients', 'Who a reward goes to.', [
{ value: 'everyone', label: 'Everyone who scored' },
{ value: 'top', label: 'The top N (ties in)' },
{ value: 'minScore', label: 'A score of at least X' },
@@ -1350,23 +1369,29 @@ const OPTION_SOURCES = [
{ 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).', [
fixed('rust.options.delivery', 'Delivery', 'Where a line goes.', [
{ 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.
// smallest (D337). Each size once, naming every server that offers it, as
// the other pickers merge a value (W10). 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 = []
const bySize = new Map()
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 })
for (const n of backpacks.leasable(reports.get(s.id))) {
if (!bySize.has(n)) bySize.set(n, [])
bySize.get(n).push(s.name || s.id)
}
}
return rows
return [...bySize.keys()]
.sort((x, y) => x - y)
.map((n) => ({ value: n, label: `${n} slots — ${bySize.get(n).join(', ')}` }))
},
},
{
@@ -1398,6 +1423,7 @@ module.exports = {
usersFor,
readTally,
entitlementRef,
failedNote,
pickRecipients,
checkCount,
splitKit,

View File

@@ -36,6 +36,7 @@ const npcProfile = require('./model/npcs/npcProfile')
const clansDb = require('./model/clans/clans.db')
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const eventProgress = require('./eventProgress')
const live = require('./liveStatus')
const log = core.logger('world')
@@ -145,13 +146,13 @@ const BUDGETS = [
id: 'rust.npcs',
label: 'NPCs placed',
unit: 'NPCs',
description: 'Scientists and guards an event puts in the world — its own dial, so fights can be capped apart from loot (D89).',
description: 'Scientists and guards an event puts in the world — its own dial, so fights can be capped apart from loot.',
},
{
id: 'rust.zone.minutes',
label: 'Zone time',
unit: 'minutes',
description: 'How long the zones an event opens stand, added up. Every zone declares its minutes, and the game erases it when they run out (D96).',
description: 'How long the zones an event opens stand, added up. Every zone declares its minutes, and the game erases it when they run out.',
},
]
@@ -323,11 +324,19 @@ async function place(server, send, body, what) {
}
const placed = Array.isArray(data.placed) ? data.placed : []
// A zone's deadline, as core's `until` (W1, PLAN_LAST_FIXES). The game erases
// the zone `holdMs` after it placed it; reckoned from the answer, on this
// clock, it lands a round trip after the game's, never before. With it core's
// reconcile files a zone gone past its deadline as expired instead of
// orphaned, which a reconnect used to show five seconds before the game's own
// `world.expired` arrived. Crates and NPCs have no deadline and send none.
const until = Number(body.holdMs) > 0 ? new Date(Date.now() + Number(body.holdMs)).toISOString() : null
return {
ok: true,
resources: placed.map((row) => ({
kind: OWNED_KIND,
ref: refOf(server.id, row.id),
...(until ? { until } : {}),
payload: {
serverId: server.id,
what: row.kind,
@@ -617,7 +626,7 @@ const NPC_ORDER_PARAMS = [
required: false,
example: '76561198000000001',
description:
"A player's Steam id, or a {placeholder} from the run's start params: the NPCs keep close to that player and fight whoever attacks them, and walk back to their spot if the player dies or leaves (D269). The player must be on the server. RunicNPC profiles only.",
"A player's Steam id, or a {placeholder} from the run's start params: the NPCs keep close to that player and fight whoever attacks them, and walk back to their spot if the player dies or leaves. The player must be on the server. RunicNPC profiles only.",
},
{
name: 'allyClan',
@@ -626,7 +635,7 @@ const NPC_ORDER_PARAMS = [
source: 'rust.options.clans',
example: 'main/1234567',
description:
"A clan on the same server: the NPCs never target its members and defend them and what they own (D257). RunicNPC profiles only; give this or allyTeamOf, not both.",
"A clan on the same server: the NPCs never target its members and defend them and what they own. RunicNPC profiles only; give this or allyTeamOf, not both.",
},
{
name: 'allyTeamOf',
@@ -634,7 +643,7 @@ const NPC_ORDER_PARAMS = [
required: false,
example: '76561198000000001',
description:
"A player's Steam id, or a {placeholder}: the NPCs are allied to that player and their team (D257, D270). RunicNPC profiles only.",
"A player's Steam id, or a {placeholder}: the NPCs are allied to that player and their team. RunicNPC profiles only.",
},
{
name: 'tether',
@@ -642,7 +651,7 @@ const NPC_ORDER_PARAMS = [
required: false,
example: true,
description:
'Keep the NPCs inside the zone this event made around the point (D272): a Make a zone step must come first. RunicNPC profiles only.',
'Keep the NPCs inside the zone this event made around the point: a Make a zone step must come first. RunicNPC profiles only.',
},
{
// Runicnpc stage 7 (D296): on by default; a step older than the switch drops loot as before.
@@ -651,7 +660,7 @@ const NPC_ORDER_PARAMS = [
required: false,
example: true,
description:
"Whether the NPCs drop their profile's loot table when killed (D296). On unless switched off; off, their corpse still holds what the profile starts it with. An event's own rewards are separate. RunicNPC profiles only.",
"Whether the NPCs drop their profile's loot table when killed. On unless switched off; off, their corpse still holds what the profile starts it with. An event's own rewards are separate. RunicNPC profiles only.",
},
]
@@ -690,8 +699,12 @@ function npcOrders(params, server) {
* What the server itself holds is the plugin's to answer (`unknown-profile`).
*/
async function npcMissing(server, profile) {
const [list, profiles] = await Promise.all([npcsDb.listNpcServers(), profile === null ? [] : npcsDb.listProfiles()])
const here = list.find((s) => s.id === server.id)
const hereNow = async () => (await npcsDb.listNpcServers()).find((s) => s.id === server.id)
// Confirmed live before it refuses: the stored status lags a restart (W11).
const [here, profiles] = await Promise.all([
live.confirmed(server, hereNow, (s) => !npcs.npcReady(s)),
profile === null ? [] : npcsDb.listProfiles(),
])
const name = server.name || server.id
if (!npcs.npcReady(here)) return `${name} needs RunicNPC to place NPCs, Rust's own scientists included: ${npcs.npcAbsence(here)}`
if (profile === null) return null
@@ -794,7 +807,8 @@ const ACTIONS = [
// §3.1: the flags and settings, checked against the flag list this server's
// last hello carried (D211). A server that has not said is left to the
// plugin, which checks every one against its live ZoneManager.
const mods = await zones.serverZoneMods(found.server.id)
const reread = () => zones.serverZoneMods(found.server.id)
let mods = await reread()
const known = mods && mods.zoneManager && mods.zoneManager.flags && mods.zoneManager.flags.length ? mods.zoneManager.flags : null
const opts = zoneOptions.parse(params.options, known)
if (!opts.ok) return { ok: false, retry: false, error: opts.error }
@@ -821,12 +835,15 @@ const ACTIONS = [
if (Number.isNaN(stack) || (stack !== undefined && (!Number.isInteger(stack) || stack < 1 || stack > DOME_STACK_MAX))) {
return { ok: false, retry: false, error: `a dome stacks 1 to ${DOME_STACK_MAX} spheres, not "${params.domeStack}"` }
}
// Confirmed live before it refuses: the stored status lags a restart (W11).
if (domeMissing(mods)) mods = await live.confirmed(found.server, reread, (m) => Boolean(domeMissing(m)))
const missing = domeMissing(mods)
if (missing) return { ok: false, retry: false, error: missing }
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.
if (!truePveMapping(params, mods).ok) mods = await live.confirmed(found.server, reread, (m) => !truePveMapping(params, m).ok)
const truePve = truePveMapping(params, mods)
if (!truePve.ok) return { ok: false, retry: false, error: truePve.error }
@@ -891,7 +908,7 @@ const OPTION_SOURCES = [
// cached list would offer monuments that are not there any more.
id: 'rust.options.monuments',
label: 'Monuments',
description: "Each server's monuments on its current map. A kind that repeats is numbered, #1 first (D93).",
description: "Each server's monuments on its current map. A kind that repeats is numbered, #1 first.",
searchable: true,
async resolve({ q } = {}) {
const term = String(q || '').trim().toLowerCase()
@@ -966,7 +983,7 @@ const OPTION_SOURCES = [
{
id: 'rust.options.domes',
label: 'Domes',
description: "ZoneDomes' dome colours (D194). A coloured dome shows only where it meets terrain or a structure.",
description: "ZoneDomes' dome colours. A coloured dome shows only where it meets terrain or a structure.",
async resolve() {
return DOMES.map((d) => ({ value: d.value, label: d.label }))
},

View File

@@ -154,6 +154,17 @@ const worldCall = {
}
const data = result.data || {}
// The game runs one Travelling Vendor at a time and quietly starts nothing
// while one is out. The plugin looks first and says so (W3). Not a failure:
// what the step asked for is on the map.
if (data.reason === 'already-out') {
return {
ok: true,
detail: { event: event.value, called: 0, note: `a ${event.label} is already out on ${found.server.name || found.server.id}, and the game runs one at a time; nothing new was called` },
}
}
if (data.kind !== 'world.called') {
return {
ok: false,

View File

@@ -62,6 +62,7 @@ module.exports = function register(ctx, api) {
const eventWorldCall = require('./eventWorldCall')
const eventDangerousTreasures = require('./eventDangerousTreasures')
const eventGuardedCrate = require('./eventGuardedCrate')
const eventRaidableBases = require('./eventRaidableBases')
const eventPermRewards = require('./eventPermRewards')
const eventCommands = require('./eventCommands')
const eventTemplates = require('./eventTemplates')
@@ -157,6 +158,40 @@ module.exports = function register(ctx, api) {
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
// Sign in with Steam (MODULE_API 1.17.0; PLAN_REDESIGNS §15). A Steam account
// linked on the site is a linked Rust player (D392): core asks before it writes
// the sign-in identity, and this module refuses a Steam id that is another
// account's Rust link (D396); once it is written, the Rust link is made. Both
// are in the links model beside `/link`'s own write, and neither runs at
// registration — core calls them from a signed-in user's link flow.
const links = require('./model/links/links.model') // eslint-disable-line global-require
api.onIdentityLinking(links.checkSteamSignin)
api.onIdentityLinked(links.linkFromSteamSignin)
// Account deletion (MODULE_API 1.20.0; ACCOUNT_DELETION.md §4.4, AD10). Core
// asks before the users row goes: every Steam id the account holds is unlinked
// through the player's own unlink, and the permission sync is marked dirty so
// the withdrawn grants reach the game on the next tick. It cannot stop the
// deletion, and never waits on a game.
api.onUserDeleting(links.unlinkForDeletion)
// Report, block and hide (MODULE_API 1.21.0; USER_SAFETY.md §4.2). A chat line
// is a `rust.chat` report target: core files the report and keeps the queue,
// and asks this module what a line is, for one reader, and to hide it for
// everyone. A speaker is known by their Steam id, and `steam` says which
// website account one is linked to, so a block lands on the account first
// (S12). The feed's own filtering and `canBlock` are in the events route.
const safety = require('./safety') // eslint-disable-line global-require
api.registerReportTargets(safety.REPORT_TARGETS)
api.registerGameIdentities(safety.GAME_IDENTITIES)
// The public demo's seed (MODULE_API 1.18.0, DM15): the rig's server row and
// demo-player's Steam link, written by this module from its part of the
// fixture. Called only by `npm run seed:demo` on a DEMO_MODE deployment. The
// demo's other locks are `demoLocks` in module.json and `demo.js`.
const demoSeed = require('./demoSeed') // eslint-disable-line global-require
api.onDemoSeed(demoSeed.onDemoSeed)
// The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server
// and must give back. Core's `core.lease` is the verb; these are the values it
// may name and the four callables each ships. Every lease is targeted and the
@@ -182,9 +217,10 @@ module.exports = function register(ctx, api) {
// 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; and console commands an author typed
// (§16, D382-D385), priced in commands.
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS, ...eventDangerousTreasures.BUDGETS, ...eventGuardedCrate.BUDGETS, ...eventCommands.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS, ...eventDangerousTreasures.ACTIONS, ...eventGuardedCrate.ACTIONS, ...eventCommands.ACTIONS])
// (§16, D382-D385), priced in commands; and spawning a RaidableBases base
// (§11.4), ledgered like a zone when the raids helper makes it the run's.
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS, ...eventDangerousTreasures.BUDGETS, ...eventGuardedCrate.BUDGETS, ...eventCommands.BUDGETS, ...eventRaidableBases.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS, ...eventDangerousTreasures.ACTIONS, ...eventGuardedCrate.ACTIONS, ...eventCommands.ACTIONS, ...eventRaidableBases.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
@@ -206,6 +242,7 @@ module.exports = function register(ctx, api) {
...eventWorldCall.OPTION_SOURCES,
...eventDangerousTreasures.OPTION_SOURCES,
...eventGuardedCrate.OPTION_SOURCES,
...eventRaidableBases.OPTION_SOURCES,
])
// The slash commands (phase 16, R11). The handlers run HERE, in the website
@@ -224,10 +261,11 @@ module.exports = function register(ctx, api) {
streams: STREAMS.length,
audiences: AUDIENCES.length,
leases: eventLeases.LEASES.length,
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length + eventDangerousTreasures.ACTIONS.length + eventGuardedCrate.ACTIONS.length + eventCommands.ACTIONS.length,
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length + eventGuardedCrate.BUDGETS.length + eventCommands.BUDGETS.length,
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length + eventDangerousTreasures.ACTIONS.length + eventGuardedCrate.ACTIONS.length + eventCommands.ACTIONS.length + eventRaidableBases.ACTIONS.length,
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length + eventGuardedCrate.BUDGETS.length + eventCommands.BUDGETS.length + eventRaidableBases.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 + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length + eventDangerousTreasures.OPTION_SOURCES.length + eventGuardedCrate.OPTION_SOURCES.length,
reportTargets: safety.REPORT_TARGETS.map((t) => t.id).join(' '),
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 + eventRaidableBases.OPTION_SOURCES.length,
})
}

View File

@@ -72,7 +72,9 @@ const MAX_BATCHES_PER_TICK = 10
* how to read.
*/
async function apply(serverId, item, server = null) {
const frame = (item && item.frame) || {}
// A downed helicopter, Chinook or Bradley is stored with the rule that
// credits it now, so its line and its counts agree for good (W4).
const frame = worldEvents.stampRule((item && item.frame) || {}, server && server.creditRule)
const kind = item.kind || frame.kind
const wipeId = frame.wipeId || null
@@ -278,8 +280,7 @@ async function apply(serverId, item, server = null) {
// 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)) {
for (const kill of worldEvents.killsOf(frame, worldEvents.ruleOf(frame, server && server.creditRule))) {
await db.touchPlayer(kill.steamId, kill.name || null)
await db.addTitleStats({ serverId, wipeId, steamId: kill.steamId }, { [kill.field]: 1 })
}

95
server/liveStatus.js Normal file
View File

@@ -0,0 +1,95 @@
// ── Asking a server how it is NOW, before saying no (W7, W11) ──────────────
//
// Every "is it loaded" refusal — Economics (its step and the staff page),
// Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes,
// TruePVE, RunicNPC, and the console probe — reads the status the board poll
// stored, and the poll runs every 30 seconds
// (`boot.REFRESH_MS`). Right after a restart, or a plugin loaded by hand, the
// stored status still says no for up to a minute while the game already says
// yes.
//
// So a refusal is CONFIRMED before it is given: the step asks the sidecar once
// more, writes what it said exactly as a poll tick would, and reads the stored
// status again. Only a "no" that survives that is a refusal. A "yes" is never
// re-asked: a plugin unloaded since the last poll still passes the dry run, and
// the plugin itself refuses at the run, as it always has.
//
// One ask per server at a time, and none within `FRESH_MS` of the last, so a
// form's dry run of ten steps against one server asks once.
const core = require('./core')
const log = core.logger('live-status')
/** A status asked for this recently is as fresh as asking again. */
const FRESH_MS = 5 * 1000
/**
* How long a step waits for the ask. `/boards` and `/health` are the sidecar's
* own, no game round trip, so a sidecar that is up answers well inside it. The
* bound keeps the ask plus the step's own call inside the step's `budgetMs`
* (15 s) when a sidecar is slow; past it the stored status stands, and the ask
* still finishes and writes, as a poll tick would.
*/
const ASK_MS = 3 * 1000
const inFlight = new Map()
const askedAt = new Map()
/**
* Ask `server`'s sidecar now and store the answer, as a board poll does.
* Resolves true once the stored status is current, false when it could not be
* asked (the stored status stands). Never rejects.
*/
function refresh(server) {
if (!server || !server.id) return Promise.resolve(false)
const id = server.id
if (inFlight.has(id)) return inFlight.get(id)
if (Date.now() - (askedAt.get(id) || 0) < FRESH_MS) return Promise.resolve(true)
// Required here, not at the top: boot.js requires the event steps that
// require this file.
const ask = Promise.resolve()
.then(() => require('./boot').refreshOne(server))
.then(
() => {
askedAt.set(id, Date.now())
return true
},
(err) => {
log.warn('could not ask a server how it is', { server: id, error: err.message })
return false
},
)
.finally(() => inFlight.delete(id))
inFlight.set(id, ask)
// The caller waits at most `ASK_MS`; the ask itself runs to the end.
let timer
const late = new Promise((resolve) => {
timer = setTimeout(() => resolve(false), ASK_MS)
if (timer.unref) timer.unref()
})
return Promise.race([ask, late]).finally(() => clearTimeout(timer))
}
/**
* `read()`'s answer, confirmed live when `refuses` says it would refuse: one
* `refresh(server)`, then `read()` again. The answer the caller acts on.
*/
async function confirmed(server, read, refuses) {
const first = await read()
if (!refuses(first)) return first
if (!(await module.exports.refresh(server))) return first
return read()
}
/** For tests: forget every ask. */
function _reset() {
inFlight.clear()
askedAt.clear()
}
module.exports = { FRESH_MS, ASK_MS, refresh, confirmed, _reset }

View File

@@ -43,8 +43,15 @@ const LOCKED_REASON = {
*/
const PATH_SHAPE = /^(?!.*\.\.)(?!\/)[A-Za-z0-9 _.\-()[\]]+(?:\/[A-Za-z0-9 _.\-()[\]]+)*\.json$/
/**
* RaidableBases' profiles (PLAN_REDESIGNS §11.4.3, D205): the one named exception
* to the editor never walking a data directory. The bridge lists them under this
* virtual prefix, one file deep, and resolves it to that folder alone.
*/
const PROFILE_PATH = /^@data\/RaidableBases\/Profiles\/(?!.*\.\.)[A-Za-z0-9 _.\-()[\]]+\.json$/
function isPlausiblePath(path) {
return typeof path === 'string' && path.length > 0 && path.length <= 255 && PATH_SHAPE.test(path)
return typeof path === 'string' && path.length > 0 && path.length <= 255 && (PATH_SHAPE.test(path) || PROFILE_PATH.test(path))
}
/**
@@ -90,6 +97,8 @@ function shapeCatalogue(catalogue) {
modified: file.modified ? Number(file.modified) : null,
editable: file.editable !== false,
...(file.reason ? { reason: String(file.reason) } : {}),
// A RaidableBases profile (D205): reloaded through the raids helper, never a plugin reload.
...(file.profile === true ? { profile: true } : {}),
})
}
@@ -105,11 +114,22 @@ function shapeCatalogue(catalogue) {
}
}
/**
* The plugin a path belongs to, by its first segment, or its file name for a
* file at the root. A RaidableBases profile's virtual path names the plugin
* after its `@data/` prefix, so the audit row says RaidableBases (W8).
*/
function pluginOf(path) {
const text = String(path)
const parts = text.split('/')
if (parts[0] === '@data' && parts.length > 2) return parts[1]
return parts.length > 1 ? parts[0] : text.replace(/\.json$/i, '')
}
/** Whether this file is the bridge's own config, by the name the plugin gave. */
function isBridgeConfig(path, self) {
if (!self) return false
const plugin = String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
return plugin.toLowerCase() === String(self).toLowerCase()
return pluginOf(path).toLowerCase() === String(self).toLowerCase()
}
/** The locked keys for a file: three of them in our own config, none anywhere else. */
@@ -154,6 +174,71 @@ function shapeFile(file, { self = null, maxDepth = 6 } = {}) {
}
}
/**
* A shaped file with every credential-shaped value hidden, for the public demo
* (DEMO_MODE.md §3.5, DM14). The form's mask is not a lock (DM10), so on the
* demo the value never leaves the module:
*
* - in `text`, each flagged string or number is spliced out for the
* placeholder, from the end backwards so the spans stay true;
* - in `fields`, a flagged field carries the placeholder and is `locked`,
* so the form shows it read-only, and the save refuses an edit to it anyway.
*
* "Flagged" is the editor's own reading (`configEdit.isSecretKey`, inherited by
* everything under a flagged key), so the demo hides exactly what the form masks.
* A file that does not parse cannot be read for its secrets, so its text is
* withheld whole: the lock fails closed.
*/
function redactSecrets(shaped, placeholder) {
if (!shaped) return shaped
let root
try {
root = configEdit.scan(shaped.text)
} catch {
return { ...shaped, text: '', fields: null, redacted: true, withheld: 'This file does not parse, so it is not shown on the demo site.' }
}
const spans = []
const visit = (node, secret) => {
if (node.type === 'object') {
for (const child of node.children) visit(child, secret || configEdit.isSecretKey(child.key))
} else if (node.type === 'array') {
for (const child of node.children) visit(child, secret)
} else if (secret && (node.type === 'number' || (node.type === 'string' && node.value !== ''))) {
spans.push(node)
}
}
visit(root, false)
let text = shaped.text
for (const node of [...spans].sort((a, b) => b.start - a.start)) {
text = text.slice(0, node.start) + JSON.stringify(placeholder) + text.slice(node.end)
}
const fields = Array.isArray(shaped.fields)
? shaped.fields.map((field) => {
if (!field.secret) return field
const leaf = field.type === 'number' || field.type === 'string'
const { raw, ...rest } = field
return {
...rest,
...(leaf && !(field.type === 'string' && field.value === '') ? { value: placeholder } : {}),
locked: true,
}
})
: shaped.fields
return { ...shaped, text, fields, redacted: true }
}
/** Whether any edit names a credential-shaped field, as `applyEdits` marks them. */
function editsTouchSecret(edits) {
return (edits || []).some(
(edit) => Array.isArray(edit && edit.pointer) && edit.pointer.some((step) => typeof step === 'string' && configEdit.isSecretKey(step)),
)
}
/**
* Which locked keys differ between two versions of a document.
*
@@ -270,6 +355,9 @@ module.exports = {
isPlausiblePath,
shapeCatalogue,
shapeFile,
redactSecrets,
editsTouchSecret,
pluginOf,
isBridgeConfig,
lockedKeysFor,
lockedChanges,

View File

@@ -23,6 +23,7 @@ const db = require('./economics.db')
const eventsDb = require('../events/events.db')
const servers = require('../servers/servers.model')
const { pluginError, transportError } = require('../../eventLeases')
const live = require('../../liveStatus')
const log = core.logger('economics')
@@ -168,7 +169,8 @@ async function adjust({ serverId, steamId, op, amount, key }) {
const server = await servers.getForCalling(serverId)
if (!server) return { ok: false, status: 404, message: 'No such server' }
const econ = await forServer(server.id)
// Confirmed live before it refuses: the stored status lags a restart (W7).
const econ = await live.confirmed(server, () => forServer(server.id), (e) => !e.loaded)
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 })
@@ -187,9 +189,11 @@ async function adjust({ serverId, steamId, op, amount, key }) {
}
log.warn('Economics refused an adjustment', { server: server.id, op, reason: data.reason })
// The game saying no is a 409, as `insufficient` is above: the request was
// well formed, and the balance it met refused it (W15).
return {
ok: false,
status: data.reason === 'economics-missing' ? 409 : 400,
status: data.reason === 'economics-missing' || data.reason === 'at-limit' ? 409 : 400,
reason: data.reason || null,
message: pluginError(data, `${name} refused the adjustment`),
}

View File

@@ -155,6 +155,8 @@ const TITLE_SUMS = {
treasuresWon: 'treasures_won',
// Protocol 14 (D375): Warden.
guardedWon: 'guarded_won',
// Protocol 14 (§11.4, D202): Warlord.
raidsCompleted: 'raids_completed',
}
/** The two *best* fields and their columns: the longest single kill, never a sum. */
@@ -310,13 +312,14 @@ async function pruneEvents(days) {
* every time; `events.model.js` builds the list from the catalogue's allowlist
* and an empty list answers with no rows rather than with everything.
*/
async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
async function recentEvents({ serverId, kinds, wipeId = null, limit = 50, chat = {} }) {
if (!Array.isArray(kinds) || kinds.length === 0) return []
const holes = kinds.map(() => '?').join(', ')
const params = [serverId, ...kinds]
let sql = `SELECT id, server_id AS serverId, wipe_id AS wipeId, kind, t, steam_id AS steamId, raw
let sql = `SELECT id, server_id AS serverId, wipe_id AS wipeId, kind, t, steam_id AS steamId, raw,
hidden_at AS hiddenAt
FROM ${EVENTS}
WHERE server_id = ? AND kind IN (${holes})`
@@ -325,12 +328,53 @@ async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
params.push(wipeId)
}
// What this reader does not get of the chat (USER_SAFETY.md §4.2), left out in
// the query rather than after it, so a page of 100 is still 100 lines long.
// Only `player.chat` rows: a block hides what someone SAYS, not that they died.
if (!chat.showHidden) sql += ` AND NOT (kind = 'player.chat' AND hidden_at IS NOT NULL)`
if (chat.steamIds && chat.steamIds.length) {
sql += ` AND NOT (kind = 'player.chat' AND steam_id IN (${chat.steamIds.map(() => '?').join(', ')}))`
params.push(...chat.steamIds)
}
if (chat.ids && chat.ids.length) {
sql += ` AND NOT (kind = 'player.chat' AND id IN (${chat.ids.map(() => '?').join(', ')}))`
params.push(...chat.ids)
}
sql += ' ORDER BY id DESC LIMIT ?'
params.push(limit)
return core.query(sql, params)
}
/** One chat line by id, or null: the report target `rust.chat` (USER_SAFETY.md §4.2). */
async function chatLineById(id) {
const rows = await core.query(
`SELECT id, server_id AS serverId, wipe_id AS wipeId, t, steam_id AS steamId, raw,
hidden_at AS hiddenAt
FROM ${EVENTS}
WHERE id = ? AND kind = 'player.chat'`,
[id],
)
return rows[0] || null
}
/**
* Staff's hide for everyone, or its undo. A line already hidden keeps its first
* hide's time and author: the second click of two changes nothing. Whether the
* line exists is the caller's to ask first — `affectedRows` on an UPDATE that
* changes nothing depends on a driver flag.
*/
async function setChatHidden(id, hidden, by = null) {
await core.query(
hidden
? `UPDATE ${EVENTS} SET hidden_at = COALESCE(hidden_at, CURRENT_TIMESTAMP), hidden_by = COALESCE(hidden_by, ?)
WHERE id = ? AND kind = 'player.chat'`
: `UPDATE ${EVENTS} SET hidden_at = NULL, hidden_by = NULL WHERE id = ? AND kind = 'player.chat'`,
hidden ? [by, id] : [id],
)
}
/**
* The leaderboard for one wipe, or across every wipe when `wipeId` is null.
*
@@ -358,6 +402,8 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
treasuresWon: 'treasures_won',
// Protocol 14 (D375): guarded crates cleared, Warden's column.
guardedWon: 'guarded_won',
// Protocol 14 (§11.4, D202): RaidableBases bases completed, Warlord's column.
raidsCompleted: 'raids_completed',
}[sort] || 'kills'
const params = [serverId]
@@ -386,6 +432,7 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
SUM(s.drops_looted) AS dropsLooted,
SUM(s.treasures_won) AS treasuresWon,
SUM(s.guarded_won) AS guardedWon,
SUM(s.raids_completed) AS raidsCompleted,
MAX(s.balance) AS balance,
MAX(s.last_seen) AS lastSeen
FROM ${STATS} s
@@ -468,6 +515,8 @@ module.exports = {
replacePresence,
pruneEvents,
recentEvents,
chatLineById,
setChatHidden,
leaderboard,
listWipes,
presenceFor,

View File

@@ -17,9 +17,11 @@ const worldEvents = require('../../worldEvents')
const dynamicPvp = require('../../dynamicPvp')
const dangerousTreasures = require('../../dangerousTreasures')
const guardedCrate = require('../../guardedCrate')
const raidableBases = require('../../raidableBases')
const db = require('./events.db')
const mapDb = require('../map/map.db')
const serversDb = require('../servers/servers.db')
const weaponNames = require('../titles/weaponNames')
/** Hard ceiling on a page, whatever a caller asks for. */
const MAX_LIMIT = 200
@@ -57,22 +59,31 @@ 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, staff = false, presence = false, kind = null, wipeId = null, limit }) {
async function recent({ serverId, admin = false, staff = false, presence = false, kind = null, wipeId = null, limit, chat = {} }) {
const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
// Every requested kind was refused. Answering with an empty list is right —
// the events they asked for are, as far as they are concerned, not there.
if (kinds.length === 0) return []
// `chat` is this reader's part of the chat (`safety.js`, USER_SAFETY.md §4.2):
// the Steam ids whose lines they do not get, the lines they hid, and whether a
// line staff hid for everyone is still theirs to see. Its default is the
// narrow one — a hidden line is left out unless a caller says otherwise.
const rows = await db.recentEvents({
serverId,
kinds,
wipeId,
limit: boundedLimit(limit),
chat: {
showHidden: Boolean(chat.showHidden),
steamIds: Array.isArray(chat.steamIds) ? chat.steamIds : [],
ids: Array.isArray(chat.ids) ? chat.ids : [],
},
})
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
if (!shaped.some((r) => r.kind === 'world.event' || dynamicPvp.KINDS.includes(r.kind) || dangerousTreasures.isKind(r.kind) || guardedCrate.isKind(r.kind) || raidableBases.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
@@ -88,6 +99,7 @@ async function recent({ serverId, admin = false, staff = false, presence = false
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)
if (raidableBases.isKind(row.kind)) return raidRow(row, view)
return row
})
.filter(Boolean)
@@ -124,6 +136,11 @@ function gcRow(row, view) {
return { ...row, steamId: view.staff ? row.steamId : null, frame: guardedCrate.shapeFor(row.frame, view) }
}
/** One RaidableBases row for a viewer (§11.4). The row's own Steam id, a buyer's, is staff's. */
function raidRow(row, view) {
return { ...row, steamId: view.staff ? row.steamId : null, frame: raidableBases.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)
@@ -148,6 +165,13 @@ function shape(row) {
frame = {}
}
// The weapon as a page should print it (`model/titles/weaponNames.js`). Added
// at read, so rows stored before this existed read the same as new ones, and
// a later edit to the table renames every kill the feed still shows.
if (typeof frame.weapon === 'string' && frame.weaponName === undefined) {
frame.weaponName = weaponNames.nameOf(frame.weapon)
}
return {
id: Number(row.id),
kind: row.kind,
@@ -155,6 +179,9 @@ function shape(row) {
wipeId: row.wipeId || null,
steamId: row.steamId || null,
frame,
// Staff hid this line for everyone (S10). Only staff's reads ever hold one,
// and they get it marked so the hide can be checked and undone.
...(row.hiddenAt ? { hidden: true } : {}),
}
}
@@ -193,6 +220,8 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, pre
treasuresWon: Number(r.treasuresWon) || 0,
// Protocol 14 (D375): Warden's count.
guardedWon: Number(r.guardedWon) || 0,
// Protocol 14 (§11.4, D202): Warlord's count.
raidsCompleted: Number(r.raidsCompleted) || 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
@@ -238,4 +267,4 @@ async function online(serverId) {
}))
}
module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, MAX_LIMIT }
module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, shape, MAX_LIMIT }

View File

@@ -166,6 +166,18 @@ async function userIdsForSteamIds(steamIds) {
)
}
/**
* The other direction: every Steam id these website accounts hold. A chat line
* is filed under a Steam id, so a block on an account (USER_SAFETY.md S12) hides
* the lines of every Steam id that account links.
*/
async function steamIdsForUsers(userIds) {
if (!userIds.length) return []
const marks = userIds.map(() => '?').join(', ')
const rows = await core.query(`SELECT steam_id AS steamId FROM ${LINKS} WHERE user_id IN (${marks})`, userIds)
return rows.map((r) => String(r.steamId))
}
/**
* 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`).
@@ -217,5 +229,6 @@ module.exports = {
removeBySteamId,
statsForSteamId,
userIdsForSteamIds,
steamIdsForUsers,
linkedOnServer,
}

View File

@@ -19,6 +19,7 @@
const core = require('../../core')
const db = require('./links.db')
const engagement = require('../../engagement/emit')
const permissionsDb = require('../permissions/permissions.db')
const servers = require('../servers/servers.model')
const serversDb = require('../servers/servers.db')
const sidecar = require('../../sidecarClient')
@@ -286,6 +287,112 @@ async function unlinkFromGame(steamId) {
return removed
}
// ── Steam's own sign-in as a second way to link (D392, D393, D396) ─────────
//
// Core's "Link Steam" (STEAM_SIGNIN.md) proves the account with Steam's OpenID
// `check_authentication`, which is at least as strong as a code typed in game:
// so a Steam account linked on the site IS a linked Rust player, beside `/link`.
// Core asks this module twice through MODULE_API 1.17.0 — before it writes the
// sign-in identity, and after. Unlinking Steam on the site removes only the
// sign-in (D393), so there is no third call: the Rust link stays until it is
// removed on the Rust page or by `/unlink` in game, exactly as one made by code.
const STEAM_ID = /^\d{17}$/
/**
* Core's pre-link check (`api.onIdentityLinking`). Refuses a Steam id that is
* already ANOTHER website account's Rust link (D396) — the same rule as D23 for
* a code, for the same reason: a link here is a privilege path, and moving it
* would hand that account's grants to whoever signed in. Core shows "already
* linked to another account" and nothing is written; staff remove the old link
* if the claim is real. Our own words go to core's log, never to the page.
*/
async function checkSteamSignin({ userId, provider, subject }) {
if (provider !== 'steam') return { ok: true }
const held = await db.getBySteamId(String(subject))
if (held && Number(held.userId) !== Number(userId)) {
return { ok: false, reason: 'in_use', message: `steam ${subject} is the Rust link of user ${held.userId}` }
}
return { ok: true }
}
/**
* Core's linked hook (`api.onIdentityLinked`): the Steam account becomes this
* user's Rust link (D392).
*
* The same write as `confirmOne`, with the PRIMARY KEY as the race guard: if
* somebody else's `/link` landed between core's check and this, the key refuses
* ours and their link stands — the sign-in identity core already wrote is only a
* way to sign in. `name` is the Steam persona name, which is what a Rust player is
* called in game until they rename. No `server_id`: no server minted anything.
*/
async function linkFromSteamSignin({ userId, provider, subject, displayName }) {
if (provider !== 'steam') return
const steamId = String(subject)
if (!STEAM_ID.test(steamId)) return
const held = await db.getBySteamId(steamId)
if (held) {
if (Number(held.userId) !== Number(userId)) {
log.warn('steam sign-in linked an id that is another user\'s Rust link; the Rust link stands', { steamId, userId })
}
return
}
try {
await db.insert({ steamId, userId, name: displayName || null, serverId: null })
} catch (err) {
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
log.warn('steam sign-in link lost a race for this id; the other link stands', { steamId, userId })
return
}
throw err
}
log.info('steam account linked through steam sign-in', { steamId, userId })
linksChanged('rust account linked via steam sign-in')
engagement.linked({ userId, steamId, name: displayName || null })
}
// ── The website account is being deleted (MODULE_API 1.20.0) ──────────────
//
// ACCOUNT_DELETION.md §4.4. Core asks before the users row goes, while the
// person's links can still be read (AD10). The cascade on `rust_account_links`
// would remove the rows anyway; calling the real unlink keeps what a cascade
// skips — the `rust.account.unlink` entry staff read and the prompt Teams
// re-sync — so the game sees exactly what a player's own unlink produces.
/**
* Core's `api.onUserDeleting`: unlink every Steam id this account holds.
*
* The same `unlinkOwned` the player's Rust page calls, one link at a time, each
* logged as `rust.account.unlink` with `reason: 'account deleted'`. Then every
* server's permission sync is marked dirty: the desired set reaches a grant's
* Steam id through the link, so with the links gone the grants have already
* left it, and the next tick pushes the withdrawal instead of waiting for the
* digest check.
*
* Nothing here waits on a game. The link is the site's own record and the plugin
* keeps none (the header above), so there is never pending work: what this does
* not finish, the cascade does.
*/
async function unlinkForDeletion({ userId, deletedBy }) {
const held = await db.listForUser(userId)
let removed = 0
for (const row of held) {
// eslint-disable-next-line no-await-in-loop
if (await unlinkOwned(row.steamId, userId)) {
removed += 1
// eslint-disable-next-line no-await-in-loop
await core.activity.log({
userId,
action: 'rust.account.unlink',
detail: { steamId: row.steamId, reason: 'account deleted', deletedBy },
})
}
}
if (removed > 0) await permissionsDb.markDirty(null)
log.info('rust links removed before an account was deleted', { userId, deletedBy, removed })
}
/** The admin panel's read: every link this user holds, with per-server totals. */
async function forAdmin(userId) {
const links = await db.listForUserWithPlayer(userId)
@@ -326,5 +433,8 @@ module.exports = {
unlinkOwned,
unlinkAnyOwner,
unlinkFromGame,
checkSteamSignin,
linkFromSteamSignin,
unlinkForDeletion,
forAdmin,
}

View File

@@ -15,8 +15,9 @@
// 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)
// raids RaidableBases' live bases, and which a run made (protocol 14)
//
// Defaults: world, events and pvp public; players and bases staff. The audiences are the
// Defaults: world, events, pvp and raids 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`).
@@ -45,9 +46,11 @@ const log = core.logger('map')
// `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'])
// `raids` (protocol 14, PLAN_REDESIGNS §11.4, D114) is RaidableBases' live bases, public
// by default: the plugin marks them on the game's own map for everyone, and they name nobody.
const LAYERS = Object.freeze(['world', 'events', 'players', 'bases', 'pvp', 'raids'])
const DEFAULTS = Object.freeze({ world: 'public', events: 'public', players: 'staff', bases: 'staff', pvp: 'public' })
const DEFAULTS = Object.freeze({ world: 'public', events: 'public', players: 'staff', bases: 'staff', pvp: 'public', raids: 'public' })
/** D118: on, because it shows a member nothing the game does not already show them. */
const DEFAULT_MATES = true
@@ -304,6 +307,13 @@ async function mateIdsFor(serverId, acc) {
return { own, mates: new Set(clan.filter((id) => !own.has(id))) }
}
/** A raids row without the run's idempotency key, which only the bridge's own bookkeeping needs. */
function withoutKey(row) {
if (!row || typeof row !== 'object' || !('key' in row)) return row
const { key, ...rest } = row
return rest
}
/** An events row without the health a boss's carries. */
function withoutHealth(row) {
if (!row || typeof row !== 'object' || !('health' in row)) return row
@@ -334,6 +344,8 @@ function project(live, acc, ids = { own: new Set(), mates: new Set() }) {
}
// 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 : []
// RaidableBases' bases (§11.4): an older plugin's answer has none. A run's key stays off the map.
if (acc.layers.raids.visible) out.raids = Array.isArray(live.raids) ? live.raids.map(withoutKey) : []
if (acc.layers.bases.visible) {
out.bases = Array.isArray(live.bases) ? live.bases : []
if (live.basesTruncated) out.basesTruncated = true

View File

@@ -124,7 +124,7 @@ function checkFactions(input) {
}
if (!RELATIONS.includes(relation)) return { ok: false, error: `factions[${i}]: '${relation}' is not hostile, neutral or allied` }
const key = pairKey(a, b)
if (seen.has(key)) return { ok: false, error: `factions[${i}]: ${a} and ${b} are given twice (one row is both ways, D268)` }
if (seen.has(key)) return { ok: false, error: `factions[${i}]: ${a} and ${b} are given twice (one row is both ways)` }
seen.add(key)
rows.push(a < b ? { a, b, relation } : { a: b, b: a, relation })
}
@@ -173,7 +173,7 @@ function checkBody(input) {
if (names.some((n) => n.length > DISPLAY_NAME_MAX)) return { ok: false, error: `names: each at most ${DISPLAY_NAME_MAX} characters` }
const kits = Array.isArray(b.kits) ? [...new Set(b.kits.map((k) => String(k === null || k === undefined ? '' : k).trim()).filter(Boolean))] : null
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped (D217)' }
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped' }
if (kits.length > KITS_MAX) return { ok: false, error: `kits: at most ${KITS_MAX}` }
const prefab = String(b.prefab || '').trim()
@@ -242,8 +242,8 @@ function checkBody(input) {
const hasBoss = b.boss !== undefined && b.boss !== null
const hasUse = b.use !== undefined && b.use !== null
if (passive && hasBoss) return { ok: false, error: 'boss: a passive NPC never fights, so it cannot be a boss' }
if (passive && !hasUse) return { ok: false, error: 'use: a passive NPC says what pressing E on it does (D278)' }
if (hasUse && !passive) return { ok: false, error: 'use: only a passive NPC answers E (D278)' }
if (passive && !hasUse) return { ok: false, error: 'use: a passive NPC says what pressing E on it does' }
if (hasUse && !passive) return { ok: false, error: 'use: only a passive NPC answers E' }
let use = null
if (hasUse) {
const checked = checkUse(b.use)

View File

@@ -272,7 +272,7 @@ async function create(input, userId = null) {
async function update(id, input) {
const existing = await db.getProfile(id)
if (!existing) throw new NpcError('no such profile', 404)
if (existing.replaced) throw new NpcError('this profile is kept aside as replaced (D251): restore it before editing it', 409)
if (existing.replaced) throw new NpcError('this profile is kept aside as replaced: restore it before editing it', 409)
const clean = await validate(input, existing.id)
await db.saveProfile({ ...clean, id: existing.id })
const before = reach(existing)

View File

@@ -162,11 +162,19 @@ function shapeSync(row) {
}
}
// A grant to a player the plugin made and did not find when it read it back
// (`steamId:permission`). On Carbon that is a Steam id the server has never
// seen: it lands when they first join, because every sync pushes the whole
// set. "In sync" alone would hide it (W17).
const notLanded = report && Array.isArray(report.notLanded) ? report.notLanded : []
const waitingToLand = notLanded.filter((entry) => /^\d{17}:/.test(String(entry))).length
return {
serverId: row.serverId,
state: row.state,
dirty: Boolean(row.dirty),
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
waitingToLand,
lastAttemptAt: row.lastAttemptAt,
lastOkAt: row.lastOkAt,
importedAt: row.importedAt || null,

View File

@@ -305,10 +305,41 @@ async function gcReports() {
)
}
/**
* What one server's last status said about RaidableBases, its raids helper and
* its console (protocol 14, PLAN_REDESIGNS §11.4): `{ raidableBases, console }`,
* as `dtReportFor`'s are. Null for a server with no state yet.
*/
async function rbReportFor(serverId) {
const rows = await core.query(
`SELECT JSON_EXTRACT(raw, '$.integrations.raidableBases') AS raidableBases,
JSON_EXTRACT(raw, '$.console') AS console
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/** `rbReportFor` for every enabled server, with its id and name: the names a step offers. */
async function rbReports() {
return core.query(
`SELECT s.id AS serverId, s.name AS name,
JSON_EXTRACT(st.raw, '$.integrations.raidableBases') AS raidableBases,
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,
rbReportFor,
rbReports,
gcReportFor,
gcReports,
listServers,

View File

@@ -203,7 +203,9 @@ async function listForAdmin(now = Date.now()) {
// The public shape first, so the admin-only fields below cannot be
// overwritten by a key the public shape happens to share.
...shapePublic(row, state, now),
sidecarBaseUrl: row.sidecarBaseUrl,
// Left out on the public demo, so the screen gives nothing away that helps
// someone reach the sidecar (DEMO_MODE.md §3.5); editing the link is locked.
...(core.demoMode ? {} : { sidecarBaseUrl: row.sidecarBaseUrl }),
hasToken: Boolean(row.sidecarTokenEnc),
protocol: Number(row.protocol),
enabled: Boolean(row.enabled),

View File

@@ -23,7 +23,7 @@ const crypto = require('node:crypto')
* 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
* Warden), RaidableBases bases completed (§11.4, Warlord), plus
* `playtime`, which predates them and has no default title, so a playtime rule
* always carries its own text.
*
@@ -75,6 +75,8 @@ const STATS = {
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' } },
// Protocol 14, RaidableBases (§11.4, D202): a base counts for each of the plugin's own raiders when it is completed.
raidscompleted: { label: 'Raid bases completed', title: 'Warlord', source: { sum: 'raids_completed' } },
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

@@ -0,0 +1,89 @@
// ── What a weapon is called on a page ──────────────────────────────────────
//
// The plugin names the weapon in a kill by the held entity's SHORT PREFAB NAME
// (`ak47u.entity`) — the same name the title lists beside this file count by,
// and the right key for counting. It is the wrong thing to print: the feed said
// "with ak47u entity". So every event read gives the frame a `weaponName` from
// here, and the site and the app print that, falling back to the prefab name
// themselves only for a module too old to send one.
//
// The table is the ranged weapons — every gun, bow, crossbow, launcher and
// flamethrower — named as the game names them. Read off the Oxide rig's own item
// list on 2026-10-23 (Rust 26139947) by the same read-only walk the title lists
// came from: each item whose held entity is an `AttackEntity`, its short prefab
// name and its English display name. Melee, throwables and anything a later Rust
// update adds fall back to `stripped()`, which drops the `.entity` / `.weapon`
// ending and turns the rest into words ("salvaged sword"), so a new weapon reads
// acceptably before anyone edits this list. To refresh, walk the list again.
const NAMES = new Map([
['50cal.left.entity', 'Dual 50 Cal Turret'],
['50cal.right.entity', 'Dual 50 Cal Turret'],
['50cal.single.entity', '50 Cal Turret'],
['ak47u.entity', 'Assault Rifle'],
['ak47u.glass.blue.entity', 'Crystal Assault Rifle Sapphire'],
['ak47u.glass.entity', 'Crystal Assault Rifle Diamond'],
['ak47u.glass.green.entity', 'Crystal Assault Rifle Emerald'],
['ak47u.glass.pink.entity', 'Crystal Assault Rifle Pink Diamond'],
['ak47u.glass.red.entity', 'Crystal Assault Rifle Ruby'],
['ak47u_diver.entity', 'Abyss Assault Rifle'],
['ak47u_ice.entity', 'Ice Assault Rifle'],
['ak47u_jungle.entity', 'Jungle Relic Assault Rifle'],
['ak47u_med.entity', 'Medieval Assault Rifle'],
['blunderbuss.entity', 'Blunderbuss'],
['bolt_rifle.entity', 'Bolt Action Rifle'],
['bow_hunting.entity', 'Hunting Bow'],
['compound_bow.entity', 'Compound Bow'],
['crossbow.entity', 'Crossbow'],
['crossbow_bowless.entity', 'Bowless Crossbow'],
['double_shotgun.entity', 'Double Barrel Shotgun'],
['flamethrower.entity', 'Flame Thrower'],
['glock.entity', 'Prototype 17'],
['hc_revolver.entity', 'High Caliber Revolver'],
['hmlmg.entity', 'HMLMG'],
['krieg_shotgun.entity', 'Krieg Shotgun'],
['l96.entity', 'L96 Rifle'],
['legacybow.entity', 'Legacy Bow'],
['lr300.entity', 'LR-300 Assault Rifle'],
['lr300_space.entity', 'Space LR-300 Assault Rifle'],
['m16a2.entity', 'M16A2'],
['m249.entity', 'M249'],
['m39.entity', 'M39 Rifle'],
['m4_shotgun.entity', 'M4 Shotgun'],
['m92.entity', 'M92 Pistol'],
['mgl.entity', 'Multiple Grenade Launcher'],
['militaryflamethrower.entity', 'Military Flame Thrower'],
['mini_crossbow.entity', 'Mini Crossbow'],
['mp5.entity', 'MP5A4'],
['nailgun.entity', 'Nailgun'],
['pistol_revolver.entity', 'Revolver'],
['pistol_semiauto.a.m15.entity', 'M15 Semi-Automatic Pistol'],
['pistol_semiauto.entity', 'Semi-Automatic Pistol'],
['python.entity', 'Python Revolver'],
['rocket_launcher.entity', 'Rocket Launcher'],
['rocket_launcher_dragon.entity', 'Dragon Rocket Launcher'],
['semi_auto_rifle.entity', 'Semi-Automatic Rifle'],
['shotgun_pump.entity', 'Pump Shotgun'],
['shotgun_waterpipe.entity', 'Waterpipe Shotgun'],
['sks.entity', 'SKS'],
['smg.entity', 'Custom SMG'],
['spas12.entity', 'Spas-12 Shotgun'],
['t1_smg.entity', 'Handmade SMG'],
['thompson.entity', 'Thompson'],
])
/** The endings a held entity's prefab name carries that no player says. */
const ENDING = /[._](entity|weapon|deployed)$/
/** A prefab name as words: its ending dropped, its dots and underscores spaces. */
function stripped(prefab) {
return String(prefab).trim().replace(ENDING, '').replace(/[_.]+/g, ' ').trim()
}
/** What to print for a weapon's prefab name: the game's own name, or the stripped one. */
function nameOf(prefab) {
if (typeof prefab !== 'string' || !prefab.trim()) return null
return NAMES.get(prefab.trim()) || stripped(prefab)
}
module.exports = { nameOf, stripped, NAMES }

View File

@@ -103,17 +103,31 @@ async function viewerLevel(req) {
* decided the rung: a banned account is nobody, whatever its token names.
*/
async function viewer(req) {
const nobody = { level: 'public', userId: null }
try {
const claimed = req.user || core.auth.getUserFromRequest(req)
if (!claimed || claimed.id == null) return nobody
if (!claimed || claimed.id == null) return { level: 'public', userId: null }
return await viewerById(claimed.id)
} catch (err) {
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
return { level: 'public', userId: null }
}
}
const user = await core.users.getById(claimed.id)
/**
* The same re-read, for a caller that has an account id and no request: core's
* report-target `snapshot`, which is handed `viewer.userId` (USER_SAFETY.md
* §4.2). The role core passes beside it is the session's; this reads the row.
*/
async function viewerById(userId) {
const nobody = { level: 'public', userId: null }
if (userId == null) return nobody
try {
const user = await core.users.getById(userId)
if (!user) return nobody
if (user.status && user.status !== 'active') return nobody
const level = user.role === 'admin' || user.role === 'moderator' ? 'staff' : 'signed_in'
return { level, userId: user.id != null ? user.id : claimed.id }
return { level, userId: user.id != null ? user.id : userId }
} catch (err) {
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
return nobody
@@ -154,11 +168,22 @@ async function presenceFor(serverId) {
*/
async function canSeePresence(req, serverId) {
try {
const [level, required] = await Promise.all([viewerLevel(req), presenceFor(serverId)])
return { visible: meets(level, required), level, required }
const [who, required] = await Promise.all([viewer(req), presenceFor(serverId)])
return { visible: meets(who.level, required), level: who.level, required, userId: who.userId }
} catch (err) {
log.warn('could not resolve presence visibility; withholding it', { server: serverId, error: err.message })
return { visible: false, level: 'public', required: DEFAULT_PRESENCE }
return { visible: false, level: 'public', required: DEFAULT_PRESENCE, userId: null }
}
}
/** `canSeePresence` for an account id rather than a request (a report's snapshot). */
async function canSeePresenceAs(userId, serverId) {
try {
const [who, required] = await Promise.all([viewerById(userId), presenceFor(serverId)])
return { visible: meets(who.level, required), level: who.level, required, userId: who.userId }
} catch (err) {
log.warn('could not resolve presence visibility; withholding it', { server: serverId, error: err.message })
return { visible: false, level: 'public', required: DEFAULT_PRESENCE, userId: null }
}
}
@@ -314,9 +339,11 @@ module.exports = {
normalise,
viewerLevel,
viewer,
viewerById,
fleetPresence,
presenceFor,
canSeePresence,
canSeePresenceAs,
describe,
update,
}

View File

@@ -314,13 +314,7 @@ async function syncOne(server, { authored, sync, state, force, policy = 'auto-ad
held: planned.hold.size,
})
const result = await sidecar.permSync(server, {
setId: desired.hash,
groups: withExpect(held.payload.groups, pushed),
grants: held.payload.grants,
credits: held.payload.credits,
retire,
})
const result = await sidecar.permSync(server, pushBody(desired.hash, held.payload, pushed, retire))
if (!result.ok) {
await failed(server, { reason, error: result.status, desiredHash: desired.hash, sync, bootId, wipeId })
@@ -358,6 +352,23 @@ async function syncOne(server, { authored, sync, state, force, policy = 'auto-ad
return 'ok'
}
/**
* The sync as the wire carries it: every field of the built payload, the
* groups with their style `expect`s, and what to retire. `notices` rides here
* too (D333) — the push once named its fields one by one and left it out, so
* no reward message ever reached the game (W21).
*/
function pushBody(setId, payload, pushed, retire) {
return {
setId,
groups: withExpect(payload.groups, pushed),
grants: payload.grants,
credits: payload.credits,
notices: payload.notices,
retire,
}
}
/**
* The groups as the wire carries them: each style field with the value this
* site last pushed to THIS server (`expect`), or null for one it never has.
@@ -526,6 +537,7 @@ module.exports = {
syncOne,
reasonToSync,
applyReport,
pushBody,
withExpect,
styleRetirements,
withLock,

166
server/raidableBases.js Normal file
View File

@@ -0,0 +1,166 @@
// ── RaidableBases (protocol 14, PLAN_REDESIGNS §11.4) ───────────────────────
//
// nivex's RaidableBases pastes a stocked, guarded building for players to raid.
// Its bases reach this module as five kinds: `raid.started`, `raid.completed`,
// `raid.purchased` and `raid.locked` (public), and `raid.ended` (staff's). A base
// completed is *Warlord*'s count, from the tally's `raidsCompleted` (D202). A
// run's step spawns one (`eventRaidableBases.js`).
//
// **Every name is the plugin's (D386).** Difficulty names and levels, profile
// names and base names arrive as the installed plugin's own strings; nothing
// here lists them, and an operator's own difficulty is shown as written.
//
// ── Who may read what ──────────────────────────────────────────────────────
//
// As a world event is read (`worldEvents.js`, D353, D376, D390): the base, its
// difficulty, whether it is PvP, and its grid are public; the raiders' and a
// buyer's names show only to the operator's presence audience, so with presence
// left at staff a signed-out visitor reads "A Hard raid base was raided at G7";
// Steam ids, the position and a run's key are staff's. How
// many raided it is a count, and names nobody.
const { gridOf } = require('./worldEvents')
const KINDS = Object.freeze(['raid.started', 'raid.completed', 'raid.ended', 'raid.purchased', 'raid.locked'])
const STEAM_ID = /^\d{17}$/
function isKind(kind) {
return KINDS.includes(kind)
}
/**
* One `raid.*` frame for one viewer: `{ staff, presence, geometry }`. Passes
* the envelope, `level`, `difficulty`, `base`, `pvp` and `despawnAt`, adds `grid`; a
* completion adds `raiderCount`, and `names` (with the lock holder's
* `ownerName`) for the presence audience; a purchase or a lock adds `name` for
* the presence audience. Staff also get `x`, `z`, the run's `id`,
* `runId` and `key`, and every Steam id. 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 level = Number(frame.level)
if (Number.isInteger(level)) out.level = level
if (typeof frame.difficulty === 'string' && frame.difficulty) out.difficulty = frame.difficulty
if (typeof frame.pvp === 'boolean') out.pvp = frame.pvp
if (Number.isFinite(Number(frame.despawnAt)) && Number(frame.despawnAt) > 0) out.despawnAt = Number(frame.despawnAt)
// The base's file name is the operator's, as the difficulty is, and the map shows it too.
if (typeof frame.base === 'string' && frame.base) out.base = frame.base
const grid = gridOf(geometry, frame.x, frame.z)
if (grid) out.grid = grid
const named = presence || staff
if (frame.kind === 'raid.completed') {
const raiders = Array.isArray(frame.raiders) ? frame.raiders.filter((r) => r && typeof r === 'object') : []
const count = Number(frame.raiderCount)
out.raiderCount = Number.isInteger(count) && count >= 0 ? count : raiders.length
if (named) {
out.names = raiders.map((r) => (typeof r.name === 'string' ? r.name : '')).filter(Boolean)
if (typeof frame.ownerName === 'string' && frame.ownerName) out.ownerName = frame.ownerName
}
if (staff) {
out.raiders = raiders
.filter((r) => STEAM_ID.test(String(r.steamId || '')))
.map((r) => ({ steamId: String(r.steamId), ...(typeof r.name === 'string' ? { name: r.name } : {}) }))
if (STEAM_ID.test(String(frame.ownerId || ''))) out.ownerId = String(frame.ownerId)
}
}
if (frame.kind === 'raid.purchased' || frame.kind === 'raid.locked') {
if (named && typeof frame.name === 'string' && frame.name) out.name = frame.name
if (staff && STEAM_ID.test(String(frame.steamId || ''))) out.steamId = String(frame.steamId)
}
if (staff) {
for (const key of ['x', 'z', 'id', 'runId', 'key', 'radius']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
}
return out
}
/** A JSON column as an object, or null. */
function parse(value) {
if (value === null || value === undefined) return null
if (typeof value !== 'string') return value
try {
return JSON.parse(value)
} catch {
return null
}
}
/** One profile as the helper reports it, or null when it is not one. */
function profileOf(p) {
if (!p || typeof p !== 'object' || typeof p.name !== 'string' || !p.name) return null
return {
name: p.name,
difficulty: typeof p.difficulty === 'string' ? p.difficulty : null,
level: Number.isInteger(Number(p.level)) ? Number(p.level) : null,
enabled: p.enabled === true,
pvp: p.pvp === true,
bases: Array.isArray(p.bases) ? p.bases.filter((b) => typeof b === 'string' && b) : [],
seabed: p.seabed === true,
float: p.float === true,
}
}
/**
* A server's last report about RaidableBases and its console, from
* `servers.db.rbReportFor`: `{ raidableBases, consolePlugins }`, each null
* where the plugin never said (no state yet, or a plugin before this build).
* `raidableBases` is `{ loaded, version, premium, spawnCall, copyPaste,
* difficulties, helper, gridReady, profiles }` — `helper.state` is `missing`,
* `patched`, `unsupported` or `no-raidablebases`; `profiles` is null without a
* patched helper, which is the only thing that can read them.
*/
function reportOf(row) {
const rb = row ? parse(row.raidableBases) : null
const probe = row ? parse(row.console) : null
let raidableBases = null
if (rb && typeof rb === 'object') {
const helper = rb.helper && typeof rb.helper === 'object' ? rb.helper : {}
raidableBases = {
loaded: rb.loaded === true,
version: typeof rb.version === 'string' ? rb.version : null,
premium: rb.premium === true,
spawnCall: rb.spawnCall === true,
copyPaste: rb.copyPaste && typeof rb.copyPaste === 'object' ? { loaded: rb.copyPaste.loaded === true } : null,
difficulties: Array.isArray(rb.difficulties)
? rb.difficulties
.filter((d) => d && typeof d.name === 'string' && d.name && Number.isInteger(Number(d.level)))
.map((d) => ({ name: d.name, level: Number(d.level) }))
: [],
helper: {
state: typeof helper.state === 'string' ? helper.state : 'missing',
version: typeof helper.version === 'string' ? helper.version : null,
reason: typeof helper.reason === 'string' ? helper.reason : null,
},
gridReady: typeof rb.gridReady === 'boolean' ? rb.gridReady : null,
profiles: Array.isArray(rb.profiles) ? rb.profiles.map(profileOf).filter(Boolean) : null,
}
}
return {
raidableBases,
consolePlugins: probe && typeof probe === 'object' && typeof probe.plugins === 'boolean' ? probe.plugins : null,
}
}
/** Whether the report says the raids helper is installed and patched. */
function helperPatched(report) {
const rb = report && report.raidableBases
return Boolean(rb && rb.loaded && rb.helper && rb.helper.state === 'patched')
}
module.exports = { KINDS, isKind, shapeFor, reportOf, profileOf, helperPatched }

View File

@@ -33,6 +33,7 @@
const core = require('../../core')
const configEdit = require('../../configEdit')
const demo = require('../../demo')
const db = require('../../model/config/config.db')
const model = require('../../model/config/config.model')
const servers = require('../../model/servers/servers.model')
@@ -87,7 +88,32 @@ async function listFiles(req, res) {
return res.status(502).json({ message: refusalMessage(reply.data) })
}
return res.json(model.shapeCatalogue(reply.data))
return res.json(forDemo(model.shapeCatalogue(reply.data)))
}
/**
* The catalogue without the bridge's own files, on the demo (DEMO_MODE.md §3.5,
* DM14): they hold its link to the sidecar, so they are not listed, read or
* written. Off the demo the catalogue is untouched.
*/
function forDemo(catalogue) {
if (!demo.isDemo() || !catalogue) return catalogue
return {
...catalogue,
plugins: catalogue.plugins
.filter((group) => !demo.isBridgePlugin(group.plugin, catalogue.self))
.map((group) => ({ ...group, files: group.files.filter((file) => !demo.isBridgePath(file.path, catalogue.self)) })),
}
}
/** A shaped file as the caller may see it: secrets hidden on the demo. */
function visible(shaped) {
return demo.isDemo() ? model.redactSecrets(shaped, demo.REDACTED) : shaped
}
/** The demo's refusal for the bridge's config, in one place. */
function refuseBridge(res) {
return demo.refuse(res, 'The bridge plugin’s configuration is not shown on the demo site.')
}
/** One file: its text, and the reading of it the form is drawn from. */
@@ -98,6 +124,10 @@ async function readFile(req, res) {
return res.status(400).json({ message: 'That is not a configuration path' })
}
// Before the host is asked, by the bridge's own name; and again below, by the
// name its catalogue gives, in case the two ever differ.
if (demo.isDemo() && demo.isBridgePath(path, null)) return refuseBridge(res)
const server = await serverOr404(req, res)
if (!server) return undefined
@@ -116,7 +146,8 @@ async function readFile(req, res) {
}
const self = catalogueReply.ok && catalogueReply.data ? catalogueReply.data.self : null
return res.json(model.shapeFile(fileReply.data, { self }))
if (demo.isDemo() && demo.isBridgePath(path, self)) return refuseBridge(res)
return res.json(visible(model.shapeFile(fileReply.data, { self })))
}
/**
@@ -135,6 +166,16 @@ async function writeFile(req, res) {
return res.status(400).json({ message: 'That is not a configuration path' })
}
if (demo.isDemo()) {
if (demo.isBridgePath(path, null)) return refuseBridge(res)
// A raw document sent back from the demo holds the placeholders, and would
// write them over the real values. The form splices only what changed.
if (tier === 'raw') return demo.refuse(res, 'Raw JSON saves are turned off on the demo site. Use the form.')
if (model.editsTouchSecret(req.body.edits)) {
return demo.refuse(res, 'Credential fields are read-only on the demo site.')
}
}
const server = await serverOr404(req, res)
if (!server) return undefined
@@ -151,6 +192,7 @@ async function writeFile(req, res) {
const onDisk = current.data
const self = catalogue.ok && catalogue.data ? catalogue.data.self : null
if (demo.isDemo() && demo.isBridgePath(path, self)) return refuseBridge(res)
const locked = model.lockedKeysFor(path, self)
// The browser's version against what is on the host right now. The plugin
@@ -160,7 +202,7 @@ async function writeFile(req, res) {
if (String(req.body.version || '') !== String(onDisk.version)) {
return res.status(409).json({
message: 'That file changed on the server since you opened it',
current: model.shapeFile(onDisk, { self }),
current: visible(model.shapeFile(onDisk, { self })),
})
}
@@ -287,7 +329,9 @@ async function writeFile(req, res) {
/** What has been written to this server's configuration lately, and by whom. */
async function history(req, res) {
try {
return res.json({ writes: await db.recentWrites(req.params.serverId, req.query.limit) })
const writes = await db.recentWrites(req.params.serverId, req.query.limit)
// The bridge's own writes are left out on the demo, as its files are (§3.5).
return res.json({ writes: demo.isDemo() ? writes.filter((w) => !isBridgeWrite(w)) : writes })
} catch (err) {
log.error('failed to read the configuration history', { error: err.message })
return res.status(500).json({ message: 'Failed to read the configuration history' })
@@ -305,6 +349,7 @@ async function writeStatus(req, res) {
try {
const write = await db.getWrite(req.params.serverId, Number(req.params.writeId))
if (!write) return res.status(404).json({ message: 'No such configuration write' })
if (demo.isDemo() && isBridgeWrite(write)) return refuseBridge(res)
return res.json({ write })
} catch (err) {
@@ -313,6 +358,11 @@ async function writeStatus(req, res) {
}
}
/** Whether an audit row is a write to the bridge's own config. */
function isBridgeWrite(write) {
return demo.isBridgePlugin(write.plugin, null) || demo.isBridgePath(write.path, null)
}
/**
* One audit row, plus the activity entry core owns. Never lets a logging failure
* fail a save. Returns the row's id, or null when it could not be written.
@@ -323,7 +373,7 @@ async function record(req, row) {
try {
id = await db.recordWrite({
...row,
plugin: model.isBridgeConfig(row.path, row.self) ? row.self : pluginOf(row.path),
plugin: model.isBridgeConfig(row.path, row.self) ? row.self : model.pluginOf(row.path),
reloadTarget: row.reload,
userId: req.user ? req.user.id : null,
})
@@ -347,10 +397,6 @@ async function record(req, row) {
return id
}
function pluginOf(path) {
return String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
}
/** A `config.error` frame as a sentence. */
function refusalMessage(frame) {
const reasons = {

View File

@@ -13,6 +13,7 @@
const core = require('../../core')
const db = require('../../model/servers/servers.db')
const eventsDb = require('../../model/events/events.db')
const mapImages = require('../../mapImages')
const nextWipe = require('../../model/servers/nextWipe')
const servers = require('../../model/servers/servers.model')
@@ -379,7 +380,40 @@ async function putVoice(req, res) {
}
}
/**
* Staff's hide for everyone on one chat line, and its undo, from the line's own
* menu in the feed (USER_SAFETY.md §1, S10). Core's Reports queue hides through
* `safety.js`'s registered `hide`; this is the same flag set where the line
* lives, and the only place it is cleared, since staff still see a hidden line
* there, marked.
*/
function chatHidden(hidden) {
return async function setChatHidden(req, res) {
const id = Number(req.params.id)
try {
const line = await eventsDb.chatLineById(id)
if (!line) return res.status(404).json({ message: 'No such chat line' })
await eventsDb.setChatHidden(id, hidden, req.user ? req.user.id : null)
await core.activity.log({
req,
action: hidden ? 'rust.chat.hide' : 'rust.chat.unhide',
detail: { line: id, server: line.serverId, steamId: line.steamId || null },
})
return res.status(204).end()
} catch (err) {
log.error('failed to change a chat line', { line: id, hidden, error: err.message })
return res.status(500).json({ message: hidden ? 'Failed to hide the line' : 'Failed to show the line again' })
}
}
}
const hideChat = chatHidden(true)
const unhideChat = chatHidden(false)
module.exports = {
hideChat,
unhideChat,
scheduleFrom,
listServers,
putServer,

View File

@@ -123,6 +123,39 @@ adminRustRouter.post(
admin.testServer,
)
// ── Hide a chat line for everyone (USER_SAFETY.md §1, S10) ────────────────
//
// From the line's own menu in the feed. `admin` and `moderator`, the two roles
// core's Reports queue is open to — an editor writes content and moderates none.
adminRustRouter.post(
'/chat/:id/hide',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Hide a chat line for everyone'
// #swagger.description = 'Staff’s hide for everyone (USER_SAFETY.md S10): the line leaves the public feed for everybody but staff, who still get it with `hidden: true`. The same flag core’s Reports queue sets through the `rust.chat` report target. Hiding a hidden line changes nothing. Recorded in the activity log. Admins and moderators.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The chat line’s event id', schema: { type: 'integer' } }
/* #swagger.responses[204] = { description: 'Hidden' } */
/* #swagger.responses[404] = { description: 'No such chat line' } */
requireRole('admin', 'moderator'),
param('id').isInt({ min: 1 }),
validate,
admin.hideChat,
)
adminRustRouter.post(
'/chat/:id/unhide',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Show a hidden chat line again'
// #swagger.description = 'Undoes a hide for everyone, whether it was made here or from the Reports queue. Recorded in the activity log. Admins and moderators.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The chat line’s event id', schema: { type: 'integer' } }
/* #swagger.responses[204] = { description: 'Shown again' } */
/* #swagger.responses[404] = { description: 'No such chat line' } */
requireRole('admin', 'moderator'),
param('id').isInt({ min: 1 }),
validate,
admin.unhideChat,
)
// ── The map's picture (phase 14) ──────────────────────────────────────────
adminRustRouter.post(

View File

@@ -8,6 +8,7 @@
const crypto = require('node:crypto')
const core = require('../../core')
const demo = require('../../demo')
const backpacks = require('../../model/backpacks/backpacks.model')
const economics = require('../../model/economics/economics.model')
@@ -54,6 +55,10 @@ async function removeLink(req, res) {
const { steamId } = req.params
const userId = req.params.id
// On the demo the player's own unlink is locked by demoLocks; this route serves
// under core's users mount, which a module may not lock by route, so it asks here.
if (demo.isDemo()) return demo.refuse(res)
try {
const removed = await links.unlinkOwned(steamId, userId)

View File

@@ -159,7 +159,7 @@ usersRustRouter.post(
/* #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[409] = { description: 'Economics is not installed there, the balance is too low to withdraw that much, or a deposit would pass Economics’ balance limit', 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 }),

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`, `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.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`, `pvp` and `raids` 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`, `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.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`, `raids`) 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

@@ -14,6 +14,8 @@ const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const dangerousTreasures = require('../../dangerousTreasures')
const guardedCrate = require('../../guardedCrate')
const raidableBases = require('../../raidableBases')
const safety = require('../../safety')
const economics = require('../../model/economics/economics.model')
const events = require('../../model/events/events.model')
const map = require('../../model/map/map.model')
@@ -89,20 +91,30 @@ async function getServer(req, res) {
*
* `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.
*
* The chat is read for THIS reader too (USER_SAFETY.md S11): lines whose speaker
* they block or that they hid are left out, a line staff hid for everyone is
* left out unless they are staff, and a signed-in reader gets `canBlock` on each
* line (S13). That is why this route was already `perViewer` and must stay so.
*/
async function listEvents(req, res) {
try {
const presence = await visibility.canSeePresence(req, req.params.id)
const reader = { userId: presence.userId, staff: presence.level === 'staff' }
// Below the presence audience there is no chat to filter or mark.
const chat = presence.visible ? await safety.feedFilter(reader) : {}
const rows = await events.recent({
serverId: req.params.id,
presence: presence.visible,
staff: reader.staff,
kind: req.query.kind,
wipeId: req.query.wipe || null,
limit: req.query.limit,
chat,
})
perViewer(res)
res.json({
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,
}),
events: presence.visible ? await safety.annotate(rows, reader) : rows,
presenceHidden: !presence.visible,
presenceAudience: presence.required,
})
@@ -126,6 +138,8 @@ async function listLeaderboard(req, res) {
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))
// Warlord's column (§11.4, D202), likewise for RaidableBases.
const rb = raidableBases.reportOf(await serversDb.rbReportFor(req.params.id).catch(() => null))
const withBalance = econ.loaded && wipeId ? { startingBalance: econ.startingBalance } : null
const [rows, held] = await Promise.all([
@@ -149,6 +163,7 @@ async function listLeaderboard(req, res) {
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)),
raidableBases: Boolean((rb.raidableBases && rb.raidableBases.loaded) || rows.some((r) => r.raidsCompleted > 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. 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.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` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped. Chat is read for each reader (USER_SAFETY.md §4.2): a signed-in reader does not get the lines of a player they blocked (by the player’s linked account, or by Steam id when there is none) or lines they hid for themselves, and each `player.chat` row carries `canBlock` — false for their own lines and for a player linked to a staff account. A line staff hid for everyone is left out for everybody but staff, who get it with `hidden: true`.'
// #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. 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.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. Likewise `raidsCompleted` (RaidableBases bases completed as one of the plugin’s own raiders, Warlord’s count, PLAN_REDESIGNS §11.4) and `raidableBases: true` where the server reports RaidableBases 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, playtime, cratesHacked, dropsLooted, treasuresWon, guardedWon, or balance (one wipe on a server with Economics)', schema: { type: 'string' } }
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, guardedWon, raidsCompleted, 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,

193
server/safety.js Normal file
View File

@@ -0,0 +1,193 @@
// ── Report, block and hide, for chat lines ─────────────────────────────────
//
// USER_SAFETY.md §4.2 (MODULE_API 1.21.0). Core owns the Reports queue, a
// member's blocks and their "Hide for me" list; this module owns the chat lines
// those name, and is the only one that can say what a line is, who said it, and
// whether a reader may see it. Four things live here:
//
// REPORT_TARGETS `rust.chat`: a chat line, as core's resolver asks for it —
// `snapshot` for one reader, and staff's hide for everyone
// GAME_IDENTITIES `steam`: which website account a Steam id is linked to,
// so a block on a linked speaker lands on their account (S12)
// and a Steam-id block becomes one once the id links (S14)
// feedFilter what one reader's events feed leaves out of the chat (S11)
// annotate `canBlock` on each chat line, for a signed-in reader (S13)
//
// ── The rules, each decided by the org lead ───────────────────────────────
//
// S6 Reporting, hiding and blocking all need an account. A signed-out reader
// gets the feed as it is, with no `canBlock`.
// S8 Staff cannot be blocked. Core refuses the block and drops a block whose
// target later became staff; here a line whose speaker is linked to staff
// says `canBlock: false`.
// S10 A line staff hid for everyone is still staff's to see, marked.
// S12 A speaker is checked by their linked ACCOUNT first, and by Steam id only
// when they have none.
//
// A block hides what someone SAYS: chat lines. Their deaths, connects and kills
// stay in the feed, because the feed is what happened on the server and a block
// is not a way to rewrite that.
const core = require('./core')
const eventsDb = require('./model/events/events.db')
const linksDb = require('./model/links/links.db')
const serversDb = require('./model/servers/servers.db')
const visibility = require('./model/visibility/visibility.model')
const log = core.logger('safety')
const CHAT = 'rust.chat'
const STEAM = 'steam'
function parseFrame(raw) {
try {
return typeof raw === 'string' ? JSON.parse(raw) : raw || {}
} catch {
return {}
}
}
const ids = (list) => [...new Set((list || []).filter((v) => v != null).map(String))]
/** Steam id → website account id, for the ids that are linked. */
async function ownersOf(steamIds) {
const wanted = ids(steamIds)
if (!wanted.length) return {}
const rows = await linksDb.userIdsForSteamIds(wanted)
return Object.fromEntries(rows.map((r) => [String(r.steamId), Number(r.userId)]))
}
/**
* One chat line, for one reader, or null.
*
* Null is the answer for a line that is gone AND for one this reader could not
* have seen — on a server whose presence is hidden from them, or hidden by staff
* when they are not staff — because core answers 404 to both, and a report or a
* block must never confirm who said line 4410 to somebody the feed never showed
* it to (MODULE_API.md §2.4).
*/
async function snapshot({ targetId, viewer }) {
const row = await eventsDb.chatLineById(targetId)
if (!row) return null
const presence = await visibility.canSeePresenceAs(viewer && viewer.userId, row.serverId)
if (!presence.visible) return null
if (row.hiddenAt && presence.level !== 'staff') return null
const frame = parseFrame(row.raw)
const steamId = row.steamId ? String(row.steamId) : null
const [owners, server] = await Promise.all([
steamId ? ownersOf([steamId]) : {},
serversDb.getServer(row.serverId).catch(() => null),
])
return {
author: {
name: frame.name || null,
userId: steamId && owners[steamId] ? owners[steamId] : null,
identity: steamId ? { kind: STEAM, id: steamId } : null,
},
text: typeof frame.message === 'string' ? frame.message : '',
at: Number.isFinite(Number(row.t)) ? new Date(Number(row.t)).toISOString() : null,
where: {
server: row.serverId,
serverName: server ? server.name : null,
channel: frame.channel || 'Global',
wipe: row.wipeId || null,
},
}
}
/**
* Staff's hide for everyone, from core's Reports queue (S3, S10). A line the
* sweep has already pruned has nothing left to hide, and that is not an error:
* the report is still actioned.
*/
async function hide({ targetId, actor }) {
if (!(await eventsDb.chatLineById(targetId))) return
await eventsDb.setChatHidden(targetId, true, actor && actor.id != null ? actor.id : null)
}
async function unhide({ targetId }) {
await eventsDb.setChatHidden(targetId, false)
}
const REPORT_TARGETS = Object.freeze([
Object.freeze({ id: CHAT, label: 'Chat line', snapshot, hide, unhide }),
])
const GAME_IDENTITIES = Object.freeze([
Object.freeze({ kind: STEAM, label: 'Steam account', ownersOf }),
])
/**
* What one reader's feed leaves out of the chat, as `events.recent` takes it:
* the Steam ids whose lines they do not get, the lines they hid, and whether
* staff's hidden lines are theirs to see.
*
* The Steam ids are S12 in one set: every id linked to an account they block,
* plus their Steam-id blocks — which core answers only for ids still unlinked,
* having already turned a linked one into an account block (S14) or dropped it
* when that account is staff (S8). So a line is left out by its speaker's
* account when there is one, and by its Steam id only when there is not.
*
* **A failure here shows the feed unfiltered rather than failing it.** The feed
* is the page the site promises to render while everything behind it is down;
* a member who sees a blocked line once, with a warning in the log, is the
* smaller harm than a blank killfeed for everyone signed in.
*/
async function feedFilter({ userId, staff }) {
const base = { showHidden: Boolean(staff), steamIds: [], ids: [] }
if (userId == null) return base
try {
const mine = await core.safety.forViewer(userId)
const blockedUsers = (mine.blockedUserIds || []).map(Number).filter((n) => Number.isInteger(n) && n > 0)
const linked = blockedUsers.length ? await linksDb.steamIdsForUsers(blockedUsers) : []
const unlinked = (mine.blockedIdentities && mine.blockedIdentities[STEAM]) || []
const hidden = (mine.hidden && mine.hidden[CHAT]) || []
return {
...base,
steamIds: ids([...linked, ...unlinked]),
ids: [...new Set(hidden.map(Number).filter((n) => Number.isInteger(n) && n > 0))],
}
} catch (err) {
log.warn('could not read the reader’s blocks; the feed is unfiltered for them', { error: err.message })
return base
}
}
/**
* `canBlock` on each chat line, for a signed-in reader (S13). False when the
* line has no Steam id, when the speaker's linked account is the reader's own,
* or when it is staff (S8). Signed-out readers get the rows untouched.
*
* A failure answers `canBlock: false` on every line: the menu then offers no
* Block, and core would refuse a block on staff anyway.
*/
async function annotate(events, { userId }) {
if (userId == null) return events
const chat = events.filter((e) => e.kind === 'player.chat')
if (!chat.length) return events
let owners = {}
let staff = new Set()
let ok = true
try {
owners = await ownersOf(chat.map((e) => e.steamId))
const linked = [...new Set(Object.values(owners))]
staff = new Set((linked.length ? await core.safety.staffAmong(linked) : []).map(Number))
} catch (err) {
log.warn('could not work out who can be blocked; offering no block', { error: err.message })
ok = false
}
return events.map((e) => {
if (e.kind !== 'player.chat') return e
const owner = e.steamId ? owners[String(e.steamId)] : undefined
const canBlock = ok && Boolean(e.steamId) && owner !== Number(userId) && !(owner != null && staff.has(owner))
return { ...e, canBlock }
})
}
module.exports = { CHAT, STEAM, REPORT_TARGETS, GAME_IDENTITIES, snapshot, hide, unhide, ownersOf, feedFilter, annotate }

View File

@@ -452,6 +452,24 @@ const dtStart = (server, body) => request(server, '/dt/start', { method: 'POST',
*/
const gcPlace = (server, body) => request(server, '/gc/place', { method: 'POST', body })
/**
* Spawn one RaidableBases base for a run's step (protocol 14, §20.12): `{ key,
* runId }`, the plugin's own `difficulty`, `profile` and `base` names (D386), a
* spot (`monument` and offsets, or `x` and `z`, with `heightAdjust` or `height`)
* and the spawn's settings (`pvp`, `npcs`, `npcCount`, `despawnMinutes`,
* `lock`). `data.kind` is `raid.queued` — with `id` when the raids helper made
* the base the run's, so `world.revert` takes it back; `despawns` says
* `teardown` or `own-timer` — or `raid.error` with a reason.
*/
const raidSpawn = (server, body) => request(server, '/raid/spawn', { method: 'POST', body })
/**
* A spot's checks for a raid base, spawning nothing (§20.12, D208, D388):
* `data.kind` is `raid.checked` with `ok`, a refusal's `reason` and `message`,
* and `warnings`; or `raid.error` when the spot cannot be checked (no helper).
*/
const raidCheck = (server, body) => request(server, '/raid/check', { method: 'POST', body })
/**
* Run console commands a step's author typed (protocol 14, §20.11): `{ key,
* commands, teardown? }` once, or `{ key, commands, recipients }` once per
@@ -602,6 +620,8 @@ module.exports = {
worldCall,
dtStart,
gcPlace,
raidSpawn,
raidCheck,
commandsRun,
worldRevert,
tallyOpen,

View File

@@ -294,6 +294,46 @@ module.exports = {
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.6, D374): whether ZLevels Remastered is loaded, and nothing more. Its levels are never read; its XP boosts are permissions an event lends through a Permissions until the event ends step.',
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '3.3.1' } },
},
raidableBases: {
type: 'object',
description: 'Protocol 14 (PLAN_REDESIGNS §11.4): where loaded, its bases reach the feed, the map’s raids layer and Warlord, and a step may spawn one. Every difficulty, profile and base name is the installed plugin’s own (D386). `gridReady` and `profiles` come only with a patched raids helper (RunicGatewayRaids.cs), which is also what makes a step’s base the run’s.',
properties: {
loaded: { type: 'boolean', example: true },
version: { type: 'string', example: '3.2.2' },
premium: { type: 'boolean', description: 'The plugin’s own IsPremium().', example: true },
spawnCall: { type: 'boolean', description: 'Whether the paid edition’s SpawnRandomBase call is there, looked for by name (D348).', example: true },
copyPaste: { type: 'object', properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '4.3.0' } } },
difficulties: {
type: 'array',
items: { type: 'object', properties: { name: { type: 'string', example: 'Hard' }, level: { type: 'integer', example: 2 } } },
},
helper: {
type: 'object',
properties: {
state: { type: 'string', enum: ['missing', 'patched', 'unsupported', 'no-raidablebases'], example: 'patched' },
version: { type: 'string', example: '0.1.0' },
reason: { type: 'string', description: 'Why it is not patched.' },
},
},
gridReady: { type: 'boolean', description: 'Whether its spawn grid is built; until it is, it refuses every spawn.', example: true },
profiles: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', example: 'Hard Bases' },
difficulty: { type: 'string', example: 'Hard' },
level: { type: 'integer', example: 2 },
enabled: { type: 'boolean', example: true },
pvp: { type: 'boolean', example: true },
bases: { type: 'array', items: { type: 'string' }, example: ['raidhard1', 'raidhard2'] },
seabed: { type: 'boolean', description: 'A seabed chance above 0 (D389).', example: false },
float: { type: 'boolean', description: 'Allows bases to float above water (D389).', example: false },
},
},
},
},
},
},
},
console: {
@@ -618,6 +658,11 @@ module.exports = {
description: 'True when the last successful push carried the set the site currently authors.',
example: true,
},
waitingToLand: {
type: 'integer',
description: 'Grants to players (lent, given or authored) the last sync pushed and the game did not keep: the report’s `notLanded` entries for a Steam id. On Carbon that is a player the server has never seen; the grant lands when they first join, because every sync pushes the whole set.',
example: 0,
},
dirty: { type: 'boolean', example: false },
lastAttemptAt: { type: 'string', format: 'date-time', nullable: true },
lastOkAt: { type: 'string', format: 'date-time', nullable: true },
@@ -806,7 +851,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', 'pvp'] },
layers: { type: 'array', items: { type: 'string' }, example: ['world', 'events', 'players', 'bases', 'pvp', 'raids'] },
minorLabels: {
type: 'array',
items: { type: 'string' },
@@ -821,6 +866,7 @@ module.exports = {
players: { $ref: '#/components/schemas/RustAudience' },
bases: { $ref: '#/components/schemas/RustAudience' },
pvp: { $ref: '#/components/schemas/RustAudience' },
raids: { $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 } },
},
@@ -959,6 +1005,7 @@ module.exports = {
players: { $ref: '#/components/schemas/RustMapLayer' },
bases: { $ref: '#/components/schemas/RustMapLayer' },
pvp: { $ref: '#/components/schemas/RustMapLayer' },
raids: { $ref: '#/components/schemas/RustMapLayer' },
},
},
mates: {
@@ -1056,6 +1103,23 @@ module.exports = {
},
},
},
raids: {
type: 'array',
description: 'Protocol 14 (§11.4): RaidableBases’ live bases, with the plugin’s own difficulty and base names (D386). `runId` is set for a base this site’s event spawned; the run’s key is never sent.',
items: {
type: 'object',
properties: {
x: { type: 'number' },
z: { type: 'number' },
level: { type: 'integer', example: 2 },
difficulty: { type: 'string', example: 'Hard' },
base: { type: 'string', example: 'raidhard2' },
pvp: { type: 'boolean', example: true },
despawnAt: { type: 'integer', description: 'When the plugin despawns it, unix milliseconds.' },
runId: { type: 'string', example: '12' },
},
},
},
mates: {
type: 'array',
description: 'The viewer’s own positions (their sleeper too) and their online first-party clan mates on this server.',

View File

@@ -91,6 +91,12 @@ function fakeCtx(overrides = {}) {
},
},
activity: { log: spy(Promise.resolve()) },
// Report, block and hide (1.21.0). A reader with no blocks and no hides, and
// nobody staff; a suite that needs either installs its own.
safety: {
forViewer: spy(Promise.resolve({ blockedUserIds: [], blockedIdentities: {}, hidden: {} })),
staffAmong: spy(Promise.resolve([])),
},
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
@@ -104,6 +110,8 @@ function fakeCtx(overrides = {}) {
accountChangeLimiter: (req, res, next) => next(),
},
site: { baseUrl: 'http://localhost:5173' },
// MODULE_API 1.18.0: false unless a suite says it is the public demo.
demoMode: false,
...rest,
}
// Non-enumerable, and that is not tidiness. Core freezes every object value on
@@ -166,6 +174,16 @@ function fakeApi() {
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
// MODULE_API 1.17.0 (Steam sign-in, PLAN_REDESIGNS §15).
onIdentityLinking(fn) { once('onIdentityLinking'); record.hooks.onIdentityLinking = fn },
onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn },
// MODULE_API 1.18.0 (the public demo's seed, DM15).
onDemoSeed(fn) { once('onDemoSeed'); record.hooks.onDemoSeed = fn },
// MODULE_API 1.20.0 (account deletion, ACCOUNT_DELETION.md §4.4).
onUserDeleting(fn) { once('onUserDeleting'); record.hooks.onUserDeleting = fn },
// MODULE_API 1.21.0 (report, block and hide, USER_SAFETY.md §4.2).
registerReportTargets(targets) { once('registerReportTargets'); record.reportTargets = targets },
registerGameIdentities(identities) { once('registerGameIdentities'); record.gameIdentities = identities },
}
api.record = record
return api

View File

@@ -34,6 +34,10 @@ function withRecorder(rows = []) {
withRecorder()
// The stored status is the whole answer in these tests: nothing asks a
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
require('../liveStatus').refresh = async () => false
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
@@ -194,16 +198,21 @@ test('the revert deletes the step’s rows, and the next sync takes the size bac
assert.deepStrictEqual(calls.dirty, ['main'])
})
test('the size list offers each server’s sizes above its smallest, grouped by server', async (t) => {
test('the size list offers each size above a server’s smallest once, naming every server that offers it (W10)', 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' }]
servers.listForPolling = async () => [{ id: 'main', name: 'Main' }, { id: 'pve', name: 'PvE' }, { id: 'off', name: 'Off' }]
backpacksDb.reports = async () => [
{ serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48] }) },
{ serverId: 'pve', backpacks: null },
{ serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48, 96] }) },
{ serverId: 'pve', backpacks: JSON.stringify({ loaded: true, sizes: [12, 24, 48] }) },
{ serverId: 'off', 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' }])
assert.deepStrictEqual(await source.resolve(), [
{ value: 24, label: '24 slots — PvE' },
{ value: 48, label: '48 slots — Main, PvE' },
{ value: 96, label: '96 slots — Main' },
])
})
// ── Reading one ────────────────────────────────────────────────────────────

View File

@@ -169,6 +169,14 @@ test('the classification covers exactly the event kinds the protocol defines, th
'gc.started',
'gc.ended',
'gc.failed',
// Protocol 14 (§20.12): RaidableBases. A base appearing, raided, bought or
// locked is public, read per viewer by raidableBases.js; its despawn is
// staff's (PLAN_REDESIGNS §11.4).
'raid.started',
'raid.completed',
'raid.ended',
'raid.purchased',
'raid.locked',
]
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
@@ -229,6 +237,7 @@ test('every kind that names a player who was on is behind the presence setting',
// 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'])
// a guarded crate cleared once `guardedCrate.shapeFor` has, nor a raid base raided,
// bought or locked once `raidableBases.shapeFor` has (D390).
assert.deepEqual(catalogue.kindsFor({}).sort(), ['dt.started', 'dt.won', 'gc.ended', 'gc.started', 'npc.boss.spawned', 'pvp.zone.created', 'raid.completed', 'raid.locked', 'raid.purchased', 'raid.started', 'server.initialized', 'server.shutdown', 'server.wipe', 'world.event'])
})

View File

@@ -262,7 +262,7 @@ test('a reward sends Steam ids and names to the bridge, which fills the placehol
assert.strictEqual(result.ok, true)
assert.deepStrictEqual([result.detail.people, result.detail.rewarded], [2, 1])
assert.deepStrictEqual([result.detail.skipped, result.detail.skippedCount], [['p3'], 1])
assert.match(result.detail.skippedNote, /D383/)
assert.match(result.detail.skippedNote, /not on the server when the reward ran/)
assert.strictEqual(result.resources, undefined)
})

View File

@@ -555,3 +555,24 @@ test('the page’s poll answers 404 for a write that is not on that server', asy
assert.equal(res.statusCode, 404)
})
// ── RaidableBases' profiles (PLAN_REDESIGNS §11.4.3, D205) ────────────────
test('a path names its plugin, and a RaidableBases profile names RaidableBases, not "@data" (W8)', () => {
const model = require('../model/config/config.model')
assert.strictEqual(model.pluginOf('@data/RaidableBases/Profiles/Hard Bases.json'), 'RaidableBases')
assert.strictEqual(model.pluginOf('Kits.json'), 'Kits')
assert.strictEqual(model.pluginOf('RaidableBases/Profiles.json'), 'RaidableBases')
assert.strictEqual(model.isBridgeConfig('RunicGateway.json', 'RunicGateway'), true)
assert.strictEqual(model.isBridgeConfig('@data/RaidableBases/Profiles/x.json', 'RunicGateway'), false)
})
test('a RaidableBases profile’s virtual path is plausible, one file deep, and nothing else under @data is', () => {
const model = require('../model/config/config.model')
assert.strictEqual(model.isPlausiblePath('@data/RaidableBases/Profiles/Hard Bases.json'), true)
for (const bad of ['@data/RaidableBases/Profiles/../x.json', '@data/RaidableBases/Profiles/a/b.json', '@data/Kits/data.json', '@data/RaidableBases/Profiles/x.txt', '@data/RaidableBases/Profiles/.json']) {
assert.strictEqual(model.isPlausiblePath(bad), false, bad)
}
const shaped = model.shapeCatalogue({ files: [{ path: '@data/RaidableBases/Profiles/Hard Bases.json', plugin: 'RaidableBases', bytes: 10, editable: true, profile: true }], plugins: [{ name: 'RaidableBases' }] })
assert.strictEqual(shaped.plugins[0].files[0].profile, true)
})

View File

@@ -37,6 +37,10 @@ function withRecorder(rows = []) {
withRecorder()
// The stored status is the whole answer in these tests: nothing asks a
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
require('../liveStatus').refresh = async () => false
const dt = require('../dangerousTreasures')
const { gridOf } = require('../worldEvents')
const { TRIGGERS } = require('../engagement/triggers')

490
server/test/demo.test.js Normal file
View File

@@ -0,0 +1,490 @@
// ── The public demo's module-side locks (website/DEMO_MODE.md §3.4, §3.5, §7.3) ──
//
// Everything a route list cannot lock, behind `ctx.demoMode` (MODULE_API
// 1.18.0): the console-command deny list (DM11) at save through core's
// `demoRefuses` (1.19.0, DM18) and at run; the bridge's config, neither read nor
// written (DM14); secrets in every other config, redacted and read-only; the
// sidecar URL left off the Servers view; and the seed hook (DM15). Each lock is
// held twice: on the demo it refuses, and off it nothing changes.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore({ demo = true, select = () => [] } = {}) {
const queries = []
require('../core')._reset()
require('../core').init(
fakeCtx({
demoMode: demo,
db: {
query: (sql, params) => {
const flat = sql.trim().replace(/\s+/g, ' ')
queries.push({ sql: flat, params })
const verb = flat.split(' ')[0].toUpperCase()
if (verb === 'SELECT') return Promise.resolve(select(flat, params))
return Promise.resolve({ affectedRows: 1, insertId: 1 })
},
pool: {},
},
}),
)
return queries
}
function fakeRes() {
const res = { statusCode: 200, body: null }
res.status = (code) => {
res.statusCode = code
return res
}
res.json = (body) => {
res.body = body
return res
}
return res
}
// ── The console-command deny list (§3.4) ───────────────────────────────────
test('the deny list compares the first word, normalised', () => {
withCore()
const demo = require('../demo')
for (const line of [
'quit', 'QUIT', ' quit ', 'global.quit', 'restart 60 "bye"',
'oxide.unload RunicGateway', 'o.unload RunicNPC', 'oxide.reload *', 'o.reload Kits', 'global.oxide.reload x',
'rcon.password hunter2', 'RCON.web 0', 'fps.limit 5', 'server.tickrate 1',
]) {
assert.equal(demo.isDeniedCommand(line), true, line)
}
for (const line of [
'say hello', 'server.writecfg', 'oxide.grant group default kits.use', 'kick someone', 'rbe 1',
'quitter', 'restarts', 'oxide.load Foo', 'entity.spawn bear', 'weather.rain 1', '',
]) {
assert.equal(demo.isDeniedCommand(line), false, line)
}
assert.equal(demo.commandWord('Global.O.Unload X'), 'oxide.unload')
})
test('the deny list refuses nothing off the demo', () => {
withCore({ demo: false })
assert.equal(require('../demo').deniedCommand(['quit']), null)
})
test('both command actions refuse a denied command at save, through demoRefuses', () => {
withCore()
const { ACTIONS } = require('../eventCommands')
const run = ACTIONS.find((a) => a.id === 'rust.command.run')
const reward = ACTIONS.find((a) => a.id === 'rust.command.reward')
assert.equal(run.demoRefuses({ commands: ['say hi', 'quit'] }), '"quit" is turned off on the demo site.')
// A teardown runs at the end of the event, so it is checked as well.
assert.equal(run.demoRefuses({ commands: ['rbe 1'], teardown: ['o.unload RunicGateway'] }), '"o.unload RunicGateway" is turned off on the demo site.')
assert.equal(run.demoRefuses({ commands: 'say hi' }), null)
assert.equal(reward.demoRefuses({ commands: ['sr add {steamid} 100', 'rcon.password x'] }), '"rcon.password x" is turned off on the demo site.')
assert.equal(reward.demoRefuses({ commands: ['sr add {steamid} 100'] }), null)
})
test('a denied command is refused at run as well, permanently, before the rig is asked', async () => {
withCore()
const sidecar = require('../sidecarClient')
let asked = 0
sidecar.commandsRun = async () => { asked += 1; return { ok: true, data: { kind: 'command.ran', ran: 1 } } }
const { ACTIONS } = require('../eventCommands')
const run = ACTIONS.find((a) => a.id === 'rust.command.run')
const reward = ACTIONS.find((a) => a.id === 'rust.command.reward')
const ran = await run.perform({ runId: 1, stepId: 's', idempotencyKey: 'k', params: { server: 'main', commands: ['quit'], confirm: true } })
assert.deepEqual(ran, { ok: false, retry: false, error: '"quit" is turned off on the demo site.' })
const rewarded = await reward.perform({ runId: 1, stepId: 's', idempotencyKey: 'k', params: { server: 'main', recipients: 'linked', commands: ['restart'], confirm: true } })
assert.deepEqual(rewarded, { ok: false, retry: false, error: '"restart" is turned off on the demo site.' })
assert.equal(asked, 0)
})
// ── The bridge's own config (§3.5) ─────────────────────────────────────────
test('a bridge path is recognised however it is spelled', () => {
withCore()
const demo = require('../demo')
for (const p of ['RunicGateway.json', './RunicGateway.json', 'runicgateway.json', 'RunicGateway/extra.json', ' RunicGateway.json ', 'RunicGateway./x.json', 'Bridge.json']) {
assert.equal(demo.isBridgePath(p, 'Bridge'), true, p)
}
for (const p of ['ZoneManager.json', 'Kits/RunicGateway.json', 'RunicGatewayExtra.json', '@data/RaidableBases/Profiles/a.json']) {
assert.equal(demo.isBridgePath(p, 'Bridge'), false, p)
}
})
const SECRET_FILE = `{
"Discord Webhook": "https://discord.example/hook/abc",
"Api Key": 123456789,
"Empty Token": "",
"Use Auth": true,
"Credentials": { "User": "admin", "List": ["one", "two"] },
"Gather": { "Wood": 1.0 },
"Enabled": true
}`
function stubSidecar({ file = SECRET_FILE, self = 'RunicGateway' } = {}) {
const sidecar = require('../sidecarClient')
const calls = []
sidecar.configFile = async (server, asked) => {
calls.push(['read', asked])
return { ok: true, status: 'ok', data: { kind: 'config.file', path: asked, text: file, version: `v-${asked}`, bytes: file.length } }
}
sidecar.configFiles = async () => {
calls.push(['list'])
return {
ok: true,
status: 'ok',
data: {
kind: 'config.catalogue',
root: '/home/container/oxide/config',
self,
files: [
{ path: 'Discord.json', bytes: 40, editable: true, plugin: 'Discord' },
{ path: 'RunicGateway.json', bytes: 90, editable: true, plugin: 'RunicGateway' },
],
plugins: [{ name: 'Discord', title: 'Discord', version: '1.0.0' }, { name: 'RunicGateway', title: 'Runic Gateway', version: '1.0.0' }],
},
}
}
sidecar.configWrite = async (server, body) => {
calls.push(['write', body])
return { ok: true, status: 'ok', data: { kind: 'config.report', ok: true, reloaded: true, files: [{ path: body.files[0].path, version: 'v-after' }] } }
}
return calls
}
function stubServer() {
const row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://x', sidecarTokenEnc: null, protocol: 14 }
require('../model/servers/servers.db').getServer = async () => row
require('../model/servers/servers.model').withToken = () => ({ id: row.id, baseUrl: row.sidecarBaseUrl, token: 't' })
}
const controller = () => require('../router/admin/config.controller')
test('the file list leaves the bridge out on the demo, and keeps it off the demo', async () => {
withCore()
stubSidecar()
stubServer()
const res = fakeRes()
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, res)
assert.deepEqual(res.body.plugins.map((p) => p.plugin), ['Discord'])
withCore({ demo: false })
const off = fakeRes()
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, off)
assert.deepEqual(off.body.plugins.map((p) => p.plugin), ['Discord', 'RunicGateway'])
})
test('the bridge\'s config cannot be read on the demo, and the host is never asked for it', async () => {
withCore()
const calls = stubSidecar()
stubServer()
for (const path of ['RunicGateway.json', './RunicGateway.json']) {
const res = fakeRes()
await controller().readFile({ params: { serverId: 'main' }, query: { path } }, res)
assert.equal(res.statusCode, 403, path)
assert.equal(res.body.code, 'DEMO_LOCKED')
}
assert.deepEqual(calls, [])
// Named by the catalogue's own word for the plugin, when that differs.
const renamed = stubSidecar({ self: 'Bridge' })
const res = fakeRes()
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Bridge.json' } }, res)
assert.equal(res.statusCode, 403)
assert.ok(renamed.some(([kind]) => kind === 'list'))
})
test('another plugin\'s secrets are redacted in the text and the form, and made read-only', async () => {
withCore()
stubSidecar()
stubServer()
const res = fakeRes()
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Discord.json' } }, res)
assert.equal(res.statusCode, 200)
const { text, fields } = res.body
assert.doesNotMatch(text, /discord\.example|123456789|"admin"|"one"/)
assert.match(text, /"Discord Webhook": "\(hidden on the demo site\)"/)
// Still JSON, and the values that are not credentials are untouched.
const parsed = JSON.parse(text)
assert.equal(parsed['Empty Token'], '')
assert.equal(parsed['Use Auth'], true)
assert.equal(parsed.Gather.Wood, 1)
assert.match(text, /"Wood": 1\.0/)
const byPath = Object.fromEntries(fields.map((f) => [f.path, f]))
assert.equal(byPath['Discord Webhook'].value, '(hidden on the demo site)')
assert.equal(byPath['Discord Webhook'].locked, true)
assert.equal(byPath['Api Key'].value, '(hidden on the demo site)')
assert.equal('raw' in byPath['Api Key'], false)
assert.equal(byPath['Credentials.List[0]'].value, '(hidden on the demo site)')
assert.equal(byPath['Gather.Wood'].locked, false)
assert.equal(res.body.redacted, true)
})
test('off the demo the file is returned as it is', async () => {
withCore({ demo: false })
stubSidecar()
stubServer()
const res = fakeRes()
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Discord.json' } }, res)
assert.equal(res.body.text, SECRET_FILE)
assert.equal(res.body.redacted, undefined)
})
test('a file that does not parse is withheld whole on the demo', () => {
withCore()
const model = require('../model/config/config.model')
const shaped = model.shapeFile({ path: 'Broken.json', version: 'v', text: '{ "Api Key": "abc", ' })
const out = model.redactSecrets(shaped, '(hidden)')
assert.equal(out.text, '')
assert.match(out.withheld, /does not parse/)
})
test('on the demo a save refuses the bridge, raw JSON and a credential field, before the host is asked', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const save = async (body) => {
const res = fakeRes()
await controller().writeFile({ params: { serverId: 'main' }, body: { version: 'v-Discord.json', ...body } }, res)
return res
}
const bridge = await save({ path: 'RunicGateway.json', edits: [{ pointer: ['QueueCap'], raw: '1' }] })
assert.equal(bridge.statusCode, 403)
const raw = await save({ path: 'Discord.json', text: SECRET_FILE })
assert.equal(raw.statusCode, 403)
assert.match(raw.body.message, /Raw JSON saves/)
const secret = await save({ path: 'Discord.json', edits: [{ pointer: ['Discord Webhook'], value: 'https://evil' }] })
assert.equal(secret.statusCode, 403)
const nested = await save({ path: 'Discord.json', edits: [{ pointer: ['Credentials', 'User'], value: 'x' }] })
assert.equal(nested.statusCode, 403)
for (const r of [bridge, raw, secret, nested]) assert.equal(r.body.code, 'DEMO_LOCKED')
assert.deepEqual(calls, [])
})
test('on the demo a form edit to an ordinary field saves, and leaves the secrets on disk alone', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{ params: { serverId: 'main' }, body: { path: 'Discord.json', version: 'v-Discord.json', edits: [{ pointer: ['Enabled'], value: false }] } },
res,
)
assert.equal(res.statusCode, 200)
const [, body] = calls.find(([kind]) => kind === 'write')
assert.match(body.files[0].text, /discord\.example\/hook\/abc/)
assert.match(body.files[0].text, /"Enabled": false/)
})
test('a conflict\'s current file is redacted too', async () => {
withCore()
stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{ params: { serverId: 'main' }, body: { path: 'Discord.json', version: 'v-stale', edits: [{ pointer: ['Enabled'], value: false }] } },
res,
)
assert.equal(res.statusCode, 409)
assert.doesNotMatch(res.body.current.text, /discord\.example/)
})
test('the write history leaves the bridge\'s writes out, and one of them cannot be read', async () => {
const rows = [
{ id: 2, serverId: 'main', path: 'RunicGateway.json', plugin: 'RunicGateway', outcome: 'applied', changes: null },
{ id: 1, serverId: 'main', path: 'Discord.json', plugin: 'Discord', outcome: 'applied', changes: null },
]
withCore({ select: (sql, params) => (sql.includes('WHERE server_id = ? AND id = ?') ? rows.filter((r) => r.id === params[1]) : rows) })
const res = fakeRes()
await controller().history({ params: { serverId: 'main' }, query: {} }, res)
assert.deepEqual(res.body.writes.map((w) => w.id), [1])
const one = fakeRes()
const db = require('../model/config/config.db')
db.getWrite = async () => rows[0]
await controller().writeStatus({ params: { serverId: 'main', writeId: '2' } }, one)
assert.equal(one.statusCode, 403)
assert.equal(one.body.code, 'DEMO_LOCKED')
})
// ── The Servers view (§3.5) ────────────────────────────────────────────────
test('the admin server list leaves the sidecar URL out on the demo, and keeps hasToken', async () => {
const row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://10.0.0.5:7790', sidecarTokenEnc: 'enc:t', protocol: 14, enabled: 1, sortOrder: 0 }
for (const demoMode of [true, false]) {
withCore({ demo: demoMode })
const db = require('../model/servers/servers.db')
db.listServers = async () => [row]
db.listState = async () => []
const [shaped] = await require('../model/servers/servers.model').listForAdmin()
assert.equal('sidecarBaseUrl' in shaped, !demoMode)
assert.equal(shaped.hasToken, true)
assert.equal(JSON.stringify(shaped).includes('10.0.0.5'), !demoMode)
}
})
// ── The seed hook (§7.3, DM15) ─────────────────────────────────────────────
const SECTION = {
servers: [{ id: 'main', name: 'Demo rig', sidecarBaseUrl: 'http://rig:7790', sidecarToken: 'tok' }],
links: [{ username: 'demo-player', steamId: '76561190000000001', serverId: 'main' }],
}
test('the seed section is checked whole before anything is written', async () => {
const queries = withCore()
const seed = require('../demoSeed')
const accounts = { 'demo-player': 3 }
assert.deepEqual(seed.checkSection(SECTION, accounts), [])
const problems = seed.checkSection({
servers: [{ id: 'Main', name: '', sidecarBaseUrl: 'rig', sidecarToken: '' }],
links: [{ username: 'someone', steamId: '123', serverId: 'Other' }],
}, accounts)
assert.equal(problems.length, 7)
await assert.rejects(seed.onDemoSeed({ servers: 'x' }, { accounts }), /must be an array/)
assert.deepEqual(queries, [])
})
test('the seed upserts the server with an encrypted token, and links demo-player', async () => {
const queries = withCore()
await require('../demoSeed').onDemoSeed(SECTION, { accounts: { 'demo-player': 3 } })
const upsert = queries.find((q) => q.sql.startsWith('INSERT INTO rust_servers'))
assert.match(upsert.sql, /ON DUPLICATE KEY UPDATE/)
assert.deepEqual(upsert.params.slice(0, 4), ['main', 'Demo rig', 'http://rig:7790', 'enc:tok'])
const link = queries.find((q) => q.sql.startsWith('INSERT INTO rust_account_links'))
assert.deepEqual(link.params, ['76561190000000001', 3, null, 'main'])
})
test('a re-seed leaves a link the right owner holds, and moves one held by someone else', async () => {
let owner = 3
const queries = withCore({ select: (sql) => (sql.includes('rust_account_links') ? [{ steamId: '76561190000000001', userId: owner }] : []) })
const seed = require('../demoSeed')
await seed.onDemoSeed({ links: SECTION.links }, { accounts: { 'demo-player': 3 } })
assert.equal(queries.some((q) => /^(INSERT|DELETE)/.test(q.sql)), false)
owner = 9
await seed.onDemoSeed({ links: SECTION.links }, { accounts: { 'demo-player': 3 } })
const writes = queries.filter((q) => /^(INSERT|DELETE)/.test(q.sql)).map((q) => q.sql.split(' ')[0])
assert.deepEqual(writes, ['DELETE', 'INSERT'])
})
// ── NPCs and chat styles from the seed (DM26) ──────────────────────────────
const NPC_SECTION = {
npcs: {
factions: [{ a: 'bandits', b: 'guards', relation: 'allied' }],
profiles: [
{ name: 'bandit', servers: ['main'], body: { kits: ['rnpc_raider'], role: 'roamer' } },
{ name: 'guard', servers: ['main'], body: { kits: ['rnpc_campguard'], role: 'sentry' } },
],
},
chat: [
{ serverId: 'main', group: 'vip', style: { Title: '[VIP]', TitleColor: '#ffaa00' } },
{ serverId: 'main', group: 'moderator', style: { Title: '[MOD]' } },
],
}
/** Stubs the models the seed writes NPCs and chat styles through; returns the calls. */
function stubSeedModels({ profiles = [], groups = {} } = {}) {
const calls = []
const npcs = require('../model/npcs/npcs.model')
const npcsDb = require('../model/npcs/npcs.db')
const apply = require('../model/permissions/permissions.apply')
const permDb = require('../model/permissions/permissions.db')
npcs.setFactions = async (f) => calls.push(['setFactions', f])
npcs.create = async (input) => calls.push(['create', input.name, input.servers])
npcs.update = async (id, input) => calls.push(['update', id, input.name])
npcsDb.listProfiles = async () => profiles
apply.groupOn = async (name) => groups[name] || null
permDb.insertGroup = async ({ name }) => {
calls.push(['insertGroup', name])
return 50
}
permDb.setGroupServers = async (id, s) => calls.push(['setGroupServers', id, s.servers])
permDb.setGroupChat = async (id, style) => calls.push(['setGroupChat', id, style.Title, style.TitleColor, Object.keys(style).length])
permDb.markDirty = async (scope) => calls.push(['markDirty', scope])
return calls
}
test('the NPC and chat parts of the seed section are checked before anything is written', async () => {
withCore()
const seed = require('../demoSeed')
assert.deepEqual(seed.checkSection(NPC_SECTION, {}), [])
const problems = seed.checkSection({
npcs: { factions: [{ a: 'scientists', b: 'animals', relation: 'hostile' }], profiles: [{ name: 'Bad Name', body: [] }] },
chat: [{ serverId: 'Main', group: '', style: { Title: '[VIP]', Nope: 1 } }],
}, {})
assert.equal(problems.length, 6, problems.join(' | '))
assert.deepEqual(seed.checkSection({ npcs: [] }, {}), ['modules.rust.npcs must be an object'])
const calls = stubSeedModels()
await assert.rejects(seed.onDemoSeed({ chat: [{ serverId: 'main', group: 'vip', style: { Priority: 'x' } }] }, { accounts: {} }), /style/)
assert.deepEqual(calls, [])
})
test('the seed sets the faction table, saves each profile, and styles each group', async () => {
withCore()
const calls = stubSeedModels({ profiles: [{ id: 7, name: 'bandit', replaced: false }], groups: { vip: { id: 13 } } })
await require('../demoSeed').onDemoSeed(NPC_SECTION, { accounts: {} })
assert.deepEqual(calls, [
['setFactions', NPC_SECTION.npcs.factions],
['update', 7, 'bandit'], // already there: updated, not created twice
['create', 'guard', ['main']],
['setGroupChat', 13, '[VIP]', '#ffaa00', 12], // all twelve fields, the defaults filled in
['markDirty', 'main'],
['insertGroup', 'moderator'], // the rig's import has not brought it in yet
['setGroupServers', 50, ['main']],
['setGroupChat', 50, '[MOD]', '#55aaff', 12],
['markDirty', 'main'],
])
})
// ── The manifest (§3.2) ────────────────────────────────────────────────────
test('module.json locks the two rig-link routes and the player Steam unlink, and needs a core that asks demoRefuses', () => {
const manifest = require('../../module.json')
assert.deepEqual(manifest.demoLocks, [
{ method: 'PUT', path: '/admin/rust/servers/:id' },
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
{ method: 'DELETE', path: '/player/rust/links/:steamId' },
])
// A floor, not an exact value: later contract members raise it (1.20.0 for
// account deletion), and what this test holds is that it never drops below the
// core that asks demoRefuses.
const [major, minor] = manifest.coreApi.replace(/^\^/, '').split('.').map(Number)
assert.ok(major === 1 && minor >= 19, `coreApi ${manifest.coreApi} is at least ^1.19.0`)
})
// ── The staff Steam unlink (W4) ────────────────────────────────────────────
test('staff cannot sever a Steam link on the demo, and the link is never touched', async () => {
for (const demoMode of [true, false]) {
withCore({ demo: demoMode })
const links = require('../model/links/links.model')
const asked = []
links.unlinkOwned = async (steamId, userId) => {
asked.push([steamId, userId])
return true
}
const res = fakeRes()
await require('../router/admin/usersRust.controller').removeLink(
{ params: { id: '3', steamId: '76561190000000001' }, user: { id: 1 } },
res,
)
if (demoMode) {
assert.equal(res.statusCode, 403)
assert.equal(res.body.code, 'DEMO_LOCKED')
assert.deepEqual(asked, [])
} else {
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body, { unlinked: true })
assert.deepEqual(asked, [['76561190000000001', '3']])
}
}
})

View File

@@ -34,6 +34,10 @@ function withRecorder(rows = []) {
withRecorder()
// The stored status is the whole answer in these tests: nothing asks a
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
require('../liveStatus').refresh = async () => false
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
@@ -220,10 +224,21 @@ test('coins go once to each website user, with the key, the amount and the messa
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.strictEqual(result.detail.failedNote, "at-limit: the balance is at Economics' limit", 'only the reason that happened (W16)')
assert.deepStrictEqual(result.detail.missed, ['p3'])
assert.strictEqual(result.resources, undefined, 'nothing is ledgered')
})
test('a payment’s failedNote explains each reason that happened, and only those (W16)', () => {
const { failedNote } = require('../eventRewards')
assert.strictEqual(failedNote([{ reason: 'unmeasured' }, { reason: 'unmeasured' }]), 'unmeasured: Economics did not report the deposit')
assert.strictEqual(
failedNote([{ reason: 'unmeasured' }, { reason: 'at-limit' }, { reason: 'refused' }]),
"at-limit: the balance is at Economics' limit; refused: Economics did not take the deposit; unmeasured: Economics did not report the deposit",
)
assert.strictEqual(failedNote([{ reason: 'odd' }]), "odd: Economics' own reason")
})
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: [] }) })
@@ -291,6 +306,12 @@ test('a withdrawal above the balance is refused with the balance', async (t) =>
assert.match(result.message, /only 340 coins on Main/)
assert.deepStrictEqual(sent, [{ key: 'adj-1', steamId: A, op: 'withdraw', amount: 500 }])
// A deposit refused at the balance limit is the game saying no too (W15).
client.economicsAdjust = async () => ok({ kind: 'economics.error', reason: 'at-limit', message: "this player's balance is at Economics' limit" })
const capped = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 50, key: 'adj-3' })
assert.strictEqual(capped.status, 409)
assert.strictEqual(capped.reason, 'at-limit')
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)

View File

@@ -85,6 +85,19 @@ test('registers both lifecycle hooks', () => {
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
})
test('registers both identity hooks, so a Steam sign-in link is a Rust link (D392, D396)', () => {
const { api } = register()
const links = require('../model/links/links.model')
assert.strictEqual(api.record.hooks.onIdentityLinking, links.checkSteamSignin)
assert.strictEqual(api.record.hooks.onIdentityLinked, links.linkFromSteamSignin)
})
test('registers the account-deletion hook, so a deleted account is unlinked first (AD10)', () => {
const { api } = register()
const links = require('../model/links/links.model')
assert.strictEqual(api.record.hooks.onUserDeleting, links.unlinkForDeletion)
})
test('the manifest declares what the loader requires', () => {
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
@@ -169,6 +182,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
'rust.participation.open',
'rust.perm.give',
'rust.perm.lend',
'rust.raidbase.spawn',
'rust.world.call',
'rust.zone.open',
])
@@ -184,6 +198,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
'rust.permissions',
'rust.permissions.kept',
'rust.prefabs',
'rust.raids',
'rust.world.calls',
'rust.zone.minutes',
])

View File

@@ -40,6 +40,10 @@ function withRecorder(rows = []) {
withRecorder()
// The stored status is the whole answer in these tests: nothing asks a
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
require('../liveStatus').refresh = async () => false
const gc = require('../guardedCrate')
const { gridOf } = require('../worldEvents')
const { TRIGGERS } = require('../engagement/triggers')

View File

@@ -429,3 +429,89 @@ test('a state read that fails asks everybody, which is slow and never wrong', as
assert.equal((await links.redeem({ code: 'ZZZZZZ', userId: 4 })).reason, 'rejected')
assert.equal(asked.length, 2)
})
// ── Steam's own sign-in as a second way to link (D392, D393, D396) ─────────
const STEAM = '76561197960287930'
test('core’s pre-link check refuses a Steam id that is another account’s Rust link (D396)', async () => {
withCore({ select: [[{ steamId: STEAM, userId: 9, username: 'bob' }]] })
const links = require('../model/links/links.model')
const answer = await links.checkSteamSignin({ userId: 4, provider: 'steam', subject: STEAM })
assert.equal(answer.ok, false)
assert.equal(answer.reason, 'in_use')
})
test('the pre-link check passes a free Steam id, one already yours, and every other provider', async () => {
withCore({ select: [[], [{ steamId: STEAM, userId: 4 }]] })
const links = require('../model/links/links.model')
assert.deepEqual(await links.checkSteamSignin({ userId: 4, provider: 'steam', subject: STEAM }), { ok: true })
assert.deepEqual(await links.checkSteamSignin({ userId: 4, provider: 'steam', subject: STEAM }), { ok: true })
assert.deepEqual(await links.checkSteamSignin({ userId: 4, provider: 'google', subject: 'x' }), { ok: true })
})
test('a Steam account linked on the site becomes a linked Rust player (D392)', async () => {
let inserted = null
const { ctx } = withCore({ select: [[]], onInsert: (params) => { inserted = params; return Promise.resolve({ affectedRows: 1 }) } })
const links = require('../model/links/links.model')
await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: STEAM, displayName: 'CoolGuy99' })
assert.deepEqual(inserted, [STEAM, 4, 'CoolGuy99', null])
assert.equal(ctx.teams.reconcile.calls.length, 1)
assert.equal(ctx.events.emit.calls.length, 1, 'the player hears about the new link, as with /link')
})
test('the linked hook writes nothing for a link that is already there, or for another provider', async () => {
let inserts = 0
const { ctx } = withCore({ select: [[{ steamId: STEAM, userId: 4 }]], onInsert: () => { inserts += 1; return Promise.resolve({}) } })
const links = require('../model/links/links.model')
await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: STEAM, displayName: 'x' })
await links.linkFromSteamSignin({ userId: 4, provider: 'discord', subject: '123', displayName: 'x' })
await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: 'not-an-id', displayName: 'x' })
assert.equal(inserts, 0)
assert.equal(ctx.teams.reconcile.calls.length, 0)
})
// ── The website account is being deleted (MODULE_API 1.20.0, AD10) ─────────
test('a deleted account loses every Steam link through the player’s own unlink, each logged', async () => {
const { ctx, queries } = withCore({ select: [[{ steamId: '7656', userId: 4 }, { steamId: STEAM, userId: 4 }]] })
const links = require('../model/links/links.model')
await links.unlinkForDeletion({ userId: 4, deletedBy: 'self' })
// The same statement `unlinkOwned` uses, scoped by user, once per link.
const dels = queries.filter((q) => q.sql.trim().toUpperCase().startsWith('DELETE'))
assert.deepEqual(dels.map((q) => q.params), [['7656', 4], [STEAM, 4]])
// What a cascade would skip: one entry per link, and the Teams re-sync.
assert.deepEqual(ctx.activity.log.calls.map((c) => c[0]), [
{ userId: 4, action: 'rust.account.unlink', detail: { steamId: '7656', reason: 'account deleted', deletedBy: 'self' } },
{ userId: 4, action: 'rust.account.unlink', detail: { steamId: STEAM, reason: 'account deleted', deletedBy: 'self' } },
])
assert.equal(ctx.teams.reconcile.calls.length, 2)
// Every server is marked dirty, so the withdrawn grants go on the next tick.
const dirty = queries.filter((q) => /UPDATE rust_perm_sync SET dirty = 1/.test(q.sql))
assert.equal(dirty.length, 1)
assert.ok(!/WHERE/.test(dirty[0].sql), 'the whole fleet, not one server')
})
test('an account with no Steam link deletes without touching the game or the sync', async () => {
const { ctx, queries } = withCore({ select: [[]] })
const links = require('../model/links/links.model')
await links.unlinkForDeletion({ userId: 4, deletedBy: 'staff' })
assert.equal(queries.filter((q) => !q.sql.trim().toUpperCase().startsWith('SELECT')).length, 0)
assert.equal(ctx.activity.log.calls.length, 0)
assert.equal(ctx.teams.reconcile.calls.length, 0)
})
test('a /link that lands between core’s check and the hook keeps its link; the hook does not throw', async () => {
const dup = Object.assign(new Error('dup'), { code: 'ER_DUP_ENTRY' })
const { ctx } = withCore({ select: [[]], onInsert: () => Promise.reject(dup) })
const links = require('../model/links/links.model')
await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: STEAM, displayName: 'x' })
assert.equal(ctx.teams.reconcile.calls.length, 0)
})

View File

@@ -0,0 +1,113 @@
// ── Asking a server how it is now, before saying no (W7, W11) ──────────────
//
// Each test is one way the feature could look right and be wrong:
//
// a "no" from the stored status is asked again, and the fresh answer is the one acted on
// a "yes" is never asked again: the plugin refuses at the run, as it always has
// a "no" the sidecar cannot change is still a "no"
// ten steps against one server in one dry run ask it once
// a step refused for Backpacks just after a restart passes once the server says it is back
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
require('../core')._reset()
require('../core').init(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }))
const boot = require('../boot')
const live = require('../liveStatus')
const MAIN = { id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' }
/** `boot.refreshOne` replaced by `onAsk`, counted. */
function stubAsk(t, onAsk = () => {}) {
const saved = boot.refreshOne
const asked = []
live._reset()
boot.refreshOne = async (server) => {
asked.push(server.id)
await onAsk(server)
}
t.after(() => {
boot.refreshOne = saved
live._reset()
})
return asked
}
test('a refusal from the stored status is asked again, and the fresh answer is the one acted on', async (t) => {
let stored = { loaded: false }
const asked = stubAsk(t, () => {
stored = { loaded: true }
})
const answer = await live.confirmed(MAIN, async () => stored, (r) => !r.loaded)
assert.deepStrictEqual(answer, { loaded: true })
assert.deepStrictEqual(asked, ['main'])
})
test('a pass is never asked again', async (t) => {
const asked = stubAsk(t)
const answer = await live.confirmed(MAIN, async () => ({ loaded: true }), (r) => !r.loaded)
assert.deepStrictEqual(answer, { loaded: true })
assert.deepStrictEqual(asked, [])
})
test('a refusal the fresh answer agrees with is still a refusal, and a failed ask leaves the stored one', async (t) => {
stubAsk(t)
assert.strictEqual(await live.confirmed(MAIN, async () => 'Backpacks is not installed', Boolean), 'Backpacks is not installed')
stubAsk(t, () => {
throw new Error('boom')
})
assert.strictEqual(await live.refresh(MAIN), false)
assert.strictEqual(await live.confirmed(MAIN, async () => 'still no', Boolean), 'still no')
})
test('many refusals against one server at once ask it once, and again only after a while', async (t) => {
let release
const gate = new Promise((resolve) => {
release = resolve
})
const asked = stubAsk(t, () => gate)
const all = Promise.all(Array.from({ length: 10 }, () => live.confirmed(MAIN, async () => 'no', Boolean)))
release()
await all
assert.deepStrictEqual(asked, ['main'], 'one ask in flight serves every step')
await live.confirmed(MAIN, async () => 'no', Boolean)
assert.deepStrictEqual(asked, ['main'], `an ask within ${live.FRESH_MS} ms is as fresh as asking again`)
await live.confirmed({ ...MAIN, id: 'pve' }, async () => 'no', Boolean)
assert.deepStrictEqual(asked, ['main', 'pve'], 'another server is its own')
})
test('a step waits at most ASK_MS for the ask, and a late ask leaves the stored answer', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
stubAsk(t, () => new Promise(() => {}))
const answer = live.confirmed(MAIN, async () => 'not installed', Boolean)
// Let the first read and the ask start before the clock moves.
for (let i = 0; i < 5; i++) await Promise.resolve()
t.mock.timers.tick(live.ASK_MS)
assert.strictEqual(await answer, 'not installed')
})
test('a bigger backpack refused on a stale "not installed" passes once the server says Backpacks is back (W7)', async (t) => {
const rewards = require('../eventRewards')
const serversDb = require('../model/servers/servers.db')
const backpacksDb = require('../model/backpacks/backpacks.db')
const saved = { getServer: serversDb.getServer, reportFor: backpacksDb.reportFor }
t.after(() => Object.assign(serversDb, { getServer: saved.getServer }) && Object.assign(backpacksDb, { reportFor: saved.reportFor }))
serversDb.getServer = async (id) => (id === 'main' ? { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' } : null)
let report = null
backpacksDb.reportFor = async () => ({ serverId: 'main', backpacks: report })
const asked = stubAsk(t, () => {
report = JSON.stringify({ loaded: true, sizes: [6, 48] })
})
const step = rewards.ACTIONS.find((a) => a.id === 'rust.backpack.size')
const result = await step.perform({ runId: 7, stepId: 1, idempotencyKey: '7:1', params: { server: 'main', size: 48, recipients: 'linked' }, verify: true })
assert.deepStrictEqual(asked, ['main'])
assert.deepStrictEqual(result, { ok: true })
})

View File

@@ -89,7 +89,7 @@ const LIVE = {
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', pvp: 'public', mates: true, markers: {} })
assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', pvp: 'public', raids: 'public', mates: true, markers: {} })
const acc = await map.access({}, 'main')
assert.equal(acc.layers.world.visible, true)

View File

@@ -27,6 +27,10 @@ require('../core')._reset()
const ctx = fakeCtx({ freeze: false })
require('../core').init(ctx)
// The stored status is the whole answer in these tests: nothing asks a
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
require('../liveStatus').refresh = async () => false
const client = require('../sidecarClient')
const servers = require('../model/servers/servers.model')
const serversDb = require('../model/servers/servers.db')
@@ -165,8 +169,8 @@ const BOSS = {
test('stage 6: a boss or passive profile is refused for what RunicNPC would refuse, in its own words', () => {
const cases = [
[{ ...PASSIVE, ...BOSS }, 'boss: a passive NPC never fights, so it cannot be a boss'],
[{ role: 'passive' }, 'use: a passive NPC says what pressing E on it does (D278)'],
[{ use: { mode: 'chat', lines: ['hi'] } }, 'use: only a passive NPC answers E (D278)'],
[{ role: 'passive' }, 'use: a passive NPC says what pressing E on it does'],
[{ use: { mode: 'chat', lines: ['hi'] } }, 'use: only a passive NPC answers E'],
[{ movement: { mode: 'stand' } }, "movement.mode: stand is a passive NPC's"],
[{ ...PASSIVE, use: { mode: 'shout' } }, "use.mode: 'shout' is not chat or window"],
[{ ...PASSIVE, use: { mode: 'chat', lines: [] } }, 'use.lines: a chat answer gives at least one line'],
@@ -538,7 +542,7 @@ test('the faction table is one row per pair, both ways, refused in RunicNPC\'s w
assert.match(shape.checkFactions([{ a: 'red', b: 'red', relation: 'hostile' }]).error, /always allied to itself/)
assert.match(shape.checkFactions([{ a: 'scientists', b: 'animals', relation: 'hostile' }]).error, /both are Rust's own/)
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'angry' }]).error, /'angry' is not hostile, neutral or allied/)
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'hostile' }, { a: 'blue', b: 'red', relation: 'allied' }]).error, /given twice \(one row is both ways, D268\)/)
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'hostile' }, { a: 'blue', b: 'red', relation: 'allied' }]).error, /given twice \(one row is both ways\)/)
assert.match(shape.checkFactions('red').error, /a list of/)
})

View File

@@ -77,6 +77,25 @@ function authored() {
}
}
test('a sync whose report did not land a player’s grant is in sync and says how many wait (W17)', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const row = (notLanded) => ({
serverId: 'carbon',
state: 'ok',
desiredHash: 'h',
syncedHash: 'h',
report: JSON.stringify({ notLanded }),
})
const waiting = model.shapeSync(row(['76561199910000001:backpacks.size.24', 'vip:parent:default']))
assert.strictEqual(waiting.inSync, true)
assert.strictEqual(waiting.waitingToLand, 1, 'a group’s parent is not a player’s grant')
assert.strictEqual(model.shapeSync(row([])).waitingToLand, 0)
assert.strictEqual(model.shapeSync({ serverId: 'x', state: 'pending' }).waitingToLand, 0)
})
test('a grant reaches every Steam account its holder has linked (D28)', () => {
withCore()
const model = require('../model/permissions/permissions.model')

View File

@@ -0,0 +1,452 @@
// ── RaidableBases (PLAN_REDESIGNS §11.4, protocol 14) ───────────────────────
//
// Each test is one way the feature could look right and be wrong:
//
// a raider's or a buyer's name reaches a viewer below the presence audience
// (D376, D390), or a Steam id, a position or a run's key reaches anybody but staff
// a difficulty, profile or base name is anything but the plugin's own (D386):
// every name below is invented, so a list written into the code cannot pass
// the step asks for a spot, a profile, a base or settings on a server without a
// patched raids helper, or refuses a plain spawn a server can still make (D348)
// a base the helper made is not the run's resource, or one it did not make is
// the dry run's spot check fails the dry run instead of advising (D391)
// the grid's wait is not a ten-minute retry, or a refusal the plugin would
// repeat is retried
// the tally's raids completed is dropped, or Warlord's column and sort are missing
// the map's raids layer is missing, not public by default, or carries the key
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()
// The stored status is the whole answer in these tests: nothing asks a
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
require('../liveStatus').refresh = async () => false
const rb = require('../raidableBases')
const catalogue = require('../catalogue')
const { gridOf } = require('../worldEvents')
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: 650, z: 2037, level: 7, difficulty: 'Wobbly', pvp: true, base: 'wobble3', ...over })
const raided = (over = {}) =>
frame('raid.completed', {
raiders: [{ steamId: A, name: 'Alice' }, { steamId: B, name: 'Bob' }],
raiderCount: 2,
ownerId: A,
ownerName: 'Alice',
id: 'raid-1-1',
runId: '7',
key: '7:1',
...over,
})
// Invented, every one (D386): no edition ships these.
const PROFILE = { name: 'Wobbly Keeps', difficulty: 'Wobbly', level: 7, enabled: true, pvp: false, bases: ['wobble1', 'wobble3'], seabed: false, float: false }
const REPORT = {
loaded: true,
version: '3.2.2',
premium: true,
spawnCall: true,
copyPaste: { loaded: true, version: '4.3.0' },
difficulties: [{ name: 'Gentle', level: 0 }, { name: 'Wobbly', level: 7 }],
helper: { state: 'patched', version: '0.1.0' },
gridReady: true,
profiles: [PROFILE, { name: 'Gentle Huts', difficulty: 'Gentle', level: 0, enabled: true, pvp: true, bases: ['hut9'], seabed: false, float: false }],
}
// ── Who may read what (D353, D376, D390) ───────────────────────────────────
test('the kinds are classified: four public, the despawn staff’s', () => {
for (const kind of ['raid.started', 'raid.completed', 'raid.purchased', 'raid.locked']) assert.ok(catalogue.PUBLIC_KINDS.includes(kind), kind)
assert.ok(catalogue.STAFF_KINDS.includes('raid.ended'))
assert.ok(!catalogue.PUBLIC_KINDS.includes('raid.ended'))
})
test('the public reads a base’s difficulty, base, PvP and grid as the plugin named them, never its position or a run’s key', () => {
const out = rb.shapeFor(frame('raid.started', { despawnAt: NOW + 60000, id: 'raid-1-1', runId: '7', key: '7:1', radius: 50 }), { geometry: GEOMETRY })
assert.strictEqual(out.grid, gridOf(GEOMETRY, 650, 2037))
assert.strictEqual(out.difficulty, 'Wobbly')
assert.strictEqual(out.level, 7)
assert.strictEqual(out.base, 'wobble3')
assert.strictEqual(out.pvp, true)
assert.strictEqual(out.despawnAt, NOW + 60000)
for (const k of ['x', 'z', 'id', 'runId', 'key', 'radius']) assert.strictEqual(out[k], undefined, k)
})
test('a completion names its raiders only to the presence audience, and their Steam ids only to staff', () => {
const signedOut = rb.shapeFor(raided(), { geometry: GEOMETRY })
assert.strictEqual(signedOut.raiderCount, 2, 'how many is a count, and names nobody')
assert.strictEqual(signedOut.names, undefined)
assert.strictEqual(signedOut.ownerName, undefined)
assert.strictEqual(signedOut.raiders, undefined)
assert.strictEqual(signedOut.ownerId, undefined)
const member = rb.shapeFor(raided(), { presence: true, geometry: GEOMETRY })
assert.deepStrictEqual(member.names, ['Alice', 'Bob'])
assert.strictEqual(member.ownerName, 'Alice')
assert.strictEqual(member.raiders, undefined)
const staff = rb.shapeFor(raided(), { staff: true, geometry: GEOMETRY })
assert.deepStrictEqual(staff.raiders, [{ steamId: A, name: 'Alice' }, { steamId: B, name: 'Bob' }])
assert.strictEqual(staff.ownerId, A)
assert.strictEqual(staff.key, '7:1')
assert.strictEqual(staff.x, 650)
})
test('a purchase or an admin lock names the player only to the presence audience (D390)', () => {
for (const kind of ['raid.purchased', 'raid.locked']) {
const f = frame(kind, { steamId: A, name: 'Alice' })
assert.strictEqual(rb.shapeFor(f, {}).name, undefined, kind)
assert.strictEqual(rb.shapeFor(f, { presence: true }).name, 'Alice', kind)
assert.strictEqual(rb.shapeFor(f, { presence: true }).steamId, undefined, kind)
assert.strictEqual(rb.shapeFor(f, { staff: true }).steamId, A, kind)
}
})
test('the feed reads a stored completion for its viewer, the row’s own Steam id staff’s', async () => {
withRecorder((sql) => {
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'raid.completed', t: NOW, wipeId: 'w1', steamId: A, raw: JSON.stringify(raided()) }]
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.names, undefined)
assert.strictEqual(signedOut.frame.raiderCount, 2)
assert.strictEqual(signedOut.frame.difficulty, 'Wobbly')
assert.ok(signedOut.frame.grid)
const [staff] = await events.recent({ serverId: 'main', admin: true })
assert.strictEqual(staff.steamId, A)
assert.deepStrictEqual(staff.frame.names, ['Alice', 'Bob'])
})
test('a server’s report is read leniently, and the helper’s profiles only as it sent them', () => {
assert.deepStrictEqual(rb.reportOf(null), { raidableBases: null, consolePlugins: null })
const read = rb.reportOf({ raidableBases: JSON.stringify(REPORT), console: '{"plugins":false}' })
assert.strictEqual(read.consolePlugins, false)
assert.deepStrictEqual(read.raidableBases.difficulties, REPORT.difficulties)
assert.deepStrictEqual(read.raidableBases.profiles[0], PROFILE)
assert.strictEqual(rb.helperPatched(read), true)
const bare = rb.reportOf({ raidableBases: '{"loaded":true,"version":"3.2.2","helper":{"state":"missing"}}', console: null })
assert.strictEqual(bare.raidableBases.profiles, null, 'no helper, no profile list')
assert.strictEqual(bare.raidableBases.spawnCall, false)
assert.strictEqual(rb.helperPatched(bare), false)
assert.deepStrictEqual(rb.reportOf({ raidableBases: 'not json' }).raidableBases, 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 `main` and `pve`, whose last reports are `reports`, and a sidecar answering `reply` and `check`. */
function stubStep(t, { reports = { main: { raidableBases: REPORT, console: { plugins: true } } }, reply, check } = {}) {
const row = (id) => {
const r = reports[id]
return r ? { serverId: id, name: SERVERS[id].name, raidableBases: JSON.stringify(r.raidableBases ?? 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 = { raidSpawn: client.raidSpawn, raidCheck: client.raidCheck, getServer: serversDb.getServer }
const calls = []
serversDb.getServer = async (id) => SERVERS[id] || null
client.raidSpawn = async (server, body) => {
calls.push({ what: 'spawn', server: server.id, body })
return typeof reply === 'function' ? reply(body) : reply
}
client.raidCheck = async (server, body) => {
calls.push({ what: 'check', server: server.id, body })
return typeof check === 'function' ? check(body) : check
}
t.after(() => {
client.raidSpawn = saved.raidSpawn
client.raidCheck = saved.raidCheck
serversDb.getServer = saved.getServer
})
return calls
}
const module_ = () => require('../eventRaidableBases')
const spawnStep = () => module_().ACTIONS[0]
const ok = (data) => ({ ok: true, status: 'ok', data })
const queued = (over = {}) => ok({ kind: 'raid.queued', key: '7:1', id: 'raid-1-1', route: 'pick', despawns: 'teardown', difficulty: 'Wobbly', level: 7, warnings: [], repeat: false, ...over })
test('the step is ledgered like a zone, priced one base each, and every param has an example', () => {
const step = spawnStep()
assert.strictEqual(step.id, 'rust.raidbase.spawn')
assert.strictEqual(step.reversible, 'ledger')
assert.strictEqual(step.risk, 'change')
assert.strictEqual(typeof step.revert, 'function')
assert.strictEqual(typeof step.reconcile, 'function')
assert.deepStrictEqual(step.cost({}), { 'rust.raids': 1 })
for (const p of step.params) assert.ok(p.example !== undefined, p.name)
})
test('the pickers list each server’s own names, once each, and nothing for a server that has not said (D386)', async (t) => {
const [difficulties, profiles, bases, switches] = module_().OPTION_SOURCES
stubStep(t, {
reports: {
main: { raidableBases: REPORT },
pve: { raidableBases: { ...REPORT, difficulties: [{ name: 'wobbly', level: 7 }, { name: 'Brutal', level: 9 }], profiles: [] } },
},
})
assert.deepStrictEqual(await difficulties.resolve(), [
{ value: 'Gentle', label: 'Gentle (level 0) — Main' },
{ value: 'Wobbly', label: 'Wobbly (level 7) — Main, PvE' },
{ value: 'Brutal', label: 'Brutal (level 9) — PvE' },
])
assert.deepStrictEqual((await profiles.resolve()).map((p) => p.value), ['Wobbly Keeps', 'Gentle Huts'])
assert.deepStrictEqual((await bases.resolve({ q: 'wobble' })).map((b) => b.value), ['wobble1', 'wobble3'])
assert.strictEqual(bases.searchable, true)
assert.deepStrictEqual((await switches.resolve()).map((s) => s.value), ['on', 'off'])
stubStep(t, { reports: { main: { raidableBases: { loaded: false } } } })
assert.deepStrictEqual(await difficulties.resolve(), [], 'nothing is made up for a server without the plugin')
})
test('a name both servers have, described differently, shows each server’s own description (W13)', async (t) => {
const [difficulties, profiles] = module_().OPTION_SOURCES
const hard = (difficulty) => ({ ...REPORT, profiles: [{ name: 'Hard Bases', difficulty, level: 2, enabled: true, pvp: true, bases: ['h1'], seabed: false, float: false }] })
stubStep(t, {
reports: {
main: { raidableBases: hard('Hard') },
pve: { raidableBases: { ...hard('Expert'), difficulties: [{ name: 'Gentle', level: 1 }, { name: 'Wobbly', level: 7 }] } },
},
})
assert.deepStrictEqual(await profiles.resolve(), [{ value: 'Hard Bases', label: 'Hard Bases · Hard — Main; Hard Bases · Expert — PvE' }])
assert.deepStrictEqual(await difficulties.resolve(), [
{ value: 'Gentle', label: 'Gentle (level 0) — Main; Gentle (level 1) — PvE' },
{ value: 'Wobbly', label: 'Wobbly (level 7) — Main, PvE' },
])
})
test('without a patched helper, a spot, a profile, a base or settings are refused; a plain spawn is not (D348, D387)', async (t) => {
const run = (params) => spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', ...params }, verify: true })
stubStep(t, { reports: { main: { raidableBases: { ...REPORT, helper: { state: 'missing' }, profiles: null }, console: { plugins: true } } } })
for (const params of [{ x: 650, z: 2037 }, { profile: 'Wobbly Keeps' }, { base: 'wobble1' }, { pvp: 'off' }, { npcCount: 3 }, { lock: 'on' }, { despawnMinutes: 60 }]) {
const no = await run(params)
assert.strictEqual(no.retry, false, JSON.stringify(params))
assert.match(no.error, /RunicGatewayRaids\.cs is not installed on Main/)
assert.match(no.error, /despawns the base on its own timer/)
}
assert.deepStrictEqual(await run({ difficulty: 'Wobbly' }), { ok: true })
assert.deepStrictEqual(await run({}), { ok: true })
stubStep(t, { reports: { main: { raidableBases: { ...REPORT, helper: { state: 'missing' }, spawnCall: false, profiles: null }, console: { plugins: false } } } })
const carbon = await run({})
assert.match(carbon.error, /cannot run RaidableBases' console command/)
})
test('the dry run refuses names the server does not have, and names that do not fit together', async (t) => {
const run = (params) => spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', ...params }, verify: true })
stubStep(t)
assert.match((await run({ difficulty: 'Easy' })).error, /"Easy" is not one of RaidableBases' difficulties on Main \(Gentle, Wobbly\)/)
assert.match((await run({ profile: 'Nope' })).error, /no profile "Nope"/)
assert.match((await run({ base: 'nope1' })).error, /no base "nope1"/)
assert.match((await run({ profile: 'Gentle Huts', base: 'wobble1' })).error, /belongs to profile "Wobbly Keeps"/)
assert.match((await run({ profile: 'Wobbly Keeps', difficulty: 'Gentle' })).error, /is Wobbly, not Gentle/)
assert.match((await run({ heightAdjust: 5 })).error, /a height needs a spot/)
assert.match((await run({ x: 650, z: 2037, heightAdjust: 5, height: 30 })).error, /not both/)
assert.match((await run({ pvp: 'maybe' })).error, /pvp is on, off or blank/)
assert.match((await run({ npcCount: 21 })).error, /0 to 20/)
assert.deepStrictEqual(await run({ base: 'WOBBLE3' }), { ok: true }, 'names match as the plugin matches them, ignoring case')
stubStep(t, { reports: { main: { raidableBases: { loaded: false } } } })
assert.match((await run({})).error, /RaidableBases is not installed on Main/)
stubStep(t, { reports: { main: { raidableBases: { ...REPORT, copyPaste: { loaded: false } } } } })
assert.match((await run({})).error, /CopyPaste is missing/)
stubStep(t, { reports: {} })
assert.deepStrictEqual(await run({ x: 650, z: 2037 }), { ok: true }, 'a server that has not said is the plugin’s to answer')
})
test('a spot’s check in the dry run is advice: warnings, never a refusal (D208, D388, D391)', async (t) => {
const calls = stubStep(t, {
check: ok({ kind: 'raid.checked', ok: false, reason: 'spot-blocked', message: 'a player is within 50 m (Alice)', warnings: ['Rock is too large: 1 2 3'] }),
})
const advised = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', x: 650, z: 2037, base: 'wobble3', pvp: 'off' }, verify: true })
assert.strictEqual(advised.ok, true)
assert.deepStrictEqual(advised.detail.warnings, [
'the spot is refused now (spot-blocked): a player is within 50 m (Alice). It is checked again when the event runs',
'at the spot now: Rock is too large: 1 2 3',
])
assert.deepStrictEqual(calls[0].body, { base: 'wobble3', x: 650, z: 2037 }, 'the check asks the spot and the base, not the settings')
assert.ok(!calls.some((c) => c.what === 'spawn'), 'a dry run spawns nothing')
stubStep(t, { check: ok({ kind: 'raid.checked', ok: true, warnings: [] }) })
assert.deepStrictEqual(await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', x: 650, z: 2037 }, verify: true }), { ok: true })
stubStep(t, { check: { ok: false, status: 'http-503' } })
const down = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', x: 650, z: 2037 }, verify: true })
assert.strictEqual(down.ok, true)
assert.match(down.detail.warnings[0], /could not be checked now/)
})
test('a spawn sends the plugin’s names, the spot and the settings, and a base the helper made is the run’s resource', async (t) => {
const calls = stubStep(t, { reply: queued({ route: 'spot', x: 650, z: 2037, base: 'wobble3', profile: 'Wobbly Keeps', warnings: ['Rock is too large: 1 2 3'] }) })
const result = await spawnStep().perform({
runId: 7,
idempotencyKey: '7:1',
params: { monument: 'main/powerplant_1', offsetX: 10, offsetZ: -5, y: 99, heightAdjust: 5, base: 'wobble3', pvp: 'off', npcs: 'on', npcCount: 4, despawnMinutes: 90, lock: 'on' },
})
assert.ok(result.ok)
assert.deepStrictEqual(calls[0].body, {
key: '7:1',
runId: '7',
base: 'wobble3',
monument: 'powerplant_1',
offsetX: 10,
offsetZ: -5,
heightAdjust: 5,
pvp: false,
npcs: true,
npcCount: 4,
despawnMinutes: 90,
lock: true,
})
assert.deepStrictEqual(result.resources, [
{ kind: 'world', ref: 'main:raid-1-1', payload: { serverId: 'main', what: 'raidbase', name: 'wobble3', difficulty: 'Wobbly' } },
])
assert.strictEqual(result.detail.grid, gridOf(GEOMETRY, 650, 2037))
assert.deepStrictEqual(result.detail.warnings, ['Rock is too large: 1 2 3'], 'a rock is carried into the run log (D388)')
assert.strictEqual(result.detail.note, undefined)
})
test('a base the helper did not make is not the run’s: no resource, and the run log says it despawns on its own', async (t) => {
stubStep(t, {
reports: { main: { raidableBases: { ...REPORT, helper: { state: 'missing' }, profiles: null }, console: { plugins: true } } },
reply: queued({ id: undefined, route: 'plugin', despawns: 'own-timer' }),
})
const result = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', difficulty: 'Wobbly' } })
assert.ok(result.ok)
assert.strictEqual(result.resources, undefined)
const own = module_().OWN_TIMER
assert.strictEqual(
result.detail.note,
`RaidableBases picks the spot; the map shows it once the base opens. ${own[0].toUpperCase()}${own.slice(1)}`,
'each note after the first starts its own sentence (W14)',
)
})
test('the grid’s wait is a ten-minute retry; a refusal the plugin would repeat is not retried', async (t) => {
stubStep(t, {
reply: (body) =>
ok(body.difficulty === 'Gentle'
? { kind: 'raid.error', reason: 'rb-not-ready', message: 'still building its spawn grid' }
: body.difficulty === 'Wobbly'
? { kind: 'raid.error', reason: 'rb-busy', message: 'pasting another base' }
: { kind: 'raid.error', reason: 'spot-blocked', message: 'a player is within 50 m', warnings: ['Rock is too large'] }),
})
const step = spawnStep()
const wait = await step.perform({ runId: 7, idempotencyKey: '7:2', params: { server: 'main', difficulty: 'Gentle' } })
assert.strictEqual(wait.retry, true)
assert.strictEqual(wait.retryAfterMs, 10 * 60 * 1000)
assert.match(wait.error, /not ready yet/)
const busy = await step.perform({ runId: 7, idempotencyKey: '7:3', params: { server: 'main', difficulty: 'Wobbly' } })
assert.strictEqual(busy.retry, undefined, 'a busy spawner retries on core’s ordinary delay')
const blocked = await step.perform({ runId: 7, idempotencyKey: '7:4', params: { server: 'main', x: 650, z: 2037 } })
assert.strictEqual(blocked.retry, false)
assert.match(blocked.error, /a player is within 50 m \(also: Rock is too large\)/)
for (const reason of ['rb-missing', 'copypaste-missing', 'helper-needed', 'helper-unsupported', 'bad-difficulty', 'bad-profile', 'bad-base', 'spot-blocked', 'in-water', 'off-map', 'console-unavailable', 'refused', 'malformed', 'events-disabled']) {
assert.ok(module_().PERMANENT.has(reason), reason)
}
for (const reason of ['rb-not-ready', 'rb-busy']) assert.ok(!module_().PERMANENT.has(reason), reason)
})
test('a repeat of a key spawns nothing new, and says so', async (t) => {
stubStep(t, { reply: queued({ repeat: true }) })
const again = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main' } })
assert.ok(again.ok)
assert.strictEqual(again.detail.repeat, true)
assert.match(again.detail.note, /^answered from the first attempt; nothing new was spawned/)
assert.strictEqual(again.resources[0].ref, 'main:raid-1-1', 'the repeat still names the run’s base')
})
// ── Warlord (D202) ─────────────────────────────────────────────────────────
test('the tally’s raids completed 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', raidsCompleted: 2 } })
const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('raids_completed'))
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('raids_completed')], 2)
})
test('the leaderboard sorts by raids completed 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, raidsCompleted: '3' }] : []))
const events = require('../model/events/events.model')
const [row] = await events.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'raidsCompleted' })
assert.strictEqual(row.raidsCompleted, 3)
const board = rec.touching('rust_player_wipe_stats').at(-1)
assert.match(board.sql, /SUM\(s\.raids_completed\) AS raidsCompleted/)
assert.match(board.sql, /ORDER BY[^]*raids_completed/)
})
test('Warlord is a title category that sums raids completed', () => {
const titles = require('../model/titles/titles')
const stats = titles.STATS || titles._STATS || null
if (!stats) return
assert.deepStrictEqual(stats.raidscompleted, { label: 'Raid bases completed', title: 'Warlord', source: { sum: 'raids_completed' } })
})
// ── The map's raids layer (D114) ───────────────────────────────────────────
test('the map’s raids layer is public by default, and a run’s key never reaches a viewer', () => {
const map = require('../model/map/map.model')
assert.ok(map.LAYERS.includes('raids'))
assert.strictEqual(map.DEFAULTS.raids, 'public')
const layers = {}
for (const l of map.LAYERS) layers[l] = { visible: l === 'raids', audience: 'public' }
const out = map.project(
{ raids: [{ x: 1, z: 2, difficulty: 'Wobbly', runId: '7', key: '7:1' }], world: [{ kind: 'cargo' }] },
{ layers, mates: { visible: false }, steamIds: [] },
)
assert.deepStrictEqual(out.raids, [{ x: 1, z: 2, difficulty: 'Wobbly', runId: '7' }])
assert.strictEqual(out.world, undefined, 'a hidden layer is absent, not empty')
layers.raids.visible = false
assert.strictEqual(map.project({ raids: [{ x: 1, z: 2 }] }, { layers, mates: { visible: false }, steamIds: [] }).raids, undefined)
})

349
server/test/safety.test.js Normal file
View File

@@ -0,0 +1,349 @@
// ── Report, block and hide, for chat lines (USER_SAFETY.md §4.2) ───────────
//
// What this module promises core and the reader:
//
// • a chat line is answered only to a reader who could have seen it — a line
// on a server whose presence is hidden from them, or one staff hid when they
// are not staff, is the same `null` as a line that is gone;
// • a speaker is known by their linked account first, by Steam id only when
// there is none (S12);
// • the feed leaves out what this reader blocked or hid, IN the query, and
// marks each line `canBlock` for a signed-in reader (S11, S13).
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, spy } = require('./_fakes')
function withCore(overrides = {}) {
require('../core')._reset()
const ctx = fakeCtx(overrides)
require('../core').init(ctx)
return ctx
}
/** Swap named exports on a required file for the length of `fn`. */
async function stubbing(mod, stubs, fn) {
const saved = {}
for (const [name, value] of Object.entries(stubs)) {
saved[name] = mod[name]
mod[name] = value
}
try {
return await fn()
} finally {
Object.assign(mod, saved)
}
}
const LINE = {
id: 4410,
serverId: 'main',
wipeId: 'w1',
t: Date.UTC(2026, 9, 30, 10, 0, 0),
steamId: '76561198000000001',
raw: JSON.stringify({ name: 'Rusty', message: 'gg ez', channel: 'Global' }),
hiddenAt: null,
}
const SEEN = { visible: true, level: 'signed_in', required: 'signed_in', userId: 7 }
function deps() {
return {
eventsDb: require('../model/events/events.db'),
linksDb: require('../model/links/links.db'),
serversDb: require('../model/servers/servers.db'),
visibility: require('../model/visibility/visibility.model'),
safety: require('../safety'),
}
}
// ── registration ──────────────────────────────────────────────────────────
test('rust.chat and the steam identity are registered, in core’s shape', () => {
require('../core')._reset()
const { fakeApi } = require('./_fakes')
const api = fakeApi()
require('../index')(fakeCtx(), api)
const [target] = api.record.reportTargets
assert.strictEqual(api.record.reportTargets.length, 1)
assert.strictEqual(target.id, 'rust.chat')
// Core's rule: namespaced `<moduleId>.`, at most 32 characters, a label of 40.
assert.match(target.id, /^rust\.[a-z0-9._-]+$/)
assert.ok(target.id.length <= 32 && target.label.length <= 40)
for (const name of ['snapshot', 'hide', 'unhide']) assert.strictEqual(typeof target[name], 'function')
const [identity] = api.record.gameIdentities
assert.strictEqual(api.record.gameIdentities.length, 1)
assert.strictEqual(identity.kind, 'steam')
assert.strictEqual(typeof identity.ownersOf, 'function')
})
// ── snapshot ──────────────────────────────────────────────────────────────
test('a line is described for a reader who could see it, with the linked account', async () => {
withCore()
const { eventsDb, linksDb, serversDb, visibility, safety } = deps()
await stubbing(eventsDb, { chatLineById: async () => LINE }, () =>
stubbing(visibility, { canSeePresenceAs: async () => SEEN }, () =>
stubbing(linksDb, { userIdsForSteamIds: async () => [{ steamId: LINE.steamId, userId: 12 }] }, () =>
stubbing(serversDb, { getServer: async () => ({ name: 'Main' }) }, async () => {
const snap = await safety.snapshot({ targetId: 4410, viewer: { userId: 7, role: 'player' } })
assert.deepStrictEqual(snap, {
author: { name: 'Rusty', userId: 12, identity: { kind: 'steam', id: LINE.steamId } },
text: 'gg ez',
at: '2026-10-30T10:00:00.000Z',
where: { server: 'main', serverName: 'Main', channel: 'Global', wipe: 'w1' },
})
}))))
})
test('an unlinked speaker has no account, only their Steam id', async () => {
withCore()
const { eventsDb, linksDb, serversDb, visibility, safety } = deps()
await stubbing(eventsDb, { chatLineById: async () => LINE }, () =>
stubbing(visibility, { canSeePresenceAs: async () => SEEN }, () =>
stubbing(linksDb, { userIdsForSteamIds: async () => [] }, () =>
stubbing(serversDb, { getServer: async () => null }, async () => {
const snap = await safety.snapshot({ targetId: 4410, viewer: { userId: 7, role: 'player' } })
assert.strictEqual(snap.author.userId, null)
assert.deepStrictEqual(snap.author.identity, { kind: 'steam', id: LINE.steamId })
}))))
})
test('a line the reader could not have seen is the same null as a missing one', async () => {
withCore()
const { eventsDb, visibility, safety } = deps()
const viewer = { userId: 7, role: 'player' }
await stubbing(eventsDb, { chatLineById: async () => null }, async () => {
assert.strictEqual(await safety.snapshot({ targetId: 1, viewer }), null)
})
// Presence hidden from this reader on the line's server.
await stubbing(eventsDb, { chatLineById: async () => LINE }, () =>
stubbing(visibility, { canSeePresenceAs: async () => ({ ...SEEN, visible: false }) }, async () => {
assert.strictEqual(await safety.snapshot({ targetId: 4410, viewer }), null)
}))
// Hidden by staff, and the reader is not staff (S10).
const hidden = { ...LINE, hiddenAt: new Date() }
await stubbing(eventsDb, { chatLineById: async () => hidden }, () =>
stubbing(visibility, { canSeePresenceAs: async () => SEEN }, async () => {
assert.strictEqual(await safety.snapshot({ targetId: 4410, viewer }), null)
}))
})
test('staff still get a line staff hid, so the report can be read', async () => {
withCore()
const { eventsDb, linksDb, serversDb, visibility, safety } = deps()
const hidden = { ...LINE, hiddenAt: new Date() }
await stubbing(eventsDb, { chatLineById: async () => hidden }, () =>
stubbing(visibility, { canSeePresenceAs: async () => ({ ...SEEN, level: 'staff' }) }, () =>
stubbing(linksDb, { userIdsForSteamIds: async () => [] }, () =>
stubbing(serversDb, { getServer: async () => null }, async () => {
const snap = await safety.snapshot({ targetId: 4410, viewer: { userId: 1, role: 'moderator' } })
assert.strictEqual(snap.text, 'gg ez')
}))))
})
test('the presence check is made for the account core names, not the session role', async () => {
withCore()
const { eventsDb, visibility, safety } = deps()
const asked = []
await stubbing(eventsDb, { chatLineById: async () => LINE }, () =>
stubbing(visibility, { canSeePresenceAs: async (userId, serverId) => { asked.push([userId, serverId]); return { ...SEEN, visible: false } } }, async () => {
await safety.snapshot({ targetId: 4410, viewer: { userId: 7, role: 'admin' } })
}))
assert.deepStrictEqual(asked, [[7, 'main']])
})
// ── hide for everyone ─────────────────────────────────────────────────────
test('hide sets the flag with who did it; a pruned line is left alone', async () => {
withCore()
const { eventsDb, safety } = deps()
const set = spy(Promise.resolve())
await stubbing(eventsDb, { chatLineById: async () => LINE, setChatHidden: set }, async () => {
await safety.hide({ targetId: 4410, actor: { id: 3, username: 'moderator', role: 'moderator' } })
await safety.unhide({ targetId: 4410, actor: { id: 3 } })
})
assert.deepStrictEqual(set.calls, [[4410, true, 3], [4410, false]])
const none = spy(Promise.resolve())
await stubbing(eventsDb, { chatLineById: async () => null, setChatHidden: none }, async () => {
await safety.hide({ targetId: 9, actor: { id: 3 } })
})
assert.deepStrictEqual(none.calls, [])
})
function fakeRes() {
const res = { statusCode: 200, body: null }
res.status = (code) => { res.statusCode = code; return res }
res.json = (body) => { res.body = body; return res }
res.end = () => res
return res
}
test('the line’s own menu hides and unhides through admin routes, logged', async () => {
const ctx = withCore()
const { eventsDb } = deps()
const admin = require('../router/admin/rust.controller')
const set = spy(Promise.resolve())
await stubbing(eventsDb, { chatLineById: async () => LINE, setChatHidden: set }, async () => {
const res = fakeRes()
await admin.hideChat({ params: { id: '4410' }, user: { id: 3 } }, res)
assert.strictEqual(res.statusCode, 204)
await admin.unhideChat({ params: { id: '4410' }, user: { id: 3 } }, fakeRes())
})
assert.deepStrictEqual(set.calls, [[4410, true, 3], [4410, false, 3]])
assert.deepStrictEqual(ctx.activity.log.calls.map(([e]) => e.action), ['rust.chat.hide', 'rust.chat.unhide'])
await stubbing(eventsDb, { chatLineById: async () => null }, async () => {
const res = fakeRes()
await admin.hideChat({ params: { id: '9' }, user: { id: 3 } }, res)
assert.strictEqual(res.statusCode, 404)
})
})
// ── the steam identity ────────────────────────────────────────────────────
test('ownersOf answers linked ids only, and asks nothing for an empty list', async () => {
withCore()
const { linksDb, safety } = deps()
const asked = spy(async () => [{ steamId: '1', userId: 5 }])
await stubbing(linksDb, { userIdsForSteamIds: asked }, async () => {
assert.deepStrictEqual(await safety.ownersOf(['1', '2', '1', null]), { 1: 5 })
assert.deepStrictEqual(await safety.ownersOf([]), {})
})
assert.deepStrictEqual(asked.calls, [[['1', '2']]])
})
// ── the feed ──────────────────────────────────────────────────────────────
test('a signed-out reader is filtered by nothing but staff’s hides', async () => {
const ctx = withCore()
const { safety } = deps()
assert.deepStrictEqual(await safety.feedFilter({ userId: null, staff: false }), { showHidden: false, steamIds: [], ids: [] })
assert.deepStrictEqual(ctx.safety.forViewer.calls, [])
})
test('a reader’s blocks become Steam ids: an account’s linked ids, and unlinked Steam blocks', async () => {
withCore({
safety: {
forViewer: async () => ({
blockedUserIds: [12],
blockedIdentities: { steam: ['900'] },
hidden: { 'rust.chat': [4410, 4411] },
}),
staffAmong: async () => [],
},
})
const { linksDb, safety } = deps()
const asked = spy(async () => ['100', '101'])
await stubbing(linksDb, { steamIdsForUsers: asked }, async () => {
assert.deepStrictEqual(await safety.feedFilter({ userId: 7, staff: true }), {
showHidden: true,
steamIds: ['100', '101', '900'],
ids: [4410, 4411],
})
})
assert.deepStrictEqual(asked.calls, [[[12]]])
})
test('a feed whose blocks cannot be read is shown unfiltered, not failed', async () => {
withCore({ safety: { forViewer: async () => { throw new Error('db down') }, staffAmong: async () => [] } })
const { safety } = deps()
assert.deepStrictEqual(await safety.feedFilter({ userId: 7, staff: false }), { showHidden: false, steamIds: [], ids: [] })
})
test('canBlock: not your own lines, not a staff-linked player, not a line with no Steam id', async () => {
withCore({ safety: { forViewer: async () => ({}), staffAmong: async (ids) => ids.filter((id) => id === 2) } })
const { linksDb, safety } = deps()
const rows = [
{ id: 1, kind: 'player.chat', steamId: 'mine' },
{ id: 2, kind: 'player.chat', steamId: 'staff' },
{ id: 3, kind: 'player.chat', steamId: 'linked' },
{ id: 4, kind: 'player.chat', steamId: 'unlinked' },
{ id: 5, kind: 'player.chat', steamId: null },
{ id: 6, kind: 'player.death', steamId: 'linked' },
]
await stubbing(linksDb, {
userIdsForSteamIds: async () => [
{ steamId: 'mine', userId: 7 },
{ steamId: 'staff', userId: 2 },
{ steamId: 'linked', userId: 12 },
],
}, async () => {
const out = await safety.annotate(rows, { userId: 7 })
assert.deepStrictEqual(out.map((r) => r.canBlock), [false, false, true, true, false, undefined])
// Signed out: untouched.
assert.strictEqual(await safety.annotate(rows, { userId: null }), rows)
})
})
test('when who is staff cannot be read, no line offers Block', async () => {
withCore({ safety: { forViewer: async () => ({}), staffAmong: async () => { throw new Error('db down') } } })
const { linksDb, safety } = deps()
await stubbing(linksDb, { userIdsForSteamIds: async () => [{ steamId: 'a', userId: 12 }] }, async () => {
const out = await safety.annotate([{ id: 1, kind: 'player.chat', steamId: 'a' }, { id: 2, kind: 'player.chat', steamId: 'b' }], { userId: 7 })
assert.deepStrictEqual(out.map((r) => r.canBlock), [false, false])
})
})
// ── the query ─────────────────────────────────────────────────────────────
test('the feed’s query leaves hidden, blocked and hidden-for-me chat out, and only chat', async () => {
const ctx = withCore()
const db = require('../model/events/events.db')
await db.recentEvents({ serverId: 'main', kinds: ['player.chat', 'player.death'], limit: 50 })
let [sql, params] = ctx.db.query.calls.at(-1)
assert.match(sql, /NOT \(kind = 'player\.chat' AND hidden_at IS NOT NULL\)/)
assert.deepStrictEqual(params, ['main', 'player.chat', 'player.death', 50])
await db.recentEvents({
serverId: 'main',
kinds: ['player.chat'],
limit: 50,
chat: { showHidden: true, steamIds: ['100', '900'], ids: [4410] },
});
[sql, params] = ctx.db.query.calls.at(-1)
assert.doesNotMatch(sql, /hidden_at IS NOT NULL/, 'staff get hidden lines')
assert.match(sql, /NOT \(kind = 'player\.chat' AND steam_id IN \(\?, \?\)\)/)
assert.match(sql, /NOT \(kind = 'player\.chat' AND id IN \(\?\)\)/)
assert.deepStrictEqual(params, ['main', 'player.chat', '100', '900', 4410, 50])
})
test('the model passes the reader’s chat filter through, narrow by default, and marks staff’s hidden lines', async () => {
withCore()
const db = require('../model/events/events.db')
const model = require('../model/events/events.model')
let asked = null
await stubbing(db, {
recentEvents: async (args) => {
asked = args
return [{ id: 1, kind: 'player.chat', t: 1, steamId: 'a', raw: '{}', hiddenAt: new Date() }]
},
}, async () => {
await model.recent({ serverId: 'main', presence: true })
assert.deepStrictEqual(asked.chat, { showHidden: false, steamIds: [], ids: [] })
const rows = await model.recent({ serverId: 'main', presence: true, chat: { showHidden: true, steamIds: ['x'], ids: [2] } })
assert.deepStrictEqual(asked.chat, { showHidden: true, steamIds: ['x'], ids: [2] })
assert.strictEqual(rows[0].hidden, true)
})
})

View File

@@ -28,6 +28,7 @@ const rewards = require('../eventRewards')
const leases = require('../eventLeases')
const templates = require('../eventTemplates')
const model = require('../model/permissions/permissions.model')
const reconcile = require('../model/permissions/reconcile')
const entitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle')
const ok = (data) => ({ ok: true, status: 'ok', data })
@@ -163,6 +164,19 @@ test('the sync carries each untold message to the account the reward picked', ()
])
})
test('the push carries the notices, and every other field the payload built (W21)', () => {
const permSync = require('../permSync')
const desired = model.buildDesired('main', authored([grantRow({ notice: 'You earned kit vip', noticeDelivery: 'chat' })]))
const held = reconcile.withHold(desired, new Set())
const body = permSync.pushBody(desired.hash, held.payload, [], [])
assert.deepStrictEqual(body.notices, [
{ key: '7:3:11', steamId: '76561190000000001', message: 'You earned kit vip', delivery: 'chat' },
])
// A field the model adds and the push forgets is how W21 happened.
for (const field of Object.keys(held.payload)) assert.ok(field in body, `the push drops payload.${field}`)
assert.strictEqual(body.setId, desired.hash)
})
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' })]))

View File

@@ -47,8 +47,9 @@ const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...
// ── The categories ────────────────────────────────────────────────────────
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)
test('twenty-nine categories carry a default title, and playtime carries none (D172, §11.2.2, D356, D375, §11.4)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 29)
assert.strictEqual(titles.STATS.raidscompleted.title, 'Warlord')
assert.strictEqual(titles.STATS.guardedwon.title, 'Warden')
assert.strictEqual(titles.STATS.treasureswon.title, 'Treasure Hunter')
assert.strictEqual(titles.STATS.richest.title, 'Magnate')

View File

@@ -0,0 +1,49 @@
// ── What a weapon is called on a page ─────────────────────────────────────
//
// The feed printed "with ak47u entity". Every event read now carries a
// `weaponName`: the game's own name for a ranged weapon, or the prefab name as
// words with its ending dropped.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
const weaponNames = require('../model/titles/weaponNames')
test('a gun is called what the game calls it', () => {
assert.equal(weaponNames.nameOf('ak47u.entity'), 'Assault Rifle')
assert.equal(weaponNames.nameOf('bolt_rifle.entity'), 'Bolt Action Rifle')
assert.equal(weaponNames.nameOf('lr300.entity'), 'LR-300 Assault Rifle')
assert.equal(weaponNames.nameOf('semi_auto_rifle.entity'), 'Semi-Automatic Rifle')
assert.equal(weaponNames.nameOf('bow_hunting.entity'), 'Hunting Bow')
})
test('anything the table does not hold reads as words, without its ending', () => {
assert.equal(weaponNames.nameOf('machete.weapon'), 'machete')
assert.equal(weaponNames.nameOf('salvaged_sword.entity'), 'salvaged sword')
assert.equal(weaponNames.nameOf('bone.knife.obsidian.entity'), 'bone knife obsidian')
assert.equal(weaponNames.nameOf('some_new_gun.entity'), 'some new gun')
assert.equal(weaponNames.nameOf('rifle.ak'), 'rifle ak')
assert.equal(weaponNames.nameOf(''), null)
assert.equal(weaponNames.nameOf(null), null)
})
test('no name in the table still carries a prefab ending', () => {
for (const [prefab, name] of weaponNames.NAMES) {
assert.ok(prefab.endsWith('.entity'), `${prefab} is a held entity's short prefab name`)
assert.doesNotMatch(name, /entity|weapon|_/, `${prefab} -> ${name}`)
}
})
test('every event read gives a weapon its name, and leaves a frame without one alone', () => {
require('../core')._reset()
require('../core').init(fakeCtx())
const { shape } = require('../model/events/events.model')
const kill = shape({ id: 1, kind: 'player.death', t: 1, raw: JSON.stringify({ weapon: 'ak47u.entity', distance: 22 }) })
assert.equal(kill.frame.weaponName, 'Assault Rifle')
assert.equal(kill.frame.weapon, 'ak47u.entity', 'the prefab name stays: the title lists count by it')
const fall = shape({ id: 2, kind: 'player.death', t: 1, raw: JSON.stringify({ attackerType: 'environment' }) })
assert.equal('weaponName' in fall.frame, false)
})

View File

@@ -178,10 +178,13 @@ test('a zone crosses with its key, its duration, and the monument without the se
server: 'main',
body: { runId: '7', key: 'k1', monument: 'airfield_1', offsetX: 0, offsetZ: -20, radius: 40, holdMs: 5400000, name: 'Brawl' },
})
// The ref carries the server: revert and reconcile are handed no params.
assert.deepStrictEqual(result.resources, [
{ kind: 'world', ref: 'main:rg-7-1-1', payload: { serverId: 'main', what: 'zone', name: 'Z' } },
])
// The ref carries the server: revert and reconcile are handed no params. The
// zone's deadline rides as `until` (W1); zones.test.js checks its value.
const [resource] = result.resources
assert.strictEqual(typeof resource.until, 'string')
const { until, ...rest } = resource
assert.deepStrictEqual(rest, { kind: 'world', ref: 'main:rg-7-1-1', payload: { serverId: 'main', what: 'zone', name: 'Z' } })
assert.strictEqual(result.resources.length, 1)
})
test('one resource per thing placed, and a repeated key is said to be one', async (t) => {

View File

@@ -216,6 +216,19 @@ test('a Bradley counts toward Tankbreaker, and an arrival counts nothing', async
assert.strictEqual(rec2.touching('rust_player_wipe_stats').length, 0)
})
test('a downed one is stored with the rule that credited it; an arrival is stored as sent (W4)', async () => {
const { apply } = require('../ingest')
const storedRaw = async (frame) => {
const rec = withRecorder()
await apply('main', { id: 1, kind: 'world.event', frame }, { id: 'main', creditRule: 'all' })
const insert = rec.touching('rust_events').find((s) => /INSERT/i.test(s.sql))
return JSON.parse(insert.params.find((p) => typeof p === 'string' && p.startsWith('{')))
}
assert.strictEqual((await storedRaw(downed())).creditRule, 'all')
assert.strictEqual((await storedRaw(world('heli.arrived', { x: 1, z: 1 }))).creditRule, undefined)
})
test('the tally’s crates hacked and drops looted are added up (D356)', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
@@ -251,6 +264,21 @@ test('the feed reads each world event for its viewer, with the server’s rule a
assert.deepStrictEqual(member.frame.credited, [{ name: 'Bob' }])
})
test('a line keeps the rule it was stored with after the server’s rule changes (W4)', async () => {
const stamped = downed({ creditRule: 'all' })
withRecorder((sql) => {
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'world.event', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stamped) }]
if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', creditRule: 'most', enabled: 1 }]
return []
})
const events = require('../model/events/events.model')
const [member] = await events.recent({ serverId: 'main', presence: true })
assert.strictEqual(member.frame.creditRule, 'all')
assert.strictEqual(member.frame.creditedCount, 2)
assert.deepStrictEqual(member.frame.credited.map((c) => c.name).sort(), ['Alice', 'Bob'])
})
// ── Triggers (D355) ────────────────────────────────────────────────────────
test('one public trigger per type, none of them declaring a Steam id', () => {
@@ -387,6 +415,17 @@ test('a refusal the plugin would repeat is not retried; a game that is down is',
assert.match(down.error, /has no game connected/)
})
test('a Travelling Vendor already out is no failure: the step says so and counts nothing called (W3)', async (t) => {
stubCall(t, ok({ kind: 'world.error', reason: 'already-out', message: 'a Travelling Vendor is already out' }))
const result = await callStep().perform({ runId: 7, idempotencyKey: '7:6', params: { server: 'main', event: 'travelling_vendor' } })
assert.strictEqual(result.ok, true)
assert.deepStrictEqual(result.detail, {
event: 'travelling_vendor',
called: 0,
note: 'a Travelling Vendor is already out on Main, and the game runs one at a time; nothing new was called',
})
})
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' } })

View File

@@ -24,6 +24,10 @@ const { fakeCtx } = require('./_fakes')
require('../core')._reset()
require('../core').init(fakeCtx())
// The stored status is the whole answer in these tests: nothing asks a
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
require('../liveStatus').refresh = async () => false
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const zonesDb = require('../model/zones/zones.db')
@@ -228,6 +232,29 @@ test('a zone crosses with its flags, settings, messages in the chat voice, and i
})
})
test('a zone resource carries its deadline as until, a crate none (W1)', async (t) => {
// Core's reconcile reads `until`: a zone gone past it is expired, not
// orphaned. Reckoned from the answer, so it never falls before the game's own.
stubZones(t)
stubWorld(t)
const before = Date.now()
const result = await action('rust.zone.open').perform({
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 30 },
})
assert.strictEqual(result.ok, true, result.error)
const until = Date.parse(result.resources[0].until)
assert.ok(until >= before + 1800000 && until <= Date.now() + 1800000, result.resources[0].until)
const saved = client.worldPlace
client.worldPlace = async () => ({ ok: true, status: 'ok', data: { kind: 'world.ok', placed: [{ id: 'rg-7-1-2', kind: 'crate' }] } })
t.after(() => { client.worldPlace = saved })
const crate = await action('rust.crate.place').perform({
runId: 7, idempotencyKey: 'k2', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
})
assert.strictEqual(crate.ok, true, crate.error)
assert.strictEqual('until' in crate.resources[0], false)
})
test('a dome with its stack left blank stacks 3 spheres (D326)', async (t) => {
stubZones(t, { servers: [server('main', { domes: PATCHED })] })
const calls = stubWorld(t)

View File

@@ -178,7 +178,8 @@ function columnName(index) {
* `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
* `rule` the server's credit rule, for a row stored before the frame
* carried its own (`stampRule`)
* `geometry` the server's map geometry, for the grid
*
* What comes back keeps the plugin's own fields for what it describes (`event`,
@@ -215,8 +216,8 @@ function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDI
// Credit, by the server's rule.
if (known && TYPES[event].credit) {
out.creditRule = creditRuleOf(rule)
const ids = credited(frame, rule)
out.creditRule = ruleOf(frame, rule)
const ids = credited(frame, out.creditRule)
out.creditedCount = ids.length
if (presence || staff) {
out.credited = ids.map((id) => ({
@@ -235,6 +236,25 @@ function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDI
return out
}
/**
* The frame as it is stored: a downed helicopter, Chinook or Bradley carries
* the rule that credited it when it arrived (`creditRule`), so the feed line
* keeps naming who the counts were given to after the server's rule changes
* (W4). Every other frame is stored as sent.
*/
function stampRule(frame, rule) {
if (!frame || frame.kind !== 'world.event' || !isKnownType(frame.event) || !TYPES[frame.event].credit) return frame
return { ...frame, creditRule: creditRuleOf(rule) }
}
/**
* The rule a stored line is read by: its own stamp, or — for a row from before
* the stamp — the server's rule now, which is how those rows always read.
*/
function ruleOf(frame, rule) {
return frame && CREDIT_RULE_IDS.includes(frame.creditRule) ? frame.creditRule : creditRuleOf(rule)
}
/**
* 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,
@@ -270,6 +290,8 @@ module.exports = {
creditRuleOf,
isKnownType,
credited,
stampRule,
ruleOf,
nameOf,
gridOf,
shapeFor,

View File

@@ -1,5 +1,61 @@
{
"paths": {
"/api/v1/admin/rust/chat/{id}/hide": {
"post": {
"tags": [
"Admin · Rust"
],
"summary": "Hide a chat line for everyone",
"description": "Staff’s hide for everyone (USER_SAFETY.md S10): the line leaves the public feed for everybody but staff, who still get it with `hidden: true`. The same flag core’s Reports queue sets through the `rust.chat` report target. Hiding a hidden line changes nothing. Recorded in the activity log. Admins and moderators.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "The chat line’s event id"
}
],
"responses": {
"204": {
"description": "Hidden"
},
"404": {
"description": "No such chat line"
}
}
}
},
"/api/v1/admin/rust/chat/{id}/unhide": {
"post": {
"tags": [
"Admin · Rust"
],
"summary": "Show a hidden chat line again",
"description": "Undoes a hide for everyone, whether it was made here or from the Reports queue. Recorded in the activity log. Admins and moderators.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "The chat line’s event id"
}
],
"responses": {
"204": {
"description": "Shown again"
},
"404": {
"description": "No such chat line"
}
}
}
},
"/api/v1/admin/rust/config/{serverId}/file": {
"get": {
"tags": [
@@ -1948,7 +2004,7 @@
"Admin · Rust"
],
"summary": "Who may see who is online, and who may see a clan roster",
"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.",
"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`, `pvp` and `raids` 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.",
"responses": {
"200": {
"description": "The fleet default and each server",
@@ -1970,7 +2026,7 @@
"Admin · Rust"
],
"summary": "Change who may see who is online, who may see a clan roster, or which servers say news in chat",
"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.",
"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`, `raids`) 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.",
"responses": {
"200": {
"description": "Saved; answers the new state",
@@ -2387,7 +2443,7 @@
}
},
"409": {
"description": "Economics is not installed there, or the balance is too low to withdraw that much",
"description": "Economics is not installed there, the balance is too low to withdraw that much, or a deposit would pass Economics’ balance limit",
"content": {
"application/json": {
"schema": {
@@ -3203,7 +3259,7 @@
"Public · Rust"
],
"summary": "Recent events on one Rust server",
"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`.",
"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` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped. Chat is read for each reader (USER_SAFETY.md §4.2): a signed-in reader does not get the lines of a player they blocked (by the player’s linked account, or by Steam id when there is none) or lines they hid for themselves, and each `player.chat` row carries `canBlock` — false for their own lines and for a player linked to a staff account. A line staff hid for everyone is left out for everybody but staff, who get it with `hidden: true`.",
"parameters": [
{
"name": "id",
@@ -3258,7 +3314,7 @@
"Public · Rust"
],
"summary": "The leaderboard for one Rust server",
"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.",
"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. Likewise `raidsCompleted` (RaidableBases bases completed as one of the plugin’s own raiders, Warlord’s count, PLAN_REDESIGNS §11.4) and `raidableBases: true` where the server reports RaidableBases or a row already counts one.",
"parameters": [
{
"name": "id",
@@ -3282,7 +3338,7 @@
"name": "sort",
"in": "query",
"required": false,
"description": "kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, guardedWon, or balance (one wipe on a server with Economics)",
"description": "kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, guardedWon, raidsCompleted, or balance (one wipe on a server with Economics)",
"schema": {
"type": "string"
}
@@ -4922,6 +4978,7 @@
"dropslooted",
"treasureswon",
"guardedwon",
"raidscompleted",
"richest",
"playtime",
"profilekills"
@@ -5623,6 +5680,405 @@
}
}
}
},
"raidableBases": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Protocol 14 (PLAN_REDESIGNS §11.4): where loaded, its bases reach the feed, the map’s raids layer and Warlord, and a step may spawn one. Every difficulty, profile and base name is the installed plugin’s own (D386). `gridReady` and `profiles` come only with a patched raids helper (RunicGatewayRaids.cs), which is also what makes a step’s base the run’s."
},
"properties": {
"type": "object",
"properties": {
"loaded": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"version": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "3.2.2"
}
}
},
"premium": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "The plugin’s own IsPremium()."
},
"example": {
"type": "boolean",
"example": true
}
}
},
"spawnCall": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Whether the paid edition’s SpawnRandomBase call is there, looked for by name (D348)."
},
"example": {
"type": "boolean",
"example": true
}
}
},
"copyPaste": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"loaded": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"version": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "4.3.0"
}
}
}
}
}
}
},
"difficulties": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Hard"
}
}
},
"level": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 2
}
}
}
}
}
}
}
}
},
"helper": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"state": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"missing",
"patched",
"unsupported",
"no-raidablebases"
],
"items": {
"type": "string"
}
},
"example": {
"type": "string",
"example": "patched"
}
}
},
"version": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "0.1.0"
}
}
},
"reason": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"description": {
"type": "string",
"example": "Why it is not patched."
}
}
}
}
}
}
},
"gridReady": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Whether its spawn grid is built; until it is, it refuses every spawn."
},
"example": {
"type": "boolean",
"example": true
}
}
},
"profiles": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Hard Bases"
}
}
},
"difficulty": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Hard"
}
}
},
"level": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 2
}
}
},
"enabled": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"pvp": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"bases": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"example": {
"type": "array",
"example": [
"raidhard1",
"raidhard2"
],
"items": {
"type": "string"
}
}
}
},
"seabed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "A seabed chance above 0 (D389)."
},
"example": {
"type": "boolean",
"example": false
}
}
},
"float": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Allows bases to float above water (D389)."
},
"example": {
"type": "boolean",
"example": false
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
@@ -7857,6 +8313,23 @@
}
}
},
"waitingToLand": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"description": {
"type": "string",
"example": "Grants to players (lent, given or authored) the last sync pushed and the game did not keep: the report’s `notLanded` entries for a Steam id. On Carbon that is a player the server has never seen; the grant lands when they first join, because every sync pushes the whole set."
},
"example": {
"type": "number",
"example": 0
}
}
},
"dirty": {
"type": "object",
"properties": {
@@ -8977,7 +9450,8 @@
"events",
"players",
"bases",
"pvp"
"pvp",
"raids"
],
"items": {
"type": "string"
@@ -9043,6 +9517,9 @@
"pvp": {
"$ref": "#/components/schemas/RustAudience"
},
"raids": {
"$ref": "#/components/schemas/RustAudience"
},
"mates": {
"type": "object",
"properties": {
@@ -9931,6 +10408,9 @@
},
"pvp": {
"$ref": "#/components/schemas/RustMapLayer"
},
"raids": {
"$ref": "#/components/schemas/RustMapLayer"
}
}
}
@@ -10686,6 +11166,129 @@
}
}
},
"raids": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"description": {
"type": "string",
"example": "Protocol 14 (§11.4): RaidableBases’ live bases, with the plugin’s own difficulty and base names (D386). `runId` is set for a base this site’s event spawned; the run’s key is never sent."
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"x": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "number"
}
}
},
"z": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "number"
}
}
},
"level": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 2
}
}
},
"difficulty": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Hard"
}
}
},
"base": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "raidhard2"
}
}
},
"pvp": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"despawnAt": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"description": {
"type": "string",
"example": "When the plugin despawns it, unix milliseconds."
}
}
},
"runId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "12"
}
}
}
}
}
}
}
}
},
"mates": {
"type": "object",
"properties": {