22 Commits

Author SHA1 Message Date
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
49d9e42a23 Merge pull request 'feat(rust): Run console commands and Reward by console command (PLAN_REDESIGNS §16)' (#50) from feat/command-steps into edge
Reviewed-on: #50
2026-10-16 07:47:28 +00:00
5686d9fd17 feat(rust): Run console commands and Reward by console command (PLAN_REDESIGNS §16)
All checks were successful
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Successful in 56s
Two event steps in server/eventCommands.js, on one new budget,
`rust.commands` (D382-D385):

- rust.command.run: up to 10 typed commands once on one server, with
  optional teardown commands. irreversible / ledger: the ledger row keeps
  the teardown as it was, and core reverts it when the run ends or is
  cancelled. Priced one unit per command, teardown included.
- rust.command.reward: the kit reward's recipient picker (tally modes and
  linked), by Steam id, so no website account is needed. The bridge fills
  {steamid} and {name}; offline recipients are skipped and named (D383).
  irreversible / none. Priced commands x people, the bound for a mode that
  is not a count.

Both take core's `multiple` string field as a free text box (no
allowlist, D384) and a required "I am sure" toggle. {steamid}/{name} in a
run step are refused. unknown-command, malformed, events-disabled,
interrupted are permanent; the error says how many ran before it.

D385: the run also hands its teardown to the bridge, which keeps it with
the key. A revert with only the key (the step failed, or its answer was
lost, so core recorded nothing) asks every enabled server to run what it
kept; a server the step never reached runs nothing.

sidecarClient.commandsRun -> POST /commands/run; ci/bundle.json lists the
new file; entry.test.js pins the new ids; 21 tests in
test/commandSteps.test.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 02:41:10 -05:00
619081ec65 Merge pull request 'feat(rust): ZLevels Remastered on the servers page (PLAN_REDESIGNS §14.3.6)' (#49) from feat/zlevels into edge
Reviewed-on: #49
2026-10-16 06:10:28 +00:00
9c82a0c437 feat(rust): ZLevels Remastered on the servers page (PLAN_REDESIGNS §14.3.6)
All checks were successful
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 31s
PR Checks / frozen-manifest (pull_request) Successful in 51s
Stage 7 of the §14.3 integration plan (D374): Server settings' plugin
line says whether ZLevels is loaded and that an event may lend its XP
boosts as permissions. The integrations schema gains zLevels; the
swagger fragment is regenerated. No leaderboard change, nothing stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 18:27:57 -05:00
8be7e5607e Merge pull request 'feat(rust): Guarded Crate — feed, map, Warden, Place a guarded crate (PLAN_REDESIGNS §14.3.5)' (#48) from feat/guarded-crate into edge
Reviewed-on: #48
2026-10-15 23:19:14 +00:00
da1d74bdff feat(rust): Guarded Crate — feed, map, Warden, Place a guarded crate (PLAN_REDESIGNS §14.3.5)
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 34s
PR Checks / frozen-manifest (pull_request) Successful in -1m34s
Stage 6 of the §14.3 integration plan, on protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.9.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 12:26:50 -05:00
71 changed files with 4821 additions and 96 deletions

View File

@@ -135,6 +135,44 @@ one now, kept when the run ends: an airdrop or a patrol helicopter can be sent t
ten minutes after DT loads the step says "Dangerous Treasures is not ready yet" and tries again ten
minutes later. Refused where DT is absent, or where the framework cannot run DT's console command
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.4).
- **Guarded Crate** (Bazz3l, 2.0.17), with **NpcSpawn** (Codefling) for its guards. With both: a
guarded crate landing and being cleared are public feed lines and triggers (*"Alice cleared the
guarded crate · M4"*, the name only for the presence audience), a running crate is on the live map's
world layer, the **Crates cleared** leaderboard column and the *Warden* title count the crates a
player cleared by killing the last guard, and the step **Place a guarded crate** places one of its
events at a monument or a point. The crate is kept when the run ends — Guarded Crate cannot end one
alone — and the run log says so. Refused where Guarded Crate or NpcSpawn is absent, where the event
is not one the server has, or where the framework cannot run its console command
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.5).
- **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**
step, which takes effect at once and is taken back when the run ends
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.6).
**Any other plugin, by console command.** **Run console commands** runs the commands an admin types
(at most ten) once on one server, such as RaidableBases' `rbe`, and optional teardown commands when
the run ends or is cancelled. **Reward by console command** runs them once for each person the kit
reward's picker picks, with `{steamid}` and `{name}` filled in by the bridge, such as `sr add {steamid} 100`.
Anyone offline at that moment is skipped and named in the run log. There is no allowlist. Both steps are
rated `irreversible`, so they are off until an admin switches them on, only an admin can author them,
and each needs its "I am sure" toggle ticked. A command the server does not know stops the step as
`unknown-command`. The run log shows what the console answered: "it ran" is not "it worked". The
site learns nothing afterwards, so none of it reaches the feed, the map or a title
(`docs/modules/rust/PLAN_REDESIGNS.md` §16).
News and event lines said in chat can wear one styled group's title and colours — the
**announcement voice**, chosen in Admin → Rust servers. The line is said by the bridge plugin with no

View File

@@ -35,23 +35,29 @@
"dangerousTreasures.js",
"db",
"dynamicPvp.js",
"eventCommands.js",
"engagement",
"eventDangerousTreasures.js",
"eventGuardedCrate.js",
"eventLeases.js",
"eventPermRewards.js",
"eventRaidableBases.js",
"eventProgress.js",
"eventRewards.js",
"eventTemplates.js",
"eventWorld.js",
"eventWorldCall.js",
"guardedCrate.js",
"index.js",
"ingest.js",
"liveStatus.js",
"mapImages.js",
"mapLive.js",
"model",
"npcSync.js",
"package.json",
"permSync.js",
"raidableBases.js",
"router",
"sidecarClient.js",
"titleSync.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.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "eb773693d32781f2ec717f9be07816b6afefd162",
"refName": "main @ MODULE_API 1.15.0 (website#215 merged)"
"ref": "6890a74c7c4ad5690509e8c163e477d7f7b399e7",
"refName": "main @ website#220 merge (the cutover), MODULE_API 1.17.0"
}

View File

@@ -46,6 +46,14 @@ const COLUMNS = [
// rows already count a treasure, so the column is there exactly then.
const TREASURES = { key: 'treasuresWon', label: 'Treasures', sort: 'treasuresWon', value: (r) => count(r.treasuresWon) }
// Warden (PLAN_REDESIGNS §14.3.5, D375): guarded crates cleared, shown on the
// same rule from the server's `guardedCrate`.
const GUARDED = { key: 'guardedWon', label: 'Crates cleared', sort: 'guardedWon', value: (r) => count(r.guardedWon) }
// 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 }) {
@@ -104,6 +112,8 @@ function Overall({ serverId, wipeId, sort, onSort }) {
const columns = [
...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 = {
@@ -47,6 +48,7 @@ const COLOURS = {
supply: '#66bb6a',
crate: '#ffee58',
treasure: '#ffab00',
guarded: '#ff7043',
event: '#ce93d8',
boss: '#d500f9',
online: '#ffffff',
@@ -56,6 +58,8 @@ const COLOURS = {
self: '#00e5ff',
mate: '#7cffb2',
pvp: '#ff1744',
raid: '#ff9100',
raidOurs: '#ffd600',
}
const WORLD_NAMES = {
@@ -67,6 +71,8 @@ const WORLD_NAMES = {
crate: 'Locked crate',
// Dangerous Treasures (§14.3.4): its running chests.
treasure: 'Dangerous Treasures chest',
// Guarded Crate (§14.3.5): a crate whose guards still stand.
guarded: 'Guarded crate',
boss: 'Boss',
}
@@ -84,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
@@ -165,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).
@@ -243,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
@@ -286,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'))
@@ -311,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])
@@ -394,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

@@ -55,6 +55,16 @@ export const FEED_KINDS = Object.freeze([
// ending (`dt.ended`) is staff's, and the map shows the chest go.
'dt.started',
'dt.won',
// Guarded Crate (§14.3.5): a crate landing, and one cleared, read per viewer
// like a treasure. A timeout (`gc.failed`) is staff's; the map shows it go.
'gc.started',
'gc.ended',
// 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. */
@@ -69,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'] },
{ 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) {
@@ -168,6 +178,18 @@ export function describe(row) {
case 'dt.ended':
return treasureLine(row.kind, frame)
case 'gc.started':
case 'gc.ended':
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: '' }
}
@@ -297,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':
@@ -377,4 +399,67 @@ function treasureLine(kind, frame) {
: { tone: 'kill', actor: null, verb: 'The treasure was won', subject: null, detail }
}
/**
* Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5). The server has read the
* frame for this viewer: `grid`, `won` (whether a player killed the last
* guard), and the clearer's `name` only for the presence audience (D376).
*/
function guardedLine(kind, frame) {
const detail = frame.grid || ''
if (kind === 'gc.started') {
return { tone: 'server', actor: null, verb: 'A guarded crate landed', subject: null, detail }
}
if (kind === 'gc.failed') {
return { tone: 'server', actor: null, verb: 'A guarded crate timed out with its guards standing', subject: null, detail }
}
if (frame.won === false) {
return { tone: 'server', actor: null, verb: 'A guarded crate ended with nobody clearing it', subject: null, detail }
}
return frame.name
? { tone: 'kill', actor: frame.name, verb: 'cleared the guarded crate', subject: null, detail }
: { tone: 'kill', actor: null, verb: 'The guarded crate was cleared', subject: null, detail }
}
/** "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

@@ -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(' · ')
}

View File

@@ -264,7 +264,75 @@ export function integrationsLine(r) {
const withBackpacks = r.integrations.backpacks ? `${withEconomics} ${backpacksLine(r.integrations.backpacks)}` : withEconomics
const withTruePve = r.integrations.truePve ? `${withBackpacks} ${truePveLine(r.integrations.truePve)}` : withBackpacks
const withDynamicPvp = r.integrations.dynamicPvp ? `${withTruePve} ${dynamicPvpLine(r.integrations.dynamicPvp)}` : withTruePve
return r.integrations.dangerousTreasures ? `${withDynamicPvp} ${dangerousTreasuresLine(r.integrations.dangerousTreasures, r.console)}` : withDynamicPvp
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
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}`
}
/**
* ZLevels Remastered (PLAN_REDESIGNS §14.3.6), from protocol 14. Only whether it is
* loaded: its levels never reach the site (D374), and its XP boosts are permissions
* an event lends like any other.
*/
export function zLevelsLine(z) {
if (!z.loaded) return 'ZLevels is not loaded — there are no XP boosts for an event to lend.'
return `${`ZLevels ${z.version || ''}`.trim()} is loaded — an event may lend its XP boosts (zlevelsremastered.vip1 to vip3) as permissions.`
}
/**
* Guarded Crate (PLAN_REDESIGNS §14.3.5), from protocol 14, with NpcSpawn beside it
* (D364): its crates on the feed and the map, and whether a step can place one,
* which needs NpcSpawn for the guards and the framework to run its console command.
*/
export function guardedCrateLine(gc, probe) {
const npc = gc.npcSpawn && gc.npcSpawn.loaded ? `NpcSpawn ${gc.npcSpawn.version || ''}`.trim() : null
if (!gc.loaded) {
return `Guarded Crate is not loaded — a step cannot place a guarded crate.${npc ? ` ${npc} is loaded.` : ''}`
}
const name = `Guarded Crate ${gc.version || ''}`.trim()
const events = Array.isArray(gc.events) && gc.events.length ? ` Its events: ${gc.events.join(', ')}.` : ''
if (!npc) {
return `${name} is loaded but NpcSpawn is missing: its events cannot start, and a step cannot place one.`
}
if (probe && probe.plugins === false) {
return `${name} is loaded with ${npc} — its crates show on the feed and the map, but this framework cannot run its console command, so a step cannot place one (Rust-Plugins#34).${events}`
}
return `${name} is loaded with ${npc} — its crates show on the feed and the map, and a step may place one.${events}`
}
/**

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

@@ -781,7 +781,13 @@ 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>
)}

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,9 +203,11 @@ 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 and Dangerous Treasures, which the server serves publicly', () => {
assert.deepEqual(kindsFor('world'), ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won'])
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'))
assert.ok(!FEED_KINDS.includes('pvp.zone.deleted'), 'a zone closing is a staff line')
})
@@ -271,3 +273,68 @@ test('a win names its winner only when the server sent the name, and says how ma
test('a treasure event ending, for a staff reader, is its own line', () => {
assert.equal(describe(treasure('dt.ended', { grid: 'K12' })).verb, 'A Dangerous Treasures event has ended')
})
// ── Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5) ───────────────────
const guarded = (kind, frame) => row(kind, { kind, ...frame })
test('a guarded crate landing says where, and names nobody', () => {
const line = describe(guarded('gc.started', { grid: 'M4' }))
assert.equal(line.verb, 'A guarded crate landed')
assert.equal(line.detail, 'M4')
assert.equal(line.actor, null)
})
test('a clearing names its clearer only when the server sent the name', () => {
const named = describe(guarded('gc.ended', { grid: 'M4', won: true, name: 'Alice' }))
assert.equal(named.actor, 'Alice')
assert.equal(named.verb, 'cleared the guarded crate')
assert.equal(named.detail, 'M4')
const unnamed = describe(guarded('gc.ended', { grid: 'M4', won: true }))
assert.equal(unnamed.actor, null)
assert.equal(unnamed.verb, 'The guarded crate was cleared')
})
test('an ending with nobody clearing it, and a timeout for a staff reader, are their own lines', () => {
assert.equal(describe(guarded('gc.ended', { grid: 'M4', won: false })).verb, 'A guarded crate ended with nobody clearing it')
assert.equal(describe(guarded('gc.failed', { grid: 'M4' })).verb, 'A guarded crate timed out with its guards standing')
})
// ── 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')
})

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

@@ -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.17.0",
"triggers": [
{
"id": "rust.base.destroyed",
@@ -698,6 +698,142 @@
}
]
},
{
"id": "rust.gc.ended",
"label": "A guarded crate was cleared",
"description": "A player killed the last guard of a guarded crate on a server.",
"kind": "event",
"subjectKey": "subject",
"audience": "subscribers",
"ceiling": "everyone",
"version": 1,
"variables": [
{
"name": "serverId",
"type": "string",
"required": true,
"example": "main",
"description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
},
{
"name": "server",
"type": "string",
"required": true,
"example": "Runic Gateway | Main",
"description": "The server's display name."
},
{
"name": "serverUrl",
"type": "url",
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "subject",
"type": "string",
"required": true,
"example": "main:gc.ended",
"description": "The server and the event. The cooldown subject; not meant for display."
},
{
"name": "grid",
"type": "string",
"required": false,
"example": "G7",
"description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map."
},
{
"name": "player",
"type": "string",
"required": true,
"example": "Marisol",
"description": "Who killed the last guard, by in-game name."
}
]
},
{
"id": "rust.gc.started",
"label": "A guarded crate landed",
"description": "Guarded Crate dropped a locked crate ringed by guards on a server: on its own schedule, by an admin, or by an event.",
"kind": "event",
"subjectKey": "subject",
"audience": "subscribers",
"ceiling": "everyone",
"version": 1,
"variables": [
{
"name": "serverId",
"type": "string",
"required": true,
"example": "main",
"description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
},
{
"name": "server",
"type": "string",
"required": true,
"example": "Runic Gateway | Main",
"description": "The server's display name."
},
{
"name": "serverUrl",
"type": "url",
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "subject",
"type": "string",
"required": true,
"example": "main:gc.started",
"description": "The server and the event. The cooldown subject; not meant for display."
},
{
"name": "grid",
"type": "string",
"required": false,
"example": "G7",
"description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map."
},
{
"name": "byEvent",
"type": "boolean",
"required": true,
"example": false,
"description": "Whether one of this site's events placed it (a Place a guarded crate step)."
}
]
},
{
"id": "rust.kit.entitled",
"label": "An event rewarded you a kit",

View File

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

View File

@@ -76,6 +76,21 @@ const PUBLIC_KINDS = Object.freeze([
// for the presence audience, Steam ids and the position only for staff.
'dt.started',
'dt.won',
// Protocol 14: a guarded crate landed, and was cleared (PLAN_REDESIGNS
// §14.3.5). Public as kinds, read per viewer by `guardedCrate.shapeFor` as a
// treasure is: the clearer's name only for the presence audience, the Steam id
// and the position only for staff.
'gc.started',
'gc.ended',
// 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',
])
/**
@@ -144,6 +159,12 @@ const STAFF_KINDS = Object.freeze([
// Protocol 14 (§14.3.4): a Dangerous Treasures event ended, won or not. The
// plan keeps it staff's; the map shows the chest go.
'dt.ended',
// Protocol 14 (§14.3.5): a guarded crate timed out with guards still standing.
// The plan keeps it staff's; the map shows the crate go.
'gc.failed',
// Protocol 14 (§11.4): a RaidableBases base despawned. Staff's; the map shows
// the base go.
'raid.ended',
])
/**

View File

@@ -1390,3 +1390,11 @@ ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS credit_rule VARCHAR(8) NOT NUL
-- Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4, D375): the treasure
-- chests a player emptied, from the tally like the others (Treasure Hunter).
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS treasures_won INT UNSIGNED NOT NULL DEFAULT 0;
-- Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5, D375): the guarded crates
-- a player cleared by killing the last guard, from the tally like the others (Warden).
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS guarded_won INT UNSIGNED NOT NULL DEFAULT 0;
-- 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;

View File

@@ -187,6 +187,15 @@ const HEADLINES = Object.freeze({
intro: `${d.player || 'Somebody'} won the treasure${d.grid ? ` at ${d.grid}` : ''} on ${d.server}`
+ `${d.invaders > 1 ? `, with ${d.invaders} players fighting for it` : ''}.`,
}),
// Guarded Crate (protocol 14, §14.3.5). A missing grid drops out.
'rust.gc.started': (d) => ({
title: `A guarded crate landed on ${d.server}`,
intro: `A guarded crate landed${d.grid ? ` at ${d.grid}` : ''} on ${d.server}. Kill its guards to claim it.`,
}),
'rust.gc.ended': (d) => ({
title: `The guarded crate was cleared on ${d.server}`,
intro: `${d.player} cleared the guarded crate${d.grid ? ` at ${d.grid}` : ''} on ${d.server}.`,
}),
'rust.boss.killed': (d) => ({
title: `${d.boss} was killed on ${d.server}`,
intro: `${d.killer ? `${d.killer} landed the killing blow` : `${d.boss} fell`}`
@@ -605,9 +614,41 @@ async function onDangerousTreasures(server, item, frame) {
}) ? 1 : 0
}
/**
* Guarded Crate (protocol 14, §14.3.5): a crate landed, and one cleared. As a
* treasure's are: public news, a replay past a broadcast's window tells nobody
* (D63), the clearer by in-game name only. An ending with nobody named, and a
* timeout, raise nothing.
*/
async function onGuardedCrate(server, item, frame) {
const t = frameTime(item, frame)
if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
if (frame.kind === 'gc.ended' && !str(frame.name)) return 0
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
const data = { ...serverVars(server), subject: `${server.id}:${frame.kind}`, ...(grid ? { grid } : {}) }
if (frame.kind === 'gc.started') {
return fire(T['rust.gc.started'], {
data: { ...data, byEvent: Boolean(str(frame.key)) },
dedupeKey: dedupeKey('gc.started', server.id, t, frame.x, frame.z),
occurredAt: t,
}) ? 1 : 0
}
return fire(T['rust.gc.ended'], {
data: { ...data, player: str(frame.name) },
dedupeKey: dedupeKey('gc.ended', server.id, t, frame.steamId, frame.x, frame.z),
occurredAt: t,
}) ? 1 : 0
}
const HANDLERS = Object.freeze({
'dt.started': onDangerousTreasures,
'dt.won': onDangerousTreasures,
'gc.started': onGuardedCrate,
'gc.ended': onGuardedCrate,
'world.event': onWorld,
'npc.boss.spawned': onBoss,
'npc.boss.phase': onBoss,

View File

@@ -669,7 +669,50 @@ const DANGEROUS_TREASURES = [
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES, ...WORLD, ...DANGEROUS_TREASURES])
// ── Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5, §14.3.9) ────────────
//
// A guarded crate landing and being cleared are triggers, as Dangerous
// Treasures' are: public news, the server the cooldown subject. `player` is the
// clearer's in-game name only. A crate whose guards went with nobody killing
// the last (NpcSpawn reloaded under it) and one that timed out raise nothing:
// neither is news.
const GC_BY_EVENT = { name: 'byEvent', type: 'boolean', required: true, example: false,
description: 'Whether one of this site\'s events placed it (a Place a guarded crate step).' }
const GUARDED_CRATE = [
{
id: 'rust.gc.started',
label: 'A guarded crate landed',
description: 'Guarded Crate dropped a locked crate ringed by guards on a server: on its own schedule, by an admin, or by an event.',
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE, { ...DT_SUBJECT, example: 'main:gc.started' }, WORLD_GRID, GC_BY_EVENT],
},
{
id: 'rust.gc.ended',
label: 'A guarded crate was cleared',
description: 'A player killed the last guard of a guarded crate on a server.',
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ ...DT_SUBJECT, example: 'main:gc.ended' },
WORLD_GRID,
{ name: 'player', type: 'string', required: true, example: 'Marisol',
description: 'Who killed the last guard, by in-game name.' },
],
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES, ...WORLD, ...DANGEROUS_TREASURES, ...GUARDED_CRATE])
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))

471
server/eventCommands.js Normal file
View File

@@ -0,0 +1,471 @@
// ── Console commands as event steps and rewards (PLAN_REDESIGNS.md §16, D382–D385) ──
//
// Two steps that run console commands the author types, so a plugin with no
// first-class support can still be used from the scheduler — RaidableBases'
// `rbe …` as a step, `sr add {steamid} 100` as a reward:
//
// rust.command.run runs the commands once, on one server; optional
// teardown commands run when the run ends or is
// cancelled (`irreversible` / `ledger`)
// rust.command.reward runs them once per recipient a kit reward's picker
// picks, `{steamid}` and `{name}` filled in by the
// bridge (`irreversible` / `none`)
//
// One protocol-14 frame carries both, `command.run` through `POST
// /commands/run`. The bridge runs each key at most once: it writes the key down
// before anything runs, so a retried step is answered from the first attempt.
//
// ── The safety is core's, and the author's own "I am sure" (D384) ───────────
//
// No allowlist and no blocklist: the commands are a free text box (core's
// `multiple` string field, MODULE_API 1.14.0). What bounds them is what bounds
// every world-changing step — off until an admin switches it on, authored only
// by an admin, the switchboard's caps on `rust.commands` — and a required toggle
// that says the author meant it. Unticked, the dry run and the run refuse.
//
// ── The teardown has two copies (D385) ─────────────────────────────────────
//
// The ledger row carries the teardown commands as they were when the step ran,
// so an edit to the definition mid-run changes nothing. But core records no
// resource for a step that FAILED (a misspelled second command after a first
// that started a base) or whose answer was lost, and reverts it with only the
// key. So the run also hands the teardown to the bridge, which keeps it with
// the key; a revert that has only the key asks every enabled server to run the
// teardown it kept under that key, which a server the step never reached
// answers with nothing run.
const client = require('./sidecarClient')
const servers = require('./model/servers/servers.model')
const linksDb = require('./model/links/links.db')
const core = require('./core')
const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
const rewards = require('./eventRewards')
const log = core.logger('commands')
const { MAX_RECIPIENTS, MODES, oneOf, checkCount, pickRecipients, peopleOf, readTally, entitlementRef, refParts } = rewards
/** At most this many commands in one field (§16.2). */
const MAX_COMMANDS = 10
/** The longest command, in characters (§16.2). */
const COMMAND_MAX = 512
/** What the bridge fills in for each recipient, and only for a reward (§16.3). */
const PLACEHOLDER = /\{(steamid|name)\}/i
/** A ledger row's kind: the teardown commands one step left to run. */
const TEARDOWN_KIND = 'command-teardown'
/** The key a step's teardown runs under: its own, so it too runs at most once. */
const teardownKey = (key) => `${key}:teardown`
/** The bridge's refusals a second attempt would repeat. */
const PERMANENT = new Set([
'events-disabled',
'malformed',
'unknown-command',
// Some commands may have run before a reload cut the run off, and none is run
// again: a retry would only be answered the same.
'interrupted',
'failed',
])
const BUDGETS = [
{
id: 'rust.commands',
label: 'Console commands',
unit: 'commands',
description:
'Console commands an event runs, as typed by its author: one per command, teardown commands included, and one per command per recipient for a reward. A mode that is not a count is priced at the most it could reward.',
},
]
// ── The params both steps check ──────────────────────────────────────────────
/**
* One field's commands as a list, checked: trimmed, blanks dropped, each at most
* COMMAND_MAX characters on one line, at most MAX_COMMANDS. Core hands a
* `multiple` param over as an array (MODULE_API 1.14.0); a bare value is a list
* of one. Order is kept: the commands run as listed.
*/
function checkCommands(raw, { field = 'commands', required = true } = {}) {
const items = raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw]
const out = []
for (const item of items) {
const line = String(item === undefined || item === null ? '' : item).trim()
if (!line) continue
if (line.length > COMMAND_MAX) return { ok: false, error: `a command is at most ${COMMAND_MAX} characters, and one of the ${field} is ${line.length}` }
// eslint-disable-next-line no-control-regex
if (/[\u0000-\u001f\u007f]/.test(line)) return { ok: false, error: `a command is one line: one of the ${field} has a line break or a control character in it` }
out.push(line)
}
if (required && !out.length) return { ok: false, error: 'type at least one command' }
if (out.length > MAX_COMMANDS) return { ok: false, error: `a step runs at most ${MAX_COMMANDS} ${field}, not ${out.length}` }
return { ok: true, value: out }
}
/** The "I am sure" toggle (§16.2, §16.3, D343's). Only a real `true` passes; a string is not a tick. */
function checkConfirm(params, sentence) {
if (params.confirm === true) return { ok: true }
return { ok: false, error: `Tick "${sentence}" to run these commands` }
}
/** 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 command list's price: at least one, so the switchboard sees the dimension. */
function pricedCommands(raw) {
const checked = checkCommands(raw, { required: false })
return checked.ok ? checked.value.length : 1
}
/** How many teardown commands a step names, as its price counts them; none is none. */
function teardownCount(raw) {
const checked = checkCommands(raw, { required: false })
return checked.ok ? checked.value.length : 0
}
/** People priced before the tally is read: the count for a count, the bound for every other mode. */
function pricedPeople(p) {
const mode = oneOf(p.recipients, MODES)
const n = Math.round(Number(p.count) || 0)
return mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
}
/** A transport failure; permanent only when the server is not set up to be asked at all. */
function transportFailure(server, result, what) {
const permanent = result.status === 'not-configured' || result.status === 'no-token'
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) }
}
/**
* The bridge's refusal, in words, with how many commands ran before it: a typo
* in the second command of three says the first one ran.
*/
function refusal(server, data, what) {
const name = server.name || server.id
const ran = Number(data.ran) || 0
const said = pluginError(data, `${name} did not run the ${what}`)
const tail = ran ? ` (${ran} ran before it)` : ''
return {
ok: false,
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
error: `${name}: ${said}${tail}`,
}
}
/**
* What the run log shows of an answer: how many ran, what the console said
* (the bridge keeps the first 20), and who was skipped as offline.
*/
function replyDetail(data) {
const replies = Array.isArray(data.replies) ? data.replies : []
const skipped = Array.isArray(data.skipped) ? data.skipped.map(String) : []
const skippedCount = Number(data.skippedCount) || skipped.length
return {
ran: Number(data.ran) || 0,
...(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)' } : {}),
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing was run twice' } : {}),
}
}
// ── Run console commands (§16.2) ─────────────────────────────────────────────
const RUN_SURE = 'I am sure I want to run these commands on the server'
/**
* Everything the step checks before touching anything, in the order an author
* fixes them: `{ ok, commands, teardown, server }` or a refusal core reads as
* permanent.
*/
async function checkRun(params) {
const commands = checkCommands(params.commands)
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
const teardown = checkCommands(params.teardown, { field: 'teardown commands', required: false })
if (!teardown.ok) return { ok: false, retry: false, error: teardown.error }
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` }
}
const confirm = checkConfirm(params, RUN_SURE)
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
const serverId = String(params.server || '').trim()
if (!serverId) return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
const found = await serverFor(serverId)
if (!found.ok) return found
return { ok: true, commands: commands.value, teardown: teardown.value, server: found.server }
}
/**
* Give back what a step left to undo: run its teardown commands, once.
*
* With a resource, the ledger's own copy goes to the server it names. With
* none — the step failed, or its answer was lost — every enabled server is
* asked to run what it kept under the step's key (D385); a server the step
* never reached, or one where nothing ran, runs nothing and says so. A server
* that cannot be asked leaves the row visible rather than guessing.
*/
async function revertRun({ resources, idempotencyKey }) {
const errors = []
const failed = []
if (!resources || resources.length === 0) {
if (!idempotencyKey) return { ok: true }
for (const server of await servers.listForPolling()) {
const result = await client.commandsRun(server, { key: teardownKey(idempotencyKey), teardownOf: idempotencyKey })
if (!result.ok) errors.push(transportError(server, result, 'teardown commands'))
else if (!result.data || result.data.kind !== 'command.ran') errors.push(refusal(server, result.data || {}, 'teardown commands').error)
}
return errors.length ? { ok: false, error: errors.join('; ') } : { ok: true }
}
for (const r of resources) {
const payload = r.payload || {}
const teardown = Array.isArray(payload.teardown) ? payload.teardown : []
if (!teardown.length) continue
const found = await serverFor(payload.serverId || refParts(r.ref).serverId)
if (!found.ok) {
failed.push(r.ref)
errors.push(found.error)
continue
}
const result = await client.commandsRun(found.server, { key: teardownKey(payload.key || r.ref), commands: teardown })
if (!result.ok) {
failed.push(r.ref)
errors.push(transportError(found.server, result, 'teardown commands'))
continue
}
// A 200 is not a success on this bridge: a refusal (`unknown-command`) comes
// back as one, carrying `command.error`.
if (!result.data || result.data.kind !== 'command.ran') {
failed.push(r.ref)
errors.push(refusal(found.server, result.data || {}, 'teardown commands').error)
continue
}
log.info('teardown commands ran', { server: found.server.id, ref: r.ref, ran: Number(result.data.ran) || 0, repeat: !!result.data.repeat })
}
if (!failed.length) return { ok: true }
if (failed.length === resources.length) return { ok: false, error: errors.join('; ') }
return { ok: true, failed }
}
const commandRun = {
id: 'rust.command.run',
label: 'Run console commands',
description:
'Runs console commands you type, once, on one server, as the server console would: a plugin\'s own command, such as RaidableBases\' rbe, or a game command. Optional teardown commands run when the event ends or is cancelled. The site cannot know what a command does, so the step is rated the most dangerous class: it is off until an admin switches it on. The run log shows what the console answered; "it ran" is not "it worked".',
// The site cannot know what a typed command does (§16.2), so the most
// dangerous class — and with it core's default failure rule: abort the run.
risk: 'irreversible',
// The teardown is the ledger's: core reverts a ledgered step at the end of
// every run, which is exactly when the teardown commands should run.
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'The server the commands run on. One server per step; add a step per server to reach more.' },
{ name: 'commands', type: 'string', required: true, example: 'rbe 1', multiple: true, max: MAX_COMMANDS,
description: `The commands, one per entry, run in the order listed — at most ${MAX_COMMANDS}, ${COMMAND_MAX} characters each. Type each as you would at the server console. A command the server does not know stops the step there.` },
{ name: 'teardown', type: 'string', required: false, example: 'rbe stop', multiple: true, max: MAX_COMMANDS,
description: 'Optional commands run once when the event ends or is cancelled, such as a plugin\'s own "stop". They are kept as they were when the step ran.' },
{ name: 'confirm', type: 'boolean', required: true, example: false,
description: `${RUN_SURE}. Unticked, the step refuses in the dry run and when it runs.` },
],
// One unit per command, start and teardown together (§16.2).
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) + teardownCount(p.teardown) }),
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const step = await checkRun(params)
if (!step.ok) return step
if (verify) return { ok: true }
const { commands, teardown, server } = step
const name = server.name || server.id
const result = await client.commandsRun(server, { key: idempotencyKey, commands, ...(teardown.length ? { teardown } : {}) })
if (!result.ok) return transportFailure(server, result, 'console commands')
const data = result.data || {}
if (data.kind !== 'command.ran') return refusal(server, data, 'console commands')
log.info('console commands ran', { server: server.id, run: runId, step: stepId, ran: Number(data.ran) || 0, repeat: !!data.repeat })
const resources = teardown.length
? [{ kind: TEARDOWN_KIND, ref: entitlementRef(server.id, runId, stepId), payload: { serverId: server.id, key: idempotencyKey, teardown } }]
: []
return {
ok: true,
...(resources.length ? { resources } : {}),
detail: {
server: name,
...replyDetail(data),
...(teardown.length ? { teardown: `${teardown.length} command(s) run when the event ends` } : {}),
},
}
},
revert: revertRun,
// 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 }) {
return { ok: true, inForce: (resources || []).map((r) => r.ref) }
},
}
// ── Reward by console command (§16.3) ────────────────────────────────────────
const REWARD_SURE = 'I am sure I want to run these commands for every recipient'
/**
* The people a mode picks, by Steam id: `{ ok, people }`, each `{ steamId, name
* }`. No website account is needed (§16.3): a command goes by Steam id. `linked`
* reads no tally: every linked player of the server.
*/
async function recipientsOf({ server, mode, count, runId, idempotencyKey }) {
const name = server.name || server.id
if (mode === 'linked') {
// One past the bound, so "too many" is a sentence and not a silent cut.
const linked = await linksDb.linkedOnServer(server.id, MAX_RECIPIENTS + 1)
if (linked.length > MAX_RECIPIENTS) {
return { ok: false, retry: false, error: `${name} has more than ${MAX_RECIPIENTS} linked players, and a reward runs commands for at most ${MAX_RECIPIENTS} in one step — reward from a tally instead` }
}
const seen = new Set()
const people = []
for (const r of linked) {
const steamId = String(r.steamId)
if (seen.has(steamId)) continue
seen.add(steamId)
people.push({ steamId, name: r.name ? String(r.name) : steamId })
}
return { ok: true, people }
}
const tally = await readTally(server, runId)
if (!tally.ok) return tally
const picked = pickRecipients(peopleOf(tally.data), mode, count, idempotencyKey)
const bound = Math.min(Number(tally.data.maxRecipients) || MAX_RECIPIENTS, MAX_RECIPIENTS)
if (picked.length > bound) {
return { ok: false, retry: false, error: `${picked.length} people qualify, and ${name} runs reward commands for at most ${bound} in one step — pick a count-based mode or a higher bar` }
}
return { ok: true, people: picked.map((p) => ({ steamId: p.steamId, name: p.name })) }
}
const commandReward = {
id: 'rust.command.reward',
label: 'Reward by console command',
description:
'Runs console commands you type once for each person a mode picks from this run\'s tally — or every linked player of the server — with {steamid} and {name} filled in for each, such as "sr add {steamid} 100". Only people on the server when it runs are rewarded; anyone offline is skipped and named in the run log. Nothing is taken back when the event ends.',
// Irreversible, as `rust.perm.give` is (D346): core reverts a ledgered step at
// the end of every run, and a reward command cannot be run backwards.
risk: 'irreversible',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'The server the commands run on.' },
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
description: 'Who is rewarded: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
{ name: 'count', type: 'float', required: false, example: 3,
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
{ name: 'commands', type: 'string', required: true, example: 'sr add {steamid} 100', multiple: true, max: MAX_COMMANDS,
description: `The commands, run in order for each recipient — at most ${MAX_COMMANDS}, ${COMMAND_MAX} characters each. {steamid} is the recipient's Steam id; {name} is their name, passed as one quoted argument (do not put quotes around it).` },
{ name: 'confirm', type: 'boolean', required: true, example: false,
description: `${REWARD_SURE}. Unticked, the step refuses in the dry run and when it runs.` },
],
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) * pricedPeople(p) }),
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const commands = checkCommands(params.commands)
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
const confirm = checkConfirm(params, REWARD_SURE)
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
const mode = oneOf(params.recipients, MODES)
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
const count = checkCount(mode, params.count)
if (!count.ok) return { ok: false, retry: false, error: count.error }
const serverId = String(params.server || '').trim()
if (!serverId) return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
const found = await serverFor(serverId)
if (!found.ok) return found
const server = found.server
const name = server.name || server.id
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
return { ok: false, retry: false, error: `${name} runs reward commands for at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
}
if (verify) return { ok: true }
const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey })
if (!picked.ok) return picked
const base = {
server: name,
mode,
...(count.value === null ? {} : { count: count.value }),
people: picked.people.length,
}
// Nobody qualified: nothing to send, and nothing for the bridge to remember.
if (!picked.people.length) return { ok: true, detail: { ...base, ran: 0, note: 'nobody qualified, so no command ran' } }
const result = await client.commandsRun(server, { key: idempotencyKey, commands: commands.value, recipients: picked.people })
if (!result.ok) return transportFailure(server, result, 'reward commands')
const data = result.data || {}
if (data.kind !== 'command.ran') return refusal(server, data, 'reward commands')
log.info('reward commands ran', { server: server.id, run: runId, step: stepId, mode, people: picked.people.length, rewarded: Number(data.rewarded) || 0, repeat: !!data.repeat })
return {
ok: true,
detail: {
...base,
rewarded: Number(data.rewarded) || 0,
...replyDetail(data),
},
}
},
}
const ACTIONS = [commandRun, commandReward]
module.exports = {
MAX_COMMANDS,
COMMAND_MAX,
TEARDOWN_KIND,
PERMANENT,
BUDGETS,
ACTIONS,
checkCommands,
checkConfirm,
teardownKey,
replyDetail,
}

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 }

199
server/eventGuardedCrate.js Normal file
View File

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

View File

@@ -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')
@@ -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)) {
@@ -1356,17 +1375,23 @@ const OPTION_SOURCES = [
]),
{
// 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')
@@ -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,
@@ -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 }

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,

97
server/guardedCrate.js Normal file
View File

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

View File

@@ -61,7 +61,10 @@ module.exports = function register(ctx, api) {
const eventRewards = require('./eventRewards')
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')
const boot = require('./boot')
const commands = require('./commands')
@@ -155,6 +158,16 @@ 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)
// 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
@@ -178,9 +191,12 @@ module.exports = function register(ctx, api) {
//
// Calling one of Rust's own world events (PLAN_REDESIGNS.md §14.3.1, protocol
// 14) beside them: kept when the run ends, on a budget of its own. Starting a
// Dangerous Treasures event (§14.3.4) likewise, on its own budget.
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS, ...eventDangerousTreasures.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS, ...eventDangerousTreasures.ACTIONS])
// 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; 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
@@ -201,6 +217,8 @@ module.exports = function register(ctx, api) {
...eventPermRewards.OPTION_SOURCES,
...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
@@ -219,10 +237,10 @@ 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,
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.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,
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. */
@@ -270,6 +290,7 @@ module.exports = {
isPlausiblePath,
shapeCatalogue,
shapeFile,
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

@@ -153,6 +153,10 @@ const TITLE_SUMS = {
dropsLooted: 'drops_looted',
// Protocol 14 (D375): Treasure Hunter.
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. */
@@ -354,6 +358,10 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
dropsLooted: 'drops_looted',
// Protocol 14 (D375): Dangerous Treasures won, Treasure Hunter's column.
treasuresWon: 'treasures_won',
// Protocol 14 (D375): guarded crates cleared, Warden's column.
guardedWon: 'guarded_won',
// Protocol 14 (§11.4, D202): RaidableBases bases completed, Warlord's column.
raidsCompleted: 'raids_completed',
}[sort] || 'kills'
const params = [serverId]
@@ -381,6 +389,8 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
SUM(s.crates_hacked) AS cratesHacked,
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

View File

@@ -16,6 +16,8 @@ const catalogue = require('../../catalogue')
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')
@@ -71,20 +73,23 @@ async function recent({ serverId, admin = false, staff = false, presence = false
})
const shaped = rows.map(shape)
if (!shaped.some((r) => r.kind === 'world.event' || dynamicPvp.KINDS.includes(r.kind) || dangerousTreasures.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
// credit by the server's rule — and given a grid from the map's geometry.
// Dynamic PVP's zone lines (§14.3.3) are read the same way for their grid and
// position, and Dangerous Treasures' (§14.3.4) for their grid, the winner's
// name and the Steam ids. Both reads only when the page holds one of them at all.
// name and the Steam ids, and Guarded Crate's (§14.3.5) alike. Both reads only
// when the page holds one of them at all.
const view = await worldView(serverId, { staff: staff || admin, presence })
return shaped
.map((row) => {
if (row.kind === 'world.event') return worldRow(row, view)
if (dynamicPvp.KINDS.includes(row.kind)) return pvpRow(row, view)
if (dangerousTreasures.isKind(row.kind)) return dtRow(row, view)
if (guardedCrate.isKind(row.kind)) return gcRow(row, view)
if (raidableBases.isKind(row.kind)) return raidRow(row, view)
return row
})
.filter(Boolean)
@@ -116,6 +121,16 @@ function dtRow(row, view) {
return { ...row, steamId: view.staff ? row.steamId : null, frame: dangerousTreasures.shapeFor(row.frame, view) }
}
/** One Guarded Crate row for a viewer (§14.3.5). The row's own Steam id, the clearer's, is staff's. */
function gcRow(row, view) {
return { ...row, steamId: view.staff ? row.steamId : null, frame: guardedCrate.shapeFor(row.frame, view) }
}
/** One 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)
@@ -183,6 +198,10 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, pre
dropsLooted: Number(r.dropsLooted) || 0,
// Protocol 14 (D375): Treasure Hunter's count.
treasuresWon: Number(r.treasuresWon) || 0,
// Protocol 14 (D375): Warden's count.
guardedWon: Number(r.guardedWon) || 0,
// 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

View File

@@ -286,6 +286,71 @@ 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 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 +391,7 @@ module.exports = {
unlinkOwned,
unlinkAnyOwner,
unlinkFromGame,
checkSteamSignin,
linkFromSteamSignin,
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

@@ -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

@@ -275,10 +275,73 @@ async function dtReportFor(serverId) {
return rows[0] || null
}
/**
* What one server's last status said about Guarded Crate (with NpcSpawn and its
* event names) and its console (protocol 14, PLAN_REDESIGNS §14.3.5):
* `{ guardedCrate, console }`, as `dtReportFor`'s are. Null for a server with no
* state yet.
*/
async function gcReportFor(serverId) {
const rows = await core.query(
`SELECT JSON_EXTRACT(raw, '$.integrations.guardedCrate') AS guardedCrate,
JSON_EXTRACT(raw, '$.console') AS console
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/** `gcReportFor` for every enabled server, with its id and name: the event list a step offers. */
async function gcReports() {
return core.query(
`SELECT s.id AS serverId, s.name AS name,
JSON_EXTRACT(st.raw, '$.integrations.guardedCrate') AS guardedCrate,
JSON_EXTRACT(st.raw, '$.console') AS console
FROM ${SERVERS} s
JOIN ${STATE} st ON st.server_id = s.id
WHERE s.enabled = 1
ORDER BY s.sort_order ASC, s.id ASC`,
)
}
/**
* 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,
getServer,
upsertServer,

View File

@@ -22,7 +22,8 @@ const crypto = require('node:crypto')
* The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three
* 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), plus
* Treasures won (§14.3.4, Treasure Hunter), guarded crates cleared (§14.3.5,
* 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.
*
@@ -72,6 +73,10 @@ const STATS = {
dropslooted: { label: 'Airdrops looted first', title: 'Scavenger', source: { sum: 'drops_looted' } },
// Protocol 14, Dangerous Treasures (D375): a chest counts for the player who emptied it.
treasureswon: { label: 'Dangerous Treasures won', title: 'Treasure Hunter', source: { sum: 'treasures_won' } },
// Protocol 14, Guarded Crate (D375): a crate counts for the player who killed its last guard.
guardedwon: { label: 'Guarded crates cleared', title: 'Warden', source: { sum: 'guarded_won' } },
// 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

@@ -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

@@ -323,7 +323,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 +347,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

@@ -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

@@ -13,6 +13,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 economics = require('../../model/economics/economics.model')
const events = require('../../model/events/events.model')
const map = require('../../model/map/map.model')
@@ -123,6 +125,10 @@ async function listLeaderboard(req, res) {
// Treasure Hunter's column (D375): on a server that reports Dangerous
// Treasures, and on any page whose rows already count a treasure won.
const dt = dangerousTreasures.reportOf(await serversDb.dtReportFor(req.params.id).catch(() => null))
// Warden's column (D375), likewise for Guarded Crate.
const gc = guardedCrate.reportOf(await serversDb.gcReportFor(req.params.id).catch(() => null))
// 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([
@@ -145,6 +151,8 @@ async function listLeaderboard(req, res) {
leaderboard: rows.map((row) => ({ ...row, titles: held.get(row.steamId) || [] })),
economics: withBalance,
dangerousTreasures: Boolean((dt.dangerousTreasures && dt.dangerousTreasures.loaded) || rows.some((r) => r.treasuresWon > 0)),
guardedCrate: Boolean((gc.guardedCrate && gc.guardedCrate.loaded) || rows.some((r) => r.guardedWon > 0)),
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.'
// #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.'
// #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, 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,

View File

@@ -443,6 +443,44 @@ const worldCall = (server, body) => request(server, '/world/call', { method: 'PO
*/
const dtStart = (server, body) => request(server, '/dt/start', { method: 'POST', body })
/**
* Place one guarded crate for a run's step (protocol 14, §20.9): `{ key, event }`
* and a location as a world call takes one (`monument` and offsets, or `x` and
* `z`). `data.kind` is `gc.placed` (with `event`, `x`, `z`, and `repeat: true`
* for a key the plugin has seen) or `gc.error` with a reason, `npcspawn-missing`
* among them (D364). The crate is kept when the run ends (D373): there is no revert.
*/
const gcPlace = (server, body) => request(server, '/gc/place', { method: 'POST', body })
/**
* 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
* online recipient with `{steamid}` and `{name}` filled in by the plugin; `{
* key, teardownOf }` runs the teardown the plugin kept under another key
* (D385). `data.kind` is `command.ran` (`ran`, `rewarded`, `replies`,
* `skipped`, `skippedCount`, and `repeat: true` for a key the plugin has seen)
* or `command.error` with a reason, `unknown-command` among them.
*/
const commandsRun = (server, body) => request(server, '/commands/run', { method: 'POST', body })
/**
* Give back what a run owns: named ids, else everything under a key, else the
* whole run. `data` lists `removed`, `gone` (already not there — a success)
@@ -581,6 +619,10 @@ module.exports = {
worldPlace,
worldCall,
dtStart,
gcPlace,
raidSpawn,
raidCheck,
commandsRun,
worldRevert,
tallyOpen,
tallySnapshot,

View File

@@ -275,6 +275,65 @@ module.exports = {
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.4): where loaded, its treasure events reach the feed, the map and Treasure Hunter, and a step may start one (where `console.plugins` is true).',
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '3.0.0' } },
},
guardedCrate: {
type: 'object',
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.5): where loaded, its guarded crates reach the feed, the map and Warden, and a step may place one (where NpcSpawn is loaded and `console.plugins` is true). `events` are the event names in its data file, absent when the file could not be read.',
properties: {
loaded: { type: 'boolean', example: true },
version: { type: 'string', example: '2.0.17' },
npcSpawn: {
type: 'object',
description: 'NpcSpawn, which its guards need (D364).',
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '3.4.8' } },
},
events: { type: 'array', items: { type: 'string' }, example: ['Easy', 'Medium', 'Hard', 'Elite'] },
},
},
zLevels: {
type: 'object',
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: {
@@ -599,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 },
@@ -787,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' },
@@ -802,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 } },
},
@@ -940,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: {
@@ -1037,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

@@ -166,6 +166,9 @@ 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 },
}
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

@@ -163,6 +163,20 @@ test('the classification covers exactly the event kinds the protocol defines, th
'dt.started',
'dt.won',
'dt.ended',
// Protocol 14 (§20.9): Guarded Crate. The landing and the clearing are
// public, read per viewer by `guardedCrate.js`; the timeout is staff's
// (PLAN_REDESIGNS §14.3.5).
'gc.started',
'gc.ended',
'gc.failed',
// 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())
@@ -192,6 +206,11 @@ test('the classification covers exactly the event kinds the protocol defines, th
assert.equal(catalogue.isPresence(kind), false)
}
assert.equal(catalogue.isPublic('dt.ended'), false, 'a treasure event ending is a staff line')
for (const kind of ['gc.started', 'gc.ended']) {
assert.equal(catalogue.isPublic(kind), true)
assert.equal(catalogue.isPresence(kind), false)
}
assert.equal(catalogue.isPublic('gc.failed'), false, 'a guarded crate timing out is a staff line')
})
test('every kind that names a player who was on is behind the presence setting', () => {
@@ -217,6 +236,8 @@ test('every kind that names a player who was on is behind the presence setting',
// And what is left names nobody — a world event included, once `worldEvents.shapeFor`
// has taken its names off for a viewer below the presence audience (D376).
// Dynamic PVP's opening names nobody at all, and a Dangerous Treasures win names
// nobody once `dangerousTreasures.shapeFor` has read it for such a viewer.
assert.deepEqual(catalogue.kindsFor({}).sort(), ['dt.started', 'dt.won', 'npc.boss.spawned', 'pvp.zone.created', 'server.initialized', 'server.shutdown', 'server.wipe', 'world.event'])
// nobody once `dangerousTreasures.shapeFor` has read it for such a viewer, nor
// 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

@@ -0,0 +1,300 @@
// ── Console commands as event steps and rewards (PLAN_REDESIGNS §16, D382–D385) ──
//
// Each test is one way the feature could look right and be wrong:
//
// both steps are irreversible, on one `rust.commands` budget, and run is the
// one with a revert (its teardown); the price counts commands, teardown
// included, and commands × people for a reward
// the commands are core's list, at most ten, 512 characters, one line each
// unticked, both steps refuse in the dry run (D384's bound is the toggle)
// {steamid} and {name} are refused in a step that has nobody to fill them
// the run sends the key and the teardown with it (D385), files the ledger's
// own copy of the teardown, and shows the console's replies
// a misspelled command is permanent `unknown-command`, saying what ran before
// a reward sends Steam ids (no website account needed), never a site-side
// filled command, and names who was skipped as offline (D383)
// a revert runs the ledger's copy once, under its own key; one with only the
// key asks every enabled server to run what it kept (D385)
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 client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
const linksDb = require('../model/links/links.db')
const rewards = require('../eventRewards')
const cmds = require('../eventCommands')
const ok = (data) => ({ ok: true, status: 'ok', data })
const A = '76561198000000001'
const B = '76561198000000002'
const C = '76561198000000003'
const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
const run = cmds.ACTIONS.find((a) => a.id === 'rust.command.run')
const reward = cmds.ACTIONS.find((a) => a.id === 'rust.command.reward')
const person = (steamId, score, name) => ({ steamId, name: name || `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 })
/** Every call the steps make, recorded; `answer` is what the bridge says to each. */
function stub(t, { answer = () => ok({ kind: 'command.ran', ran: 1, replies: [] }), people = [], linked = [], enabled = [ROW] } = {}) {
const calls = { sent: [] }
const saved = {
getServer: serversDb.getServer,
commandsRun: client.commandsRun,
tallySnapshot: client.tallySnapshot,
linkedOnServer: linksDb.linkedOnServer,
listForPolling: servers.listForPolling,
}
serversDb.getServer = async (id) => enabled.find((s) => s.id === id) || null
client.commandsRun = async (server, body) => {
calls.sent.push({ server: server.id, body })
return answer(body, server)
}
client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 })
linksDb.linkedOnServer = async () => linked
servers.listForPolling = async () => enabled
t.after(() => {
Object.assign(serversDb, { getServer: saved.getServer })
Object.assign(client, { commandsRun: saved.commandsRun, tallySnapshot: saved.tallySnapshot })
Object.assign(linksDb, { linkedOnServer: saved.linkedOnServer })
Object.assign(servers, { listForPolling: saved.listForPolling })
})
return calls
}
const RUN = { server: 'main', commands: ['rbe 1', 'say a raid base is up'], teardown: ['rbe stop'], confirm: true }
const REWARD = { server: 'main', recipients: 'everyone', commands: ['sr add {steamid} 100'], confirm: true }
const CTX = { runId: 7, stepId: 3, idempotencyKey: 'k'.repeat(40) }
// ── The declarations ───────────────────────────────────────────────────────
test('both steps are irreversible on one budget; only the run has a teardown to revert', () => {
assert.deepStrictEqual([run.risk, run.reversible, typeof run.revert, typeof run.reconcile], ['irreversible', 'ledger', 'function', 'function'])
assert.deepStrictEqual([reward.risk, reward.reversible, reward.revert], ['irreversible', 'none', undefined])
assert.deepStrictEqual(cmds.BUDGETS.map((b) => b.id), ['rust.commands'])
// One unit per command, start and teardown together (§16.2)…
assert.deepStrictEqual(run.cost(RUN), { 'rust.commands': 3 })
assert.deepStrictEqual(run.cost({ commands: ['rbe 1'] }), { 'rust.commands': 1 })
// …and commands × people for a reward, the bound for a mode that is not a count (§16.3).
assert.deepStrictEqual(reward.cost({ recipients: 'top', count: 3, commands: ['a', 'b'] }), { 'rust.commands': 6 })
assert.deepStrictEqual(reward.cost({ recipients: 'everyone', commands: ['a'] }), { 'rust.commands': rewards.MAX_RECIPIENTS })
// The switchboard prices the declared example, a list of one.
assert.deepStrictEqual(run.cost({ commands: run.params.find((p) => p.name === 'commands').example }), { 'rust.commands': 1 })
})
test('the commands are core\'s list of at most ten, and confirm is a required toggle', () => {
for (const action of [run, reward]) {
const list = action.params.find((p) => p.name === 'commands')
assert.deepStrictEqual([list.type, list.multiple, list.max, list.required], ['string', true, cmds.MAX_COMMANDS, true])
assert.strictEqual(Array.isArray(list.example), false)
assert.strictEqual(list.source, undefined, 'a free text box: no allowlist (D384)')
const confirm = action.params.find((p) => p.name === 'confirm')
assert.deepStrictEqual([confirm.type, confirm.required, confirm.example], ['boolean', true, false])
}
const teardown = run.params.find((p) => p.name === 'teardown')
assert.deepStrictEqual([teardown.multiple, teardown.required], [true, false])
assert.strictEqual(reward.params.find((p) => p.name === 'teardown'), undefined)
})
test('a command is trimmed, one line, at most 512 characters, and eleven are refused', () => {
assert.deepStrictEqual(cmds.checkCommands([' rbe 1 ', '', 'say hi']).value, ['rbe 1', 'say hi'])
assert.deepStrictEqual(cmds.checkCommands('rbe 1').value, ['rbe 1'])
assert.match(cmds.checkCommands([]).error, /at least one command/)
assert.deepStrictEqual(cmds.checkCommands([], { required: false }).value, [])
assert.match(cmds.checkCommands(['say a\nquit']).error, /one line/)
assert.match(cmds.checkCommands(['x'.repeat(513)]).error, /at most 512 characters/)
assert.ok(cmds.checkCommands(['x'.repeat(512)]).ok)
assert.match(cmds.checkCommands(Array.from({ length: 11 }, (_, i) => `c${i}`)).error, /at most 10 commands, not 11/)
})
// ── Refusals before anything is sent ───────────────────────────────────────
test('unticked, both steps refuse in the dry run, and send nothing', async (t) => {
const calls = stub(t)
const a = await run.perform({ ...CTX, params: { ...RUN, confirm: false }, verify: true })
assert.deepStrictEqual([a.ok, a.retry], [false, false])
assert.match(a.error, /I am sure I want to run these commands on the server/)
// A string is not a tick.
const b = await reward.perform({ ...CTX, params: { ...REWARD, confirm: 'true' }, verify: true })
assert.deepStrictEqual([b.ok, b.retry], [false, false])
assert.match(b.error, /for every recipient/)
assert.strictEqual(calls.sent.length, 0)
})
test('{steamid} and {name} are refused in a run, teardown included: nobody fills them there', async (t) => {
stub(t)
const a = await run.perform({ ...CTX, params: { ...RUN, commands: ['sr add {steamid} 1'] }, verify: true })
assert.deepStrictEqual([a.ok, a.retry], [false, false])
assert.match(a.error, /Reward by console command/)
const b = await run.perform({ ...CTX, params: { ...RUN, teardown: ['say bye {Name}'] }, verify: true })
assert.strictEqual(b.ok, false)
})
test('a dry run that passes checks the server and sends nothing', async (t) => {
const calls = stub(t)
assert.deepStrictEqual(await run.perform({ ...CTX, params: RUN, verify: true }), { ok: true })
assert.deepStrictEqual(await reward.perform({ ...CTX, params: REWARD, verify: true }), { ok: true })
const off = await run.perform({ ...CTX, params: { ...RUN, server: 'nowhere' }, verify: true })
assert.deepStrictEqual([off.ok, off.retry], [false, false])
assert.strictEqual(calls.sent.length, 0)
})
// ── Run console commands ───────────────────────────────────────────────────
test('a run sends its key and its teardown, and files the ledger\'s own copy of the teardown (D385)', async (t) => {
const calls = stub(t, {
answer: () => ok({ kind: 'command.ran', ran: 2, replies: [{ command: 'rbe 1', reply: 'Raid base spawned' }, { command: 'say a raid base is up', reply: '' }] }),
})
const result = await run.perform({ ...CTX, params: RUN })
assert.deepStrictEqual(calls.sent, [{ server: 'main', body: { key: CTX.idempotencyKey, commands: RUN.commands, teardown: ['rbe stop'] } }])
assert.strictEqual(result.ok, true)
assert.deepStrictEqual(result.resources, [
{ kind: cmds.TEARDOWN_KIND, ref: 'main:7:3', payload: { serverId: 'main', key: CTX.idempotencyKey, teardown: ['rbe stop'] } },
])
assert.strictEqual(result.detail.ran, 2)
assert.deepStrictEqual(result.detail.replies[0], { command: 'rbe 1', reply: 'Raid base spawned' })
assert.match(result.detail.teardown, /1 command/)
})
test('a run with no teardown leaves nothing to undo and sends none', async (t) => {
const calls = stub(t)
const result = await run.perform({ ...CTX, params: { ...RUN, teardown: [] } })
assert.strictEqual(result.ok, true)
assert.strictEqual(result.resources, undefined)
assert.strictEqual('teardown' in calls.sent[0].body, false)
})
test('a repeat is answered from the first attempt, and says so', async (t) => {
stub(t, { answer: () => ok({ kind: 'command.ran', ran: 2, repeat: true, replies: [] }) })
const result = await run.perform({ ...CTX, params: RUN })
assert.strictEqual(result.ok, true)
assert.strictEqual(result.detail.repeat, true)
assert.match(result.detail.note, /nothing was run twice/)
})
test('a misspelled command is permanent unknown-command, saying how many ran before it', async (t) => {
stub(t, { answer: () => ok({ kind: 'command.error', reason: 'unknown-command', message: "the server does not know the command 'rbee'", ran: 1 }) })
const result = await run.perform({ ...CTX, params: RUN })
assert.deepStrictEqual([result.ok, result.retry], [false, false])
assert.match(result.error, /^Main: the server does not know the command 'rbee' \(1 ran before it\)$/)
})
test('events switched off on the server is permanent; a world still loading and a lost answer are not', async (t) => {
let answer = ok({ kind: 'world.error', reason: 'events-disabled', message: 'events are switched off on this server' })
stub(t, { answer: () => answer })
const off = await run.perform({ ...CTX, params: RUN })
assert.deepStrictEqual([off.ok, off.retry], [false, false])
answer = ok({ kind: 'world.error', reason: 'not-ready', message: 'the server is still loading its world' })
const loading = await run.perform({ ...CTX, params: RUN })
assert.deepStrictEqual([loading.ok, loading.retry], [false, undefined])
answer = { ok: false, status: 'http-504' }
const lost = await run.perform({ ...CTX, params: RUN })
assert.deepStrictEqual([lost.ok, lost.retry], [false, undefined])
assert.match(lost.error, /did not answer/)
})
// ── The teardown (§16.2, D385) ─────────────────────────────────────────────
test('a revert runs the ledger\'s copy once, under its own key, on the server it names', async (t) => {
const calls = stub(t, { answer: () => ok({ kind: 'command.ran', ran: 1, replies: [] }) })
const resource = { kind: cmds.TEARDOWN_KIND, ref: 'main:7:3', payload: { serverId: 'main', key: CTX.idempotencyKey, teardown: ['rbe stop'] } }
assert.deepStrictEqual(await run.revert({ runId: 7, resources: [resource], idempotencyKey: CTX.idempotencyKey }), { ok: true })
assert.deepStrictEqual(calls.sent, [{ server: 'main', body: { key: cmds.teardownKey(CTX.idempotencyKey), commands: ['rbe stop'] } }])
})
test('a teardown the server refuses leaves the row failed and visible', async (t) => {
stub(t, { answer: () => ok({ kind: 'command.error', reason: 'unknown-command', message: "the server does not know the command 'rbe'", ran: 0 }) })
const resource = { kind: cmds.TEARDOWN_KIND, ref: 'main:7:3', payload: { serverId: 'main', key: CTX.idempotencyKey, teardown: ['rbe stop'] } }
const result = await run.revert({ runId: 7, resources: [resource], idempotencyKey: CTX.idempotencyKey })
assert.strictEqual(result.ok, false)
assert.match(result.error, /does not know the command 'rbe'/)
})
test('a revert with only the key asks every enabled server to run what it kept (D385)', async (t) => {
const PVE = { ...ROW, id: 'pve', name: 'PvE' }
const calls = stub(t, { enabled: [ROW, PVE], answer: () => ok({ kind: 'command.ran', ran: 0, replies: [] }) })
assert.deepStrictEqual(await run.revert({ runId: 7, resources: [], idempotencyKey: CTX.idempotencyKey }), { ok: true })
assert.deepStrictEqual(calls.sent.map((c) => c.server), ['main', 'pve'])
for (const c of calls.sent) assert.deepStrictEqual(c.body, { key: cmds.teardownKey(CTX.idempotencyKey), teardownOf: CTX.idempotencyKey })
})
test('a server that cannot be asked about a lost answer keeps the row visible', async (t) => {
stub(t, { answer: () => ({ ok: false, status: 'http-503' }) })
const result = await run.revert({ runId: 7, resources: [], idempotencyKey: CTX.idempotencyKey })
assert.strictEqual(result.ok, false)
assert.match(result.error, /no game connected/)
})
test('a teardown not yet run is always in force: the site cannot look', async () => {
const resources = [{ ref: 'main:7:3' }, { ref: 'pve:7:4' }]
assert.deepStrictEqual(await run.reconcile({ runId: 7, resources }), { ok: true, inForce: ['main:7:3', 'pve:7:4'] })
})
// ── Reward by console command ──────────────────────────────────────────────
test('a reward sends Steam ids and names to the bridge, which fills the placeholders, not the site', async (t) => {
const calls = stub(t, {
people: [person(A, 5, 'Some "One"'), person(B, 0), person(C, 2)],
answer: () => ok({ kind: 'command.ran', ran: 1, rewarded: 1, skipped: ['p3'], skippedCount: 1, replies: [{ command: `sr add ${A} 100`, reply: 'added', steamId: A }] }),
})
const result = await reward.perform({ ...CTX, params: REWARD })
// Everyone who scored: A and C, never B. The command goes untouched.
assert.deepStrictEqual(calls.sent, [{
server: 'main',
body: { key: CTX.idempotencyKey, commands: ['sr add {steamid} 100'], recipients: [{ steamId: A, name: 'Some "One"' }, { steamId: C, name: 'p3' }] },
}])
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.strictEqual(result.resources, undefined)
})
test('linked reads no tally and needs no website user beyond the link', async (t) => {
const calls = stub(t, { linked: [{ steamId: A, userId: 1, name: 'Ann' }, { steamId: B, userId: 2, name: null }] })
const result = await reward.perform({ ...CTX, params: { ...REWARD, recipients: 'linked' } })
assert.strictEqual(result.ok, true)
assert.deepStrictEqual(calls.sent[0].body.recipients, [{ steamId: A, name: 'Ann' }, { steamId: B, name: B }])
})
test('nobody qualifying runs nothing, and the bridge is not asked', async (t) => {
const calls = stub(t, { people: [person(A, 0)] })
const result = await reward.perform({ ...CTX, params: REWARD })
assert.strictEqual(result.ok, true)
assert.strictEqual(result.detail.ran, 0)
assert.strictEqual(calls.sent.length, 0)
})
test('more than a hundred is refused before anything runs', async (t) => {
const many = Array.from({ length: 101 }, (_, i) => ({ steamId: String(76561198000001000n + BigInt(i)), userId: i + 1, name: `n${i}` }))
const calls = stub(t, { linked: many })
const result = await reward.perform({ ...CTX, params: { ...REWARD, recipients: 'linked' } })
assert.deepStrictEqual([result.ok, result.retry], [false, false])
assert.match(result.error, /more than 100 linked players/)
assert.strictEqual(calls.sent.length, 0)
const top = await reward.perform({ ...CTX, params: { ...REWARD, recipients: 'top', count: 101 }, verify: true })
assert.strictEqual(top.ok, false)
})
test('the run log shows a bounded, plain view of the answer', () => {
const detail = cmds.replyDetail({ ran: '3', replies: [{ command: 'a', reply: 5 }], skipped: ['x'], skippedCount: 4, repeat: true })
assert.deepStrictEqual(detail.replies, [{ command: 'a', reply: '5' }])
assert.deepStrictEqual([detail.ran, detail.skippedCount, detail.repeat], [3, 4, true])
assert.deepStrictEqual(cmds.replyDetail({}), { ran: 0 })
})

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')

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,13 @@ 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('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+/)
@@ -158,14 +165,18 @@ test('the event verbs are registered, and every budget has a verb that spends it
'rust.announce',
'rust.backpack.size',
'rust.coins.pay',
'rust.command.reward',
'rust.command.run',
'rust.crate.place',
'rust.dt.start',
'rust.gc.place',
'rust.kit.entitle',
'rust.npc.place',
'rust.participation.collect',
'rust.participation.open',
'rust.perm.give',
'rust.perm.lend',
'rust.raidbase.spawn',
'rust.world.call',
'rust.zone.open',
])
@@ -173,12 +184,15 @@ test('the event verbs are registered, and every budget has a verb that spends it
'rust.announcements',
'rust.backpacks',
'rust.coins',
'rust.commands',
'rust.dt.events',
'rust.gc.crates',
'rust.grants',
'rust.npcs',
'rust.permissions',
'rust.permissions.kept',
'rust.prefabs',
'rust.raids',
'rust.world.calls',
'rust.zone.minutes',
])
@@ -206,8 +220,10 @@ test('the event verbs are registered, and every budget has a verb that spends it
// collect filed, coins paid (D335) and permissions given for good (D346) are
// not undone, and nor is a world event called (D354): a cargo ship or a drop
// may already hold players or their loot; nor a Dangerous Treasures event
// started, which DT despawns itself (§14.3.4).
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give', 'rust.world.call', 'rust.dt.start'])
// started, which DT despawns itself (§14.3.4); nor a guarded crate placed,
// which Guarded Crate cannot end alone (§14.3.5, D373); nor a reward run by
// console command, which no command can run backwards (§16.3).
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give', 'rust.world.call', 'rust.dt.start', 'rust.gc.place', 'rust.command.reward'])
for (const a of actions) {
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
// Every param source is one this module registers.

View File

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

View File

@@ -429,3 +429,53 @@ 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)
})
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')

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)
})

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,10 @@ const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...
// ── The categories ────────────────────────────────────────────────────────
test('twenty-seven categories carry a default title, and playtime carries none (D172, §11.2.2, D356, D375)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 27)
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')
assert.strictEqual(titles.STATS.crateshacked.title, 'Safecracker')

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

@@ -1948,7 +1948,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 +1970,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 +2387,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 +3203,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.",
"parameters": [
{
"name": "id",
@@ -3258,7 +3258,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.",
"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 +3282,7 @@
"name": "sort",
"in": "query",
"required": false,
"description": "kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, 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"
}
@@ -4921,6 +4921,8 @@
"crateshacked",
"dropslooted",
"treasureswon",
"guardedwon",
"raidscompleted",
"richest",
"playtime",
"profilekills"
@@ -5460,6 +5462,567 @@
}
}
}
},
"guardedCrate": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Protocol 14 (PLAN_REDESIGNS §14.3.5): where loaded, its guarded crates reach the feed, the map and Warden, and a step may place one (where NpcSpawn is loaded and `console.plugins` is true). `events` are the event names in its data file, absent when the file could not be read."
},
"properties": {
"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": "2.0.17"
}
}
},
"npcSpawn": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "NpcSpawn, which its guards need (D364)."
},
"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.4.8"
}
}
}
}
}
}
},
"events": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"example": {
"type": "array",
"example": [
"Easy",
"Medium",
"Hard",
"Elite"
],
"items": {
"type": "string"
}
}
}
}
}
}
}
},
"zLevels": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "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": {
"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.3.1"
}
}
}
}
}
}
},
"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
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
@@ -7694,6 +8257,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": {
@@ -8814,7 +9394,8 @@
"events",
"players",
"bases",
"pvp"
"pvp",
"raids"
],
"items": {
"type": "string"
@@ -8880,6 +9461,9 @@
"pvp": {
"$ref": "#/components/schemas/RustAudience"
},
"raids": {
"$ref": "#/components/schemas/RustAudience"
},
"mates": {
"type": "object",
"properties": {
@@ -9768,6 +10352,9 @@
},
"pvp": {
"$ref": "#/components/schemas/RustMapLayer"
},
"raids": {
"$ref": "#/components/schemas/RustMapLayer"
}
}
}
@@ -10523,6 +11110,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": {