Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 161457dbf3 | |||
| 5c9cefdbc2 | |||
| 1f4ad25edb | |||
| 228e178475 | |||
| 0cdb046d30 | |||
| 791f13235e | |||
| c61f9f29ec | |||
| 7a0d0842ba | |||
| bc529962b4 | |||
| c4729315cd | |||
| 55a9ea4649 | |||
| 2e80a21597 | |||
| 3741c954f5 | |||
| 3fac860949 | |||
| 42bd6db6ad | |||
| 3453bc1867 | |||
| 72726838b5 | |||
| 9c35906d0e | |||
| 7304d68d0a | |||
| 3aa142bf06 | |||
| d69c2108c6 | |||
| 8aff9b33a6 | |||
| 5decd5cc05 | |||
| 885485d153 | |||
| 47da3a2db8 | |||
| 8172454683 | |||
| b89039142e | |||
| f93ba86883 | |||
| 481837e937 | |||
| 49d9e42a23 | |||
| 5686d9fd17 | |||
| 619081ec65 | |||
| 9c82a0c437 | |||
| 8be7e5607e | |||
| da1d74bdff | |||
| 140bc67fe1 | |||
| bfe9b5b18d | |||
| 6802dbf5f9 | |||
| 549b3a5ffd | |||
| b5f7fa0f13 | |||
| e3cc95f8ab | |||
| 4af731288a | |||
| 176ded67a8 | |||
| e7d5c8ed1a | |||
| f65d96ebb6 | |||
| 11d32436a4 | |||
| aefd752372 | |||
| 63c82d5a2f | |||
| 89557ddf66 | |||
| e4591af906 | |||
| 69ec0f6c46 | |||
| 845caef817 | |||
| 1777cb6ce1 | |||
| a64ae68158 | |||
| 21e40053ab | |||
| d21a9e2c0d |
@@ -384,6 +384,22 @@ jobs:
|
||||
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||
SIZE="$(stat -c%s "dist/module-rust-${VERSION}.tar.gz")"
|
||||
|
||||
# The wire protocol this release speaks, for the public demo's wipe
|
||||
# (website/DEMO_MODE.md §7.5, DM12). The wipe picks the newest release
|
||||
# whose `protocol` EQUALS the Rust bundle's. The Rust link has no 409,
|
||||
# so a plugin on another protocol would mis-parse rather than be
|
||||
# refused. Read from the one declaration (`server/sidecarClient.js`);
|
||||
# a release that cannot find it fails here rather than ship a manifest
|
||||
# the wipe would skip. Core's installer reads only the fields it names
|
||||
# (modules/install.js), so the extra key changes nothing there.
|
||||
PROTOCOL="$(node -e '
|
||||
const src = require("fs").readFileSync("server/sidecarClient.js", "utf8");
|
||||
const m = /^const PROTOCOL_VERSION = (\d+)$/m.exec(src);
|
||||
if (!m) { console.error("no PROTOCOL_VERSION in server/sidecarClient.js"); process.exit(1); }
|
||||
console.log(m[1]);
|
||||
')"
|
||||
echo "protocol ${PROTOCOL}"
|
||||
|
||||
# The install manifest. Same shape as the installer's bundle JSON — a
|
||||
# per-asset sha256 fetched over HTTPS, no signatures — because that is
|
||||
# the model this project already has and a second one would be a second
|
||||
@@ -396,9 +412,10 @@ jobs:
|
||||
--arg artifact "module-rust-${VERSION}.tar.gz" \
|
||||
--arg sha256 "$SHA" \
|
||||
--argjson size "$SIZE" \
|
||||
--argjson protocol "$PROTOCOL" \
|
||||
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-rust-${VERSION}.tar.gz" \
|
||||
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size, protocol:$protocol}' \
|
||||
> "dist/module-rust-${VERSION}.json"
|
||||
|
||||
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||
|
||||
83
README.md
83
README.md
@@ -38,11 +38,13 @@ rows here; the website core never learns there is more than one.
|
||||
| Public | `GET /api/v1/public/rust/servers` — every server and what it last reported |
|
||||
| Public | `GET …/servers/:id` — one server, or a `404`; the only route under `:id` that can say a server does not exist |
|
||||
| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) |
|
||||
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed; each row carries the player's chat titles |
|
||||
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed; each row carries the player's chat titles, and on one wipe of a server with Economics their coin balance |
|
||||
| Public | `GET …/servers/:id/wipes` and `…/online` |
|
||||
| Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) |
|
||||
| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
|
||||
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
|
||||
| Player | `GET /api/v1/player/rust/economics` — your coin balances on every server, per linked account |
|
||||
| Player | `GET /api/v1/player/rust/backpacks?serverId=&steamId=` — your own backpack on one server, read live |
|
||||
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` — the `PUT` carries the wipe schedule |
|
||||
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
|
||||
| Admin | `PUT …/servers/:id/titles`, `GET …/servers/:id/integrations`, `GET/PUT /api/v1/admin/rust/voice` — chat titles, the optional mods a server has, and the announcement voice |
|
||||
@@ -82,7 +84,18 @@ forced wipe (first Thursday, 19:00 UK time) — plus an optional one-off date th
|
||||
computed wipe. It is computed on every read and never stored, so it cannot go stale after a wipe.
|
||||
The server list, the server page, the Android app and `/wipe` all show it.
|
||||
|
||||
**Two uMod plugins are optional, and the module works without either** (`docs/modules/rust/PLAN.md`
|
||||
**Rust's own world events need no plugin** (`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.1). The patrol
|
||||
helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the
|
||||
satellite and the Deep Sea reach the feed (a **World events** filter) and each is an engagement trigger
|
||||
(`rust.world.<event>`), so a notice or an event's phase can follow one. The event and its grid are public;
|
||||
a player's name in it only for the presence audience, a Steam id only for staff. **Who downs a helicopter
|
||||
or a Bradley** is the operator's rule per server — killing blow (the default), last hit, most damage, or
|
||||
everyone who hurt it — and the same rule writes the line and counts toward **Rotorbane** and
|
||||
**Tankbreaker**. **Safecracker** (locked crates hacked) and **Scavenger** (airdrops looted first) are
|
||||
title categories with leaderboard columns, and the **Call a world event** step (`rust.world.call`) calls
|
||||
one now, kept when the run ends: an airdrop or a patrol helicopter can be sent to a point.
|
||||
|
||||
**Four uMod plugins are optional, and the module works without any of them** (`docs/modules/rust/PLAN.md`
|
||||
§33). The bridge plugin's hard requirements are **Kits** and **ZoneManager**.
|
||||
|
||||
- **BetterChat** (LaserHydra, 5.2.15). With it: **chat titles** an operator sets per server — "top 3
|
||||
@@ -94,6 +107,72 @@ The server list, the server page, the Android app and `/wipe` all show it.
|
||||
- **PopupNotifications** (k1lly0u, 0.2.1). With it: `rust.announce` and a server's news posts can be
|
||||
a popup instead of a chat line. Without it a popup is refused with a sentence saying so, and chat
|
||||
works as before.
|
||||
- **Economics** (Wulf, 3.9.2). With it: the **Pay coins** event step (`rust.coins.pay`, kept once
|
||||
paid), each player's balance on their account page and in the app, staff deposit / withdraw / set
|
||||
from a user's admin page, a **Coins** leaderboard column and the **Magnate** title — both ranking
|
||||
only balances above the server's starting balance. Without it each of those says "Economics is not
|
||||
installed on this server" (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2).
|
||||
- **Backpacks** (WhiteThunder, 3.17.8). With it: staff open any player's backpack from their admin
|
||||
page and a player sees their own on the account page and in the app — read live, never stored, and
|
||||
never written — and the **Bigger backpack** event step (`rust.backpack.size`) lends one of the
|
||||
server's `backpacks.size.N` sizes until the run ends, pushed like a kit reward. Without it each of
|
||||
those says "Backpacks is not installed on this server" (`docs/modules/rust/PLAN_REDESIGNS.md` §11.3).
|
||||
- **TruePVE** (nivex, 2.4.6). With it: the **Open a zone** step (`rust.zone.open`) can say **No PvP
|
||||
protection in this zone**, or name one of the server's TruePVE rulesets, which holds for the zone's
|
||||
life and goes with it. A ruleset TruePVE does not have fails the step when it runs, with the name.
|
||||
Without it either one says "TruePVE is not installed on this server"
|
||||
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.2).
|
||||
- **Dynamic PVP** (HunterZ, 5.2.0; needs ZoneManager and TruePVE). With it: the PvP zones it opens
|
||||
around a downed patrol helicopter, a Bradley, the cargo ship and the rest show on the live map's
|
||||
**PvP zones** layer (public by default, set in Admin → Rust visibility) and as a public feed line —
|
||||
*"A PvP zone opened around the downed patrol helicopter · G7 · for 10 min"* — that names nobody.
|
||||
Its zones are its own: no step and no trigger (`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.3).
|
||||
- **Dangerous Treasures** (nivex, 3.0.0). With it: a treasure event starting and being won are public
|
||||
feed lines and triggers (*"Alice won the treasure · K12 · 3 players fought for it"*, the name only
|
||||
for the presence audience), a running chest is on the live map's world layer, the **Treasures**
|
||||
leaderboard column and the *Treasure Hunter* title count the chests a player emptied, and the step
|
||||
**Start a Dangerous Treasures event** opens one at a chosen difficulty where DT picks. In the first
|
||||
ten minutes after DT loads the step says "Dangerous Treasures is not ready yet" and tries again ten
|
||||
minutes later. Refused where DT is absent, or where the framework cannot run DT's console command
|
||||
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.4).
|
||||
- **Guarded Crate** (Bazz3l, 2.0.17), with **NpcSpawn** (Codefling) for its guards. With both: a
|
||||
guarded crate landing and being cleared are public feed lines and triggers (*"Alice cleared the
|
||||
guarded crate · M4"*, the name only for the presence audience), a running crate is on the live map's
|
||||
world layer, the **Crates cleared** leaderboard column and the *Warden* title count the crates a
|
||||
player cleared by killing the last guard, and the step **Place a guarded crate** places one of its
|
||||
events at a monument or a point. The crate is kept when the run ends — Guarded Crate cannot end one
|
||||
alone — and the run log says so. Refused where Guarded Crate or NpcSpawn is absent, where the event
|
||||
is not one the server has, or where the framework cannot run its console command
|
||||
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.5).
|
||||
- **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
|
||||
|
||||
@@ -32,23 +32,38 @@
|
||||
"commands",
|
||||
"configEdit.js",
|
||||
"core.js",
|
||||
"dangerousTreasures.js",
|
||||
"db",
|
||||
"demo.js",
|
||||
"demoSeed.js",
|
||||
"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"
|
||||
"titleSync.js",
|
||||
"worldEvents.js"
|
||||
],
|
||||
"root": [
|
||||
"engagement-triggers.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as.",
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change. **2026-10-26, the public demo (DEMO_MODE.md §3.4, §3.5, DM18):** this module declares demoLocks, reads ctx.demoMode, registers onDemoSeed (MODULE_API 1.18.0) and gives its two console-command actions demoRefuses (1.19.0), so coreApi is ^1.19.0. It was pinned to the head of website feature/demo-step-refusal (website#224) while that PR was open — a 1.18.0 core never asks demoRefuses — and is back on `main` at the sha #224 merged as (the same tree, so routes.manifest.json does not change).",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "f0e7d2aa2a4dbdd848f92648085181611b7418f0",
|
||||
"refName": "main @ MODULE_API 1.11.0 (website#209 merged)"
|
||||
"ref": "69998ec54854d46681def92985a2b299ee3582a6",
|
||||
"refName": "website main @ website#224 merge, MODULE_API 1.19.0"
|
||||
}
|
||||
|
||||
@@ -129,6 +129,18 @@ export const playerPermissions = {
|
||||
list: () => req('/player/rust/permissions'),
|
||||
}
|
||||
|
||||
// Your coin balances on every server, per linked account (PLAN_REDESIGNS §11.2.2):
|
||||
// live where a server answers, the last one it reported where it does not.
|
||||
export const playerEconomics = {
|
||||
list: () => req('/player/rust/economics'),
|
||||
}
|
||||
|
||||
// Your own backpack on one server, from one of your linked accounts
|
||||
// (PLAN_REDESIGNS §11.3.2): read live from the game, once per open.
|
||||
export const playerBackpacks = {
|
||||
read: (serverId, steamId) => req(`/player/rust/backpacks${query({ serverId, steamId })}`),
|
||||
}
|
||||
|
||||
// ── admin ─────────────────────────────────────────────────────────────────
|
||||
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
|
||||
// and a save that omits the field leaves the stored credential alone — so an
|
||||
@@ -323,6 +335,21 @@ export const adminUserPermissions = {
|
||||
),
|
||||
}
|
||||
|
||||
// The same panel's Economics half (§11.2.2): this person's coins, and staff's
|
||||
// hand on them. `key` is one per submission, so a resent form moves nothing.
|
||||
export const adminUserEconomics = {
|
||||
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/economics`),
|
||||
adjust: (userId, body) =>
|
||||
req(`/admin/users/${encodeURIComponent(userId)}/rust/economics`, { method: 'POST', body }),
|
||||
}
|
||||
|
||||
// …and its Backpacks half (§11.3.2): what this person carries, read live. There
|
||||
// is no write, here or in the game (R2).
|
||||
export const adminUserBackpacks = {
|
||||
read: (userId, serverId, steamId) =>
|
||||
req(`/admin/users/${encodeURIComponent(userId)}/rust/backpacks${query({ serverId, steamId })}`),
|
||||
}
|
||||
|
||||
// ── core's public event calendar ───────────────────────────────────────────
|
||||
// The one CORE route this module reads, and core published `runId` on it for
|
||||
// exactly this: a map marker holds a run id and nothing else, and the calendar is
|
||||
@@ -342,6 +369,8 @@ export default {
|
||||
playerServers,
|
||||
playerLinks,
|
||||
playerPermissions,
|
||||
playerEconomics,
|
||||
playerBackpacks,
|
||||
admin,
|
||||
adminPermissions,
|
||||
adminConfig,
|
||||
@@ -351,6 +380,8 @@ export default {
|
||||
playerNpcKills,
|
||||
adminUserLinks,
|
||||
adminUserPermissions,
|
||||
adminUserEconomics,
|
||||
adminUserBackpacks,
|
||||
coreEvents,
|
||||
BASE,
|
||||
}
|
||||
|
||||
153
client/src/components/Backpack.jsx
Normal file
153
client/src/components/Backpack.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
// ── A Backpacks backpack, read live (PLAN_REDESIGNS §11.3.2) ──────────────
|
||||
//
|
||||
// One component for both readers — staff on a user's admin page, a player on
|
||||
// their own account page — because the backpack is the same thing to both and
|
||||
// only the route differs. The caller hands over `read(serverId, steamId)`.
|
||||
//
|
||||
// **Opening reads once; nothing polls.** A read runs on the game's main thread
|
||||
// (§11.3.1), so the panel asks when somebody asks it to and not again until
|
||||
// they do. Nothing is stored on the site either.
|
||||
//
|
||||
// **No icons (D340).** The module has no item art (R9 kept it out of the asset
|
||||
// bridge), so a slot says the game's own English name, which the plugin sends.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { count, shortId } from '../lib/format.js'
|
||||
import { backpackPages, overflowCount, stackLabel } from '../lib/backpack.js'
|
||||
|
||||
const CELL = 92
|
||||
|
||||
/** One slot: its name, amount, and what rides inside it. */
|
||||
function Cell({ cell }) {
|
||||
const { stack, overflow } = cell
|
||||
const border = overflow ? '1px dashed #d89b3c' : '1px solid var(--line-soft)'
|
||||
const title = stack
|
||||
? [stackLabel(stack), stack.name ? `“${stack.name}”` : null, stack.skin ? `skin ${stack.skin}` : null, overflow ? 'past the capacity' : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: overflow
|
||||
? 'past the capacity'
|
||||
: `slot ${cell.slot + 1}`
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
title={title}
|
||||
style={{
|
||||
border,
|
||||
borderRadius: 4,
|
||||
minHeight: 58,
|
||||
padding: '4px 6px',
|
||||
fontSize: '0.72rem',
|
||||
lineHeight: 1.25,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
background: stack ? 'transparent' : 'var(--line-soft)',
|
||||
opacity: stack ? 1 : 0.35,
|
||||
}}
|
||||
>
|
||||
{stack && (
|
||||
<>
|
||||
<span style={{ color: 'var(--head)', overflowWrap: 'anywhere' }}>{stackLabel(stack)}</span>
|
||||
{stack.name && <span className="dim" style={{ overflowWrap: 'anywhere' }}>“{stack.name}”</span>}
|
||||
{(stack.contents || []).map((c) => (
|
||||
<span key={c.slot} className="dim" style={{ overflowWrap: 'anywhere' }}>+ {stackLabel(c)}{c.amount > 1 ? ` ×${count(c.amount)}` : ''}</span>
|
||||
))}
|
||||
<span style={{ display: 'flex', justifyContent: 'space-between', gap: 4 }}>
|
||||
<span className="dim">{stack.skin ? 'skinned' : ''}</span>
|
||||
{stack.amount > 1 && <strong>×{count(stack.amount)}</strong>}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The grid itself, one block per page. */
|
||||
export function BackpackGrid({ backpack }) {
|
||||
if (!backpack.exists) {
|
||||
return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>No backpack on {backpack.server} yet — it has never been opened.</p>
|
||||
}
|
||||
|
||||
const pages = backpackPages(backpack)
|
||||
const waiting = overflowCount(backpack)
|
||||
const held = (backpack.items || []).length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 8px' }}>
|
||||
{count(held)} stack{held === 1 ? '' : 's'} in {count(backpack.capacity)} slots on {backpack.server}
|
||||
{waiting > 0 && (
|
||||
<span style={{ color: '#d89b3c' }}>
|
||||
{' '}· {count(waiting)} past the capacity, moved to the owner’s inventory (or dropped at their feet if it is full) the next time they open it
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{pages.map((page) => (
|
||||
<div key={page.index} style={{ marginBottom: 10 }}>
|
||||
{pages.length > 1 && <div className="field-label" style={{ margin: '0 0 6px', fontSize: '0.7rem' }}>Page {page.index + 1}</div>}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(auto-fill, minmax(${CELL}px, 1fr))`, gap: 4 }}>
|
||||
{page.cells.map((cell) => (
|
||||
<Cell key={cell.slot} cell={cell} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a server and an account, open, read once. `servers` is `[{ id, name }]`,
|
||||
* `accounts` `[{ steamId, name }]`, and `read` the route's binding.
|
||||
*/
|
||||
export default function Backpack({ servers, accounts, read }) {
|
||||
const [serverId, setServerId] = useState(servers.length ? servers[0].id : '')
|
||||
const [steamId, setSteamId] = useState(accounts.length ? accounts[0].steamId : '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [backpack, setBackpack] = useState(null)
|
||||
|
||||
if (!servers.length || !accounts.length) return null
|
||||
|
||||
async function open(event) {
|
||||
event.preventDefault()
|
||||
if (busy || !serverId || !steamId) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
setBackpack(await read(serverId, steamId))
|
||||
} catch (err) {
|
||||
// "Backpacks is not installed on this server (Main)" is the server's sentence (D199).
|
||||
setBackpack(null)
|
||||
setError(err.message || 'That backpack could not be read.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={open} style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
{servers.length > 1 && (
|
||||
<select className="input" value={serverId} onChange={(e) => setServerId(e.target.value)} aria-label="Server">
|
||||
{servers.map((s) => <option key={s.id} value={s.id}>{s.name || s.id}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{accounts.length > 1 && (
|
||||
<select className="input" value={steamId} onChange={(e) => setSteamId(e.target.value)} aria-label="Steam account">
|
||||
{accounts.map((a) => <option key={a.steamId} value={a.steamId}>{a.name || shortId(a.steamId)}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
{busy ? 'Reading…' : backpack ? 'Read again' : 'Open backpack'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '0 0 6px' }}>{error}</p>}
|
||||
{backpack && <BackpackGrid backpack={backpack} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { ago, contrastInk, count, duration, shortId } from '../lib/format.js'
|
||||
import { ago, coins, contrastInk, count, duration, shortId } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
|
||||
@@ -30,8 +30,32 @@ const COLUMNS = [
|
||||
{ key: 'npcKills', label: 'NPC kills', sort: 'npcKills', value: (r) => count(r.npcKills) },
|
||||
{ key: 'structures', label: 'Structures', sort: null, value: (r) => count(r.structures) },
|
||||
{ key: 'playtimeSec', label: 'Played', sort: 'playtime', value: (r) => duration(r.playtimeSec) },
|
||||
// Rust's own world events (protocol 14, D356, D377): the counts Safecracker
|
||||
// and Scavenger rank, on every server, since the game itself raises them.
|
||||
{ key: 'cratesHacked', label: 'Crates hacked', sort: 'cratesHacked', value: (r) => count(r.cratesHacked) },
|
||||
{ key: 'dropsLooted', label: 'Drops looted', sort: 'dropsLooted', value: (r) => count(r.dropsLooted) },
|
||||
]
|
||||
|
||||
// The richest (PLAN_REDESIGNS §11.2.2): the last known Economics balance. The
|
||||
// server adds it only for one wipe on a server that reports Economics, and says
|
||||
// so with `economics` on the answer — so the column is there exactly when the
|
||||
// rows carry it. Sorting by it ranks only balances above the starting balance
|
||||
// (D336), which is why a fresh wipe's sort shows nobody.
|
||||
// Treasure Hunter (PLAN_REDESIGNS §14.3.4, D375): Dangerous Treasures won. The
|
||||
// server says `dangerousTreasures` where the server reports the plugin, or the
|
||||
// rows already count a treasure, so the column is there exactly then.
|
||||
const TREASURES = { key: 'treasuresWon', label: 'Treasures', sort: 'treasuresWon', value: (r) => count(r.treasuresWon) }
|
||||
|
||||
// Warden (PLAN_REDESIGNS §14.3.5, D375): guarded crates cleared, shown on the
|
||||
// same rule from the server's `guardedCrate`.
|
||||
const GUARDED = { key: 'guardedWon', label: 'Crates cleared', sort: 'guardedWon', value: (r) => count(r.guardedWon) }
|
||||
|
||||
// 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 }) {
|
||||
const { data: npc } = useAsync(() => api.servers.npcProfiles(serverId).catch(() => ({ profiles: [] })), [serverId])
|
||||
const [profileId, setProfileId] = useState('')
|
||||
@@ -85,6 +109,13 @@ function Overall({ serverId, wipeId, sort, onSort }) {
|
||||
)
|
||||
|
||||
const rows = data ? data.leaderboard : []
|
||||
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.
|
||||
const showLastSeen = rows.some((row) => 'lastSeen' in row)
|
||||
|
||||
@@ -96,9 +127,11 @@ function Overall({ serverId, wipeId, sort, onSort }) {
|
||||
<Empty
|
||||
title="No scores yet"
|
||||
message={
|
||||
wipeId
|
||||
? 'Nobody has done anything countable on this wipe yet.'
|
||||
: 'This server has not reported anything countable yet.'
|
||||
sort === 'balance' && wipeId
|
||||
? 'Nobody holds more coins than the starting balance on this wipe yet.'
|
||||
: wipeId
|
||||
? 'Nobody has done anything countable on this wipe yet.'
|
||||
: 'This server has not reported anything countable yet.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -110,7 +143,7 @@ function Overall({ serverId, wipeId, sort, onSort }) {
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em' }}>
|
||||
<th style={{ ...cell, textTransform: 'uppercase' }}>Player</th>
|
||||
{COLUMNS.map((column) => (
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>
|
||||
{column.sort ? (
|
||||
<button
|
||||
@@ -179,7 +212,7 @@ function Overall({ serverId, wipeId, sort, onSort }) {
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
{COLUMNS.map((column) => (
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>
|
||||
{column.value(row)}
|
||||
</td>
|
||||
@@ -190,7 +223,7 @@ function Overall({ serverId, wipeId, sort, onSort }) {
|
||||
</tr>,
|
||||
open === row.steamId && (
|
||||
<tr key={`${row.steamId}-npcs`}>
|
||||
<td colSpan={COLUMNS.length + 1 + (showLastSeen ? 1 : 0)} style={{ ...cell, whiteSpace: 'normal', paddingLeft: 34 }}>
|
||||
<td colSpan={columns.length + 1 + (showLastSeen ? 1 : 0)} style={{ ...cell, whiteSpace: 'normal', paddingLeft: 34 }}>
|
||||
<PlayerNpcKills serverId={serverId} steamId={row.steamId} wipeId={wipeId} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -21,7 +21,7 @@ import Empty from './Empty.jsx'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import { ago } from '../lib/format.js'
|
||||
import { boundsOf, countdown, fromLatLng, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
|
||||
import { eventTip, isBoss, titlesFrom, unknownRuns } from '../lib/mapEvents.js'
|
||||
import { eventTip, isBoss, pvpTip, raidTip, titlesFrom, unknownRuns } from '../lib/mapEvents.js'
|
||||
import api, { BASE } from '../api.js'
|
||||
|
||||
const STYLE_ID = 'rust-leaflet-css'
|
||||
@@ -35,6 +35,8 @@ const LAYERS = [
|
||||
{ id: 'events', label: 'Site events' },
|
||||
{ id: 'players', label: 'Players' },
|
||||
{ id: 'bases', label: 'Bases' },
|
||||
{ id: 'pvp', label: 'PvP zones' },
|
||||
{ id: 'raids', label: 'Raid bases' },
|
||||
]
|
||||
|
||||
const COLOURS = {
|
||||
@@ -45,6 +47,8 @@ const COLOURS = {
|
||||
bradley: '#a1887f',
|
||||
supply: '#66bb6a',
|
||||
crate: '#ffee58',
|
||||
treasure: '#ffab00',
|
||||
guarded: '#ff7043',
|
||||
event: '#ce93d8',
|
||||
boss: '#d500f9',
|
||||
online: '#ffffff',
|
||||
@@ -53,6 +57,9 @@ const COLOURS = {
|
||||
vending: '#26a69a',
|
||||
self: '#00e5ff',
|
||||
mate: '#7cffb2',
|
||||
pvp: '#ff1744',
|
||||
raid: '#ff9100',
|
||||
raidOurs: '#ffd600',
|
||||
}
|
||||
|
||||
const WORLD_NAMES = {
|
||||
@@ -62,6 +69,10 @@ const WORLD_NAMES = {
|
||||
bradley: 'Bradley APC',
|
||||
supply: 'Supply drop',
|
||||
crate: 'Locked crate',
|
||||
// Dangerous Treasures (§14.3.4): its running chests.
|
||||
treasure: 'Dangerous Treasures chest',
|
||||
// Guarded Crate (§14.3.5): a crate whose guards still stand.
|
||||
guarded: 'Guarded crate',
|
||||
boss: 'Boss',
|
||||
}
|
||||
|
||||
@@ -79,7 +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, 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
|
||||
@@ -160,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', '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).
|
||||
@@ -238,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', '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
|
||||
@@ -274,6 +285,20 @@ export default function MapView({ serverId, online, onPick = null, pins = null }
|
||||
dot(L, at(b), COLOURS[b.kind] || COLOURS.tc, 4).bindTooltip(b.kind === 'tc' ? 'Tool cupboard' : 'Vending machine').addTo(made.bases)
|
||||
}
|
||||
|
||||
// Dynamic PVP's own zones (§14.3.3): a red ring with its event's name.
|
||||
for (const z of answer.pvp || []) {
|
||||
L.circle(at(z), { radius: px(Number(z.radius) || 0), color: COLOURS.pvp, weight: 2, fillOpacity: 0.1 })
|
||||
.bindTooltip(escape(pvpTip(z)))
|
||||
.addTo(made.pvp)
|
||||
}
|
||||
|
||||
// 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'))
|
||||
@@ -299,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, 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])
|
||||
@@ -381,6 +406,8 @@ function Legend({ meta, live, shown, onToggle }) {
|
||||
events: [COLOURS.event],
|
||||
players: [COLOURS.online, COLOURS.sleeping],
|
||||
bases: [COLOURS.tc, COLOURS.vending],
|
||||
pvp: [COLOURS.pvp],
|
||||
raids: [COLOURS.raid, COLOURS.raidOurs],
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
52
client/src/lib/backpack.js
Normal file
52
client/src/lib/backpack.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// ── A backpack as a grid (PLAN_REDESIGNS §11.3.2) ─────────────────────────
|
||||
//
|
||||
// Pure, like `format.js`, so the suite can ask it without a DOM. The page that
|
||||
// draws a backpack only maps over what these return.
|
||||
//
|
||||
// The read's `slot` runs across pages: page two of a 96-slot backpack starts at
|
||||
// slot 48 when the page size is 48 (§11.3.1). A stack at or past the capacity
|
||||
// is overflow — it waits for the owner's next open to be moved to their
|
||||
// inventory, or the ground (D338) — so the grid draws those slots too, after
|
||||
// the backpack's own, marked.
|
||||
|
||||
/**
|
||||
* The pages to draw: `[{ index, cells: [{ slot, stack, overflow }] }]`.
|
||||
* Enough slots for the capacity and for every stack, whichever reaches
|
||||
* further; a backpack that does not exist is no pages at all.
|
||||
*/
|
||||
export function backpackPages(backpack) {
|
||||
if (!backpack || !backpack.exists) return []
|
||||
|
||||
const items = Array.isArray(backpack.items) ? backpack.items : []
|
||||
const capacity = Math.max(0, Number(backpack.capacity) || 0)
|
||||
const pageSize = Math.max(1, Number(backpack.pageSize) || 48)
|
||||
const bySlot = new Map(items.map((s) => [Number(s.slot), s]))
|
||||
const furthest = items.reduce((max, s) => Math.max(max, Number(s.slot) + 1), 0)
|
||||
const total = Math.max(capacity, furthest)
|
||||
|
||||
const pages = []
|
||||
for (let start = 0; start < total; start += pageSize) {
|
||||
const cells = []
|
||||
for (let slot = start; slot < Math.min(start + pageSize, total); slot++) {
|
||||
cells.push({ slot, stack: bySlot.get(slot) || null, overflow: slot >= capacity })
|
||||
}
|
||||
pages.push({ index: pages.length, cells })
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
/**
|
||||
* What a slot says: the game's name (D340), "… blueprint" for a blueprint
|
||||
* (D341), then the shortname, then the bare item id for an item the game no
|
||||
* longer knows.
|
||||
*/
|
||||
export function stackLabel(stack) {
|
||||
if (!stack) return ''
|
||||
const name = stack.display || stack.shortname || (stack.itemId !== undefined ? `item ${stack.itemId}` : 'unknown item')
|
||||
return stack.blueprint ? `${name} blueprint` : name
|
||||
}
|
||||
|
||||
/** How many stacks wait to be moved out at the owner's next open. */
|
||||
export function overflowCount(backpack) {
|
||||
return backpack && Array.isArray(backpack.items) ? backpack.items.filter((s) => s.overflow).length : 0
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
// here is presentation, and the honest presentation of a kind we have no words
|
||||
// for is its own name.
|
||||
|
||||
import { attacker, duration, prefab } from './format.js'
|
||||
import { attacker, duration, weapon } from './format.js'
|
||||
|
||||
/**
|
||||
* Kinds this feed asks for.
|
||||
@@ -44,6 +44,27 @@ export const FEED_KINDS = Object.freeze([
|
||||
// The death names its killer, so the server shows it only where presence is on.
|
||||
'npc.boss.spawned',
|
||||
'npc.boss.died',
|
||||
// Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1): the server
|
||||
// reads each one for the viewer, so a name is here only where it may be.
|
||||
'world.event',
|
||||
// Dynamic PVP's zones opening (§14.3.3). A closing (`pvp.zone.deleted`) is
|
||||
// staff's and is not asked for here; the map shows a zone go.
|
||||
'pvp.zone.created',
|
||||
// Dangerous Treasures (§14.3.4): a chest opening, and one won. The server reads
|
||||
// each for the viewer, so the winner's name is here only where it may be; the
|
||||
// ending (`dt.ended`) is staff's, and the map shows the chest go.
|
||||
'dt.started',
|
||||
'dt.won',
|
||||
// Guarded Crate (§14.3.5): a crate landing, and one cleared, read per viewer
|
||||
// like a treasure. A timeout (`gc.failed`) is staff's; the map shows it go.
|
||||
'gc.started',
|
||||
'gc.ended',
|
||||
// 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. */
|
||||
@@ -58,6 +79,7 @@ export const FILTERS = Object.freeze([
|
||||
},
|
||||
{ id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] },
|
||||
{ id: 'bosses', label: 'Bosses', kinds: ['npc.boss.spawned', 'npc.boss.died'] },
|
||||
{ id: 'world', label: 'World events', kinds: ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended', 'raid.started', 'raid.completed', 'raid.purchased', 'raid.locked'] },
|
||||
])
|
||||
|
||||
export function kindsFor(filterId) {
|
||||
@@ -144,6 +166,30 @@ export function describe(row) {
|
||||
: { tone: 'death', actor: name, verb: 'was killed', subject: null, detail: fought }
|
||||
}
|
||||
|
||||
case 'world.event':
|
||||
return worldLine(frame)
|
||||
|
||||
case 'pvp.zone.created':
|
||||
case 'pvp.zone.deleted':
|
||||
return pvpLine(row.kind, frame)
|
||||
|
||||
case 'dt.started':
|
||||
case 'dt.won':
|
||||
case 'dt.ended':
|
||||
return treasureLine(row.kind, frame)
|
||||
|
||||
case 'gc.started':
|
||||
case 'gc.ended':
|
||||
case 'gc.failed':
|
||||
return guardedLine(row.kind, frame)
|
||||
|
||||
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: '' }
|
||||
}
|
||||
@@ -159,7 +205,7 @@ export function describe(row) {
|
||||
*/
|
||||
function death(frame, name) {
|
||||
const where = [
|
||||
frame.weapon ? `with ${prefab(frame.weapon)}` : null,
|
||||
frame.weapon ? `with ${weapon(frame)}` : null,
|
||||
frame.distance ? `${Math.round(frame.distance)}m` : null,
|
||||
frame.grid || null,
|
||||
frame.sleeping ? 'while sleeping' : null,
|
||||
@@ -193,4 +239,227 @@ function death(frame, name) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a locked crate is, as the end of a sentence. */
|
||||
const CRATE_PLACES = Object.freeze({
|
||||
oilrig_large: 'on Large Oil Rig',
|
||||
oilrig_small: 'on Small Oil Rig',
|
||||
cargo: 'on the cargo ship',
|
||||
ghostship: 'on a ghost ship',
|
||||
chinook: 'dropped by a Chinook',
|
||||
})
|
||||
|
||||
/**
|
||||
* Who a downed helicopter, Chinook or Bradley credits, as a phrase: "Alice",
|
||||
* "Alice and Bob", "Alice, Bob and 2 others". Null when the line names nobody
|
||||
* — below the presence audience the server sends `creditedCount` and no names.
|
||||
*/
|
||||
export function creditedPhrase(frame) {
|
||||
const names = (Array.isArray(frame.credited) ? frame.credited : []).map((c) => c && c.name).filter(Boolean)
|
||||
if (names.length === 0) return null
|
||||
const total = Math.max(names.length, Number(frame.creditedCount) || 0)
|
||||
const shown = names.slice(0, 2)
|
||||
const more = total - shown.length
|
||||
if (more > 0) return `${shown.join(', ')} and ${more} other${more === 1 ? '' : 's'}`
|
||||
return shown.length === 2 ? `${shown[0]} and ${shown[1]}` : shown[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Rust's own world events (protocol 14), one sentence per type. The server has
|
||||
* already read the frame for this viewer: it carries `grid`, a `name` only for
|
||||
* the presence audience, and `credited` by the server's rule (D372, D376). A
|
||||
* line therefore never decides who may read a name; it only says what it has.
|
||||
*/
|
||||
function worldLine(frame) {
|
||||
const grid = frame.grid || ''
|
||||
const who = frame.name || null
|
||||
const place = CRATE_PLACES[frame.where] || 'somewhere on the map'
|
||||
const by = creditedPhrase(frame)
|
||||
const downed = (what) => (by
|
||||
? { tone: 'kill', actor: null, verb: `${what} by`, subject: by, detail: grid }
|
||||
: { tone: 'kill', actor: null, verb: what, subject: null, detail: grid })
|
||||
const say = (verb, detail = grid) => ({ tone: 'server', actor: null, verb, subject: null, detail })
|
||||
const harbour = frame.harbourName || 'the harbour'
|
||||
|
||||
switch (frame.event) {
|
||||
case 'heli.arrived':
|
||||
return say('A patrol helicopter has arrived')
|
||||
case 'heli.left':
|
||||
return say('The patrol helicopter has left', '')
|
||||
case 'heli.downed':
|
||||
return downed('Patrol helicopter downed')
|
||||
case 'chinook.downed':
|
||||
return downed('Chinook shot down')
|
||||
case 'bradley.destroyed':
|
||||
return downed(frame.where === 'launch_site' ? "Launch Site's Bradley destroyed" : frame.where === 'road' ? 'Road Bradley destroyed' : 'Bradley destroyed')
|
||||
case 'bradley.spawned': {
|
||||
const n = Number(frame.count) || 1
|
||||
if (frame.where === 'road') return say(n > 1 ? `${n} road Bradleys are out` : 'A road Bradley is out', '')
|
||||
return say('Bradley is patrolling Launch Site', '')
|
||||
}
|
||||
case 'cargo.arrived':
|
||||
return say('The cargo ship has arrived')
|
||||
case 'cargo.docking':
|
||||
return say(`The cargo ship is docking at ${harbour}`)
|
||||
case 'cargo.docked':
|
||||
return say(`The cargo ship has docked at ${harbour}`)
|
||||
case 'cargo.undocked':
|
||||
return say(`The cargo ship has cast off from ${harbour}`)
|
||||
case 'cargo.left':
|
||||
return say('The cargo ship is leaving', '')
|
||||
case 'chinook.arrived':
|
||||
return say('A Chinook is inbound with a locked crate')
|
||||
case 'chinook.dropped':
|
||||
return say(frame.monumentName ? `A Chinook dropped a locked crate at ${frame.monumentName}` : 'A Chinook dropped a locked crate')
|
||||
case 'airdrop.called':
|
||||
return who ? { tone: 'server', actor: who, verb: 'called an airdrop', subject: null, detail: grid } : say('An airdrop is on its way')
|
||||
case 'airdrop.landed':
|
||||
return say('An airdrop has landed', [who ? `called by ${who}` : null, grid].filter(Boolean).join(' · '))
|
||||
case 'airdrop.looted':
|
||||
return who ? { tone: 'server', actor: who, verb: 'looted an airdrop', subject: null, detail: grid } : say('An airdrop was looted')
|
||||
case 'crate.hack.started':
|
||||
return who
|
||||
? { tone: 'server', actor: who, verb: `started hacking the locked crate ${place}`, subject: null, detail: grid }
|
||||
: say(`A locked crate ${place} is being hacked`)
|
||||
case 'crate.unlocked':
|
||||
return say(`The locked crate ${place} is open`)
|
||||
case 'crate.looted':
|
||||
return who
|
||||
? { tone: 'server', actor: who, verb: `looted the locked crate ${place}`, subject: null, detail: grid }
|
||||
: say(`The locked crate ${place} was looted`)
|
||||
case 'vendor.arrived':
|
||||
return say('The Travelling Vendor has arrived')
|
||||
case 'vendor.left':
|
||||
return say('The Travelling Vendor has left', '')
|
||||
case 'satellite.crashed':
|
||||
return say('A satellite has crashed')
|
||||
case 'deepsea.opened':
|
||||
return say('The Deep Sea is open', '')
|
||||
case 'deepsea.closed':
|
||||
return say('The Deep Sea has closed', '')
|
||||
default:
|
||||
// A type this build has no words for, which only staff are sent: its own name.
|
||||
return { tone: 'other', actor: who, verb: String(frame.event || 'world event'), subject: null, detail: grid }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Dynamic PVP zone is around, by its event (Dynamic PVP 5.2.0's own
|
||||
* names). The server sends `event` to the public only for these; a monument's or
|
||||
* an operator's own event reaches staff as written.
|
||||
*/
|
||||
export const PVP_AROUND = Object.freeze({
|
||||
Helicopter: 'the downed patrol helicopter',
|
||||
Bradley: 'the destroyed Bradley',
|
||||
Satellite: 'the crashed satellite',
|
||||
TimedSupply: 'an airdrop',
|
||||
SupplySignal: 'a signalled airdrop',
|
||||
CargoShip: 'the cargo ship',
|
||||
HackableCrate: 'a locked crate',
|
||||
ExcavatorIgnition: 'the Giant Excavator',
|
||||
GhostShip: 'a ghost ship',
|
||||
DeepSeaIsland: 'a Deep Sea island',
|
||||
IslandCannon: 'an island cannon',
|
||||
})
|
||||
|
||||
/** How long a zone was given, as a detail: "for 10 min", or cargo's whole visit. */
|
||||
function pvpLasts(frame) {
|
||||
const sec = Number(frame.durationSec)
|
||||
if (!(sec > 0)) return frame.event === 'CargoShip' ? 'while the ship is here' : ''
|
||||
return sec < 120 ? `for ${Math.round(sec)} s` : `for ${Math.round(sec / 60)} min`
|
||||
}
|
||||
|
||||
/** Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3). The line names nobody. */
|
||||
function pvpLine(kind, frame) {
|
||||
const around = PVP_AROUND[frame.event] ? ` around ${PVP_AROUND[frame.event]}` : frame.event ? ` (${frame.event})` : ''
|
||||
if (kind === 'pvp.zone.deleted') {
|
||||
return { tone: 'server', actor: null, verb: `The PvP zone${around} has closed`, subject: null, detail: '' }
|
||||
}
|
||||
const detail = [frame.grid || null, pvpLasts(frame) || null].filter(Boolean).join(' · ')
|
||||
return { tone: 'kill', actor: null, verb: `A PvP zone opened${around}`, subject: null, detail }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4). The server has read
|
||||
* the frame for this viewer: `grid`, `invaderCount`, and the winner's `name` only
|
||||
* for the presence audience (D376).
|
||||
*/
|
||||
function treasureLine(kind, frame) {
|
||||
const grid = frame.grid || ''
|
||||
if (kind === 'dt.started') {
|
||||
return { tone: 'server', actor: null, verb: 'A Dangerous Treasures event has started', subject: null, detail: grid }
|
||||
}
|
||||
if (kind === 'dt.ended') {
|
||||
return { tone: 'server', actor: null, verb: 'A Dangerous Treasures event has ended', subject: null, detail: grid }
|
||||
}
|
||||
const n = Number(frame.invaderCount)
|
||||
const fought = n > 1 ? `${n} players fought for it` : ''
|
||||
const detail = [grid || null, fought || null].filter(Boolean).join(' · ')
|
||||
return frame.name
|
||||
? { tone: 'kill', actor: frame.name, verb: 'won the treasure', subject: null, detail }
|
||||
: { tone: 'kill', actor: null, verb: 'The treasure was won', subject: null, detail }
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5). The server has read the
|
||||
* frame for this viewer: `grid`, `won` (whether a player killed the last
|
||||
* guard), and the clearer's `name` only for the presence audience (D376).
|
||||
*/
|
||||
function guardedLine(kind, frame) {
|
||||
const detail = frame.grid || ''
|
||||
if (kind === 'gc.started') {
|
||||
return { tone: 'server', actor: null, verb: 'A guarded crate landed', subject: null, detail }
|
||||
}
|
||||
if (kind === 'gc.failed') {
|
||||
return { tone: 'server', actor: null, verb: 'A guarded crate timed out with its guards standing', subject: null, detail }
|
||||
}
|
||||
if (frame.won === false) {
|
||||
return { tone: 'server', actor: null, verb: 'A guarded crate ended with nobody clearing it', subject: null, detail }
|
||||
}
|
||||
return frame.name
|
||||
? { tone: 'kill', actor: frame.name, verb: 'cleared the guarded crate', subject: null, detail }
|
||||
: { tone: 'kill', actor: null, verb: 'The guarded crate was cleared', subject: null, detail }
|
||||
}
|
||||
|
||||
/** "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 }
|
||||
|
||||
@@ -105,6 +105,17 @@ export function count(value) {
|
||||
return Number.isFinite(n) ? n.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
/**
|
||||
* An Economics balance (PLAN_REDESIGNS §11.2.2): up to two places, and a dash
|
||||
* for one the server never reported. Not `count`'s zero — "0 coins" is a claim
|
||||
* about somebody's balance, and an unknown one is not it.
|
||||
*/
|
||||
export function coins(value) {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n.toLocaleString(undefined, { maximumFractionDigits: 2 }) : '—'
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefab short name as something readable — `patrolhelicopter` stays itself,
|
||||
* `rifle.ak` becomes `rifle ak`.
|
||||
@@ -119,6 +130,18 @@ export function prefab(name) {
|
||||
return String(name).replace(/[_.]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* The weapon in a kill. The server sends `weaponName` — the game's own name for
|
||||
* a gun, or the prefab name as words (`server/model/titles/weaponNames.js`) —
|
||||
* and that is what prints. A server too old to send it gets the same fallback
|
||||
* here: the `.entity` / `.weapon` ending dropped, so never "ak47u entity".
|
||||
*/
|
||||
export function weapon(frame) {
|
||||
if (!frame || !frame.weapon) return ''
|
||||
if (frame.weaponName) return String(frame.weaponName)
|
||||
return prefab(String(frame.weapon).trim().replace(/[._](entity|weapon|deployed)$/, ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* The NPC families whose prefab reads badly as words, by the prefix the game's
|
||||
* short names share. First match wins, so a longer prefix sits above a shorter
|
||||
|
||||
@@ -42,3 +42,44 @@ export function eventTip(row, titles) {
|
||||
export function isBoss(row) {
|
||||
return Boolean(row && (row.boss || row.kind === 'boss'))
|
||||
}
|
||||
|
||||
// ── Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3) ──────────────
|
||||
|
||||
/** Dynamic PVP's own events as the map names them; a monument's or an operator's is shown as written. */
|
||||
export const PVP_NAMES = Object.freeze({
|
||||
Helicopter: 'Patrol helicopter',
|
||||
Bradley: 'Bradley APC',
|
||||
Satellite: 'Satellite crash',
|
||||
TimedSupply: 'Airdrop',
|
||||
SupplySignal: 'Signalled airdrop',
|
||||
CargoShip: 'Cargo ship',
|
||||
HackableCrate: 'Locked crate',
|
||||
ExcavatorIgnition: 'Giant Excavator',
|
||||
GhostShip: 'Ghost ship',
|
||||
DeepSeaIsland: 'Deep Sea island',
|
||||
IslandCannon: 'Island cannon',
|
||||
})
|
||||
|
||||
/** A Dynamic PVP ring's tooltip: "PvP zone · Patrol helicopter". */
|
||||
export function pvpTip(z) {
|
||||
const name = PVP_NAMES[z.event] || z.event
|
||||
return name ? `PvP zone · ${name}` : 'PvP zone'
|
||||
}
|
||||
|
||||
// ── 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(' · ')
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function titlesSummary(titles, push, categories) {
|
||||
const rules = (titles && titles.rules) || []
|
||||
if (!rules.length) return 'No chat titles.'
|
||||
const list = rules.map((r) => `${ruleTitle(r, categories)} (top ${r.topN} ${statOf(categories, r.stat).label.toLowerCase()})`).join(', ')
|
||||
const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? '' : 's'} hold one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
|
||||
const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? ' holds' : 's hold'} one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
|
||||
return `Chat titles: ${list}.${shown}`
|
||||
}
|
||||
|
||||
@@ -259,7 +259,134 @@ export function integrationsLine(r) {
|
||||
: 'This server’s plugin is older than protocol 12, so it cannot say which optional mods it has.'
|
||||
}
|
||||
const one = (name, x, without) => (x && x.loaded ? `${name} ${x.version || ''} is loaded`.trim() : `${name} is not loaded — ${without}`)
|
||||
return `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
|
||||
const line = `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
|
||||
const withEconomics = r.integrations.economics ? `${line} ${economicsLine(r.integrations.economics)}` : line
|
||||
const withBackpacks = r.integrations.backpacks ? `${withEconomics} ${backpacksLine(r.integrations.backpacks)}` : withEconomics
|
||||
const withTruePve = r.integrations.truePve ? `${withBackpacks} ${truePveLine(r.integrations.truePve)}` : withBackpacks
|
||||
const withDynamicPvp = r.integrations.dynamicPvp ? `${withTruePve} ${dynamicPvpLine(r.integrations.dynamicPvp)}` : withTruePve
|
||||
const withDt = r.integrations.dangerousTreasures ? `${withDynamicPvp} ${dangerousTreasuresLine(r.integrations.dangerousTreasures, r.console)}` : withDynamicPvp
|
||||
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}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Dangerous Treasures (PLAN_REDESIGNS §14.3.4), from protocol 14: its events on the
|
||||
* feed and the map, and whether a step can start one, which needs the framework
|
||||
* to run its console command (hello's `console.plugins`, D363).
|
||||
*/
|
||||
export function dangerousTreasuresLine(dt, probe) {
|
||||
if (!dt.loaded) return 'Dangerous Treasures is not loaded — a step cannot start a treasure event.'
|
||||
const name = `Dangerous Treasures ${dt.version || ''}`.trim()
|
||||
if (probe && probe.plugins === false) {
|
||||
return `${name} is loaded — its events show on the feed and the map, but this framework cannot run its console command, so a step cannot start one (Rust-Plugins#34).`
|
||||
}
|
||||
return `${name} is loaded — its events show on the feed and the map, and a step may start one.`
|
||||
}
|
||||
|
||||
/** Dynamic PVP (PLAN_REDESIGNS §14.3.3), from protocol 14: its zones on the feed and the map. */
|
||||
export function dynamicPvpLine(d) {
|
||||
if (!d.loaded) return 'Dynamic PVP is not loaded — no PvP zones of its own on the feed or the map.'
|
||||
return `${`Dynamic PVP ${d.version || ''}`.trim()} is loaded — its PvP zones show on the feed and the map.`
|
||||
}
|
||||
|
||||
/** TruePVE (PLAN_REDESIGNS §14.3.2), from protocol 14: what a zone step may do with PvP. */
|
||||
export function truePveLine(tp) {
|
||||
if (!tp.loaded) return 'TruePVE is not loaded — a zone step cannot change PvP rules.'
|
||||
return `${`TruePVE ${tp.version || ''}`.trim()} is loaded — a zone step may turn off PvP protection or name a ruleset.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Backpacks (PLAN_REDESIGNS §11.3.2), from protocol 14. Its sizes are what a
|
||||
* Bigger backpack step may lease (D337), so they are said.
|
||||
*/
|
||||
export function backpacksLine(b) {
|
||||
if (!b.loaded) return 'Backpacks is not loaded — backpack views and bigger-backpack rewards wait for it.'
|
||||
const name = `Backpacks ${b.version || ''}`.trim()
|
||||
const sizes = Array.isArray(b.sizes) ? b.sizes : []
|
||||
return sizes.length > 1
|
||||
? `${name} is loaded — sizes ${sizes.join(', ')}; an event may lend any above ${sizes[0]}.`
|
||||
: `${name} is loaded, but offers no size above its smallest, so an event has nothing bigger to lend.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Economics (PLAN_REDESIGNS §11.2.2), from protocol 14. Its starting balance
|
||||
* decides who the richest column ranks (D336), so a config the plugin could not
|
||||
* read is said: the site then assumes Economics' default of 1000.
|
||||
*/
|
||||
export function economicsLine(e) {
|
||||
if (!e.loaded) return 'Economics is not loaded — coin rewards, balances and the richest column wait for it.'
|
||||
const name = `Economics ${e.version || ''}`.trim()
|
||||
if (e.startingBalance === undefined || e.startingBalance === null) {
|
||||
return `${name} is loaded, but its config could not be read — the site assumes a starting balance of 1,000.`
|
||||
}
|
||||
const wipes = e.wipesWithMap === true ? 'balances wipe with the map' : e.wipesWithMap === false ? 'balances carry over a wipe' : null
|
||||
return `${name} is loaded — starting balance ${Number(e.startingBalance).toLocaleString()}${wipes ? `, ${wipes}` : ''}.`
|
||||
}
|
||||
|
||||
export function VoiceCard() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -191,12 +191,15 @@ function playerState(facts, view, player, permission) {
|
||||
if (direct) {
|
||||
const wider = direct.sources.some((s) => (s.type === 'userGrant' || s.type === 'steamGrant') && s.scope !== view.server.id)
|
||||
const fromEvent = direct.sources.every((s) => s.type === 'runGrant')
|
||||
// Given for good by an event (PLAN_REDESIGNS.md §12.5): a standing grant like
|
||||
// a staff member's, toggled off like one, and marked with its note.
|
||||
const given = direct.sources.find((s) => s.type === 'userGrant' && s.origin === 'event')
|
||||
let chip
|
||||
if (!facts.registered.has(permission)) chip = <Chip tone="bad" title="No loaded plugin registers it here">not registered here</Chip>
|
||||
else if (facts.notLanded.has(`${player.steamId}:${permission}`)) chip = <Chip tone="bad">did not land</Chip>
|
||||
else if (facts.landed.has(`grant ${player.steamId} ${permission}`)) chip = <Chip tone="good">granted</Chip>
|
||||
else chip = <Chip tone="warn">waiting for a sync</Chip>
|
||||
return { on: true, chip, wider, fromEvent, excepted: null, viaGroups }
|
||||
return { on: true, chip, wider, fromEvent, given: given ? given.note || 'an event' : null, excepted: null, viaGroups }
|
||||
}
|
||||
|
||||
if (viaGroups.length) {
|
||||
@@ -294,6 +297,7 @@ function PlayerPanel({ view, player, act, busy }) {
|
||||
<Row key={perm}>
|
||||
<code style={{ flex: 1, fontSize: '0.8rem' }}>{perm}</code>
|
||||
{s.fromEvent && <Chip tone="info" title="An event gave it; the event’s revert takes it back">from an event</Chip>}
|
||||
{s.given && <Chip tone="info" title={`Given for good by ${s.given}; turn it off to take it back`}>given by {s.given}</Chip>}
|
||||
{s.chip}
|
||||
<Toggle on={s.on} disabled={busy || s.readOnly || s.fromEvent} onChange={(on) => toggle(perm, on)} />
|
||||
</Row>
|
||||
@@ -538,7 +542,7 @@ function GroupPanel({ view, group, act, busy, chatFields }) {
|
||||
{tab === 'servers' && (
|
||||
<div className="sans" style={{ fontSize: '0.82rem' }}>
|
||||
<p className="dim" style={{ margin: '0 0 8px' }}>
|
||||
A group belongs to one server unless it is shared (D189). A shared group carries the same permissions and players on every server
|
||||
A group belongs to one server unless it is shared. A shared group carries the same permissions and players on every server
|
||||
it is on; a change made in one game gives that server its own copy.
|
||||
</p>
|
||||
<label style={{ display: 'block', marginBottom: 6 }}>
|
||||
@@ -777,11 +781,17 @@ export default function Permissions() {
|
||||
</select>
|
||||
</label>
|
||||
{sync && (
|
||||
sync.inSync ? <Chip tone="good">in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''}</Chip>
|
||||
sync.inSync && sync.waitingToLand > 0
|
||||
? (
|
||||
<Chip tone="warn" title="A grant to a Steam id this server has never seen lands when that player first joins: every sync pushes it again">
|
||||
in sync · {sync.waitingToLand} {sync.waitingToLand === 1 ? 'waits' : 'wait'} for their player to join
|
||||
</Chip>
|
||||
)
|
||||
: sync.inSync ? <Chip tone="good">in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''}</Chip>
|
||||
: sync.state === 'failed' ? <Chip tone="bad" title={sync.error || ''}>last sync failed</Chip>
|
||||
: <Chip tone="warn">waiting for a sync</Chip>
|
||||
)}
|
||||
{sync && !sync.importedAt && <Chip tone="warn" title="The first sync imports everything on the server (D198)">not imported yet</Chip>}
|
||||
{sync && !sync.importedAt && <Chip tone="warn" title="The first sync imports everything on the server">not imported yet</Chip>}
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync(server.id))}>Sync now</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -38,6 +38,16 @@ const RULES = [
|
||||
{ id: 'biweekly', label: 'Every other week', hint: 'Every second week, on the weeks the date below falls in, and the forced wipe.' },
|
||||
]
|
||||
|
||||
// Who a downed patrol helicopter, Chinook or Bradley credits (protocol 14, D372).
|
||||
// The same rule writes the feed line and the Rotorbane / Tankbreaker counts; the
|
||||
// server's own list, mirrored here so the form needs no round trip.
|
||||
const CREDIT_RULES = [
|
||||
{ id: 'blow', label: 'Killing blow', hint: 'The player whose hit destroyed it — for the patrol helicopter, the crippling hit, else the last hit. The default.' },
|
||||
{ id: 'last', label: 'Last hit', hint: 'The last player to hurt it.' },
|
||||
{ id: 'most', label: 'Most damage', hint: 'The player who dealt it the most damage.' },
|
||||
{ id: 'all', label: 'Everyone who hurt it', hint: 'Every player who damaged it, one each.' },
|
||||
]
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
const SOURCE = { forced: 'the forced wipe', rule: 'its own schedule', once: 'the one-off date' }
|
||||
@@ -110,6 +120,7 @@ function blankForm() {
|
||||
wipeTz: localZone(),
|
||||
wipeAnchor: '',
|
||||
wipeOnce: '',
|
||||
creditRule: 'blow',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +130,7 @@ function formFrom(server) {
|
||||
isNew: false,
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
sidecarBaseUrl: server.sidecarBaseUrl,
|
||||
sidecarBaseUrl: server.sidecarBaseUrl || '',
|
||||
sidecarToken: '',
|
||||
enabled: server.enabled,
|
||||
sortOrder: server.sortOrder,
|
||||
@@ -131,6 +142,7 @@ function formFrom(server) {
|
||||
wipeAnchor: s.anchor || '',
|
||||
wipeOnce: toLocalInput(s.onceAt),
|
||||
storedOnceAt: s.onceAt || null,
|
||||
creditRule: server.creditRule || 'blow',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +167,7 @@ function bodyFrom(form) {
|
||||
? null
|
||||
: new Date(form.wipeOnce).toISOString(),
|
||||
}
|
||||
if (form.creditRule) body.creditRule = form.creditRule
|
||||
if (form.sidecarToken) body.sidecarToken = form.sidecarToken
|
||||
if (form.protocol !== undefined) body.protocol = form.protocol
|
||||
return body
|
||||
@@ -251,7 +264,7 @@ export default function ServerSettings() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
|
||||
{s.sidecarBaseUrl} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
|
||||
{s.sidecarBaseUrl || 'sidecar address hidden'} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
|
||||
{s.sidecarProtocol != null && s.sidecarProtocol !== s.protocol ? ` (sidecar speaks ${s.sidecarProtocol})` : ''}
|
||||
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
|
||||
</div>
|
||||
@@ -409,6 +422,17 @@ function ServerForm({ form, set, busy, error, onSave, onCancel }) {
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '12px 14px', display: 'grid', gap: 12 }}>
|
||||
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>World events</legend>
|
||||
<Field
|
||||
label="Who downs a helicopter or a Bradley"
|
||||
hint={`${(CREDIT_RULES.find((r) => r.id === form.creditRule) || CREDIT_RULES[0]).hint} It names them in the feed and counts toward Rotorbane and Tankbreaker, from now on.`}
|
||||
>
|
||||
<select value={form.creditRule} onChange={set('creditRule')} style={inputStyle}>
|
||||
{CREDIT_RULES.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</fieldset>
|
||||
<datalist id="rust-zones">{ZONES.map((z) => <option key={z} value={z} />)}</datalist>
|
||||
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
// majority of them. Silence is the honest answer to "what does the Rust module
|
||||
// know about this person" when it is nothing.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ago, count, duration } from '../../lib/format.js'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ago, coins, count, duration } from '../../lib/format.js'
|
||||
import { useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
import Backpack from '../../components/Backpack.jsx'
|
||||
|
||||
/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */
|
||||
function SectionTitle({ children }) {
|
||||
@@ -229,6 +230,151 @@ function PermissionsPanel({ userId, data, onChanged }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** One submission's key, so a resent form moves nothing (§11.2.2). */
|
||||
function submissionKey() {
|
||||
const c = globalThis.crypto
|
||||
return c && typeof c.randomUUID === 'function' ? c.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
/** One server's coins for this person, and the form that changes them. */
|
||||
function EconomicsServer({ userId, server, onChanged }) {
|
||||
const [steamId, setSteamId] = useState(server.accounts.length ? server.accounts[0].steamId : '')
|
||||
const [op, setOp] = useState('deposit')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [done, setDone] = useState('')
|
||||
|
||||
// D199: said, not hidden — staff asking why the form is missing are owed it.
|
||||
if (!server.economics) {
|
||||
return (
|
||||
<div className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{server.server}</span>{' '}
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>{server.reason}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
if (busy || !steamId || amount === '') return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setDone('')
|
||||
try {
|
||||
const result = await api.adminUserEconomics.adjust(userId, { serverId: server.serverId, steamId, op, amount: Number(amount), key: submissionKey() })
|
||||
setDone(`${coins(result.before)} → ${coins(result.after)}`)
|
||||
setAmount('')
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
// "They have only 340 coins on Main" is the server's sentence, and the useful one.
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '6px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div className="sans" style={{ fontSize: '0.84rem' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{server.server}</span>{' '}
|
||||
{server.accounts.map((a, i) => (
|
||||
<span key={a.steamId}>
|
||||
{i > 0 && <span className="dim"> · </span>}
|
||||
{server.accounts.length > 1 && <span className="dim">{a.name || a.steamId} </span>}
|
||||
<strong>{coins(a.balance)}</strong>
|
||||
{a.source === 'last-known' && <span className="dim"> (last known)</span>}
|
||||
</span>
|
||||
))}
|
||||
{!server.startingKnown && (
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>
|
||||
{' '}· its Economics config could not be read, so a starting balance of {coins(server.startingBalance)} is assumed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} style={{ display: 'flex', gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
{server.accounts.length > 1 && (
|
||||
<select className="input" value={steamId} onChange={(e) => setSteamId(e.target.value)} aria-label="Steam account">
|
||||
{server.accounts.map((a) => <option key={a.steamId} value={a.steamId}>{a.name || a.steamId}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<select className="input" value={op} onChange={(e) => setOp(e.target.value)} aria-label="What to do">
|
||||
<option value="deposit">Deposit</option>
|
||||
<option value="withdraw">Withdraw</option>
|
||||
<option value="set">Set to</option>
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={op === 'set' ? 0 : 0.01}
|
||||
max={1000000}
|
||||
step="0.01"
|
||||
placeholder="500"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-label="Coins"
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={busy || amount === ''}>
|
||||
{busy ? 'Working…' : 'Apply'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{done && <p className="sans dim" style={{ fontSize: '0.78rem', margin: '6px 0 0' }}>Done: {done}. Recorded in the activity log.</p>}
|
||||
{error && <p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Economics (PLAN_REDESIGNS §11.2.2): this person's coins on every server, and
|
||||
* staff's hand on them — the only way coins come back after an event paid them
|
||||
* (D335). Its own reload, and it keeps showing the last answer while it
|
||||
* re-reads, so a deposit's "1,000 → 1,500" is not blanked by the read it caused.
|
||||
*/
|
||||
function EconomicsPanel({ userId }) {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data } = useAsync(() => api.adminUserEconomics.list(userId), [userId, reloads])
|
||||
const last = useRef(null)
|
||||
if (data) last.current = data
|
||||
const shown = data || last.current
|
||||
const onChanged = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
const list = (shown && shown.servers) || []
|
||||
if (!list.length) return null
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Coins</div>
|
||||
{list.map((server) => (
|
||||
<EconomicsServer key={server.serverId} userId={userId} server={server} onChanged={onChanged} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Backpacks (PLAN_REDESIGNS §11.3.2): what this person carries, per server and
|
||||
* linked account, read live when staff open it. Read only (R2): staff see the
|
||||
* overflow too, and only the owner's own open moves it (D338).
|
||||
*/
|
||||
function BackpackPanel({ userId, links }) {
|
||||
const { data } = useAsync(() => api.servers.list(), [])
|
||||
const list = (data && data.servers) || []
|
||||
if (!list.length) return null
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Backpack</div>
|
||||
<Backpack
|
||||
servers={list.map((s) => ({ id: s.id, name: s.name }))}
|
||||
accounts={links.map((l) => ({ steamId: l.steamId, name: l.name }))}
|
||||
read={(serverId, steamId) => api.adminUserBackpacks.read(userId, serverId, steamId)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserRustSections({ userId }) {
|
||||
// Core's `useAsync` has no refresh, so a counter in the deps is how this
|
||||
// re-reads after its own write (the same shape the player page uses).
|
||||
@@ -278,6 +424,12 @@ export default function UserRustSections({ userId }) {
|
||||
above belongs to the links, so it sits with them rather than under
|
||||
the panel it would otherwise appear to describe. */}
|
||||
<PermissionsPanel userId={userId} data={permissions} onChanged={reload} />
|
||||
|
||||
{/* Coins belong to a Steam account, so only with one linked. */}
|
||||
{data.links.length > 0 && <EconomicsPanel userId={userId} />}
|
||||
|
||||
{/* A backpack belongs to a Steam account too. */}
|
||||
{data.links.length > 0 && <BackpackPanel userId={userId} links={data.links} />}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -384,6 +384,8 @@ const MAP_LAYERS = [
|
||||
{ id: 'events', label: 'Site events', hint: 'The zones, crates and NPCs this site’s events placed, while they run.' },
|
||||
{ id: 'players', label: 'Players', hint: 'Where everybody is, with names, and the sleepers.' },
|
||||
{ id: 'bases', label: 'Bases', hint: 'Tool cupboards and vending machines, as positions only.' },
|
||||
{ id: 'pvp', label: 'PvP zones', hint: 'Dynamic PVP’s own PvP zones around a downed helicopter, a Bradley, cargo and the rest. Needs Dynamic PVP on the server.' },
|
||||
{ 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 }
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import { ago, shortId } from '../../lib/format.js'
|
||||
import { ago, coins, shortId } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
import Backpack from '../../components/Backpack.jsx'
|
||||
|
||||
/** The code field, and the four answers it can produce. */
|
||||
function LinkForm({ onLinked }) {
|
||||
@@ -292,6 +293,59 @@ function NpcKills() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Economics (PLAN_REDESIGNS §11.2.2): your coins on each server, per linked
|
||||
* account. Live where the server answers; otherwise the last balance it
|
||||
* reported, marked so. A server without Economics says so (D199) rather than
|
||||
* vanishing — a player asking "where are my coins" is owed the reason.
|
||||
*/
|
||||
function Coins() {
|
||||
const { data, loading, error } = useAsync(() => api.playerEconomics.list(), [])
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
const list = (data && data.servers) || []
|
||||
if (list.length === 0) return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>No servers yet.</p>
|
||||
return (
|
||||
<ul className="sans" style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.86rem', display: 'grid', gap: 6 }}>
|
||||
{list.map((server) => (
|
||||
<li key={server.serverId}>
|
||||
<span className="dim">{server.server}: </span>
|
||||
{!server.economics ? (
|
||||
<span className="dim">{server.reason}</span>
|
||||
) : (
|
||||
server.accounts.map((a, i) => (
|
||||
<span key={a.steamId}>
|
||||
{i > 0 && <span className="dim"> · </span>}
|
||||
{server.accounts.length > 1 && <span className="dim">{a.name || shortId(a.steamId)} </span>}
|
||||
<strong>{coins(a.balance)}</strong>
|
||||
{a.source === 'last-known' && <span className="dim"> (last known — the server did not answer)</span>}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Backpacks (PLAN_REDESIGNS §11.3.2): your own backpack, from one of your
|
||||
* linked accounts, read live when you open it. A server without Backpacks
|
||||
* says so when asked (D199).
|
||||
*/
|
||||
function OwnBackpack({ links }) {
|
||||
const { data } = useAsync(() => api.playerServers.list(), [])
|
||||
const list = (data && data.servers) || []
|
||||
if (!list.length) return null
|
||||
return (
|
||||
<Backpack
|
||||
servers={list.map((s) => ({ id: s.id, name: s.name }))}
|
||||
accounts={links.map((l) => ({ steamId: l.steamId, name: l.name }))}
|
||||
read={(serverId, steamId) => api.playerBackpacks.read(serverId, steamId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Account() {
|
||||
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
|
||||
// the person looking at it changes it, and a page that re-asked every twenty
|
||||
@@ -323,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 />}
|
||||
@@ -360,6 +423,12 @@ export default function Account() {
|
||||
|
||||
<div className="field-label" style={{ margin: '30px 0 12px' }}>NPCs you have killed</div>
|
||||
{data && links.length > 0 ? <NpcKills /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see them.</p>}
|
||||
|
||||
<div className="field-label" style={{ margin: '30px 0 12px' }}>Coins</div>
|
||||
{data && links.length > 0 ? <Coins /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see your balance.</p>}
|
||||
|
||||
<div className="field-label" style={{ margin: '30px 0 12px' }}>Backpack</div>
|
||||
{data && links.length > 0 ? <OwnBackpack links={links} /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see your backpack.</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
62
client/test/backpack.test.js
Normal file
62
client/test/backpack.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// ── A backpack as a grid (PLAN_REDESIGNS §11.3.2) ─────────────────────────
|
||||
//
|
||||
// The grid is drawn from these, so each test is a backpack that could be drawn
|
||||
// wrong: a two-page one, one with overflow past its capacity, one that never
|
||||
// existed, and the labels a slot can carry (D340, D341).
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { backpackPages, overflowCount, stackLabel } from '../src/lib/backpack.js'
|
||||
|
||||
test('a backpack that does not exist is no pages', () => {
|
||||
assert.deepEqual(backpackPages(null), [])
|
||||
assert.deepEqual(backpackPages({ exists: false }), [])
|
||||
})
|
||||
|
||||
test('slots run across pages of the page size', () => {
|
||||
const pages = backpackPages({
|
||||
exists: true,
|
||||
capacity: 96,
|
||||
pageSize: 48,
|
||||
items: [{ slot: 0, shortname: 'wood', amount: 1000 }, { slot: 50, shortname: 'note', amount: 1 }],
|
||||
})
|
||||
assert.equal(pages.length, 2)
|
||||
assert.equal(pages[0].cells.length, 48)
|
||||
assert.equal(pages[1].cells.length, 48)
|
||||
assert.equal(pages[0].cells[0].stack.shortname, 'wood')
|
||||
assert.equal(pages[1].cells[2].slot, 50)
|
||||
assert.equal(pages[1].cells[2].stack.shortname, 'note')
|
||||
assert.ok(pages.every((p) => p.cells.every((c) => !c.overflow)))
|
||||
})
|
||||
|
||||
test('stacks past the capacity are drawn after it, marked overflow', () => {
|
||||
// A size revoked at the end of a run: capacity 6, five notes still on what was page two.
|
||||
const backpack = {
|
||||
exists: true,
|
||||
capacity: 6,
|
||||
pageSize: 48,
|
||||
items: [{ slot: 0, shortname: 'wood', amount: 1 }, ...[48, 49, 50, 51, 52].map((slot) => ({ slot, shortname: 'note', amount: 1, overflow: true }))],
|
||||
}
|
||||
const pages = backpackPages(backpack)
|
||||
assert.equal(pages.length, 2)
|
||||
assert.equal(pages[0].cells.slice(0, 6).filter((c) => c.overflow).length, 0)
|
||||
assert.ok(pages[0].cells.slice(6).every((c) => c.overflow))
|
||||
assert.equal(pages[1].cells.length, 5)
|
||||
assert.equal(overflowCount(backpack), 5)
|
||||
})
|
||||
|
||||
test('an empty backpack still draws its capacity', () => {
|
||||
const pages = backpackPages({ exists: true, capacity: 12, pageSize: 48, items: [] })
|
||||
assert.equal(pages.length, 1)
|
||||
assert.equal(pages[0].cells.length, 12)
|
||||
assert.ok(pages[0].cells.every((c) => c.stack === null))
|
||||
})
|
||||
|
||||
test('a slot is named by the game, a blueprint by what it makes', () => {
|
||||
assert.equal(stackLabel({ shortname: 'rifle.ak', display: 'Assault Rifle' }), 'Assault Rifle')
|
||||
assert.equal(stackLabel({ shortname: 'blueprintbase', display: 'Assault Rifle', blueprint: 'rifle.ak' }), 'Assault Rifle blueprint')
|
||||
assert.equal(stackLabel({ shortname: 'wood' }), 'wood')
|
||||
assert.equal(stackLabel({ shortname: null, itemId: 999 }), 'item 999')
|
||||
assert.equal(stackLabel(null), '')
|
||||
})
|
||||
@@ -155,3 +155,201 @@ test('an unknown filter falls back to everything rather than to nothing', () =>
|
||||
assert.deepEqual(kindsFor('nonsense'), FEED_KINDS)
|
||||
assert.deepEqual(kindsFor(undefined), FEED_KINDS)
|
||||
})
|
||||
|
||||
// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ─────────
|
||||
//
|
||||
// The frames here are what the SERVER sends a viewer (`worldEvents.shapeFor`),
|
||||
// not what the plugin emitted: a grid instead of a position, names only for the
|
||||
// presence audience, `credited` by the server's rule.
|
||||
|
||||
const world = (frame) => row('world.event', { kind: 'world.event', ...frame })
|
||||
|
||||
test('a downed helicopter names whom the rule credits, and only them', () => {
|
||||
const one = describe(world({ event: 'heli.downed', grid: 'G7', creditRule: 'blow', creditedCount: 1, credited: [{ name: 'Alice' }] }))
|
||||
assert.deepEqual([one.tone, one.verb, one.subject, one.detail], ['kill', 'Patrol helicopter downed by', 'Alice', 'G7'])
|
||||
|
||||
const many = describe(world({ event: 'heli.downed', grid: 'G7', creditRule: 'all', creditedCount: 4, credited: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Cleo' }, { name: 'Dov' }] }))
|
||||
assert.equal(many.subject, 'Alice, Bob and 2 others')
|
||||
})
|
||||
|
||||
test('below the presence audience the same line names nobody (D376)', () => {
|
||||
const line = describe(world({ event: 'heli.downed', grid: 'G8', creditRule: 'blow', creditedCount: 1 }))
|
||||
assert.deepEqual([line.verb, line.subject, line.detail], ['Patrol helicopter downed', null, 'G8'])
|
||||
|
||||
const hack = describe(world({ event: 'crate.hack.started', where: 'oilrig_large', grid: 'C3' }))
|
||||
assert.equal(hack.actor, null)
|
||||
assert.equal(hack.verb, 'A locked crate on Large Oil Rig is being hacked')
|
||||
})
|
||||
|
||||
test('a named line reads as the plan writes it', () => {
|
||||
const hack = describe(world({ event: 'crate.hack.started', where: 'oilrig_large', name: 'Bob' }))
|
||||
assert.equal(`${hack.actor} ${hack.verb}`, 'Bob started hacking the locked crate on Large Oil Rig')
|
||||
|
||||
const road = describe(world({ event: 'bradley.spawned', where: 'road', count: 12 }))
|
||||
assert.equal(road.verb, '12 road Bradleys are out')
|
||||
|
||||
assert.equal(describe(world({ event: 'deepsea.opened' })).verb, 'The Deep Sea is open')
|
||||
assert.equal(describe(world({ event: 'cargo.docked', harbourName: 'Harbor', grid: 'Q9' })).verb, 'The cargo ship has docked at Harbor')
|
||||
assert.equal(describe(world({ event: 'chinook.dropped', monumentName: 'Water Treatment Plant' })).verb, 'A Chinook dropped a locked crate at Water Treatment Plant')
|
||||
})
|
||||
|
||||
test('every type the server knows has its own words, and an unknown one says its name', () => {
|
||||
const { TYPE_IDS } = createRequire(import.meta.url)('../../server/worldEvents.js')
|
||||
for (const event of TYPE_IDS) {
|
||||
const line = describe(world({ event }))
|
||||
assert.notEqual(line.tone, 'other', `${event} has no words of its own`)
|
||||
assert.ok(line.verb && line.verb !== event, event)
|
||||
}
|
||||
assert.equal(describe(world({ event: 'meteor.landed' })).verb, 'meteor.landed')
|
||||
})
|
||||
|
||||
test('the world filter asks for world events, Dynamic PVP zones opening, Dangerous Treasures, 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')
|
||||
})
|
||||
|
||||
// ── Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3) ─────────────
|
||||
|
||||
const pvp = (kind, frame) => row(kind, { kind, ...frame })
|
||||
|
||||
test('a zone opening says what it is around, where, and for how long, and names nobody', () => {
|
||||
const heli = describe(pvp('pvp.zone.created', { event: 'Helicopter', grid: 'G7', durationSec: 600 }))
|
||||
assert.equal(heli.verb, 'A PvP zone opened around the downed patrol helicopter')
|
||||
assert.equal(heli.detail, 'G7 · for 10 min')
|
||||
assert.equal(heli.actor, null)
|
||||
assert.equal(heli.subject, null)
|
||||
|
||||
const cargo = describe(pvp('pvp.zone.created', { event: 'CargoShip', grid: 'Q9', durationSec: -1 }))
|
||||
assert.equal(cargo.verb, 'A PvP zone opened around the cargo ship')
|
||||
assert.equal(cargo.detail, 'Q9 · while the ship is here')
|
||||
|
||||
assert.equal(describe(pvp('pvp.zone.created', { event: 'Bradley', durationSec: 90 })).detail, 'for 90 s')
|
||||
})
|
||||
|
||||
test('a zone the public are not told the event of is just a zone; staff read a monument’s name as written', () => {
|
||||
assert.equal(describe(pvp('pvp.zone.created', { grid: 'C3' })).verb, 'A PvP zone opened')
|
||||
assert.equal(describe(pvp('pvp.zone.created', { event: 'Airfield' })).verb, 'A PvP zone opened (Airfield)')
|
||||
})
|
||||
|
||||
test('a zone closing, for a staff reader, says which one closed', () => {
|
||||
assert.equal(describe(pvp('pvp.zone.deleted', { event: 'Helicopter' })).verb, 'The PvP zone around the downed patrol helicopter has closed')
|
||||
assert.equal(describe(pvp('pvp.zone.deleted', {})).verb, 'The PvP zone has closed')
|
||||
})
|
||||
|
||||
test('every one of Dynamic PVP’s own events has words, and the server and client agree on which they are', () => {
|
||||
const { GENERAL_EVENTS } = createRequire(import.meta.url)('../../server/dynamicPvp.js')
|
||||
for (const event of GENERAL_EVENTS) {
|
||||
assert.ok(!describe(pvp('pvp.zone.created', { event })).verb.includes(`(${event})`), `${event} has no words of its own`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4) ─────────────
|
||||
|
||||
const treasure = (kind, frame) => row(kind, { kind, ...frame })
|
||||
|
||||
test('a treasure event starting says where, and names nobody', () => {
|
||||
const line = describe(treasure('dt.started', { grid: 'K12' }))
|
||||
assert.equal(line.verb, 'A Dangerous Treasures event has started')
|
||||
assert.equal(line.detail, 'K12')
|
||||
assert.equal(line.actor, null)
|
||||
})
|
||||
|
||||
test('a win names its winner only when the server sent the name, and says how many fought', () => {
|
||||
const named = describe(treasure('dt.won', { grid: 'K12', name: 'Alice', invaderCount: 3 }))
|
||||
assert.equal(named.actor, 'Alice')
|
||||
assert.equal(named.verb, 'won the treasure')
|
||||
assert.equal(named.detail, 'K12 · 3 players fought for it')
|
||||
|
||||
const unnamed = describe(treasure('dt.won', { grid: 'K12', invaderCount: 1 }))
|
||||
assert.equal(unnamed.actor, null)
|
||||
assert.equal(unnamed.verb, 'The treasure was won')
|
||||
assert.equal(unnamed.detail, 'K12')
|
||||
})
|
||||
|
||||
test('a treasure event ending, for a staff reader, is its own line', () => {
|
||||
assert.equal(describe(treasure('dt.ended', { grid: 'K12' })).verb, 'A Dangerous Treasures event has ended')
|
||||
})
|
||||
|
||||
// ── Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5) ───────────────────
|
||||
|
||||
const guarded = (kind, frame) => row(kind, { kind, ...frame })
|
||||
|
||||
test('a guarded crate landing says where, and names nobody', () => {
|
||||
const line = describe(guarded('gc.started', { grid: 'M4' }))
|
||||
assert.equal(line.verb, 'A guarded crate landed')
|
||||
assert.equal(line.detail, 'M4')
|
||||
assert.equal(line.actor, null)
|
||||
})
|
||||
|
||||
test('a clearing names its clearer only when the server sent the name', () => {
|
||||
const named = describe(guarded('gc.ended', { grid: 'M4', won: true, name: 'Alice' }))
|
||||
assert.equal(named.actor, 'Alice')
|
||||
assert.equal(named.verb, 'cleared the guarded crate')
|
||||
assert.equal(named.detail, 'M4')
|
||||
|
||||
const unnamed = describe(guarded('gc.ended', { grid: 'M4', won: true }))
|
||||
assert.equal(unnamed.actor, null)
|
||||
assert.equal(unnamed.verb, 'The guarded crate was cleared')
|
||||
})
|
||||
|
||||
test('an ending with nobody clearing it, and a timeout for a staff reader, are their own lines', () => {
|
||||
assert.equal(describe(guarded('gc.ended', { grid: 'M4', won: false })).verb, 'A guarded crate ended with nobody clearing it')
|
||||
assert.equal(describe(guarded('gc.failed', { grid: 'M4' })).verb, 'A guarded crate timed out with its guards standing')
|
||||
})
|
||||
|
||||
// ── RaidableBases (protocol 14, PLAN_REDESIGNS §11.4) ─────────────────────
|
||||
//
|
||||
// Every difficulty here is invented (D386): the line says the plugin's own name.
|
||||
|
||||
const raid = (kind, frame) => row(kind, { kind, ...frame })
|
||||
|
||||
test('a raid base appearing says its difficulty as the plugin named it, where, and whether it is PvP', () => {
|
||||
const line = describe(raid('raid.started', { difficulty: 'Wobbly', grid: 'G7', pvp: true }))
|
||||
assert.equal(line.verb, 'A Wobbly raid base appeared')
|
||||
assert.equal(line.detail, 'G7 · PvP')
|
||||
assert.equal(line.actor, null)
|
||||
assert.equal(describe(raid('raid.started', { difficulty: 'Eerie', grid: 'G7' })).verb, 'An Eerie raid base appeared')
|
||||
assert.equal(describe(raid('raid.started', { grid: 'G7' })).verb, 'A raid base appeared')
|
||||
})
|
||||
|
||||
test('a raid names its raiders only when the server sent the names, and otherwise counts them', () => {
|
||||
const named = describe(raid('raid.completed', { difficulty: 'Wobbly', grid: 'G7', names: ['Alice', 'Bob', 'Cy'], raiderCount: 4 }))
|
||||
assert.equal(named.actor, 'Alice, Bob and 2 others')
|
||||
assert.equal(named.verb, 'raided a Wobbly raid base')
|
||||
|
||||
const two = describe(raid('raid.completed', { difficulty: 'Wobbly', names: ['Alice', 'Bob'], raiderCount: 2 }))
|
||||
assert.equal(two.actor, 'Alice and Bob')
|
||||
|
||||
const unnamed = describe(raid('raid.completed', { difficulty: 'Wobbly', grid: 'G7', raiderCount: 3 }))
|
||||
assert.equal(unnamed.actor, null)
|
||||
assert.equal(unnamed.verb, 'A Wobbly raid base was raided')
|
||||
assert.equal(unnamed.detail, 'G7 · by 3 players')
|
||||
})
|
||||
|
||||
test('a purchase and an admin lock name the player only when the server sent the name (D390), and a despawn is its own line', () => {
|
||||
assert.equal(describe(raid('raid.purchased', { difficulty: 'Wobbly', name: 'Alice' })).actor, 'Alice')
|
||||
assert.equal(describe(raid('raid.purchased', { difficulty: 'Wobbly', name: 'Alice' })).verb, 'bought a Wobbly raid base')
|
||||
assert.equal(describe(raid('raid.purchased', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base was bought')
|
||||
assert.equal(describe(raid('raid.locked', { difficulty: 'Wobbly', name: 'Alice' })).verb, 'A Wobbly raid base was locked to Alice')
|
||||
assert.equal(describe(raid('raid.locked', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base was locked to a player')
|
||||
assert.equal(describe(raid('raid.ended', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base despawned')
|
||||
})
|
||||
|
||||
test('a kill line says the weapon by name', () => {
|
||||
const named = describe(row('player.death', {
|
||||
name: 'Bob', attackerType: 'player', attackerName: 'Alice',
|
||||
weapon: 'ak47u.entity', weaponName: 'Assault Rifle', distance: 22,
|
||||
}))
|
||||
assert.match(named.detail, /with Assault Rifle/)
|
||||
assert.doesNotMatch(named.detail, /entity/)
|
||||
|
||||
const older = describe(row('player.death', {
|
||||
name: 'Bob', attackerType: 'player', attackerName: 'Alice', weapon: 'bolt_rifle.entity',
|
||||
}))
|
||||
assert.match(older.detail, /with bolt rifle/)
|
||||
assert.doesNotMatch(older.detail, /entity/)
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { ago, attacker, clock, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
|
||||
import { ago, attacker, clock, coins, contrastInk, count, day, duration, nextWipe, prefab, shortId, weapon } from '../src/lib/format.js'
|
||||
|
||||
const NOW = Date.parse('2026-09-16T12:00:00Z')
|
||||
|
||||
@@ -70,6 +70,13 @@ test('a count that is not a number is zero, never NaN on the page', () => {
|
||||
assert.equal(count(null), '0')
|
||||
})
|
||||
|
||||
test('a balance never reported is a dash, not zero coins (PLAN_REDESIGNS §11.2.2)', () => {
|
||||
assert.equal(coins(null), '—')
|
||||
assert.equal(coins(undefined), '—')
|
||||
assert.equal(coins(0), (0).toLocaleString())
|
||||
assert.equal(coins(1500.456), (1500.46).toLocaleString(undefined, { maximumFractionDigits: 2 }))
|
||||
})
|
||||
|
||||
test("a feed row from another day carries its date, not just a time", () => {
|
||||
// Found by the page walk: with the feed filtered to the previous wipe, three
|
||||
// events from six weeks ago rendered as `02:03 PM` and read as this afternoon.
|
||||
@@ -140,3 +147,15 @@ test('an NPC attacker reads as a name: a family, or the prefab without its varia
|
||||
test('a weapon keeps the light touch — attacker() is for what did the killing', () => {
|
||||
assert.equal(prefab('rifle.ak'), 'rifle ak')
|
||||
})
|
||||
|
||||
test('a kill prints the weapon the server named, and never an entity ending', () => {
|
||||
// weaponName is the server's (model/titles/weaponNames.js): the game's own name for a gun.
|
||||
assert.equal(weapon({ weapon: 'ak47u.entity', weaponName: 'Assault Rifle' }), 'Assault Rifle')
|
||||
// A server too old to send it: the ending dropped, the rest as words.
|
||||
assert.equal(weapon({ weapon: 'ak47u.entity' }), 'ak47u')
|
||||
assert.equal(weapon({ weapon: 'bolt_rifle.entity' }), 'bolt rifle')
|
||||
assert.equal(weapon({ weapon: 'machete.weapon' }), 'machete')
|
||||
assert.equal(weapon({ weapon: 'rifle.ak' }), 'rifle ak')
|
||||
assert.equal(weapon({}), '')
|
||||
assert.equal(weapon(null), '')
|
||||
})
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { eventTip, isBoss, titlesFrom, unknownRuns } from '../src/lib/mapEvents.js'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { eventTip, isBoss, pvpTip, PVP_NAMES, raidTip, titlesFrom, unknownRuns } from '../src/lib/mapEvents.js'
|
||||
|
||||
const CALENDAR = {
|
||||
entries: [
|
||||
@@ -47,3 +49,20 @@ test('a boss is an event boss or a world one', () => {
|
||||
test('the run ids still to look up', () => {
|
||||
assert.deepEqual(unknownRuns([{ runId: '41' }, { runId: '7' }, { runId: '7' }, {}], { 41: 'x' }), ['7'])
|
||||
})
|
||||
|
||||
// Dynamic PVP's rings (PLAN_REDESIGNS §14.3.3): its own events by a plain name,
|
||||
// a monument's or an operator's as written, and nothing at all as just a zone.
|
||||
test('a Dynamic PVP ring says what it is around', () => {
|
||||
assert.equal(pvpTip({ event: 'Helicopter' }), 'PvP zone · Patrol helicopter')
|
||||
assert.equal(pvpTip({ event: 'Airfield' }), 'PvP zone · Airfield')
|
||||
assert.equal(pvpTip({}), 'PvP zone')
|
||||
const { GENERAL_EVENTS } = createRequire(import.meta.url)('../../server/dynamicPvp.js')
|
||||
assert.deepEqual(Object.keys(PVP_NAMES).sort(), [...GENERAL_EVENTS].sort())
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"id": "rust",
|
||||
"name": "Rust",
|
||||
"version": "0.1.0",
|
||||
"coreApi": "^1.11.0",
|
||||
"coreApi": "^1.19.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
@@ -13,5 +13,9 @@
|
||||
"player": ["/rust"]
|
||||
},
|
||||
"extensions": ["admin.users.detail"],
|
||||
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"]
|
||||
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"],
|
||||
"demoLocks": [
|
||||
{ "method": "PUT", "path": "/admin/rust/servers/:id" },
|
||||
{ "method": "DELETE", "path": "/admin/rust/servers/:id" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -126,6 +126,16 @@
|
||||
"path": "/api/v1/admin/rust/zones",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/rust/backpacks",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/rust/economics",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/rust/links",
|
||||
@@ -136,6 +146,16 @@
|
||||
"path": "/api/v1/admin/users/:id/rust/permissions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/backpacks",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/economics",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/links",
|
||||
@@ -351,6 +371,11 @@
|
||||
"path": "/api/v1/admin/rust/zones/presets",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/:id/rust/economics",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/:id/rust/permissions/grants",
|
||||
|
||||
@@ -59,6 +59,38 @@ const PUBLIC_KINDS = Object.freeze([
|
||||
// the players near it (D286), so `npc.boss.phase` is staff's, below.
|
||||
'npc.boss.spawned',
|
||||
'npc.boss.died',
|
||||
// Protocol 14: Rust's own world events (PLAN_REDESIGNS §14.3.1, D353, D376).
|
||||
// Public as a KIND, and filtered per `event` inside it by `worldEvents.js`:
|
||||
// an event type this build does not know is staff's alone, a player's name
|
||||
// shows only to the presence audience, a Steam id only to staff, and a
|
||||
// position to nobody. So it is not a presence kind — the event names nobody
|
||||
// once `worldEvents.shapeFor` has read it for a viewer below that audience.
|
||||
'world.event',
|
||||
// Protocol 14: Dynamic PVP opened a PvP zone (PLAN_REDESIGNS §14.3.3). It
|
||||
// names nobody, and `dynamicPvp.shapeFor` keeps its position for staff and an
|
||||
// operator's own event names off public pages.
|
||||
'pvp.zone.created',
|
||||
// Protocol 14: a Dangerous Treasures event opened, and was won
|
||||
// (PLAN_REDESIGNS §14.3.4). Public as kinds, read per viewer by
|
||||
// `dangerousTreasures.shapeFor` as a world event is: the winner's name only
|
||||
// for the presence audience, Steam ids and the position only for staff.
|
||||
'dt.started',
|
||||
'dt.won',
|
||||
// Protocol 14: a guarded crate landed, and was cleared (PLAN_REDESIGNS
|
||||
// §14.3.5). Public as kinds, read per viewer by `guardedCrate.shapeFor` as a
|
||||
// treasure is: the clearer's name only for the presence audience, the Steam id
|
||||
// and the position only for staff.
|
||||
'gc.started',
|
||||
'gc.ended',
|
||||
// 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',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -121,6 +153,18 @@ const STAFF_KINDS = Object.freeze([
|
||||
'npc.placement.changed',
|
||||
// Runicnpc stage 6: a boss's phase is said in game only to the players near it (D286).
|
||||
'npc.boss.phase',
|
||||
// Protocol 14 (§14.3.3): a Dynamic PVP zone closed. The plan keeps its close
|
||||
// a staff line: the public learns a zone opened, and the map shows it going.
|
||||
'pvp.zone.deleted',
|
||||
// Protocol 14 (§14.3.4): a Dangerous Treasures event ended, won or not. The
|
||||
// plan keeps it staff's; the map shows the chest go.
|
||||
'dt.ended',
|
||||
// Protocol 14 (§14.3.5): a guarded crate timed out with guards still standing.
|
||||
// The plan keeps it staff's; the map shows the crate go.
|
||||
'gc.failed',
|
||||
// Protocol 14 (§11.4): a RaidableBases base despawned. Staff's; the map shows
|
||||
// the base go.
|
||||
'raid.ended',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -152,7 +196,7 @@ const PRESENCE_KINDS = Object.freeze([
|
||||
'npc.boss.died',
|
||||
])
|
||||
|
||||
/** Every event kind the protocol defines, through protocol 13. */
|
||||
/** Every event kind the protocol defines, through protocol 14. */
|
||||
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
||||
|
||||
const PUBLIC = new Set(PUBLIC_KINDS)
|
||||
|
||||
@@ -186,4 +186,9 @@ module.exports = {
|
||||
// origin to be relative to. A getter over core's getter, so an operator who
|
||||
// changes `APP_BASE_URL` is read at the call, never captured at load.
|
||||
get baseUrl() { return need().site.baseUrl },
|
||||
|
||||
// Whether this is the public demo (MODULE_API 1.18.0, DEMO_MODE.md §3.4, §3.5).
|
||||
// Read through `demo.js`, which holds every lock it switches on. Only a real
|
||||
// `true` counts, so a core that predates the member reads as "not the demo".
|
||||
get demoMode() { return need().demoMode === true },
|
||||
}
|
||||
|
||||
97
server/dangerousTreasures.js
Normal file
97
server/dangerousTreasures.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4) ──────────────
|
||||
//
|
||||
// nivex's Dangerous Treasures opens a guarded, burning treasure chest somewhere
|
||||
// on the map. Its events reach this module as three kinds: `dt.started`
|
||||
// (public), `dt.won` (public) and `dt.ended` (staff's). The chest emptied is
|
||||
// *Treasure Hunter*'s count, from the tally's `treasuresWon` (D375). A run's step
|
||||
// opens one (`eventDangerousTreasures.js`).
|
||||
//
|
||||
// ── Who may read what ──────────────────────────────────────────────────────
|
||||
//
|
||||
// As a world event is read (`worldEvents.js`, D353, D376): the event and its
|
||||
// grid are public; the winner's name shows only to the operator's presence
|
||||
// audience, so with presence left at staff a signed-out visitor reads "The
|
||||
// treasure was won at K12"; Steam ids, the invaders' among them, and the
|
||||
// position are staff's. How many fought for it is a count, and names nobody.
|
||||
// The step's key, when a step opened it, is staff's too.
|
||||
|
||||
const { gridOf } = require('./worldEvents')
|
||||
|
||||
const KINDS = Object.freeze(['dt.started', 'dt.won', 'dt.ended'])
|
||||
|
||||
/** DT's three shipped difficulties, as `dtevent <level>` takes them. */
|
||||
const LEVELS = Object.freeze([
|
||||
{ value: 0, label: 'Easy' },
|
||||
{ value: 1, label: 'Medium' },
|
||||
{ value: 2, label: 'Hard' },
|
||||
])
|
||||
|
||||
const STEAM_ID = /^\d{17}$/
|
||||
|
||||
function isKind(kind) {
|
||||
return KINDS.includes(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* One `dt.*` frame for one viewer: `{ staff, presence, geometry }`. Passes the
|
||||
* envelope, adds `grid`; a win adds `invaderCount`, and `name` for the presence
|
||||
* audience. Staff also get `x`, `z`, the step's `key`, the winner's `steamId` and
|
||||
* the `invaders`. Any other frame is returned as is.
|
||||
*/
|
||||
function shapeFor(frame, { staff = false, presence = false, geometry = null } = {}) {
|
||||
if (!frame || !isKind(frame.kind)) return frame
|
||||
|
||||
const out = {}
|
||||
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId']) {
|
||||
if (frame[key] !== undefined) out[key] = frame[key]
|
||||
}
|
||||
|
||||
const grid = gridOf(geometry, frame.x, frame.z)
|
||||
if (grid) out.grid = grid
|
||||
|
||||
if (frame.kind === 'dt.won') {
|
||||
const invaders = Array.isArray(frame.invaders) ? frame.invaders : []
|
||||
const count = Number(frame.invaderCount)
|
||||
out.invaderCount = Number.isInteger(count) && count >= 0 ? count : invaders.length
|
||||
if ((presence || staff) && typeof frame.name === 'string' && frame.name) out.name = frame.name
|
||||
if (staff) {
|
||||
if (STEAM_ID.test(String(frame.steamId || ''))) out.steamId = String(frame.steamId)
|
||||
out.invaders = invaders.map(String).filter((id) => STEAM_ID.test(id))
|
||||
}
|
||||
}
|
||||
|
||||
if (staff) {
|
||||
for (const key of ['x', 'z', 'key']) {
|
||||
if (frame[key] !== undefined) out[key] = frame[key]
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A server's last report about Dangerous Treasures and its console, from
|
||||
* `servers.db.dtReportFor`: `{ dangerousTreasures, consolePlugins }`, each null
|
||||
* where the plugin never said (no state yet, or a plugin before protocol 14).
|
||||
* `dangerousTreasures` is `{ loaded, version }`.
|
||||
*/
|
||||
function reportOf(row) {
|
||||
const parse = (value) => {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const dt = row ? parse(row.dangerousTreasures) : null
|
||||
const probe = row ? parse(row.console) : null
|
||||
return {
|
||||
dangerousTreasures:
|
||||
dt && typeof dt === 'object' ? { loaded: dt.loaded === true, version: typeof dt.version === 'string' ? dt.version : null } : null,
|
||||
consolePlugins: probe && typeof probe === 'object' && typeof probe.plugins === 'boolean' ? probe.plugins : null,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { KINDS, LEVELS, isKind, shapeFor, reportOf }
|
||||
@@ -857,6 +857,16 @@ CREATE TABLE IF NOT EXISTS rust_perm_run_grants (
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The reward's message to its player (protocol 14, PLAN_REDESIGNS.md §2.4,
|
||||
-- D333): "You earned kit vip — type /kit vip", said once in game, at once or
|
||||
-- when they next wake. NULL `notice` is a reward that says nothing, which is
|
||||
-- every row written before this. `notice_told_at` is set when the plugin
|
||||
-- reports the player was told; until then every sync carries it. A withdrawn
|
||||
-- reward deletes its row, and its untold message goes with it.
|
||||
ALTER TABLE rust_perm_run_grants ADD COLUMN IF NOT EXISTS notice VARCHAR(300) NULL;
|
||||
ALTER TABLE rust_perm_run_grants ADD COLUMN IF NOT EXISTS notice_delivery VARCHAR(8) NULL;
|
||||
ALTER TABLE rust_perm_run_grants ADD COLUMN IF NOT EXISTS notice_told_at DATETIME NULL;
|
||||
|
||||
-- The news switch (D104): whether a published news post is also said in this
|
||||
-- server's chat. Off, because core enqueues every registered leg for every
|
||||
-- post, and without it the day this module updates every post would start
|
||||
@@ -1353,3 +1363,38 @@ CREATE TABLE IF NOT EXISTS rust_npc_factions (
|
||||
-- stage 4, D250). Null for every other stat. No foreign key: a deleted profile
|
||||
-- leaves a rule that ranks nobody, which the form shows and an admin removes.
|
||||
ALTER TABLE rust_title_rules ADD COLUMN IF NOT EXISTS profile_id INT UNSIGNED NULL;
|
||||
|
||||
-- Economics (PLAN_REDESIGNS §11.2.2, D336): a player's last known coin balance
|
||||
-- on the server, for the wipe it was reported in. A VALUE, written over, never
|
||||
-- summed — the tally carries the balance itself, not a change. NULL until the
|
||||
-- server has reported one, and again after `ecowipe` clears the wipe's. The
|
||||
-- richest column and the Magnate title rank only balances above the server's
|
||||
-- starting balance, so the untouched accounts of a fresh wipe rank nobody.
|
||||
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS balance DECIMAL(14,2) NULL;
|
||||
|
||||
-- Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1). Two title
|
||||
-- counts (D356): locked crates a player started hacking, once per crate
|
||||
-- (Safecracker), and airdrops they looted first (Scavenger). Sums, from the
|
||||
-- tally like the others. APC and helicopter kills keep their columns; under
|
||||
-- protocol 14 the site writes them from `world.event`'s credit block instead.
|
||||
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS crates_hacked INT UNSIGNED NOT NULL DEFAULT 0;
|
||||
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS drops_looted INT UNSIGNED NOT NULL DEFAULT 0;
|
||||
|
||||
-- Who a downed helicopter, Chinook or Bradley credits on this server (D372,
|
||||
-- D379): `blow` (the killing blow; for the patrol helicopter the crippling hit,
|
||||
-- else the last hit), `last`, `most` (damage) or `all` (everyone who hurt it,
|
||||
-- one each). The same rule writes the feed line and the count. A change applies
|
||||
-- from then on; counts already written are not recounted.
|
||||
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS credit_rule VARCHAR(8) NOT NULL DEFAULT 'blow';
|
||||
|
||||
-- Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4, D375): the treasure
|
||||
-- chests a player emptied, from the tally like the others (Treasure Hunter).
|
||||
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS treasures_won INT UNSIGNED NOT NULL DEFAULT 0;
|
||||
|
||||
-- Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5, D375): the guarded crates
|
||||
-- a player cleared by killing the last guard, from the tally like the others (Warden).
|
||||
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS guarded_won INT UNSIGNED NOT NULL DEFAULT 0;
|
||||
|
||||
-- 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;
|
||||
|
||||
135
server/demo.js
Normal file
135
server/demo.js
Normal file
@@ -0,0 +1,135 @@
|
||||
// ── The public demo's module-side locks (DEMO_MODE.md §3.4, §3.5) ──────────
|
||||
//
|
||||
// Core locks this module's two link routes by `demoLocks` in module.json. What a
|
||||
// route list cannot express lives here, behind `ctx.demoMode` (MODULE_API
|
||||
// 1.18.0), and every handler that enforces one of these asks this file:
|
||||
//
|
||||
// the console-command deny list (§3.4, DM11): the commands that would break
|
||||
// the rig for every visitor until the next wipe. Asked at save, through
|
||||
// core's `demoRefuses` hook (1.19.0, DM18), and again at run;
|
||||
// the bridge's own config (§3.5, DM14): neither readable nor writable, because
|
||||
// it holds the bridge's link to the sidecar and its credentials;
|
||||
// secrets in every other config: redacted on the way out, read-only on the
|
||||
// way in, and no raw-JSON saves.
|
||||
//
|
||||
// Off the demo nothing here refuses anything. The server is the lock (DM10):
|
||||
// the UI may explain a refusal, but no screen is relied on to make one.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const CODE = 'DEMO_LOCKED'
|
||||
const MESSAGE = 'This is turned off on the demo site.'
|
||||
|
||||
/** Whether this is the public demo. */
|
||||
function isDemo() {
|
||||
return core.demoMode
|
||||
}
|
||||
|
||||
/** The demo's refusal, in core's shape, so core's one toast shows it. */
|
||||
function refuse(res, message = MESSAGE, extra = {}) {
|
||||
return res.status(403).json({ message, code: CODE, ...extra })
|
||||
}
|
||||
|
||||
// ── The console-command deny list (§3.4, DM11) ─────────────────────────────
|
||||
|
||||
/** Denied by first word, exactly. */
|
||||
const DENIED = new Set(['quit', 'restart', 'oxide.unload', 'oxide.reload', 'fps.limit', 'server.tickrate'])
|
||||
|
||||
/** Denied by prefix: every RCON command. */
|
||||
const DENIED_PREFIXES = ['rcon.']
|
||||
|
||||
/**
|
||||
* The command a line runs, as the deny list compares it: the first word,
|
||||
* lower-cased, with a leading `global.` stripped (Rust accepts `global.quit`)
|
||||
* and the `o.` short form spelled `oxide.` (`o.unload`). Only the first word,
|
||||
* so arguments cannot hide a denied command; `;` does not split a Rust console
|
||||
* line (the §16 spike), so there is no second command to look for.
|
||||
*/
|
||||
function commandWord(line) {
|
||||
let word = String(line === undefined || line === null ? '' : line).trim().split(/\s+/)[0].toLowerCase()
|
||||
if (word.startsWith('global.')) word = word.slice('global.'.length)
|
||||
if (word.startsWith('o.')) word = `oxide.${word.slice('o.'.length)}`
|
||||
return word
|
||||
}
|
||||
|
||||
/** Whether one console line is denied on the demo, by its first word. */
|
||||
function isDeniedCommand(line) {
|
||||
const word = commandWord(line)
|
||||
if (!word) return false
|
||||
return DENIED.has(word) || DENIED_PREFIXES.some((prefix) => word.startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal sentence for a list of console lines, or null when none is
|
||||
* denied — or when this is not the demo. Names the line as the author typed it.
|
||||
*/
|
||||
function deniedCommand(lines) {
|
||||
if (!isDemo()) return null
|
||||
const items = Array.isArray(lines) ? lines : lines === undefined || lines === null ? [] : [lines]
|
||||
const hit = items.find((line) => isDeniedCommand(line))
|
||||
return hit === undefined ? null : `"${String(hit).trim()}" is turned off on the demo site.`
|
||||
}
|
||||
|
||||
// ── The bridge's own config (§3.5, DM14) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* The bridge plugin's name. The catalogue's `self` is the plugin's own word for
|
||||
* it and is used when it is there; this is the floor, so a catalogue that could
|
||||
* not be read never unlocks the file (the lock fails closed).
|
||||
*/
|
||||
const BRIDGE = 'RunicGateway'
|
||||
|
||||
/**
|
||||
* A config path in the form the host will resolve it to, for the bridge test
|
||||
* only: `.` segments dropped, each segment trimmed of the spaces and trailing
|
||||
* dots Windows ignores, and lower-cased. `./RunicGateway.json` and
|
||||
* `RunicGateway /x.json` are the bridge's files on some host, so they are the
|
||||
* bridge's files here.
|
||||
*/
|
||||
function canonicalSegments(path) {
|
||||
return String(path || '')
|
||||
.split(/[\\/]+/)
|
||||
// A `.` segment trims to nothing and is dropped with the empty ones.
|
||||
.map((segment) => segment.trim().replace(/[. ]+$/, ''))
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a config path belongs to the bridge plugin: its first segment, or its
|
||||
* file name at the root, is the bridge's name. `self` is the catalogue's.
|
||||
*/
|
||||
function isBridgePath(path, self) {
|
||||
const names = new Set([BRIDGE.toLowerCase()])
|
||||
if (self) names.add(String(self).toLowerCase())
|
||||
const parts = canonicalSegments(path)
|
||||
if (!parts.length) return false
|
||||
const first = parts.length > 1 ? parts[0] : parts[0].replace(/\.json$/, '')
|
||||
return names.has(first)
|
||||
}
|
||||
|
||||
/** Whether a plugin name (a catalogue group, an audit row's plugin) is the bridge. */
|
||||
function isBridgePlugin(plugin, self) {
|
||||
const name = String(plugin || '').toLowerCase()
|
||||
return name === BRIDGE.toLowerCase() || (self ? name === String(self).toLowerCase() : false)
|
||||
}
|
||||
|
||||
// ── Secrets in every other config (§3.5) ───────────────────────────────────
|
||||
|
||||
/** What a redacted value reads as, in the text and in the form. */
|
||||
const REDACTED = '(hidden on the demo site)'
|
||||
|
||||
module.exports = {
|
||||
CODE,
|
||||
MESSAGE,
|
||||
BRIDGE,
|
||||
REDACTED,
|
||||
isDemo,
|
||||
refuse,
|
||||
commandWord,
|
||||
isDeniedCommand,
|
||||
deniedCommand,
|
||||
canonicalSegments,
|
||||
isBridgePath,
|
||||
isBridgePlugin,
|
||||
}
|
||||
190
server/demoSeed.js
Normal file
190
server/demoSeed.js
Normal file
@@ -0,0 +1,190 @@
|
||||
// ── The public demo's seed: this module's rows (MODULE_API 1.18.0, DM15) ────
|
||||
//
|
||||
// `npm run seed:demo` hands each module its `modules.<id>` section of the
|
||||
// fixture, with every `"$env:NAME"` already replaced, and the demo accounts' ids
|
||||
// (DEMO_MODE.md §7.3). This module writes two kinds of row from it, through its
|
||||
// own models; core never writes them:
|
||||
//
|
||||
// servers the link to the rig: the sidecar URL and token come from the host's
|
||||
// .env, so the token is never in the repo
|
||||
// links demo-player's stand-in Steam id (DM6), so the player screens have
|
||||
// somebody to show
|
||||
// npcs the NPC faction table and profiles of the demo default set (DM26)
|
||||
// chat a BetterChat style per group (DM26)
|
||||
//
|
||||
// { "servers": [{ "id": "main", "name": "Demo", "sidecarBaseUrl": "$env:RUST_SIDECAR_URL",
|
||||
// "sidecarToken": "$env:RUST_SIDECAR_TOKEN" }],
|
||||
// "links": [{ "username": "demo-player", "steamId": "7656119…", "serverId": "main" }],
|
||||
// "npcs": { "factions": [{ "a": "bandits", "b": "guards", "relation": "allied" }],
|
||||
// "profiles": [{ "name": "bandit", "servers": ["main"], "body": { … } }] },
|
||||
// "chat": [{ "serverId": "main", "group": "vip", "style": { "Title": "[VIP]", … } }] }
|
||||
//
|
||||
// **Why NPCs and chat come from here, not the rig's baseline (DM26).** The rig is
|
||||
// restored before the site is wiped, and a fresh site imports what it finds
|
||||
// (§7.1). That holds for permissions, but not for these two. The restored rig's
|
||||
// RunicNPC says it is already managed, by the site before this one, so the new
|
||||
// site adopts nothing (D244) and its first push empties the profiles. The import
|
||||
// never adopts a group's chat style. So the site holds them from the start and
|
||||
// pushes them, and the placements in the baseline spawn again.
|
||||
//
|
||||
// A chat style names a group the rig's import has not brought in yet, because
|
||||
// the seed runs first. The group is created for that server, and the import
|
||||
// then adopts the rig's title, rank and permissions into it. A style lists only
|
||||
// the fields that differ from BetterChat's defaults.
|
||||
//
|
||||
// **It runs on every seed, so it upserts.** Core seeds content on a database's
|
||||
// first run only, but calls this every time. The section's shape is checked
|
||||
// before anything is written, and a problem throws, which fails the seed. A
|
||||
// profile is checked by the NPC model as it is saved, as the admin page saves
|
||||
// one, so a profile the model refuses fails the seed too.
|
||||
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const npcs = require('./model/npcs/npcs.model')
|
||||
const npcsDb = require('./model/npcs/npcs.db')
|
||||
const npcShape = require('./model/npcs/npcProfile')
|
||||
const permApply = require('./model/permissions/permissions.apply')
|
||||
const permDb = require('./model/permissions/permissions.db')
|
||||
const chatStyle = require('./model/permissions/chatStyle')
|
||||
const sidecar = require('./sidecarClient')
|
||||
const core = require('./core')
|
||||
|
||||
const log = core.logger('demo')
|
||||
|
||||
const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
|
||||
const STEAM_ID = /^7656119\d{10}$/
|
||||
// Oxide group names as the permission model keeps them: trimmed and lower-cased.
|
||||
const GROUP_NAME = /^[a-z0-9_.-]{1,64}$/
|
||||
|
||||
/** BetterChat's defaults for the group, with the fixture's fields over them. */
|
||||
function fullStyle(group, style) {
|
||||
return { ...chatStyle.defaults(String(group || '')), ...(style && typeof style === 'object' ? style : {}) }
|
||||
}
|
||||
|
||||
/** Every problem with the section, as sentences. Empty when it is good to write. */
|
||||
function checkSection(data, accounts = {}) {
|
||||
const problems = []
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return ['modules.rust must be an object']
|
||||
|
||||
const list = (key) => (data[key] === undefined ? [] : data[key])
|
||||
for (const key of ['servers', 'links', 'chat']) {
|
||||
if (data[key] !== undefined && !Array.isArray(data[key])) problems.push(`modules.rust.${key} must be an array`)
|
||||
}
|
||||
const n = data.npcs
|
||||
if (n !== undefined) {
|
||||
if (!n || typeof n !== 'object' || Array.isArray(n)) problems.push('modules.rust.npcs must be an object')
|
||||
else {
|
||||
if (n.factions !== undefined && !Array.isArray(n.factions)) problems.push('modules.rust.npcs.factions must be an array')
|
||||
if (n.profiles !== undefined && !Array.isArray(n.profiles)) problems.push('modules.rust.npcs.profiles must be an array')
|
||||
}
|
||||
}
|
||||
if (problems.length) return problems
|
||||
|
||||
if (n && n.factions !== undefined) {
|
||||
const checked = npcShape.checkFactions(n.factions)
|
||||
if (!checked.ok) problems.push(`modules.rust.npcs.${checked.error}`)
|
||||
}
|
||||
;((n && n.profiles) || []).forEach((p, i) => {
|
||||
const at = `modules.rust.npcs.profiles[${i}]`
|
||||
if (!p || typeof p !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!npcShape.NAME_RULE.test(String(p.name || ''))) problems.push(`${at}.name must be 1 to 40 of a-z, 0-9, _ and -`)
|
||||
if (!p.body || typeof p.body !== 'object' || Array.isArray(p.body)) problems.push(`${at}.body must be an object`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
list('chat').forEach((c, i) => {
|
||||
const at = `modules.rust.chat[${i}]`
|
||||
if (!c || typeof c !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!SERVER_ID.test(String(c.serverId || ''))) problems.push(`${at}.serverId must be a server id`)
|
||||
if (!GROUP_NAME.test(String(c.group || ''))) problems.push(`${at}.group must be a group name`)
|
||||
const checked = chatStyle.validateStyle(fullStyle(c.group, c.style))
|
||||
if (!checked.ok) problems.push(`${at}.style: ${checked.errors.join(' ')}`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
list('servers').forEach((s, i) => {
|
||||
const at = `modules.rust.servers[${i}]`
|
||||
if (!s || typeof s !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!SERVER_ID.test(String(s.id || ''))) problems.push(`${at}.id must be lowercase letters, digits and hyphens`)
|
||||
if (typeof s.name !== 'string' || !s.name.trim()) problems.push(`${at}.name is required`)
|
||||
if (!/^https?:\/\//.test(String(s.sidecarBaseUrl || ''))) problems.push(`${at}.sidecarBaseUrl must be an http(s) URL`)
|
||||
if (typeof s.sidecarToken !== 'string' || !s.sidecarToken) problems.push(`${at}.sidecarToken is required`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
list('links').forEach((l, i) => {
|
||||
const at = `modules.rust.links[${i}]`
|
||||
if (!l || typeof l !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!accounts[l.username]) problems.push(`${at}.username must be one of the demo accounts`)
|
||||
if (!STEAM_ID.test(String(l.steamId || ''))) problems.push(`${at}.steamId must be a 17-digit Steam id`)
|
||||
if (l.serverId !== undefined && !SERVER_ID.test(String(l.serverId))) problems.push(`${at}.serverId must be a server id`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
return problems
|
||||
}
|
||||
|
||||
/** The hook core calls: `onDemoSeed(data, { accounts })`. */
|
||||
async function onDemoSeed(data, { accounts = {} } = {}) {
|
||||
const problems = checkSection(data, accounts)
|
||||
if (problems.length) throw new Error(`the Rust demo seed has problems: ${problems.join('; ')}`)
|
||||
|
||||
for (const s of data.servers || []) {
|
||||
await serversDb.upsertServer({
|
||||
id: s.id,
|
||||
name: s.name.trim(),
|
||||
sidecarBaseUrl: s.sidecarBaseUrl,
|
||||
sidecarTokenEnc: servers.encryptToken(s.sidecarToken),
|
||||
protocol: s.protocol === undefined ? sidecar.PROTOCOL_VERSION : Number(s.protocol),
|
||||
enabled: s.enabled === undefined ? true : Boolean(s.enabled),
|
||||
sortOrder: s.sortOrder === undefined ? 0 : Number(s.sortOrder),
|
||||
})
|
||||
log.info('demo server ready', { server: s.id })
|
||||
}
|
||||
|
||||
// A link is a plain insert by design (D23), so the upsert is spelled out here:
|
||||
// the right owner already holds it, or it is moved to them. Only the seed may
|
||||
// move a link, and only to a demo account.
|
||||
for (const l of data.links || []) {
|
||||
const userId = accounts[l.username]
|
||||
const steamId = String(l.steamId)
|
||||
const existing = await linksDb.getBySteamId(steamId)
|
||||
if (existing && Number(existing.userId) === Number(userId)) continue
|
||||
if (existing) await linksDb.removeBySteamId(steamId)
|
||||
await linksDb.insert({ steamId, userId, name: l.name || null, serverId: l.serverId || null })
|
||||
log.info('demo link ready', { username: l.username })
|
||||
}
|
||||
|
||||
// After the servers: a profile is refused for a server the site does not have.
|
||||
const n = data.npcs || {}
|
||||
if (n.factions !== undefined) {
|
||||
await npcs.setFactions(n.factions)
|
||||
log.info('demo NPC factions ready', { pairs: n.factions.length })
|
||||
}
|
||||
if (n.profiles && n.profiles.length) {
|
||||
const existing = await npcsDb.listProfiles()
|
||||
for (const p of n.profiles) {
|
||||
const input = { name: p.name, body: p.body, servers: p.servers, allServers: p.allServers, killsScope: p.killsScope }
|
||||
const same = existing.find((e) => e.name === p.name && !e.replaced)
|
||||
if (same) await npcs.update(same.id, input)
|
||||
else await npcs.create(input)
|
||||
log.info('demo NPC profile ready', { profile: p.name })
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of data.chat || []) {
|
||||
const name = String(c.group)
|
||||
let group = await permApply.groupOn(name, c.serverId)
|
||||
if (!group) {
|
||||
const id = await permDb.insertGroup({ name, title: name })
|
||||
await permDb.setGroupServers(id, { allServers: false, servers: [c.serverId] })
|
||||
group = { id }
|
||||
}
|
||||
await permDb.setGroupChat(group.id, chatStyle.validateStyle(fullStyle(name, c.style)).fields)
|
||||
await permDb.markDirty(c.serverId)
|
||||
log.info('demo chat style ready', { server: c.serverId, group: name })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkSection, onDemoSeed }
|
||||
71
server/dynamicPvp.js
Normal file
71
server/dynamicPvp.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// ── Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3) ──────────────
|
||||
//
|
||||
// HunterZ's Dynamic PVP opens its own temporary PvP zones around a downed
|
||||
// patrol helicopter, a Bradley, a locked crate, an airdrop, the cargo ship and
|
||||
// the rest. They are the plugin's own: there is no step and no trigger here,
|
||||
// only the feed's lines (`pvp.zone.created`, public; `pvp.zone.deleted`,
|
||||
// staff's) and the map's `pvp` layer.
|
||||
//
|
||||
// ── Who may read what ──────────────────────────────────────────────────────
|
||||
//
|
||||
// As a world event is read (`worldEvents.js`, D353): the zone and its grid are
|
||||
// public, and its position is staff's, because the map has its own audience for
|
||||
// where things are (D111). A zone names nobody.
|
||||
//
|
||||
// **Only Dynamic PVP's own vanilla events carry their name to the public.** Its
|
||||
// monument zones are named after the monument and an operator's own events by
|
||||
// whatever the operator typed into its config; those reach staff as written and
|
||||
// everyone else as "A PvP zone opened at G7".
|
||||
|
||||
const { gridOf } = require('./worldEvents')
|
||||
|
||||
/** Dynamic PVP 5.2.0's own events (its `GeneralEventType`), as its hooks name them. */
|
||||
const GENERAL_EVENTS = Object.freeze([
|
||||
'Bradley',
|
||||
'Helicopter',
|
||||
'Satellite',
|
||||
'TimedSupply',
|
||||
'SupplySignal',
|
||||
'CargoShip',
|
||||
'HackableCrate',
|
||||
'ExcavatorIgnition',
|
||||
'GhostShip',
|
||||
'DeepSeaIsland',
|
||||
'IslandCannon',
|
||||
])
|
||||
|
||||
const KINDS = Object.freeze(['pvp.zone.created', 'pvp.zone.deleted'])
|
||||
|
||||
function isGeneral(event) {
|
||||
return GENERAL_EVENTS.includes(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* One `pvp.zone.*` frame for one viewer: `{ staff, geometry }`. Passes
|
||||
* `kind`, `type`, `t`, `serverId`, `wipeId`, `radius`, `shape`, `durationSec`,
|
||||
* adds `grid`, and `event` when it is one of Dynamic PVP's own or the viewer is
|
||||
* staff. Staff also get `x`, `z` and `zoneId`. Any other frame is returned as is.
|
||||
*/
|
||||
function shapeFor(frame, { staff = false, geometry = null } = {}) {
|
||||
if (!frame || !KINDS.includes(frame.kind)) return frame
|
||||
|
||||
const out = {}
|
||||
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId', 'radius', 'shape', 'durationSec']) {
|
||||
if (frame[key] !== undefined) out[key] = frame[key]
|
||||
}
|
||||
|
||||
const grid = gridOf(geometry, frame.x, frame.z)
|
||||
if (grid) out.grid = grid
|
||||
|
||||
if (typeof frame.event === 'string' && frame.event && (staff || isGeneral(frame.event))) out.event = frame.event
|
||||
|
||||
if (staff) {
|
||||
for (const key of ['x', 'z', 'zoneId']) {
|
||||
if (frame[key] !== undefined) out[key] = frame[key]
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { GENERAL_EVENTS, KINDS, isGeneral, shapeFor }
|
||||
@@ -42,6 +42,8 @@ const clansDb = require('../model/clans/clans.db')
|
||||
const eventsDb = require('../model/events/events.db')
|
||||
const linksDb = require('../model/links/links.db')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const mapDb = require('../model/map/map.db')
|
||||
const worldEvents = require('../worldEvents')
|
||||
const { TRIGGER_IDS: T, PATHS, serverPath, leaderboardPath, clanPath } = require('./triggers')
|
||||
|
||||
const log = core.logger('engagement')
|
||||
@@ -175,6 +177,25 @@ const HEADLINES = Object.freeze({
|
||||
title: `${d.boss} is down to ${d.percent}% on ${d.server}`,
|
||||
intro: `${d.boss} fell to ${d.percent}% of its health on ${d.server} and entered phase ${d.phase}.`,
|
||||
}),
|
||||
// Dangerous Treasures (protocol 14, §14.3.4). A missing grid drops out.
|
||||
'rust.dt.started': (d) => ({
|
||||
title: `A treasure event has started on ${d.server}`,
|
||||
intro: `A Dangerous Treasures chest opened${d.grid ? ` at ${d.grid}` : ''} on ${d.server}. It is guarded, and it is burning.`,
|
||||
}),
|
||||
'rust.dt.won': (d) => ({
|
||||
title: `The treasure was won on ${d.server}`,
|
||||
intro: `${d.player || 'Somebody'} won the treasure${d.grid ? ` at ${d.grid}` : ''} on ${d.server}`
|
||||
+ `${d.invaders > 1 ? `, with ${d.invaders} players fighting for it` : ''}.`,
|
||||
}),
|
||||
// Guarded Crate (protocol 14, §14.3.5). A missing grid drops out.
|
||||
'rust.gc.started': (d) => ({
|
||||
title: `A guarded crate landed on ${d.server}`,
|
||||
intro: `A guarded crate landed${d.grid ? ` at ${d.grid}` : ''} on ${d.server}. Kill its guards to claim it.`,
|
||||
}),
|
||||
'rust.gc.ended': (d) => ({
|
||||
title: `The guarded crate was cleared on ${d.server}`,
|
||||
intro: `${d.player} cleared the guarded crate${d.grid ? ` at ${d.grid}` : ''} on ${d.server}.`,
|
||||
}),
|
||||
'rust.boss.killed': (d) => ({
|
||||
title: `${d.boss} was killed on ${d.server}`,
|
||||
intro: `${d.killer ? `${d.killer} landed the killing blow` : `${d.boss} fell`}`
|
||||
@@ -186,9 +207,65 @@ const HEADLINES = Object.freeze({
|
||||
}),
|
||||
})
|
||||
|
||||
/** Where a crate is, as a sentence ends: "on Large Oil Rig", "dropped by a Chinook". */
|
||||
const CRATE_PLACES = Object.freeze({
|
||||
oilrig_large: 'on Large Oil Rig',
|
||||
oilrig_small: 'on Small Oil Rig',
|
||||
cargo: 'on the cargo ship',
|
||||
ghostship: 'on a ghost ship',
|
||||
chinook: 'dropped by a Chinook',
|
||||
})
|
||||
|
||||
/**
|
||||
* The headline of a world event (protocol 14), one sentence per type. Plain
|
||||
* register like the rest; a part that is missing drops out rather than leaving
|
||||
* a hole, so "at G7" disappears when the server has no map geometry yet.
|
||||
*/
|
||||
function worldHeadline(event, d) {
|
||||
const at = d.grid ? ` at ${d.grid}` : ''
|
||||
const on = ` on ${d.server}`
|
||||
const by = d.player ? ` by ${d.player}` : ''
|
||||
const place = CRATE_PLACES[d.where] || 'somewhere on the map'
|
||||
const bradley = d.where === 'launch_site' ? "Launch Site's Bradley" : d.where === 'road' ? 'A road Bradley' : 'A Bradley'
|
||||
const lines = {
|
||||
'heli.arrived': ['A patrol helicopter is overhead', `A patrol helicopter flew onto the map${at}${on}.`],
|
||||
'heli.left': ['The patrol helicopter has left', `The patrol helicopter left the map${on}.`],
|
||||
'heli.downed': ['Patrol helicopter downed', `The patrol helicopter was downed${by}${at}${on}.`],
|
||||
'bradley.spawned': [
|
||||
d.where === 'road' ? (d.count > 1 ? `${d.count} road Bradleys are out` : 'A road Bradley is out') : 'Bradley is patrolling Launch Site',
|
||||
d.where === 'road' ? `Road Bradleys took to the roads${on}.` : `Launch Site's Bradley is back${on}.`,
|
||||
],
|
||||
'bradley.destroyed': [`${bradley} was destroyed`, `${bradley} was destroyed${by}${at}${on}.`],
|
||||
'cargo.arrived': ['The cargo ship has arrived', `The cargo ship sailed onto the map${at}${on}.`],
|
||||
'cargo.docking': ['The cargo ship is docking', `The cargo ship is coming in to dock${d.harbour ? ` at ${d.harbour}` : ''}${on}.`],
|
||||
'cargo.docked': ['The cargo ship has docked', `The cargo ship docked${d.harbour ? ` at ${d.harbour}` : ''}${on}, with fresh crates.`],
|
||||
'cargo.undocked': ['The cargo ship cast off', `The cargo ship left${d.harbour ? ` ${d.harbour}` : ' the harbour'}${on}.`],
|
||||
'cargo.left': ['The cargo ship is leaving', `The cargo ship turned for the edge of the map${on}.`],
|
||||
'chinook.arrived': ['A Chinook is inbound', `A Chinook flew onto the map${at}${on} with a locked crate.`],
|
||||
'chinook.dropped': ['A Chinook dropped a locked crate', `A Chinook dropped a locked crate${d.monument ? ` at ${d.monument}` : at}${on}.`],
|
||||
'chinook.downed': ['A Chinook was shot down', `A Chinook was shot down${by}${at}${on}.`],
|
||||
'airdrop.called': ['An airdrop is on its way', `An airdrop is on its way${at}${on}${d.player ? `, called by ${d.player}` : ''}.`],
|
||||
'airdrop.landed': ['An airdrop has landed', `An airdrop landed${at}${on}.`],
|
||||
'airdrop.looted': ['An airdrop was looted', `${d.player || 'Somebody'} looted an airdrop${at}${on}.`],
|
||||
'crate.hack.started': ['A locked crate is being hacked', `${d.player || 'Somebody'} started hacking the locked crate ${place}${on}.`],
|
||||
'crate.unlocked': ['A locked crate is open', `The locked crate ${place} finished unlocking${on}.`],
|
||||
'crate.looted': ['A locked crate was looted', `${d.player || 'Somebody'} looted the locked crate ${place}${on}.`],
|
||||
'vendor.arrived': ['The Travelling Vendor is here', `The Travelling Vendor drove onto the map${at}${on}.`],
|
||||
'vendor.left': ['The Travelling Vendor has left', `The Travelling Vendor left the map${on}.`],
|
||||
'satellite.crashed': ['A satellite has crashed', `A satellite came down${at}${on}. Its crates are burning.`],
|
||||
'deepsea.opened': ['The Deep Sea is open', `The Deep Sea opened${on}.`],
|
||||
'deepsea.closed': ['The Deep Sea has closed', `The Deep Sea closed${on}.`],
|
||||
}
|
||||
const line = lines[event]
|
||||
return line ? { title: `${line[0]}${on}`, intro: line[1] } : {}
|
||||
}
|
||||
|
||||
const WORLD_PREFIX = 'rust.world.'
|
||||
|
||||
function headline(triggerId, data) {
|
||||
const make = HEADLINES[triggerId]
|
||||
return make ? make(data || {}) : {}
|
||||
if (make) return make(data || {})
|
||||
return String(triggerId).startsWith(WORLD_PREFIX) ? worldHeadline(triggerId.slice(WORLD_PREFIX.length), data || {}) : {}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -460,7 +537,119 @@ async function onBoss(server, item, frame) {
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Rust's own world events (protocol 14, D355): one trigger per type. Public
|
||||
* news, so a replay older than a broadcast's window tells nobody (D63). The
|
||||
* grid comes from the map's geometry, as the feed's does; a downed helicopter,
|
||||
* Chinook or Bradley names whom the server's credit rule names, as its feed
|
||||
* line and its count do (D372). Names only, never a Steam id.
|
||||
*/
|
||||
async function onWorld(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
const event = str(frame.event)
|
||||
if (!event || !worldEvents.isKnownType(event) || !stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
|
||||
|
||||
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
|
||||
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
|
||||
const type = worldEvents.TYPES[event]
|
||||
const crate = event.startsWith('crate.')
|
||||
|
||||
const data = {
|
||||
...serverVars(server),
|
||||
subject: crate ? `${server.id}:${event}:${frame.where || ''}` : `${server.id}:${event}`,
|
||||
...(grid ? { grid } : {}),
|
||||
...(frame.where !== undefined ? { where: str(frame.where) } : {}),
|
||||
...(event === 'bradley.spawned' ? { count: Math.max(1, Math.floor(Number(frame.count) || 1)) } : {}),
|
||||
...(frame.harbourName || frame.harbour ? { harbour: str(frame.harbourName) || str(frame.harbour) } : {}),
|
||||
...(frame.monumentName || frame.monument ? { monument: str(frame.monumentName) || str(frame.monument) } : {}),
|
||||
}
|
||||
|
||||
if (type.player && str(frame.name)) data.player = str(frame.name)
|
||||
|
||||
if (type.credit) {
|
||||
const ids = worldEvents.credited(frame, worldEvents.creditRuleOf(server.creditRule))
|
||||
const phrase = worldEvents.namesPhrase(ids.map((id) => worldEvents.nameOf(frame, id)), ids.length)
|
||||
data.credited = ids.length
|
||||
if (phrase) data.player = phrase
|
||||
}
|
||||
|
||||
return fire(`${WORLD_PREFIX}${event}`, {
|
||||
data,
|
||||
dedupeKey: dedupeKey('world', server.id, event, t, frame.x, frame.z),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Dangerous Treasures (protocol 14, §14.3.4): a chest opened, and one won.
|
||||
* Public news, so a replay older than a broadcast's window tells nobody (D63).
|
||||
* The grid comes from the map's geometry, as the feed's does; the winner by
|
||||
* in-game name only, never a Steam id. An ending raises nothing.
|
||||
*/
|
||||
async function onDangerousTreasures(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
|
||||
|
||||
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
|
||||
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
|
||||
const data = { ...serverVars(server), subject: `${server.id}:${frame.kind}`, ...(grid ? { grid } : {}) }
|
||||
|
||||
if (frame.kind === 'dt.started') {
|
||||
return fire(T['rust.dt.started'], {
|
||||
data: { ...data, byEvent: Boolean(str(frame.key)) },
|
||||
dedupeKey: dedupeKey('dt.started', server.id, t, frame.x, frame.z),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
const invaders = Number(frame.invaderCount)
|
||||
return fire(T['rust.dt.won'], {
|
||||
data: {
|
||||
...data,
|
||||
...(str(frame.name) ? { player: str(frame.name) } : {}),
|
||||
invaders: Number.isInteger(invaders) && invaders >= 0 ? invaders : Array.isArray(frame.invaders) ? frame.invaders.length : 0,
|
||||
},
|
||||
dedupeKey: dedupeKey('dt.won', server.id, t, frame.steamId, frame.x, frame.z),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarded Crate (protocol 14, §14.3.5): a crate landed, and one cleared. As a
|
||||
* treasure's are: public news, a replay past a broadcast's window tells nobody
|
||||
* (D63), the clearer by in-game name only. An ending with nobody named, and a
|
||||
* timeout, raise nothing.
|
||||
*/
|
||||
async function onGuardedCrate(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
|
||||
if (frame.kind === 'gc.ended' && !str(frame.name)) return 0
|
||||
|
||||
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
|
||||
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
|
||||
const data = { ...serverVars(server), subject: `${server.id}:${frame.kind}`, ...(grid ? { grid } : {}) }
|
||||
|
||||
if (frame.kind === 'gc.started') {
|
||||
return fire(T['rust.gc.started'], {
|
||||
data: { ...data, byEvent: Boolean(str(frame.key)) },
|
||||
dedupeKey: dedupeKey('gc.started', server.id, t, frame.x, frame.z),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
return fire(T['rust.gc.ended'], {
|
||||
data: { ...data, player: str(frame.name) },
|
||||
dedupeKey: dedupeKey('gc.ended', server.id, t, frame.steamId, frame.x, frame.z),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
const HANDLERS = Object.freeze({
|
||||
'dt.started': onDangerousTreasures,
|
||||
'dt.won': onDangerousTreasures,
|
||||
'gc.started': onGuardedCrate,
|
||||
'gc.ended': onGuardedCrate,
|
||||
'world.event': onWorld,
|
||||
'npc.boss.spawned': onBoss,
|
||||
'npc.boss.phase': onBoss,
|
||||
'npc.boss.died': onBoss,
|
||||
|
||||
@@ -548,7 +548,171 @@ const BOSSES = [
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES])
|
||||
// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1, D355) ─────
|
||||
//
|
||||
// One trigger per `world.event` type, `rust.world.<type>`. Public news, as a
|
||||
// boss is: `everyone` is the ceiling and `subscribers` the default. A phase
|
||||
// gate reads the same catalogue, so "advance when Bradley is destroyed" is
|
||||
// `{ on: 'rust.world.bradley.destroyed' }` with no further code.
|
||||
//
|
||||
// The cooldown subject is the server and the type, and the crate types add
|
||||
// where the crate is — one line per oil rig per cooldown, not one per hack on
|
||||
// the map. `player` is the in-game name only, as a boss's killer is; no Steam
|
||||
// id is declared.
|
||||
|
||||
const WORLD_SUBJECT = { name: 'subject', type: 'string', required: true, example: 'main:heli.downed',
|
||||
description: 'The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display.' }
|
||||
const WORLD_GRID = { name: 'grid', type: 'string', required: false, example: 'G7',
|
||||
description: 'The map grid square. Absent when the server has no map geometry yet, or the event is off the map.' }
|
||||
const WORLD_PLAYER = { name: 'player', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'The player it names, by in-game name. Absent when nobody is named.' }
|
||||
const WORLD_CREDITED = [
|
||||
{ name: 'player', type: 'string', required: false, example: 'Marisol and Darrow',
|
||||
description: 'Who is credited, by in-game name and by the server\'s credit rule: "Alice", "Alice and Bob", "Alice, Bob and 2 others". Absent when nobody is.' },
|
||||
{ name: 'credited', type: 'int', required: true, example: 2,
|
||||
description: 'How many players the credit rule names.' },
|
||||
]
|
||||
const WORLD_WHERE = (example, list) => ({ name: 'where', type: 'string', required: true, example,
|
||||
description: `Where it is: ${list}.` })
|
||||
const CRATE_WHERE = WORLD_WHERE('oilrig_large', 'oilrig_large, oilrig_small, cargo, ghostship or chinook (a crate a Chinook dropped)')
|
||||
const WORLD_HARBOUR = { name: 'harbour', type: 'string', required: false, example: 'Harbor',
|
||||
description: 'The harbour, by the game\'s own name for it.' }
|
||||
const WORLD_MONUMENT = { name: 'monument', type: 'string', required: false, example: 'Water Treatment Plant',
|
||||
description: 'The monument it dropped at, by the game\'s own name for it.' }
|
||||
const WORLD_COUNT = { name: 'count', type: 'int', required: true, example: 12,
|
||||
description: 'How many: road Bradleys come several at once.' }
|
||||
|
||||
/** Each world event's label, description and the variables it carries beyond the server's. */
|
||||
const WORLD_EVENTS = [
|
||||
['heli.arrived', 'A patrol helicopter arrived', 'A patrol helicopter flew onto the map.', [WORLD_GRID]],
|
||||
['heli.left', 'The patrol helicopter left', 'A patrol helicopter left the map without being downed.', [WORLD_GRID]],
|
||||
['heli.downed', 'A patrol helicopter was downed', 'Players brought down a patrol helicopter.', [WORLD_GRID, ...WORLD_CREDITED]],
|
||||
['bradley.spawned', 'Bradley is out', 'Launch Site\'s Bradley spawned, or road Bradleys took to the roads.', [WORLD_WHERE('road', 'launch_site or road'), WORLD_COUNT]],
|
||||
['bradley.destroyed', 'A Bradley was destroyed', 'Players destroyed a Bradley APC: Launch Site\'s, a road one, or another.', [WORLD_WHERE('launch_site', 'launch_site, road or other'), WORLD_GRID, ...WORLD_CREDITED]],
|
||||
['cargo.arrived', 'The cargo ship arrived', 'The cargo ship sailed onto the map.', [WORLD_GRID]],
|
||||
['cargo.docking', 'The cargo ship is docking', 'The cargo ship turned in to dock at a harbour.', [WORLD_GRID, WORLD_HARBOUR]],
|
||||
['cargo.docked', 'The cargo ship docked', 'The cargo ship docked at a harbour, with fresh crates.', [WORLD_GRID, WORLD_HARBOUR]],
|
||||
['cargo.undocked', 'The cargo ship cast off', 'The cargo ship left the harbour it was docked at.', [WORLD_GRID, WORLD_HARBOUR]],
|
||||
['cargo.left', 'The cargo ship is leaving', 'The cargo ship turned for the edge of the map.', [WORLD_GRID]],
|
||||
['chinook.arrived', 'A Chinook arrived', 'A Chinook flew onto the map with a locked crate.', [WORLD_GRID]],
|
||||
['chinook.dropped', 'A Chinook dropped its crate', 'A Chinook dropped a locked crate at a monument.', [WORLD_GRID, WORLD_MONUMENT]],
|
||||
['chinook.downed', 'A Chinook was shot down', 'Players shot down a Chinook.', [WORLD_GRID, ...WORLD_CREDITED]],
|
||||
['airdrop.called', 'An airdrop is on its way', 'A cargo plane is bringing an airdrop: called by a supply signal, or by the schedule.', [WORLD_GRID, WORLD_PLAYER]],
|
||||
['airdrop.landed', 'An airdrop landed', 'An airdrop reached the ground.', [WORLD_GRID, WORLD_PLAYER]],
|
||||
['airdrop.looted', 'An airdrop was looted', 'Somebody opened an airdrop first.', [WORLD_GRID, WORLD_PLAYER]],
|
||||
['crate.hack.started', 'A locked crate is being hacked', 'Somebody started hacking a locked crate. On an oil rig this also calls its scientists.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
|
||||
['crate.unlocked', 'A locked crate unlocked', 'A hacked locked crate finished unlocking.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
|
||||
['crate.looted', 'A locked crate was looted', 'Somebody opened an unlocked locked crate first.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
|
||||
['vendor.arrived', 'The Travelling Vendor arrived', 'The Travelling Vendor drove onto the map.', [WORLD_GRID]],
|
||||
['vendor.left', 'The Travelling Vendor left', 'The Travelling Vendor left the map.', [WORLD_GRID]],
|
||||
['satellite.crashed', 'A satellite crashed', 'A satellite came down, and its crates are burning.', [WORLD_GRID]],
|
||||
['deepsea.opened', 'The Deep Sea opened', 'The Deep Sea is open: its ghost ships and islands can be reached.', []],
|
||||
['deepsea.closed', 'The Deep Sea closed', 'The Deep Sea closed.', []],
|
||||
]
|
||||
|
||||
const WORLD = WORLD_EVENTS.map(([event, label, description, vars]) => ({
|
||||
id: `rust.world.${event}`,
|
||||
label,
|
||||
description,
|
||||
kind: 'event',
|
||||
subjectKey: 'subject',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE, WORLD_SUBJECT, ...vars],
|
||||
}))
|
||||
|
||||
// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4, §14.3.9) ──────
|
||||
//
|
||||
// A treasure event opening and being won are triggers, as a world event is
|
||||
// (D355): public news, `everyone` the ceiling and `subscribers` the default.
|
||||
// The cooldown subject is the server, so a busy hour of treasures is one line
|
||||
// per cooldown. `player` is the winner's in-game name only; no Steam id is
|
||||
// declared. Its ending is staff's on the feed and raises nothing.
|
||||
|
||||
const DT_SUBJECT = { name: 'subject', type: 'string', required: true, example: 'main:dt.won',
|
||||
description: 'The server and the event. The cooldown subject; not meant for display.' }
|
||||
const DT_BY_EVENT = { name: 'byEvent', type: 'boolean', required: true, example: false,
|
||||
description: 'Whether one of this site\'s events started it (a Start a Dangerous Treasures event step).' }
|
||||
|
||||
const DANGEROUS_TREASURES = [
|
||||
{
|
||||
id: 'rust.dt.started',
|
||||
label: 'A treasure event started',
|
||||
description: 'Dangerous Treasures opened a guarded, burning treasure chest on a server: on its own schedule, by an admin, or by an event.',
|
||||
kind: 'event',
|
||||
subjectKey: 'subject',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE, DT_SUBJECT, WORLD_GRID, DT_BY_EVENT],
|
||||
},
|
||||
{
|
||||
id: 'rust.dt.won',
|
||||
label: 'A treasure was won',
|
||||
description: 'A player emptied a Dangerous Treasures chest on a server.',
|
||||
kind: 'event',
|
||||
subjectKey: 'subject',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
DT_SUBJECT,
|
||||
WORLD_GRID,
|
||||
{ name: 'player', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'Who emptied the chest, by in-game name.' },
|
||||
{ name: 'invaders', type: 'int', required: true, example: 3,
|
||||
description: 'How many players fought for it, the winner included.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5, §14.3.9) ────────────
|
||||
//
|
||||
// A guarded crate landing and being cleared are triggers, as Dangerous
|
||||
// Treasures' are: public news, the server the cooldown subject. `player` is the
|
||||
// clearer's in-game name only. A crate whose guards went with nobody killing
|
||||
// the last (NpcSpawn reloaded under it) and one that timed out raise nothing:
|
||||
// neither is news.
|
||||
|
||||
const GC_BY_EVENT = { name: 'byEvent', type: 'boolean', required: true, example: false,
|
||||
description: 'Whether one of this site\'s events placed it (a Place a guarded crate step).' }
|
||||
|
||||
const GUARDED_CRATE = [
|
||||
{
|
||||
id: 'rust.gc.started',
|
||||
label: 'A guarded crate landed',
|
||||
description: 'Guarded Crate dropped a locked crate ringed by guards on a server: on its own schedule, by an admin, or by an event.',
|
||||
kind: 'event',
|
||||
subjectKey: 'subject',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE, { ...DT_SUBJECT, example: 'main:gc.started' }, WORLD_GRID, GC_BY_EVENT],
|
||||
},
|
||||
{
|
||||
id: 'rust.gc.ended',
|
||||
label: 'A guarded crate was cleared',
|
||||
description: 'A player killed the last guard of a guarded crate on a server.',
|
||||
kind: 'event',
|
||||
subjectKey: 'subject',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ ...DT_SUBJECT, example: 'main:gc.ended' },
|
||||
WORLD_GRID,
|
||||
{ name: 'player', type: 'string', required: true, example: 'Marisol',
|
||||
description: 'Who killed the last guard, by in-game name.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES, ...WORLD, ...DANGEROUS_TREASURES, ...GUARDED_CRATE])
|
||||
|
||||
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))
|
||||
|
||||
|
||||
489
server/eventCommands.js
Normal file
489
server/eventCommands.js
Normal file
@@ -0,0 +1,489 @@
|
||||
// ── 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 demo = require('./demo')
|
||||
|
||||
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 `multiple` param as a list: a bare value is a list of one, nothing is none. */
|
||||
const asList = (raw) => (raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw])
|
||||
|
||||
/** A command list's price: at least one, so the switchboard sees the dimension. */
|
||||
function pricedCommands(raw) {
|
||||
const checked = checkCommands(raw, { required: false })
|
||||
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' } : {}),
|
||||
...(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 }
|
||||
|
||||
// The demo's deny list (DEMO_MODE.md §3.4), at run: core asked at save
|
||||
// (demoRefuses), but a definition can predate the list.
|
||||
const denied = demo.deniedCommand([...commands.value, ...teardown.value])
|
||||
if (denied) return { ok: false, retry: false, error: denied }
|
||||
|
||||
const placeholder = withPlaceholder([...commands.value, ...teardown.value])
|
||||
if (placeholder) {
|
||||
return { ok: false, retry: false, error: `{steamid} and {name} are filled in only by Reward by console command, and "${placeholder}" has one` }
|
||||
}
|
||||
|
||||
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 demo's deny list at save (MODULE_API 1.19.0, DM18). Core asks only on the
|
||||
// demo; null lets the step through.
|
||||
demoRefuses: (p) => demo.deniedCommand([...asList(p.commands), ...asList(p.teardown)]),
|
||||
|
||||
// The site cannot ask a server whether a command's effect is still there, so
|
||||
// a teardown not yet run is always in force: "I do not know" is never "gone".
|
||||
async reconcile({ resources }) {
|
||||
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) }),
|
||||
|
||||
demoRefuses: (p) => demo.deniedCommand(asList(p.commands)),
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const commands = checkCommands(params.commands)
|
||||
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
|
||||
|
||||
const denied = demo.deniedCommand(commands.value)
|
||||
if (denied) return { ok: false, retry: false, error: denied }
|
||||
|
||||
const confirm = checkConfirm(params, REWARD_SURE)
|
||||
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
|
||||
|
||||
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,
|
||||
}
|
||||
174
server/eventDangerousTreasures.js
Normal file
174
server/eventDangerousTreasures.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// ── Starting a Dangerous Treasures event (protocol 14, PLAN_REDESIGNS §14.3.4) ──
|
||||
//
|
||||
// `rust.dt.start`, "Start a Dangerous Treasures event": one of nivex's treasure
|
||||
// events, at the difficulty the step picks, where DT picks. The plugin runs DT's
|
||||
// own console command, `dtevent <level>`, and joins the start DT raises to the
|
||||
// step's key, so a retry is answered with the first start and never opens a
|
||||
// second chest.
|
||||
//
|
||||
// **Kept (`reversible: 'none'`).** DT guards, unlocks and despawns its own chest
|
||||
// (900 s after it unlocks, if nobody empties it); a run ending takes nothing back.
|
||||
// `risk: 'change'`, so an operator switches it on deliberately.
|
||||
//
|
||||
// **Refused at verify** where the server said DT is not loaded, or that its
|
||||
// framework cannot run another plugin's console command (hello's
|
||||
// `console.plugins`, D363, Rust-Plugins#34). A server that has not said (a plugin
|
||||
// before protocol 14, or never reached) is left to the plugin, as a zone's
|
||||
// TruePVE is (§14.3.16).
|
||||
//
|
||||
// ── D365: DT's warm-up ─────────────────────────────────────────────────────
|
||||
//
|
||||
// For about ten minutes after it loads DT is still finding where events may go,
|
||||
// and every start fails. The plugin calls that `dt-not-ready`; the step reports
|
||||
// "Dangerous Treasures is not ready yet" and asks core to try once more in ten
|
||||
// minutes (`retryAfterMs`, MODULE_API 1.15.0, D371). `dt-no-spot` retries on
|
||||
// core's ordinary delay. Past core's attempts the run pauses on its own
|
||||
// `on_failure`, and staff press Retry on the paused step (EVENTS.md §I), which
|
||||
// sends the same key.
|
||||
|
||||
const client = require('./sidecarClient')
|
||||
const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
const { gridOf } = require('./worldEvents')
|
||||
const { LEVELS, reportOf } = require('./dangerousTreasures')
|
||||
const live = require('./liveStatus')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const mapDb = require('./model/map/map.db')
|
||||
|
||||
/** How long core waits before trying a start DT was not ready for (D365). */
|
||||
const NOT_READY_RETRY_MS = 10 * 60 * 1000
|
||||
|
||||
/** What the step says DT is not ready, in the plan's words (D365). */
|
||||
const NOT_READY = 'Dangerous Treasures is not ready yet'
|
||||
|
||||
/** The plugin's refusals a second attempt would repeat. */
|
||||
const PERMANENT = new Set([
|
||||
'events-disabled',
|
||||
'malformed',
|
||||
'bad-level',
|
||||
'dt-missing',
|
||||
'console-unavailable',
|
||||
// DT's own refusal in its own words: a difficulty its config no longer has, or
|
||||
// its console command renamed. Neither is fixed by waiting.
|
||||
'refused',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.dt.events',
|
||||
label: 'Dangerous Treasures events',
|
||||
unit: 'events',
|
||||
description: 'Dangerous Treasures events an event starts, each one counted. Dangerous Treasures despawns its own chest; nothing is taken back when the run ends.',
|
||||
},
|
||||
]
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'rust.options.dt_levels',
|
||||
label: 'Dangerous Treasures difficulty',
|
||||
description: "Dangerous Treasures' three difficulties, as it ships them.",
|
||||
async resolve() {
|
||||
return LEVELS.map((l) => ({ value: l.value, label: l.label }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** A level param: 0, 1 or 2, or null for anything else. Blank is Easy. */
|
||||
function levelOf(raw) {
|
||||
if (raw === undefined || raw === null || raw === '') return 0
|
||||
const n = Number(raw)
|
||||
return LEVELS.some((l) => l.value === n) ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a server cannot take the step, from what it last said, or null. Only a
|
||||
* report that SAYS no refuses; one that said nothing is the plugin's to answer.
|
||||
*/
|
||||
function missingOn(report, name) {
|
||||
if (report.dangerousTreasures && !report.dangerousTreasures.loaded) {
|
||||
return `Dangerous Treasures is not installed on ${name}`
|
||||
}
|
||||
if (report.consolePlugins === false) {
|
||||
return `${name}'s modding framework cannot run Dangerous Treasures' console command from a plugin, so a treasure event cannot be started there yet (Rust-Plugins#34)`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The grid an opened chest is in, or null without the map's geometry. */
|
||||
async function gridAt(serverId, x, z) {
|
||||
const meta = await mapDb.getMeta(serverId).catch(() => null)
|
||||
if (!meta) return null
|
||||
return gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, x, z)
|
||||
}
|
||||
|
||||
const dtStart = {
|
||||
id: 'rust.dt.start',
|
||||
label: 'Start a Dangerous Treasures event',
|
||||
description:
|
||||
'Starts one Dangerous Treasures event: a guarded, burning treasure chest that unlocks after a few minutes. Dangerous Treasures picks the spot, and despawns the chest itself if nobody empties it. Needs Dangerous Treasures on the server. In the first ten minutes after Dangerous Treasures loads it is not ready, and the step tries again ten minutes later.',
|
||||
risk: 'change',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'Which server. It needs Dangerous Treasures installed.' },
|
||||
{ name: 'level', type: 'int', required: false, example: 1, source: 'rust.options.dt_levels',
|
||||
description: 'The difficulty: 0 Easy, 1 Medium, 2 Hard. Left blank, Easy.' },
|
||||
],
|
||||
|
||||
cost: () => ({ 'rust.dt.events': 1 }),
|
||||
|
||||
async perform({ idempotencyKey, params, verify }) {
|
||||
const level = levelOf(params.level)
|
||||
if (level === null) {
|
||||
return { ok: false, retry: false, error: `a Dangerous Treasures event is 0 (Easy), 1 (Medium) or 2 (Hard), not "${params.level}"` }
|
||||
}
|
||||
|
||||
const serverId = String(params.server || '').trim()
|
||||
if (!serverId) return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
|
||||
|
||||
const found = await serverFor(serverId)
|
||||
if (!found.ok) return found
|
||||
const name = found.server.name || found.server.id
|
||||
|
||||
// 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 }
|
||||
|
||||
const result = await client.dtStart(found.server, { key: idempotencyKey, level })
|
||||
|
||||
if (!result.ok) {
|
||||
const permanent = result.status === 'not-configured' || result.status === 'no-token'
|
||||
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'Dangerous Treasures start') }
|
||||
}
|
||||
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'dt.opened') {
|
||||
if (data.reason === 'dt-not-ready') {
|
||||
return { ok: false, retry: true, retryAfterMs: NOT_READY_RETRY_MS, error: `${NOT_READY} on ${name}` }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
|
||||
error: pluginError(data, `${name} did not start the Dangerous Treasures event`),
|
||||
}
|
||||
}
|
||||
|
||||
const grid = await gridAt(found.server.id, data.x, data.z)
|
||||
return {
|
||||
ok: true,
|
||||
detail: {
|
||||
level: LEVELS.find((l) => l.value === Number(data.level))?.label || String(data.level),
|
||||
...(grid ? { grid } : {}),
|
||||
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing new was started' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const ACTIONS = [dtStart]
|
||||
|
||||
module.exports = { BUDGETS, ACTIONS, OPTION_SOURCES, PERMANENT, NOT_READY, NOT_READY_RETRY_MS, levelOf, missingOn }
|
||||
199
server/eventGuardedCrate.js
Normal file
199
server/eventGuardedCrate.js
Normal 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 }
|
||||
@@ -100,7 +100,7 @@ function pluginError(data, fallback) {
|
||||
* of them to forget the drift check, which is the one thing §F says a lease must
|
||||
* not be allowed to skip.
|
||||
*/
|
||||
function lease({ id, label, description, type, min, max, family, targetLabel, source, example, wire }) {
|
||||
function lease({ id, label, description, type, min, max, family, targetLabel, source, example, valueExample, wire }) {
|
||||
async function resolve(target) {
|
||||
const { serverId, rest } = splitTarget(target)
|
||||
const found = await serverFor(serverId)
|
||||
@@ -130,6 +130,8 @@ function lease({ id, label, description, type, min, max, family, targetLabel, so
|
||||
type,
|
||||
...(min === undefined ? {} : { min }),
|
||||
...(max === undefined ? {} : { max }),
|
||||
// The VALUE's example (core MODULE_API 1.13.0), beside the target's below.
|
||||
...(valueExample === undefined ? {} : { example: valueExample }),
|
||||
maxDurationMs: MAX_LEASE_MS,
|
||||
target: { label: targetLabel, source, example },
|
||||
|
||||
@@ -322,6 +324,7 @@ const LEASES = [
|
||||
targetLabel: 'Which server, group and permission',
|
||||
source: 'rust.options.grouppermissions',
|
||||
example: 'main/default/kits.vip',
|
||||
valueExample: true,
|
||||
wire: (rest) => {
|
||||
const slash = rest.lastIndexOf('/')
|
||||
if (slash <= 0 || slash >= rest.length - 1) {
|
||||
@@ -371,11 +374,85 @@ async function familyOptions(family, sourceId) {
|
||||
return bounded(rows, sourceId)
|
||||
}
|
||||
|
||||
// ── The kit warning (PLAN_REDESIGNS.md §2.6, protocol 14) ────────────────────
|
||||
//
|
||||
// "Kit vip: 2 uses, 24 h cooldown — 9 of 41 linked players have used it up."
|
||||
// Shown under a group-permission target that is a kit's permission, which is
|
||||
// what a kit weekend borrows: it tells the author that the weekend's extra use
|
||||
// (step 2) is what lets those players take the kit again.
|
||||
|
||||
/** How long one server's kit counts are reused. The editor asks while typing. */
|
||||
const KIT_NOTE_TTL_MS = 60_000
|
||||
|
||||
/** The most kits one server's notes ask about. One `kits.uses` each. */
|
||||
const KIT_NOTE_MAX_KITS = 25
|
||||
|
||||
const kitNoteCache = new Map()
|
||||
|
||||
/** "24 h", "90 min", "45 s". */
|
||||
function durationWords(seconds) {
|
||||
const s = Math.max(0, Math.round(Number(seconds) || 0))
|
||||
if (s >= 3600 && s % 3600 === 0) return `${s / 3600} h`
|
||||
if (s >= 60) return `${Math.round(s / 60)} min`
|
||||
return `${s} s`
|
||||
}
|
||||
|
||||
/** One kit's note, from its `kits.list` row and its `kits.uses` answer. */
|
||||
function kitNote(kit, uses) {
|
||||
const limit = Number(kit.max) > 0 ? `${kit.max} use${Number(kit.max) === 1 ? '' : 's'}` : 'no use limit'
|
||||
const cooldown = Number(kit.cooldown) > 0 ? `, ${durationWords(kit.cooldown)} cooldown` : ''
|
||||
const asked = Number(uses.asked) || 0
|
||||
if (!asked) return `Kit ${kit.name}: ${limit}${cooldown} — no linked player has played on this server yet.`
|
||||
const used = Number(kit.max) > 0
|
||||
? `${Number(uses.exhausted) || 0} of ${asked} linked players have used it up`
|
||||
: `${asked} linked players`
|
||||
const waiting = Number(uses.onCooldown) > 0 ? `, ${uses.onCooldown} are on its cooldown` : ''
|
||||
return `Kit ${kit.name}: ${limit}${cooldown} — ${used}${waiting}. A kit weekend's extra use is what lets them take it again.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission → note, for the kits on one server that a permission gates.
|
||||
* Cached a minute per server; anything that fails is simply no note — a warning
|
||||
* is advice, and its absence must never break the field it advises.
|
||||
*/
|
||||
async function kitNotes(server) {
|
||||
const cached = kitNoteCache.get(server.id)
|
||||
if (cached && Date.now() - cached.at < KIT_NOTE_TTL_MS) return cached.notes
|
||||
|
||||
const notes = new Map()
|
||||
try {
|
||||
const listed = await client.kits(server)
|
||||
const gated = ((listed.ok && listed.data && listed.data.kits) || [])
|
||||
.filter((k) => k && k.permission)
|
||||
.slice(0, KIT_NOTE_MAX_KITS)
|
||||
|
||||
if (gated.length) {
|
||||
// eslint-disable-next-line global-require
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const steamIds = (await linksDb.linkedOnServer(server.id)).map((r) => String(r.steamId))
|
||||
|
||||
for (const kit of gated) {
|
||||
const uses = await client.kitsUses(server, kit.name, steamIds)
|
||||
if (!uses.ok || !uses.data || uses.data.kind !== 'kits.uses') continue
|
||||
const permission = String(kit.permission).toLowerCase()
|
||||
// Two kits behind one permission: both are named, one after the other.
|
||||
const note = kitNote(kit, uses.data)
|
||||
notes.set(permission, notes.has(permission) ? `${notes.get(permission)} ${note}` : note)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('kit notes could not be read', { server: server.id, message: err.message })
|
||||
}
|
||||
|
||||
kitNoteCache.set(server.id, { at: Date.now(), notes })
|
||||
return notes
|
||||
}
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'rust.options.servers',
|
||||
label: 'Rust servers',
|
||||
description: 'Every enabled server on this site. A lease holds a value on one of them (D73).',
|
||||
description: 'Every enabled server on this site. A lease holds a value on one of them.',
|
||||
async resolve() {
|
||||
const list = await servers.listForPolling()
|
||||
return list.map((s) => ({ value: s.id, label: s.name || s.id }))
|
||||
@@ -411,13 +488,19 @@ const OPTION_SOURCES = [
|
||||
for (const { server, result } of answers) {
|
||||
const data = result.data || {}
|
||||
const perms = (data.permissions || []).map((p) => String(p).toLowerCase())
|
||||
// The kit warning (PLAN_REDESIGNS.md §2.6): a row that grants a kit's
|
||||
// permission says how many of the server's linked players have used it
|
||||
// up. A kit's permission is whatever its operator typed into Kits, not
|
||||
// always `kits.*`, so every server is asked — once a minute (cached).
|
||||
const notes = await kitNotes(server)
|
||||
for (const g of data.groups || []) {
|
||||
const group = g && g.name ? String(g.name).toLowerCase() : null
|
||||
if (!group) continue
|
||||
for (const perm of perms) {
|
||||
const value = `${server.id}/${group}/${perm}`
|
||||
if (term && !value.includes(term)) continue
|
||||
rows.push({ value, label: `${group} · ${perm}`, group: server.name || server.id })
|
||||
const note = notes.get(perm)
|
||||
rows.push({ value, label: `${group} · ${perm}`, group: server.name || server.id, ...(note ? { note } : {}) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,4 +520,7 @@ module.exports = {
|
||||
pluginError,
|
||||
perServer,
|
||||
bounded,
|
||||
kitNote,
|
||||
kitNotes,
|
||||
kitNoteCache,
|
||||
}
|
||||
|
||||
524
server/eventPermRewards.js
Normal file
524
server/eventPermRewards.js
Normal file
@@ -0,0 +1,524 @@
|
||||
// ── Permissions as event rewards (PLAN_REDESIGNS.md §12, D342–D346) ───────
|
||||
//
|
||||
// Any permission a server's plugins registered can be a reward — night vision,
|
||||
// a chat colour, a `/remove` command — with no code written for the perk. Two
|
||||
// verbs, because core gives an action one risk class (D346):
|
||||
//
|
||||
// rust.perm.lend until the run ends: a kit reward's lease, one
|
||||
// `rust_perm_run_grants` row per recipient per permission,
|
||||
// withdrawn by the step's revert (`change` / `ledger`)
|
||||
// rust.perm.give for good: a standing `rust_perm_grants` row per website
|
||||
// user per permission, scoped to the step's server, source
|
||||
// `event` (`irreversible` / `none`); staff take it back in
|
||||
// the permission manager
|
||||
//
|
||||
// …and the picker both use, `rust.options.permissions`: the enabled servers'
|
||||
// catalogues, grouped by the plugin that registered each permission.
|
||||
//
|
||||
// Nothing here is new machinery. Both write rows the permission mirror already
|
||||
// pushes, so there is no plugin code, no sidecar code and no protocol change
|
||||
// (§12.1). The message is D333's notice and rides `perm.sync` like a kit
|
||||
// reward's.
|
||||
//
|
||||
// ── The safety is the author's own "I am sure" (D343) ───────────────────────
|
||||
//
|
||||
// No blocklist and no allowlist: the bridge's own `runicgateway.*` permissions
|
||||
// are in the picker like any other. A required toggle in the step says the
|
||||
// author meant it, and unticked the dry run and the run refuse, naming what the
|
||||
// step would have given.
|
||||
|
||||
const permDb = require('./model/permissions/permissions.db')
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const core = require('./core')
|
||||
const { serverFor, bounded } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
const rewards = require('./eventRewards')
|
||||
|
||||
const log = core.logger('perm-rewards')
|
||||
|
||||
const { MAX_RECIPIENTS, MAX_CHAT, DELIVERIES, MODES, oneOf, checkCount, pickRecipients, peopleOf, usersFor, readTally, entitlementRef } = rewards
|
||||
|
||||
/** At most this many permissions in one step (§12.6): a perk bundle, with a price that stays readable. */
|
||||
const MAX_PERMISSIONS = 10
|
||||
|
||||
/** A permission name as Oxide and Carbon store it: lower case, and at most the column's width. */
|
||||
const PERMISSION_MAX = 128
|
||||
|
||||
/** What a for-good grant says it came from, in the permission manager (§12.5). */
|
||||
const SOURCE = 'event'
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.permissions',
|
||||
label: 'Permissions lent',
|
||||
unit: 'permissions',
|
||||
description:
|
||||
'Permissions an event lends until it ends: one per recipient per permission. A mode that is not a count is priced at the most it could lend.',
|
||||
},
|
||||
{
|
||||
id: 'rust.permissions.kept',
|
||||
label: 'Permissions given for good',
|
||||
unit: 'permissions',
|
||||
description:
|
||||
'Permissions an event gives for good: one per recipient per permission. Ending the event does not take them back; staff remove them in the permission manager. A mode that is not a count is priced at the most it could give.',
|
||||
},
|
||||
]
|
||||
|
||||
// ── The params both steps check ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The picked permissions as a list, checked: lower case, trimmed, each once, at
|
||||
* least one and at most MAX_PERMISSIONS. Core hands a `multiple` param over as
|
||||
* an array (MODULE_API 1.14.0); a bare value is a list of one.
|
||||
*/
|
||||
function checkPermissions(raw) {
|
||||
const items = raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw]
|
||||
const out = []
|
||||
for (const item of items) {
|
||||
const name = String(item === undefined || item === null ? '' : item).trim().toLowerCase()
|
||||
if (!name) continue
|
||||
if (name.length > PERMISSION_MAX || /\s/.test(name)) return { ok: false, error: `"${item}" is not a permission name` }
|
||||
if (!out.includes(name)) out.push(name)
|
||||
}
|
||||
if (!out.length) return { ok: false, error: 'pick at least one permission' }
|
||||
if (out.length > MAX_PERMISSIONS) return { ok: false, error: `a step gives at most ${MAX_PERMISSIONS} permissions, not ${out.length}` }
|
||||
return { ok: true, value: out }
|
||||
}
|
||||
|
||||
/** The "I am sure" toggle (D343). Only a real `true` passes; a string is not a tick. */
|
||||
function checkConfirm(params, permissions) {
|
||||
if (params.confirm === true) return { ok: true }
|
||||
return { ok: false, error: `Tick "I am sure" to make ${permissions.join(', ')} a reward` }
|
||||
}
|
||||
|
||||
/**
|
||||
* The step's message, checked like a kit reward's (D333): `{ ok, text,
|
||||
* delivery }`, text null for a step that tells nobody — the default (§12.6),
|
||||
* because a permission's name means nothing to a player and only the author can
|
||||
* say what it does. `{permissions}` is the names; measured with them in place.
|
||||
*/
|
||||
function permNotice(params, permissions) {
|
||||
const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
|
||||
if (!text) return { ok: true, text: null, delivery: null }
|
||||
|
||||
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
|
||||
const delivery = oneOf(rawDelivery, DELIVERIES)
|
||||
if (!delivery) return { ok: false, error: `a reward's message goes to chat or to a popup, not to "${rawDelivery}"` }
|
||||
|
||||
const said = sayPermissions(text, permissions)
|
||||
if (said.length > MAX_CHAT) {
|
||||
return { ok: false, error: `a reward's message is at most ${MAX_CHAT} characters with the permissions in it, not ${said.length}` }
|
||||
}
|
||||
return { ok: true, text, delivery }
|
||||
}
|
||||
|
||||
const sayPermissions = (text, permissions) => text.replace(/\{permissions\}/g, permissions.join(', '))
|
||||
|
||||
/**
|
||||
* Every picked permission must be in the server's catalogue (§12.2), and one
|
||||
* that is not is refused by name rather than skipped (§12.6): the plugin may be
|
||||
* unloaded for the wipe, and a reward that silently gives less than it says is
|
||||
* the worse failure. Runs in the dry run too.
|
||||
*/
|
||||
async function checkCatalogue(server, permissions) {
|
||||
const name = server.name || server.id
|
||||
const registered = new Set(
|
||||
(await permDb.listCatalogue())
|
||||
.filter((row) => row.serverId === server.id)
|
||||
.map((row) => String(row.permission).toLowerCase()),
|
||||
)
|
||||
if (!registered.size) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} has reported no permissions yet — its plugins' permissions arrive with its next permission sync`,
|
||||
}
|
||||
}
|
||||
const missing = permissions.filter((p) => !registered.has(p))
|
||||
if (missing.length) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${name} has no permission ${missing.join(', ')} — its plugins did not register ${missing.length === 1 ? 'it' : 'them'}`,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything both steps check before touching anything, in the order an author
|
||||
* fixes them: the permissions, the toggle, the mode and its count, the message,
|
||||
* the server, its catalogue, the bound. `{ ok, permissions, mode, count, notice,
|
||||
* server }` or a refusal core reads as permanent.
|
||||
*/
|
||||
async function checkStep(params, verb) {
|
||||
const permissions = checkPermissions(params.permissions)
|
||||
if (!permissions.ok) return { ok: false, retry: false, error: permissions.error }
|
||||
|
||||
const confirm = checkConfirm(params, permissions.value)
|
||||
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
|
||||
|
||||
const mode = oneOf(params.recipients, MODES)
|
||||
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
|
||||
|
||||
const count = checkCount(mode, params.count)
|
||||
if (!count.ok) return { ok: false, retry: false, error: count.error }
|
||||
|
||||
const notice = permNotice(params, permissions.value)
|
||||
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
|
||||
|
||||
const found = await serverFor(String(params.server || '').trim())
|
||||
if (!found.ok) return found
|
||||
const server = found.server
|
||||
|
||||
const listed = await checkCatalogue(server, permissions.value)
|
||||
if (!listed.ok) return listed
|
||||
|
||||
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
|
||||
return { ok: false, retry: false, error: `${server.name || server.id} ${verb} permissions to at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
|
||||
}
|
||||
|
||||
return { ok: true, permissions: permissions.value, mode, count, notice, server }
|
||||
}
|
||||
|
||||
/**
|
||||
* The people a mode picks, as website users (D101, D103): `{ ok, people,
|
||||
* missed }`, each person `{ steamId, name, userId }` and each website user once —
|
||||
* of two linked accounts that both took part, the earlier in the list, the
|
||||
* higher score, is the one. `linked` reads no tally (§2.5).
|
||||
*/
|
||||
async function recipientsOf({ server, mode, count, runId, idempotencyKey, verb }) {
|
||||
const name = server.name || server.id
|
||||
let picked
|
||||
let bound = MAX_RECIPIENTS
|
||||
let users
|
||||
|
||||
if (mode === 'linked') {
|
||||
// One past the bound, so "too many" is a sentence and not a silent cut.
|
||||
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
|
||||
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
|
||||
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
|
||||
if (picked.length > bound) {
|
||||
return { ok: false, retry: false, error: `${name} has more than ${bound} linked players, and ${verb} permissions to at most ${bound} in one step — reward from a tally instead` }
|
||||
}
|
||||
} else {
|
||||
const tally = await readTally(server, runId)
|
||||
if (!tally.ok) return tally
|
||||
|
||||
picked = pickRecipients(peopleOf(tally.data), mode, count, idempotencyKey)
|
||||
bound = Number(tally.data.maxRecipients) || bound
|
||||
if (picked.length > bound) {
|
||||
return { ok: false, retry: false, error: `${picked.length} people qualify, and ${name} ${verb} permissions to at most ${bound} in one step — pick a count-based mode or a higher bar` }
|
||||
}
|
||||
users = await usersFor(picked.map((p) => p.steamId))
|
||||
}
|
||||
|
||||
const people = []
|
||||
const byUser = new Set()
|
||||
const missed = []
|
||||
for (const p of picked) {
|
||||
const userId = users.get(p.steamId)
|
||||
if (!userId) {
|
||||
missed.push(p.name)
|
||||
continue
|
||||
}
|
||||
if (byUser.has(userId)) continue
|
||||
byUser.add(userId)
|
||||
people.push({ ...p, userId })
|
||||
}
|
||||
return { ok: true, people, missed }
|
||||
}
|
||||
|
||||
const missedDetail = (missed) =>
|
||||
missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}
|
||||
|
||||
/** People priced before the tally is read: the count for a count, the bound for every other mode. */
|
||||
function pricedPeople(p) {
|
||||
const mode = oneOf(p.recipients, MODES)
|
||||
const n = Math.round(Number(p.count) || 0)
|
||||
return mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
|
||||
}
|
||||
|
||||
/** How many permissions a step names, as its price counts them: at least one, at most the bound. */
|
||||
function pricedPermissions(p) {
|
||||
const checked = checkPermissions(p.permissions)
|
||||
return checked.ok ? checked.value.length : 1
|
||||
}
|
||||
|
||||
// ── The params, declared once for both steps (§12.5: "the same params") ─────
|
||||
|
||||
const paramsFor = (messageExample) => [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'The server the permissions are given on. Each one must be registered there by one of its plugins.' },
|
||||
{ name: 'permissions', type: 'string', required: true, example: 'nightvision.allowed', source: 'rust.options.permissions',
|
||||
multiple: true, max: MAX_PERMISSIONS,
|
||||
description: `The permissions, by the plugin that registered them — at most ${MAX_PERMISSIONS}.` },
|
||||
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
|
||||
description: 'Who gets them: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
|
||||
{ name: 'count', type: 'float', required: false, example: 3,
|
||||
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
|
||||
{ name: 'message', type: 'string', required: false, example: messageExample,
|
||||
description: 'Said once to each player, in game: at once if they are up, otherwise when they next wake on the server. {permissions} is the names. Leave it empty to say nothing — a permission\'s name means little to a player, so say what it does.' },
|
||||
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
||||
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
|
||||
{ name: 'confirm', type: 'boolean', required: true, example: false,
|
||||
description: 'I am sure I want to give these permissions as a reward. Unticked, the step refuses in the dry run and when it runs.' },
|
||||
]
|
||||
|
||||
// ── Until the run ends (§12.4) ───────────────────────────────────────────────
|
||||
|
||||
const kitEntitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle')
|
||||
|
||||
const permLend = {
|
||||
id: 'rust.perm.lend',
|
||||
label: 'Permissions until the event ends',
|
||||
description:
|
||||
'Lends the permissions you pick to the people a mode picks from this run\'s tally — or every linked player of the server — until the run ends. Waits for them if they are offline. Someone who already held one keeps it when the event ends.',
|
||||
// A kit reward's lease with other permissions (D346): the same rows, the same
|
||||
// revert, so the same risk. Core reverts it at the end of every run.
|
||||
risk: 'change',
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: paramsFor('You can use /nv until the event ends'),
|
||||
|
||||
cost: (p) => ({ 'rust.permissions': pricedPeople(p) * pricedPermissions(p) }),
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const step = await checkStep(params, 'lends')
|
||||
if (!step.ok) return step
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const { permissions, mode, count, notice, server } = step
|
||||
const name = server.name || server.id
|
||||
const ref = entitlementRef(server.id, runId, stepId)
|
||||
const resource = { kind: 'permission-lease', ref, payload: { serverId: server.id, permissions } }
|
||||
|
||||
// As a kit reward's: the rows ARE the lease, and a repeated key finds them.
|
||||
const existing = await permDb.listRunGrantsForStep(runId, stepId)
|
||||
if (existing.length) {
|
||||
return {
|
||||
ok: true,
|
||||
resources: [resource],
|
||||
detail: { repeat: true, lent: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was lent' },
|
||||
}
|
||||
}
|
||||
|
||||
const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey, verb: 'lends' })
|
||||
if (!picked.ok) return picked
|
||||
|
||||
// One row per recipient per permission; the message on the first only, so
|
||||
// each person is told once (the notice key is per run, step and user).
|
||||
const said = notice.text ? sayPermissions(notice.text, permissions) : null
|
||||
const rows = []
|
||||
for (const p of picked.people) {
|
||||
permissions.forEach((permission, i) => {
|
||||
rows.push({
|
||||
runId,
|
||||
stepId,
|
||||
idemKey: idempotencyKey,
|
||||
userId: p.userId,
|
||||
serverId: server.id,
|
||||
steamId: p.steamId,
|
||||
permission,
|
||||
kit: '',
|
||||
credit: false,
|
||||
...(said && i === 0 ? { notice: said, noticeDelivery: notice.delivery } : {}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await permDb.insertRunGrants(rows)
|
||||
await permDb.markDirty(server.id)
|
||||
|
||||
log.info('permissions lent', { server: server.id, run: runId, step: stepId, permissions, mode, people: picked.people.length, missed: picked.missed.length })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
resources: [resource],
|
||||
detail: {
|
||||
server: name,
|
||||
permissions,
|
||||
mode,
|
||||
...(count.value === null ? {} : { count: count.value }),
|
||||
lent: picked.people.length,
|
||||
...(said && picked.people.length ? { told: `${picked.people.length} will be told in game, by ${notice.delivery}` } : {}),
|
||||
...missedDetail(picked.missed),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
// The lease ends as a kit reward's does: the step's rows go and the next sync
|
||||
// revokes what nobody else grants. A player who already held a permission
|
||||
// keeps it, because the push reads the union (§12.1).
|
||||
revert: (ctx) => kitEntitle.revert(ctx),
|
||||
reconcile: (ctx) => kitEntitle.reconcile(ctx),
|
||||
}
|
||||
|
||||
// ── For good (§12.5) ─────────────────────────────────────────────────────────
|
||||
|
||||
/** What a for-good grant's note says, in the permission manager. */
|
||||
const grantNote = (runId, stepId) => `event run ${runId}, step ${stepId}`
|
||||
|
||||
const permGive = {
|
||||
id: 'rust.perm.give',
|
||||
label: 'Permissions for good',
|
||||
description:
|
||||
'Gives the permissions you pick to the people a mode picks, for good. Ending or cancelling the event does not take them back; staff remove them in the permission manager.',
|
||||
// Irreversible, as `rust.coins.pay` is (D335, D346): core reverts a ledgered
|
||||
// step at the end of EVERY run, so a ledger here would take the gift back.
|
||||
risk: 'irreversible',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: paramsFor('You can use /nv from now on'),
|
||||
|
||||
cost: (p) => ({ 'rust.permissions.kept': pricedPeople(p) * pricedPermissions(p) }),
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const step = await checkStep(params, 'gives')
|
||||
if (!step.ok) return step
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const { permissions, mode, count, notice, server } = step
|
||||
const name = server.name || server.id
|
||||
|
||||
const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey, verb: 'gives' })
|
||||
if (!picked.ok) return picked
|
||||
|
||||
// A grant already there is left alone (§12.5). One this step's own first
|
||||
// attempt wrote carries its note, and is counted as given, not as held: a
|
||||
// retry after a lost answer reads the same as the attempt that landed.
|
||||
const note = grantNote(runId, stepId)
|
||||
const held = await permDb.listGrantsForUsers(picked.people.map((p) => p.userId), server.id)
|
||||
const heldBy = new Map(held.map((g) => [`${g.userId}:${String(g.permission).toLowerCase()}`, g]))
|
||||
|
||||
const rows = []
|
||||
let alreadyHeld = 0
|
||||
let repeated = 0
|
||||
for (const p of picked.people) {
|
||||
for (const permission of permissions) {
|
||||
const there = heldBy.get(`${p.userId}:${permission}`)
|
||||
if (there) {
|
||||
if (there.source === SOURCE && there.note === note) repeated++
|
||||
else alreadyHeld++
|
||||
continue
|
||||
}
|
||||
rows.push({ userId: p.userId, permission, scope: server.id, source: SOURCE, note })
|
||||
}
|
||||
}
|
||||
|
||||
const inserted = await permDb.insertGrants(rows)
|
||||
|
||||
// The message rides a notice-only row (§12.5): no permission, no kit, no
|
||||
// credit, so the push grants nothing for it and tells it once. Nothing
|
||||
// reverts this step, so the row stays — a sentence that has been said.
|
||||
const said = notice.text ? sayPermissions(notice.text, permissions) : null
|
||||
if (said && picked.people.length) {
|
||||
await permDb.insertRunGrants(picked.people.map((p) => ({
|
||||
runId,
|
||||
stepId,
|
||||
idemKey: idempotencyKey,
|
||||
userId: p.userId,
|
||||
serverId: server.id,
|
||||
steamId: p.steamId,
|
||||
permission: '',
|
||||
kit: '',
|
||||
credit: false,
|
||||
notice: said,
|
||||
noticeDelivery: notice.delivery,
|
||||
})))
|
||||
}
|
||||
|
||||
if (inserted || said) await permDb.markDirty(server.id)
|
||||
|
||||
log.info('permissions given', { server: server.id, run: runId, step: stepId, permissions, mode, people: picked.people.length, given: inserted, alreadyHeld, repeated })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
detail: {
|
||||
server: name,
|
||||
permissions,
|
||||
mode,
|
||||
...(count.value === null ? {} : { count: count.value }),
|
||||
people: picked.people.length,
|
||||
given: inserted + repeated,
|
||||
...(alreadyHeld ? { alreadyHeld, alreadyHeldNote: 'already held: the grant was there before, and is left as it was' } : {}),
|
||||
...(repeated ? { repeat: true, repeatNote: 'answered from the first attempt; nothing was given twice' } : {}),
|
||||
...(said && picked.people.length ? { told: `${picked.people.length} will be told in game, by ${notice.delivery}` } : {}),
|
||||
...missedDetail(picked.missed),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ── The picker (§12.2) ───────────────────────────────────────────────────────
|
||||
|
||||
/** A permission's group: the plugin that registered it, or its prefix, saying so (the permission manager's rule). */
|
||||
function groupOf(permission, owner) {
|
||||
if (owner) return owner
|
||||
const prefix = permission.includes('.') ? permission.slice(0, permission.indexOf('.')) : permission
|
||||
return `${prefix} (not registered by a plugin)`
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's rows from the catalogue and the enabled servers. Pure, so the
|
||||
* rule is testable without a database: each permission once, grouped by its
|
||||
* plugin, sorted by group and then permission without case, and a permission not
|
||||
* on every enabled server says where it is (`note`, MODULE_API 1.13.0).
|
||||
*/
|
||||
function permissionRows(catalogue, enabled) {
|
||||
const names = new Map(enabled.map((s) => [s.id, s.name || s.id]))
|
||||
const byPermission = new Map()
|
||||
for (const row of catalogue) {
|
||||
if (!names.has(row.serverId)) continue
|
||||
const permission = String(row.permission || '').trim()
|
||||
if (!permission) continue
|
||||
const key = permission.toLowerCase()
|
||||
if (!byPermission.has(key)) byPermission.set(key, { permission, owner: null, on: new Set() })
|
||||
const entry = byPermission.get(key)
|
||||
if (!entry.owner && row.owner) entry.owner = String(row.owner)
|
||||
entry.on.add(row.serverId)
|
||||
}
|
||||
|
||||
const rows = [...byPermission.values()].map((e) => {
|
||||
const group = groupOf(e.permission, e.owner)
|
||||
const row = { value: e.permission, label: e.permission, group }
|
||||
if (e.on.size < names.size) {
|
||||
const where = enabled.filter((s) => e.on.has(s.id)).map((s) => names.get(s.id))
|
||||
row.note = `Only on ${where.join(', ')}`
|
||||
}
|
||||
return row
|
||||
})
|
||||
|
||||
const fold = (s) => s.toLowerCase()
|
||||
rows.sort((a, b) => fold(a.group).localeCompare(fold(b.group)) || fold(a.value).localeCompare(fold(b.value)))
|
||||
return rows
|
||||
}
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'rust.options.permissions',
|
||||
label: 'Permissions',
|
||||
description: "Every permission the enabled servers' plugins registered, grouped by plugin; one missing from a server says where it is.",
|
||||
async resolve() {
|
||||
const [catalogue, enabled] = await Promise.all([permDb.listCatalogue(), servers.listForPolling()])
|
||||
return bounded(permissionRows(catalogue, enabled), 'rust.options.permissions')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const ACTIONS = [permLend, permGive]
|
||||
|
||||
module.exports = {
|
||||
MAX_PERMISSIONS,
|
||||
SOURCE,
|
||||
BUDGETS,
|
||||
ACTIONS,
|
||||
OPTION_SOURCES,
|
||||
checkPermissions,
|
||||
checkConfirm,
|
||||
permNotice,
|
||||
permissionRows,
|
||||
grantNote,
|
||||
}
|
||||
471
server/eventRaidableBases.js
Normal file
471
server/eventRaidableBases.js
Normal 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 }
|
||||
@@ -8,6 +8,11 @@
|
||||
// rust.kit.entitle the right to redeem a kit, and one more use of
|
||||
// it (R16, D103), for the people a mode picks
|
||||
// rust.announce one line in a server's chat, or every server's
|
||||
// rust.coins.pay Economics coins for the people a mode picks,
|
||||
// kept once paid (protocol 14, D335)
|
||||
// rust.backpack.size a bigger Backpacks backpack until the run ends:
|
||||
// a kit reward with `backpacks.size.N` as its
|
||||
// permission and no kit (protocol 14, D337)
|
||||
//
|
||||
// …and the announce leg, `rust.chat`, which says a published news post in the
|
||||
// chat of every server whose switch is on (D104).
|
||||
@@ -40,6 +45,9 @@ const servers = require('./model/servers/servers.model')
|
||||
const permDb = require('./model/permissions/permissions.db')
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const emit = require('./engagement/emit')
|
||||
const economics = require('./model/economics/economics.model')
|
||||
const backpacks = require('./model/backpacks/backpacks.model')
|
||||
const live = require('./liveStatus')
|
||||
const voice = require('./model/permissions/voice')
|
||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
@@ -57,7 +65,9 @@ const DEFAULT_KILL_WEIGHT = 5
|
||||
|
||||
const SCORES = ['seconds', 'kills', 'both']
|
||||
const KILLS_OF = ['players', 'npcs', 'both']
|
||||
const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent']
|
||||
// `linked` (PLAN_REDESIGNS.md §2.5) reads no tally: every linked player of the
|
||||
// kit's server, one use each — the kit weekend's second step.
|
||||
const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent', 'linked']
|
||||
|
||||
/** The fleet, in `rust.announce`'s `server` param (D105). */
|
||||
const EVERY_SERVER = '*'
|
||||
@@ -79,6 +89,11 @@ const PERMANENT = new Set([
|
||||
// Protocol 12: a popup on a server without PopupNotifications. Waiting does not
|
||||
// install it.
|
||||
'popup-unavailable',
|
||||
// Protocol 14, Economics (§11.2.2). Nor does it install Economics, and a bad
|
||||
// amount or too long a list is the step's, not the moment's.
|
||||
'economics-missing',
|
||||
'bad-amount',
|
||||
'too-large',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
@@ -95,6 +110,20 @@ const BUDGETS = [
|
||||
unit: 'lines',
|
||||
description: 'Lines an event says in a server\'s chat: one per server reached.',
|
||||
},
|
||||
{
|
||||
id: 'rust.coins',
|
||||
label: 'Coins paid',
|
||||
unit: 'coins',
|
||||
description:
|
||||
'Economics coins an event pays: the amount times the people paid. A mode that is not a count is priced at the most it could pay. Coins paid are kept — nothing takes them back.',
|
||||
},
|
||||
{
|
||||
id: 'rust.backpacks',
|
||||
label: 'Backpack sizes',
|
||||
unit: 'backpacks',
|
||||
description:
|
||||
'Bigger backpacks an event lends until it ends: one per recipient. A mode that is not a count is priced at the most it could lend.',
|
||||
},
|
||||
]
|
||||
|
||||
/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
|
||||
@@ -135,7 +164,7 @@ function refParts(ref) {
|
||||
* `everyone` takes none.
|
||||
*/
|
||||
function checkCount(mode, raw) {
|
||||
if (mode === 'everyone') return { ok: true, value: null }
|
||||
if (mode === 'everyone' || mode === 'linked') return { ok: true, value: null }
|
||||
|
||||
const value = Number(raw)
|
||||
if (mode === 'top' || mode === 'random') {
|
||||
@@ -287,6 +316,28 @@ async function readKit(server, kit) {
|
||||
return { ok: true, kit: String(row.name), ...reward, maxRecipients: Number(data.maxRecipients) || MAX_RECIPIENTS }
|
||||
}
|
||||
|
||||
/**
|
||||
* A reward's message to its player, checked (protocol 14, PLAN_REDESIGNS.md
|
||||
* §2.4, D333). `{ ok, text, delivery }`, with `text` null for a step that says
|
||||
* nothing — the default, and every step saved before this. Measured with the
|
||||
* kit's name in place, against the plugin's line bound, so a long kit name
|
||||
* cannot push a message the form accepted past what the game will say.
|
||||
*/
|
||||
function rewardNotice(params, kitName) {
|
||||
const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
|
||||
if (!text) return { ok: true, text: null, delivery: null }
|
||||
|
||||
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
|
||||
const delivery = oneOf(rawDelivery, DELIVERIES)
|
||||
if (!delivery) return { ok: false, error: `a reward's message goes to chat or to a popup, not to "${rawDelivery}"` }
|
||||
|
||||
const said = text.replace(/\{kit\}/g, kitName)
|
||||
if (said.length > MAX_CHAT) {
|
||||
return { ok: false, error: `a reward's message is at most ${MAX_CHAT} characters with the kit's name in it, not ${said.length}` }
|
||||
}
|
||||
return { ok: true, text, delivery }
|
||||
}
|
||||
|
||||
// ── The verbs ────────────────────────────────────────────────────────────────
|
||||
|
||||
const participationOpen = {
|
||||
@@ -305,14 +356,14 @@ const participationOpen = {
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'Which server counts.' },
|
||||
{ name: 'zone', type: 'string', required: false, example: 'Airfield brawl', source: 'rust.options.runzones',
|
||||
description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts (D100).' },
|
||||
description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts.' },
|
||||
{ name: 'score', type: 'string', required: true, example: 'both', source: 'rust.options.scoremodes',
|
||||
description: 'What earns a place: seconds present, kills, or both.' },
|
||||
{ name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsof',
|
||||
description: 'Whose deaths count as a kill: players, NPCs (animals included), or both. The last hit gets it. Needed unless the score is seconds.' },
|
||||
{ name: 'killWeight', type: 'float', required: false, example: DEFAULT_KILL_WEIGHT,
|
||||
description: `For a score of both: how many minutes one kill is worth. Left blank, ${DEFAULT_KILL_WEIGHT}.` },
|
||||
{ name: 'minutes', type: 'int', required: false, example: 60,
|
||||
{ name: 'minutes', type: 'int', required: false, example: 60, duration: 'minutes',
|
||||
description: `How long it counts, up to ${TALLY_MAX_MINUTES} (seven days). Left blank, seven days. The game forgets a tally seven days after it opened, however long it counted.` },
|
||||
],
|
||||
cost: () => ({}),
|
||||
@@ -480,18 +531,23 @@ const kitEntitle = {
|
||||
id: 'rust.kit.entitle',
|
||||
label: 'Reward a kit',
|
||||
description:
|
||||
'Gives the people a mode picks from this run\'s tally the right to redeem a kit on its server, and one more use of it. Waits for them if they are offline. Teardown withdraws what is not yet redeemed.',
|
||||
'Gives the people a mode picks from this run\'s tally — or every linked player of the server — the right to redeem a kit on its server, and one more use of it, and can tell each of them in game. Waits for them if they are offline. Teardown withdraws what is not yet redeemed.',
|
||||
risk: 'change',
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'kit', type: 'string', required: true, example: 'main/vip-starter', source: 'rust.options.kits',
|
||||
description: 'The kit, as server/kit. The reward reaches only that server (D102).' },
|
||||
description: 'The kit, as server/kit. The reward reaches only that server.' },
|
||||
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
|
||||
description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, or the top X per cent.' },
|
||||
description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all (a kit weekend).' },
|
||||
{ name: 'count', type: 'float', required: false, example: 3,
|
||||
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone.' },
|
||||
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
|
||||
// Protocol 14 (PLAN_REDESIGNS.md §2.4, D333): the reward tells its player.
|
||||
{ name: 'message', type: 'string', required: false, example: 'You earned kit {kit} — type /kit {kit}',
|
||||
description: 'Said once to each player rewarded, in game: at once if they are up, otherwise when they next wake on the server. {kit} is the kit\'s name. Leave it empty to say nothing.' },
|
||||
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
||||
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
|
||||
],
|
||||
|
||||
// Priced before the tally is read, so at the most it could grant: the count
|
||||
@@ -513,6 +569,9 @@ const kitEntitle = {
|
||||
const count = checkCount(mode, params.count)
|
||||
if (!count.ok) return { ok: false, retry: false, error: count.error }
|
||||
|
||||
const notice = rewardNotice(params, parsed.kit)
|
||||
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
|
||||
|
||||
const found = await serverFor(parsed.serverId)
|
||||
if (!found.ok) return found
|
||||
const server = found.server
|
||||
@@ -540,20 +599,40 @@ const kitEntitle = {
|
||||
}
|
||||
}
|
||||
|
||||
const tally = await readTally(server, runId)
|
||||
if (!tally.ok) return tally
|
||||
let picked
|
||||
let bound
|
||||
let users
|
||||
|
||||
const picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
|
||||
const bound = Number(tally.data.maxRecipients) || kit.maxRecipients
|
||||
if (picked.length > bound) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${picked.length} people qualify, and ${server.name || server.id} rewards at most ${bound} in one step — pick a count-based mode or a higher bar`,
|
||||
if (mode === 'linked') {
|
||||
// A kit weekend (§2.5): every linked player of the server, no tally.
|
||||
// Read one past the bound so "too many" is a sentence, not a silent cut.
|
||||
const linked = await linksDb.linkedOnServer(server.id, kit.maxRecipients + 1)
|
||||
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
|
||||
bound = kit.maxRecipients
|
||||
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
|
||||
if (picked.length > bound) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${server.name || server.id} has more than ${bound} linked players, and rewards at most ${bound} in one step — raise the plugin's recipient bound (EventsMaxRecipients) or reward from a tally`,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const tally = await readTally(server, runId)
|
||||
if (!tally.ok) return tally
|
||||
|
||||
const users = await usersFor(picked.map((p) => p.steamId))
|
||||
picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
|
||||
bound = Number(tally.data.maxRecipients) || kit.maxRecipients
|
||||
if (picked.length > bound) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${picked.length} people qualify, and ${server.name || server.id} rewards at most ${bound} in one step — pick a count-based mode or a higher bar`,
|
||||
}
|
||||
}
|
||||
|
||||
users = await usersFor(picked.map((p) => p.steamId))
|
||||
}
|
||||
const rows = []
|
||||
const byUser = new Set()
|
||||
const missed = []
|
||||
@@ -579,6 +658,7 @@ const kitEntitle = {
|
||||
permission: kit.permission,
|
||||
kit: kit.kit,
|
||||
credit: kit.max > 0,
|
||||
...(notice.text ? { notice: notice.text.replace(/\{kit\}/g, kit.kit), noticeDelivery: notice.delivery } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -598,6 +678,7 @@ const kitEntitle = {
|
||||
mode,
|
||||
...(count.value === null ? {} : { count: count.value }),
|
||||
granted: rows.length,
|
||||
...(notice.text && rows.length ? { told: `${rows.length} will be told in game, by ${notice.delivery}` } : {}),
|
||||
...(missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}),
|
||||
},
|
||||
}
|
||||
@@ -631,6 +712,430 @@ const kitEntitle = {
|
||||
},
|
||||
}
|
||||
|
||||
// ── Coins (protocol 14, PLAN_REDESIGNS.md §11.2.2) ──────────────────────────
|
||||
|
||||
/** The plugin's bounds on one payment's amount (PROTOCOL.md §20.3). */
|
||||
const COINS_MIN = economics.AMOUNT_MIN
|
||||
const COINS_MAX = economics.AMOUNT_MAX
|
||||
|
||||
/** The plugin keeps a payment's key in a file; core's key is used as it is unless it is long. */
|
||||
const PAYMENT_KEY_MAX = 120
|
||||
|
||||
/**
|
||||
* A payment's message, checked like a kit reward's (D333): `{ ok, text,
|
||||
* delivery }`, text null for a step that says nothing. Measured with the amount
|
||||
* in place of `{amount}`, against the plugin's line bound. The plugin fills in
|
||||
* what each player RECEIVED, which is never more than the amount asked.
|
||||
*/
|
||||
function coinsNotice(params, amount) {
|
||||
const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
|
||||
if (!text) return { ok: true, text: null, delivery: null }
|
||||
|
||||
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
|
||||
const delivery = oneOf(rawDelivery, DELIVERIES)
|
||||
if (!delivery) return { ok: false, error: `a payment's message goes to chat or to a popup, not to "${rawDelivery}"` }
|
||||
|
||||
const said = text.replace(/\{amount\}/g, economics.coins(amount))
|
||||
if (said.length > MAX_CHAT) {
|
||||
return { ok: false, error: `a payment's message is at most ${MAX_CHAT} characters with the amount in it, not ${said.length}` }
|
||||
}
|
||||
return { ok: true, text, delivery }
|
||||
}
|
||||
|
||||
/** 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}`)
|
||||
return key.length <= PAYMENT_KEY_MAX ? key : `coins:${crypto.createHash('sha256').update(key).digest('hex').slice(0, 40)}`
|
||||
}
|
||||
|
||||
const coinsPay = {
|
||||
id: 'rust.coins.pay',
|
||||
label: 'Pay coins',
|
||||
description:
|
||||
'Pays Economics coins to the people a mode picks from this run\'s tally — or to every linked player of the server — and can tell each of them in game. One payment per website account. Coins paid are kept: a run cancelled or aborted afterwards does not take them back.',
|
||||
// Irreversible, like Module-uo's `uo.item.grant` (D335): coins in a player's
|
||||
// balance may be spent the moment they land, and the platform reverts a
|
||||
// ledgered step at the end of EVERY run, a normal finish included — so a
|
||||
// ledger here would take a prize back. Staff take coins back by hand.
|
||||
risk: 'irreversible',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'The server whose Economics pays. It needs Economics installed.' },
|
||||
{ name: 'amount', type: 'float', required: true, example: 500,
|
||||
description: `How many coins each person receives, ${economics.coins(COINS_MIN)} to ${economics.coins(COINS_MAX)}.` },
|
||||
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
|
||||
description: 'Who is paid: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
|
||||
{ name: 'count', type: 'float', required: false, example: 3,
|
||||
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
|
||||
{ name: 'message', type: 'string', required: false, example: 'You earned {amount} coins',
|
||||
description: 'Said once to each player paid, in game: at once if they are up, otherwise when they next wake on the server. {amount} is what they received. Leave it empty to say nothing.' },
|
||||
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
||||
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
|
||||
],
|
||||
|
||||
// Priced before the tally is read, so at the most it could pay: the amount
|
||||
// times the count for a count, times the recipient bound for every other mode.
|
||||
cost: (p) => {
|
||||
const mode = oneOf(p.recipients, MODES)
|
||||
const n = Math.round(Number(p.count) || 0)
|
||||
const people = mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
|
||||
return { 'rust.coins': Math.ceil(Math.max(0, Number(p.amount) || 0) * people) }
|
||||
},
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const amount = Math.round(Number(params.amount) * 100) / 100
|
||||
if (!Number.isFinite(amount) || amount < COINS_MIN || amount > COINS_MAX) {
|
||||
return { ok: false, retry: false, error: `a payment is ${economics.coins(COINS_MIN)} to ${economics.coins(COINS_MAX)} coins, not "${params.amount}"` }
|
||||
}
|
||||
|
||||
const mode = oneOf(params.recipients, MODES)
|
||||
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
|
||||
|
||||
const count = checkCount(mode, params.count)
|
||||
if (!count.ok) return { ok: false, retry: false, error: count.error }
|
||||
|
||||
const notice = coinsNotice(params, amount)
|
||||
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
|
||||
|
||||
const found = await serverFor(String(params.server || '').trim())
|
||||
if (!found.ok) return found
|
||||
const server = found.server
|
||||
const name = server.name || server.id
|
||||
|
||||
// D199: refused with the reason where Economics is absent, in the dry run
|
||||
// too — the author learns it on the form, not at the run's reward.
|
||||
// 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) {
|
||||
return { ok: false, retry: false, error: `${name} pays at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
let picked
|
||||
let bound = MAX_RECIPIENTS
|
||||
let users
|
||||
|
||||
if (mode === 'linked') {
|
||||
// Every linked player of the server, as a kit weekend's (§2.5). One past
|
||||
// the bound, so "too many" is a sentence and not a silent cut.
|
||||
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
|
||||
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
|
||||
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
|
||||
if (picked.length > bound) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${name} has more than ${bound} linked players, and pays at most ${bound} in one step — pay from a tally instead`,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const tally = await readTally(server, runId)
|
||||
if (!tally.ok) return tally
|
||||
|
||||
picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
|
||||
bound = Number(tally.data.maxRecipients) || bound
|
||||
if (picked.length > bound) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${picked.length} people qualify, and ${name} pays at most ${bound} in one step — pick a count-based mode or a higher bar`,
|
||||
}
|
||||
}
|
||||
|
||||
users = await usersFor(picked.map((p) => p.steamId))
|
||||
}
|
||||
|
||||
// One payment per website user (D103's rule): of two linked accounts that
|
||||
// both took part, the earlier in the list — the higher score — is paid.
|
||||
const payees = []
|
||||
const byUser = new Set()
|
||||
const missed = []
|
||||
|
||||
for (const p of picked) {
|
||||
const userId = users.get(p.steamId)
|
||||
if (!userId) {
|
||||
missed.push(p.name)
|
||||
continue
|
||||
}
|
||||
if (byUser.has(userId)) continue
|
||||
byUser.add(userId)
|
||||
payees.push(p)
|
||||
}
|
||||
|
||||
const missedDetail = missed.length
|
||||
? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' }
|
||||
: {}
|
||||
|
||||
if (!payees.length) {
|
||||
return { ok: true, detail: { server: name, mode, amount, paid: 0, ...missedDetail } }
|
||||
}
|
||||
|
||||
const result = await client.economicsPay(server, {
|
||||
key: paymentKey(idempotencyKey, runId, stepId),
|
||||
amount,
|
||||
steamIds: payees.map((p) => p.steamId),
|
||||
...(notice.text ? { message: notice.text, delivery: notice.delivery } : {}),
|
||||
})
|
||||
// A lost answer is retried with the same key, and the plugin answers it from
|
||||
// its first reply: nobody is paid twice.
|
||||
if (!result.ok) return transportFailure(server, result, 'Economics')
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'economics.paid') return refusal(server, data, 'payment')
|
||||
|
||||
const named = new Map(payees.map((p) => [p.steamId, p.name]))
|
||||
const paid = (data.paid || []).map((row) => ({
|
||||
name: named.get(String(row.steamId)) || String(row.steamId),
|
||||
received: Math.round((Number(row.after) - Number(row.before)) * 100) / 100,
|
||||
}))
|
||||
const failed = (data.failed || []).map((row) => ({ name: named.get(String(row.steamId)) || String(row.steamId), reason: row.reason }))
|
||||
const total = Math.round(paid.reduce((sum, row) => sum + row.received, 0) * 100) / 100
|
||||
|
||||
log.info('coins paid', { server: server.id, run: runId, step: stepId, amount, paid: paid.length, failed: failed.length, repeat: Boolean(data.repeat) })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
detail: {
|
||||
server: name,
|
||||
mode,
|
||||
...(count.value === null ? {} : { count: count.value }),
|
||||
amount,
|
||||
paid: paid.length,
|
||||
total,
|
||||
received: paid.slice(0, 50),
|
||||
...(failed.length ? { failed: failed.slice(0, 50), failedNote: 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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ── A bigger backpack (protocol 14, PLAN_REDESIGNS.md §11.3.2, D337, D338) ──
|
||||
|
||||
/** Said when the author writes nothing: the take-back surprises a player nobody warned (D338). */
|
||||
const BACKPACK_NOTICE =
|
||||
'Your backpack holds {size} until the event ends. Anything past your normal size then moves to your inventory, or drops at your feet if it is full.'
|
||||
|
||||
/**
|
||||
* A bigger backpack's message, checked like a kit reward's (D333): `{ ok, text,
|
||||
* delivery }`. Unlike a kit reward's it is never silent: an empty message is
|
||||
* the default warning above. Measured with the size in place of `{size}`.
|
||||
*/
|
||||
function backpackNotice(params, size) {
|
||||
const written = params.message === undefined || params.message === null ? '' : String(params.message).trim()
|
||||
const text = written || BACKPACK_NOTICE
|
||||
|
||||
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
|
||||
const delivery = oneOf(rawDelivery, DELIVERIES)
|
||||
if (!delivery) return { ok: false, error: `a bigger backpack's message goes to chat or to a popup, not to "${rawDelivery}"` }
|
||||
|
||||
const said = text.replace(/\{size\}/g, String(size))
|
||||
if (said.length > MAX_CHAT) {
|
||||
return { ok: false, error: `a bigger backpack's message is at most ${MAX_CHAT} characters with the size in it, not ${said.length}` }
|
||||
}
|
||||
return { ok: true, text, delivery }
|
||||
}
|
||||
|
||||
const backpackSize = {
|
||||
id: 'rust.backpack.size',
|
||||
label: 'Bigger backpack',
|
||||
description:
|
||||
'Lends the people a mode picks from this run\'s tally — or every linked player of the server — a bigger Backpacks backpack until the run ends, and tells each of them in game. Waits for them if they are offline. Someone who already holds a size at least as big gains nothing and loses nothing. At the end, whatever is past their normal size moves to their inventory the next time they open the backpack, or drops at their feet if the inventory is full.',
|
||||
// A kit reward with a different permission (D337): the same lease, the same
|
||||
// revert, so the same risk.
|
||||
risk: 'change',
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'The server whose backpacks grow. It needs Backpacks installed.' },
|
||||
{ name: 'size', type: 'int', required: true, example: 48, source: 'rust.options.backpacksizes',
|
||||
description: 'How many slots, one of the sizes the server\'s Backpacks offers (its backpacks.size.N permissions) above the smallest.' },
|
||||
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
|
||||
description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
|
||||
{ name: 'count', type: 'float', required: false, example: 3,
|
||||
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
|
||||
{ name: 'message', type: 'string', required: false, example: BACKPACK_NOTICE,
|
||||
description: 'Said once to each player, in game: at once if they are up, otherwise when they next wake on the server. {size} is the size. Left empty, it is the example, which warns about the end.' },
|
||||
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
||||
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
|
||||
],
|
||||
|
||||
// Priced as a kit reward is: the count for a count, the recipient bound for
|
||||
// every other mode.
|
||||
cost: (p) => {
|
||||
const mode = oneOf(p.recipients, MODES)
|
||||
const n = Math.round(Number(p.count) || 0)
|
||||
return { 'rust.backpacks': mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS }
|
||||
},
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const size = Number(params.size)
|
||||
if (!Number.isInteger(size) || size < 1) return { ok: false, retry: false, error: `"${params.size}" is not a backpack size — pick one from the list` }
|
||||
|
||||
const mode = oneOf(params.recipients, MODES)
|
||||
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
|
||||
|
||||
const count = checkCount(mode, params.count)
|
||||
if (!count.ok) return { ok: false, retry: false, error: count.error }
|
||||
|
||||
const notice = backpackNotice(params, size)
|
||||
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
|
||||
|
||||
const found = await serverFor(String(params.server || '').trim())
|
||||
if (!found.ok) return found
|
||||
const server = found.server
|
||||
const name = server.name || server.id
|
||||
|
||||
// D199: refused with the reason where Backpacks is absent, in the dry run
|
||||
// too, and a size the server does not offer is refused by name.
|
||||
// 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)) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: offered.length
|
||||
? `${name}'s Backpacks offers ${offered.join(', ')} slots above its smallest size, not ${size}`
|
||||
: `${name}'s Backpacks offers no size above its smallest, so a bigger backpack gives nobody anything — add sizes to its "Permission sizes"`,
|
||||
}
|
||||
}
|
||||
|
||||
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
|
||||
return { ok: false, retry: false, error: `${name} lends at most ${MAX_RECIPIENTS} backpacks in one step, not ${count.value}` }
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const permission = `backpacks.size.${size}`
|
||||
const ref = entitlementRef(server.id, runId, stepId)
|
||||
const resource = { kind: 'backpack-size', ref, payload: { serverId: server.id, permission } }
|
||||
|
||||
// As a kit reward's: the rows ARE the lease, and a repeated key finds them.
|
||||
const existing = await permDb.listRunGrantsForStep(runId, stepId)
|
||||
if (existing.length) {
|
||||
return {
|
||||
ok: true,
|
||||
resources: [resource],
|
||||
detail: { repeat: true, lent: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was lent' },
|
||||
}
|
||||
}
|
||||
|
||||
let picked
|
||||
let bound = MAX_RECIPIENTS
|
||||
let users
|
||||
|
||||
if (mode === 'linked') {
|
||||
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
|
||||
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
|
||||
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
|
||||
if (picked.length > bound) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${name} has more than ${bound} linked players, and lends at most ${bound} backpacks in one step — lend from a tally instead`,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const tally = await readTally(server, runId)
|
||||
if (!tally.ok) return tally
|
||||
|
||||
picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
|
||||
bound = Number(tally.data.maxRecipients) || bound
|
||||
if (picked.length > bound) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${picked.length} people qualify, and ${name} lends at most ${bound} backpacks in one step — pick a count-based mode or a higher bar`,
|
||||
}
|
||||
}
|
||||
|
||||
users = await usersFor(picked.map((p) => p.steamId))
|
||||
}
|
||||
|
||||
// One lease per website user (D103's rule). The permission reaches every
|
||||
// account the user has linked, as a kit reward's does (D28).
|
||||
const rows = []
|
||||
const byUser = new Set()
|
||||
const missed = []
|
||||
|
||||
for (const p of picked) {
|
||||
const userId = users.get(p.steamId)
|
||||
if (!userId) {
|
||||
missed.push(p.name)
|
||||
continue
|
||||
}
|
||||
if (byUser.has(userId)) continue
|
||||
byUser.add(userId)
|
||||
rows.push({
|
||||
runId,
|
||||
stepId,
|
||||
idemKey: idempotencyKey,
|
||||
userId,
|
||||
serverId: server.id,
|
||||
steamId: p.steamId,
|
||||
permission,
|
||||
kit: '',
|
||||
credit: false,
|
||||
notice: notice.text.replace(/\{size\}/g, String(size)),
|
||||
noticeDelivery: notice.delivery,
|
||||
})
|
||||
}
|
||||
|
||||
await permDb.insertRunGrants(rows)
|
||||
await permDb.markDirty(server.id)
|
||||
|
||||
log.info('backpacks lent', { server: server.id, run: runId, step: stepId, size, mode, lent: rows.length, missed: missed.length })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
resources: [resource],
|
||||
detail: {
|
||||
server: name,
|
||||
size,
|
||||
permission,
|
||||
mode,
|
||||
...(count.value === null ? {} : { count: count.value }),
|
||||
lent: rows.length,
|
||||
...(rows.length ? { told: `${rows.length} will be told in game, by ${notice.delivery}` } : {}),
|
||||
...(missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
// The lease ends as a kit reward's does: the step's rows go and the next sync
|
||||
// revokes the permission. Backpacks moves the overflow at the owner's next
|
||||
// open (D338).
|
||||
revert: (ctx) => kitEntitle.revert(ctx),
|
||||
reconcile: (ctx) => kitEntitle.reconcile(ctx),
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of a chat line: its delivery, and the voice's format when the line
|
||||
* goes to chat and a voice is chosen (D140). A popup is not a chat line and
|
||||
@@ -667,13 +1172,13 @@ const announce = {
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.chatservers',
|
||||
description: 'Which server, or * for every server (D105).' },
|
||||
description: 'Which server, or * for every server.' },
|
||||
{ name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!',
|
||||
description: `The line, up to ${MAX_CHAT} characters.` },
|
||||
// Optional, and the action stays version 1: a bump would stop every step
|
||||
// already written from dispatching until somebody re-saved it (§33.2).
|
||||
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
||||
description: 'chat (the default), or popup — which needs PopupNotifications on the server, and is refused with a reason where it is missing (D141).' },
|
||||
description: 'chat (the default), or popup — which needs PopupNotifications on the server, and is refused with a reason where it is missing.' },
|
||||
],
|
||||
|
||||
// One per server reached. `*` is priced at the enabled servers when core asks,
|
||||
@@ -856,17 +1361,39 @@ const OPTION_SOURCES = [
|
||||
{ value: 'npcs', label: 'NPCs, animals included' },
|
||||
{ value: 'both', label: 'Players and NPCs' },
|
||||
]),
|
||||
fixed('rust.options.recipientmodes', 'Recipients', 'Who a reward goes to (D101).', [
|
||||
fixed('rust.options.recipientmodes', 'Recipients', 'Who a reward goes to.', [
|
||||
{ value: 'everyone', label: 'Everyone who scored' },
|
||||
{ value: 'top', label: 'The top N (ties in)' },
|
||||
{ value: 'minScore', label: 'A score of at least X' },
|
||||
{ value: 'random', label: 'N drawn at random' },
|
||||
{ value: 'topPercent', label: 'The top X per cent (ties in)' },
|
||||
{ value: 'linked', label: 'Every linked player of the server (no tally)' },
|
||||
]),
|
||||
fixed('rust.options.delivery', 'Delivery', 'Where a line goes (D141).', [
|
||||
fixed('rust.options.delivery', 'Delivery', 'Where a line goes.', [
|
||||
{ value: 'chat', label: 'Chat' },
|
||||
{ value: 'popup', label: 'A popup — needs PopupNotifications on the server' },
|
||||
]),
|
||||
{
|
||||
// From each server's last hello: the sizes its Backpacks offers above the
|
||||
// smallest (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 bySize = new Map()
|
||||
for (const s of list) {
|
||||
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 [...bySize.keys()]
|
||||
.sort((x, y) => x - y)
|
||||
.map((n) => ({ value: n, label: `${n} slots — ${bySize.get(n).join(', ')}` }))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rust.options.chatservers',
|
||||
label: 'Chat servers',
|
||||
@@ -878,7 +1405,7 @@ const OPTION_SOURCES = [
|
||||
},
|
||||
]
|
||||
|
||||
const ACTIONS = [participationOpen, participationCollect, kitEntitle, announce]
|
||||
const ACTIONS = [participationOpen, participationCollect, kitEntitle, coinsPay, backpackSize, announce]
|
||||
|
||||
module.exports = {
|
||||
MAX_RECIPIENTS,
|
||||
@@ -889,10 +1416,24 @@ module.exports = {
|
||||
ACTIONS,
|
||||
LEG,
|
||||
OPTION_SOURCES,
|
||||
MODES,
|
||||
PERMANENT,
|
||||
oneOf,
|
||||
peopleOf,
|
||||
usersFor,
|
||||
readTally,
|
||||
entitlementRef,
|
||||
failedNote,
|
||||
pickRecipients,
|
||||
checkCount,
|
||||
splitKit,
|
||||
kitReward,
|
||||
readKit,
|
||||
rewardNotice,
|
||||
coinsNotice,
|
||||
paymentKey,
|
||||
backpackNotice,
|
||||
BACKPACK_NOTICE,
|
||||
chatLine,
|
||||
chatKey,
|
||||
lineBody,
|
||||
|
||||
154
server/eventTemplates.js
Normal file
154
server/eventTemplates.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// ── Event templates (PLAN_REDESIGNS.md §2.5, D164, D192) ────────────────────
|
||||
//
|
||||
// A named starting point for a new event, through core's
|
||||
// `api.registerEventTemplates` (MODULE_API 1.13.0). The author fills in a few
|
||||
// inputs; `build()` turns them into an ordinary draft — phases, steps, params —
|
||||
// which the editor opens unsaved. Core validates the plan as a spec and keeps
|
||||
// nothing about the template, so this file can change without touching a
|
||||
// single event it was ever used for.
|
||||
//
|
||||
// ── The kit weekend ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// "Everybody gets kit vip this weekend." One phase that lasts the weekend, and
|
||||
// two steps in it:
|
||||
//
|
||||
// 1. core.lease → rust.group.permission: the `default` group holds the kit's
|
||||
// permission for the weekend, so every player can see and redeem it. The
|
||||
// game holds the deadline and gives it back on time whether or not the site
|
||||
// is up.
|
||||
// 2. rust.kit.entitle → that kit, every linked player of the server (`linked`),
|
||||
// one more use each — the use a player who already spent theirs needs to
|
||||
// take the kit again. Withdrawn at teardown if not yet redeemed (D103).
|
||||
//
|
||||
// A kit with no permission is open to everyone already, so its weekend is step
|
||||
// 2 alone; a kit with no use limit has no "one more use", so its weekend is step
|
||||
// 1 alone. A kit with neither is refused by `readKit`, because a weekend of it
|
||||
// gives nobody anything.
|
||||
|
||||
const { serverFor } = require('./eventLeases')
|
||||
const { readKit, splitKit, rewardNotice, MAX_CHAT } = require('./eventRewards')
|
||||
|
||||
/** The lease's own ceiling (D77): a weekend longer than seven days is refused by the game. */
|
||||
const MAX_WEEKEND_MINUTES = 7 * 24 * 60
|
||||
|
||||
/** "48 hours", "90 minutes". */
|
||||
function minutesWords(n) {
|
||||
if (n >= 120 && n % 60 === 0) return `${n / 60} hours`
|
||||
return `${n} minute${n === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
const kitWeekend = {
|
||||
id: 'rust.template.kit_weekend',
|
||||
label: 'Kit weekend',
|
||||
description:
|
||||
'A kit for everybody, for a while: the default group holds the kit\'s permission for the length of the weekend, and every linked player of the server gets one more use of it. You can change every part of the plan before saving it.',
|
||||
inputs: [
|
||||
{
|
||||
name: 'kit',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'main/vip',
|
||||
source: 'rust.options.kits',
|
||||
description: 'The kit, as server/kit. The weekend happens on that server only.',
|
||||
},
|
||||
{
|
||||
name: 'minutes',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 2880,
|
||||
duration: 'minutes',
|
||||
description: `How long the weekend lasts, up to ${MAX_WEEKEND_MINUTES} (seven days). 2880 is two days.`,
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'Kit weekend! One more use of {kit} — type /kit {kit}',
|
||||
description: `Told once to each linked player in game, at once or when they next wake. {kit} is the kit's name; at most ${MAX_CHAT} characters. Leave it empty to say nothing.`,
|
||||
},
|
||||
{
|
||||
name: 'delivery',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'popup',
|
||||
source: 'rust.options.delivery',
|
||||
description: 'Chat, or a popup where the server has PopupNotifications.',
|
||||
},
|
||||
],
|
||||
|
||||
async build({ inputs }) {
|
||||
const parsed = splitKit(inputs.kit)
|
||||
if (!parsed) return { ok: false, error: `"${inputs.kit}" is not a kit — pick one from the list, as server/kit` }
|
||||
|
||||
const minutes = Number(inputs.minutes)
|
||||
if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_WEEKEND_MINUTES) {
|
||||
return { ok: false, error: `a kit weekend lasts 1 to ${MAX_WEEKEND_MINUTES} minutes, not ${inputs.minutes}` }
|
||||
}
|
||||
|
||||
const notice = rewardNotice({ message: inputs.message, delivery: inputs.delivery }, parsed.kit)
|
||||
if (!notice.ok) return { ok: false, error: notice.error }
|
||||
|
||||
const found = await serverFor(parsed.serverId)
|
||||
if (!found.ok) return { ok: false, error: found.error }
|
||||
const server = found.server
|
||||
const serverName = server.name || server.id
|
||||
|
||||
// The kit as its server describes it, now. A server that is down cannot say
|
||||
// which permission gates the kit, and a plan guessed at would be wrong.
|
||||
const kit = await readKit(server, parsed.kit)
|
||||
if (!kit.ok) return { ok: false, error: kit.error }
|
||||
|
||||
const steps = []
|
||||
if (kit.permission) {
|
||||
steps.push({
|
||||
actionId: 'core.lease',
|
||||
label: `Open ${kit.kit} to everyone`,
|
||||
params: {
|
||||
lease: 'rust.group.permission',
|
||||
target: `${server.id}/default/${kit.permission}`,
|
||||
value: 'true',
|
||||
minutes,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (kit.max > 0) {
|
||||
steps.push({
|
||||
actionId: 'rust.kit.entitle',
|
||||
label: 'One more use for every linked player',
|
||||
params: {
|
||||
kit: `${server.id}/${kit.kit}`,
|
||||
recipients: 'linked',
|
||||
...(notice.text ? { message: notice.text, delivery: notice.delivery } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const parts = [
|
||||
kit.permission ? `everybody on ${serverName} may redeem ${kit.kit}` : null,
|
||||
kit.max > 0 ? 'every linked player gets one more use of it' : null,
|
||||
].filter(Boolean)
|
||||
|
||||
return {
|
||||
title: `Kit weekend: ${kit.kit} on ${serverName}`,
|
||||
summary: `For ${minutesWords(minutes)}, ${parts.join(', and ')}.`,
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{
|
||||
key: 'weekend',
|
||||
label: 'The weekend',
|
||||
// The phase IS the weekend: it holds the run open for as long as the
|
||||
// lease lasts, so teardown gives the permission back on time and not
|
||||
// a second after the steps finish (§2.2, U-6).
|
||||
advance: { after: `${minutes}m` },
|
||||
steps,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const TEMPLATES = [kitWeekend]
|
||||
|
||||
module.exports = { TEMPLATES, MAX_WEEKEND_MINUTES, minutesWords }
|
||||
@@ -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')
|
||||
|
||||
@@ -62,6 +63,7 @@ const ZONE_MESSAGE_MAX = 256
|
||||
const DOME_STACK_MAX = 10
|
||||
/** A blank stack: 3 spheres, chosen by eye at noon in RunicNPC's 9d player session (D326). */
|
||||
const DOME_STACK_DEFAULT = 3
|
||||
const TRUEPVE_RULESET_MAX = 64
|
||||
|
||||
/**
|
||||
* ZoneDomes' own sphere types, by the number its API takes (D194). Only Standard
|
||||
@@ -127,6 +129,10 @@ const PERMANENT = new Set([
|
||||
// the server (`escort-offline`) is left to retry: they may join.
|
||||
'no-zone',
|
||||
'runicnpc-old',
|
||||
// PLAN_REDESIGNS §14.3.2 (D368): no TruePVE on the server, or no ruleset of
|
||||
// the name the step typed. Only the call can tell the second.
|
||||
'truepve-missing',
|
||||
'truepve-ruleset',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
@@ -140,13 +146,13 @@ const BUDGETS = [
|
||||
id: 'rust.npcs',
|
||||
label: 'NPCs placed',
|
||||
unit: 'NPCs',
|
||||
description: 'Scientists and guards an event puts in the world — its own dial, so fights can be capped apart from loot (D89).',
|
||||
description: 'Scientists and guards an event puts in the world — its own dial, so fights can be capped apart from loot.',
|
||||
},
|
||||
{
|
||||
id: 'rust.zone.minutes',
|
||||
label: 'Zone time',
|
||||
unit: 'minutes',
|
||||
description: 'How long the zones an event opens stand, added up. Every zone declares its minutes, and the game erases it when they run out (D96).',
|
||||
description: 'How long the zones an event opens stand, added up. Every zone declares its minutes, and the game erases it when they run out.',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -165,6 +171,28 @@ function domeMissing(mods) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone's TruePVE mapping (§14.3.2, D368): `{ exclude: true }` for "no PvP
|
||||
* protection", `{ ruleset }` for a ruleset staff typed, or null for neither.
|
||||
* Both is refused, and either is refused where the server said it has no
|
||||
* TruePVE. A server that has not said is left to the plugin (`truepve-missing`),
|
||||
* as a dome is. Whether the ruleset exists is the plugin's to answer.
|
||||
*/
|
||||
function truePveMapping(params, mods) {
|
||||
const raw = params.noPvpProtection
|
||||
const exclude = raw === true || raw === 'true'
|
||||
const ruleset = params.pvpRuleset === undefined || params.pvpRuleset === null ? '' : String(params.pvpRuleset).trim()
|
||||
if (exclude && ruleset) {
|
||||
return { ok: false, error: 'a zone has no PvP protection or a TruePVE ruleset, not both' }
|
||||
}
|
||||
if (ruleset.length > TRUEPVE_RULESET_MAX) {
|
||||
return { ok: false, error: `a TruePVE ruleset's name is at most ${TRUEPVE_RULESET_MAX} characters` }
|
||||
}
|
||||
if (!exclude && !ruleset) return { ok: true, wire: null }
|
||||
if (mods && mods.truePve && !mods.truePve.loaded) return { ok: false, error: 'TruePVE is not installed on this server' }
|
||||
return { ok: true, wire: exclude ? { exclude: true } : { ruleset } }
|
||||
}
|
||||
|
||||
/** A number param, or undefined when left blank. */
|
||||
function num(raw) {
|
||||
if (raw === undefined || raw === null || raw === '') return undefined
|
||||
@@ -296,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,
|
||||
@@ -590,7 +626,7 @@ const NPC_ORDER_PARAMS = [
|
||||
required: false,
|
||||
example: '76561198000000001',
|
||||
description:
|
||||
"A player's Steam id, or a {placeholder} from the run's start params: the NPCs keep close to that player and fight whoever attacks them, and walk back to their spot if the player dies or leaves (D269). The player must be on the server. RunicNPC profiles only.",
|
||||
"A player's Steam id, or a {placeholder} from the run's start params: the NPCs keep close to that player and fight whoever attacks them, and walk back to their spot if the player dies or leaves. The player must be on the server. RunicNPC profiles only.",
|
||||
},
|
||||
{
|
||||
name: 'allyClan',
|
||||
@@ -599,7 +635,7 @@ const NPC_ORDER_PARAMS = [
|
||||
source: 'rust.options.clans',
|
||||
example: 'main/1234567',
|
||||
description:
|
||||
"A clan on the same server: the NPCs never target its members and defend them and what they own (D257). RunicNPC profiles only; give this or allyTeamOf, not both.",
|
||||
"A clan on the same server: the NPCs never target its members and defend them and what they own. RunicNPC profiles only; give this or allyTeamOf, not both.",
|
||||
},
|
||||
{
|
||||
name: 'allyTeamOf',
|
||||
@@ -607,7 +643,7 @@ const NPC_ORDER_PARAMS = [
|
||||
required: false,
|
||||
example: '76561198000000001',
|
||||
description:
|
||||
"A player's Steam id, or a {placeholder}: the NPCs are allied to that player and their team (D257, D270). RunicNPC profiles only.",
|
||||
"A player's Steam id, or a {placeholder}: the NPCs are allied to that player and their team. RunicNPC profiles only.",
|
||||
},
|
||||
{
|
||||
name: 'tether',
|
||||
@@ -615,7 +651,7 @@ const NPC_ORDER_PARAMS = [
|
||||
required: false,
|
||||
example: true,
|
||||
description:
|
||||
'Keep the NPCs inside the zone this event made around the point (D272): a Make a zone step must come first. RunicNPC profiles only.',
|
||||
'Keep the NPCs inside the zone this event made around the point: a Make a zone step must come first. RunicNPC profiles only.',
|
||||
},
|
||||
{
|
||||
// Runicnpc stage 7 (D296): on by default; a step older than the switch drops loot as before.
|
||||
@@ -624,7 +660,7 @@ const NPC_ORDER_PARAMS = [
|
||||
required: false,
|
||||
example: true,
|
||||
description:
|
||||
"Whether the NPCs drop their profile's loot table when killed (D296). On unless switched off; off, their corpse still holds what the profile starts it with. An event's own rewards are separate. RunicNPC profiles only.",
|
||||
"Whether the NPCs drop their profile's loot table when killed. On unless switched off; off, their corpse still holds what the profile starts it with. An event's own rewards are separate. RunicNPC profiles only.",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -663,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
|
||||
@@ -696,6 +736,9 @@ const ACTIONS = [
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 120,
|
||||
// A timed step (core MODULE_API 1.13.0): the editor keeps the run open
|
||||
// this long, because teardown erases the zone when the run ends.
|
||||
duration: 'minutes',
|
||||
description: `How long it stands, up to ${ZONE_MAX_MINUTES} (seven days). Counted against zone time.`,
|
||||
},
|
||||
{ name: 'name', type: 'string', required: false, example: 'Airfield brawl', description: 'What the zone is called.' },
|
||||
@@ -728,6 +771,20 @@ const ACTIONS = [
|
||||
'A ZoneDomes dome over the zone. "standard" is a full shaded dome; the colours show only where the sphere meets the ground or a building. Needs ZoneDomes and RunicGatewayDomes.cs on the server. Blank is no dome.',
|
||||
},
|
||||
{ name: 'domeStack', type: 'int', required: false, example: DOME_STACK_DEFAULT, description: `How many spheres the dome stacks, 1 to ${DOME_STACK_MAX}; more is darker. Left blank, ${DOME_STACK_DEFAULT}.` },
|
||||
{
|
||||
name: 'noPvpProtection',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
example: true,
|
||||
description: 'No PvP protection in this zone: TruePVE leaves it alone, so players can hurt each other inside. Needs TruePVE on the server. Not with a ruleset.',
|
||||
},
|
||||
{
|
||||
name: 'pvpRuleset',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'pvp-arena',
|
||||
description: `Or this TruePVE ruleset in the zone, by its name in TruePVE's config (names are case-sensitive, up to ${TRUEPVE_RULESET_MAX} characters). A name TruePVE does not have fails the step when it runs. Needs TruePVE on the server.`,
|
||||
},
|
||||
],
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
@@ -750,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 }
|
||||
@@ -777,11 +835,18 @@ 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 }
|
||||
|
||||
// The dry run stops here, and has checked everything it can without the
|
||||
// game. It does not ask whether the monument exists: a step authored for
|
||||
// next wipe's map would fail every dry run until the wipe.
|
||||
@@ -807,6 +872,7 @@ const ACTIONS = [
|
||||
...(hasMessage ? { delivery } : {}),
|
||||
...(format ? { format } : {}),
|
||||
...(dome ? { dome } : {}),
|
||||
...(truePve.wire ? { truePve: truePve.wire } : {}),
|
||||
},
|
||||
'zone',
|
||||
)
|
||||
@@ -842,7 +908,7 @@ const OPTION_SOURCES = [
|
||||
// cached list would offer monuments that are not there any more.
|
||||
id: 'rust.options.monuments',
|
||||
label: 'Monuments',
|
||||
description: "Each server's monuments on its current map. A kind that repeats is numbered, #1 first (D93).",
|
||||
description: "Each server's monuments on its current map. A kind that repeats is numbered, #1 first.",
|
||||
searchable: true,
|
||||
async resolve({ q } = {}) {
|
||||
const term = String(q || '').trim().toLowerCase()
|
||||
@@ -917,7 +983,7 @@ const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'rust.options.domes',
|
||||
label: 'Domes',
|
||||
description: "ZoneDomes' dome colours (D194). A coloured dome shows only where it meets terrain or a structure.",
|
||||
description: "ZoneDomes' dome colours. A coloured dome shows only where it meets terrain or a structure.",
|
||||
async resolve() {
|
||||
return DOMES.map((d) => ({ value: d.value, label: d.label }))
|
||||
},
|
||||
|
||||
191
server/eventWorldCall.js
Normal file
191
server/eventWorldCall.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// ── Calling one of Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ──
|
||||
//
|
||||
// `rust.world.call`, "Call a world event": a patrol helicopter, a cargo ship, a
|
||||
// Chinook, an airdrop, the Travelling Vendor, road Bradleys, the F-15 or a
|
||||
// satellite crash, now, the way the game's own schedule runs them (D354, D367).
|
||||
// The plugin runs the schedule's `TriggeredEvent` — no console command — so it
|
||||
// works the same on Carbon.
|
||||
//
|
||||
// **Kept (`reversible: 'none'`).** Nothing a call starts is removed when the run
|
||||
// ends: a cargo ship or a drop may already hold players or their loot (D354).
|
||||
// `risk: 'change'` all the same, so an operator switches it on deliberately.
|
||||
//
|
||||
// **A position only where the game has a route to one** (stage 1's spike): the
|
||||
// airdrop's drop point and the patrol helicopter's first destination. The
|
||||
// Chinook drops only at the map's drop zones, and the one route to a chosen
|
||||
// zone borrows the Airfield terminal's shared state, so it takes no position
|
||||
// (D378); the rest go where the game puts them.
|
||||
|
||||
const client = require('./sidecarClient')
|
||||
const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS, location } = require('./eventWorld')
|
||||
|
||||
/** The plugin's ceiling on calls per run (`WorldCallsHardMax`); an operator may set it lower. */
|
||||
const MAX_CALLS_PER_RUN = 10
|
||||
|
||||
/**
|
||||
* The events a call may run, mirroring the plugin's list. `max` is how many one
|
||||
* step may call at once: the game runs the cargo ship, the vendor and road
|
||||
* Bradleys (twelve at a time) one at a time.
|
||||
*/
|
||||
const EVENTS = Object.freeze([
|
||||
{ value: 'patrol_heli', label: 'Patrol helicopter', max: 5, placeable: true },
|
||||
{ value: 'airdrop', label: 'Airdrop', max: 5, placeable: true },
|
||||
{ value: 'chinook', label: 'Chinook (drops a locked crate where the game chooses)', max: 5 },
|
||||
{ value: 'cargo', label: 'Cargo ship', max: 1 },
|
||||
{ value: 'bradley_road', label: 'Road Bradleys (several at once)', max: 1 },
|
||||
{ value: 'travelling_vendor', label: 'Travelling Vendor', max: 1 },
|
||||
{ value: 'satellite', label: 'Satellite crash', max: 5 },
|
||||
{ value: 'f15', label: 'F-15 flyover (decoration only)', max: 5 },
|
||||
])
|
||||
|
||||
/** The plugin's refusals a second attempt would repeat. */
|
||||
const PERMANENT = new Set([
|
||||
'events-disabled',
|
||||
'malformed',
|
||||
'bad-event',
|
||||
'bad-count',
|
||||
'not-placeable',
|
||||
'too-many',
|
||||
'out-of-range',
|
||||
'no-monument',
|
||||
'off-map',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.world.calls',
|
||||
label: 'World events called',
|
||||
unit: 'events',
|
||||
description: "Rust's own world events an event calls — patrol helicopters, cargo ships, airdrops and the rest — each one counted. What a call starts is kept when the run ends.",
|
||||
},
|
||||
]
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
// The plugin's own list, mirrored, so the form fills with every server off.
|
||||
id: 'rust.options.world_events',
|
||||
label: 'World events',
|
||||
description: "Rust's own world events a step can call. Only the airdrop and the patrol helicopter take a position; the rest go where the game puts them.",
|
||||
async resolve() {
|
||||
return EVENTS.map((e) => ({ value: e.value, label: e.label }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** Whether a step gives a position at all: a monument, or x or z. */
|
||||
function hasPosition(params) {
|
||||
const blank = (v) => v === undefined || v === null || v === ''
|
||||
return !blank(params.monument) || !blank(params.x) || !blank(params.z)
|
||||
}
|
||||
|
||||
const worldCall = {
|
||||
id: 'rust.world.call',
|
||||
label: 'Call a world event',
|
||||
description:
|
||||
"Calls one of Rust's own world events now — a patrol helicopter, an airdrop, a Chinook, the cargo ship, road Bradleys, the Travelling Vendor, a satellite crash or an F-15 flyover — as the game's own schedule would. An airdrop or a patrol helicopter can be sent to a monument or a point; the rest go where the game puts them. What it starts is kept when the run ends.",
|
||||
risk: 'change',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'event', type: 'string', required: true, example: 'airdrop', source: 'rust.options.world_events',
|
||||
description: 'Which world event to call.' },
|
||||
{ name: 'count', type: 'int', required: false, example: 1,
|
||||
description: 'How many at once: 1 to 5, and 1 for the cargo ship, road Bradleys and the vendor. Left blank, 1.' },
|
||||
{ name: 'server', type: 'string', required: false, example: 'main', source: 'rust.options.servers',
|
||||
description: 'Which server. A monument already says; otherwise required.' },
|
||||
{ name: 'monument', type: 'string', required: false, example: 'main/powerplant_1', source: 'rust.options.monuments',
|
||||
description: 'Airdrop and patrol helicopter only: where to send it, by monument. Give this OR x and z, or neither for where the game chooses.' },
|
||||
{ name: 'offsetX', type: 'float', required: false, example: 0, description: "Metres east of the monument's centre (negative is west)." },
|
||||
{ name: 'offsetZ', type: 'float', required: false, example: 0, description: "Metres north of the monument's centre (negative is south)." },
|
||||
{ name: 'x', type: 'float', required: false, example: -604, description: 'Airdrop and patrol helicopter only: world x, instead of a monument.' },
|
||||
{ name: 'z', type: 'float', required: false, example: -342, description: 'Airdrop and patrol helicopter only: world z, instead of a monument.' },
|
||||
],
|
||||
|
||||
cost: (p) => ({ 'rust.world.calls': Math.max(1, Math.round(Number(p.count) || 1)) }),
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const event = EVENTS.find((e) => e.value === String(params.event || '').trim())
|
||||
if (!event) {
|
||||
return { ok: false, retry: false, error: `"${params.event}" is not one of the world events a Rust server calls` }
|
||||
}
|
||||
|
||||
const blank = params.count === undefined || params.count === null || params.count === ''
|
||||
const count = blank ? 1 : Number(params.count)
|
||||
if (!Number.isInteger(count) || count < 1 || count > event.max) {
|
||||
return { ok: false, retry: false, error: `call 1 to ${event.max} of the ${event.label.toLowerCase()} at a time, and "${params.count}" is not that` }
|
||||
}
|
||||
|
||||
let serverId = String(params.server || '').trim()
|
||||
let wire = {}
|
||||
|
||||
if (hasPosition(params)) {
|
||||
if (!event.placeable) {
|
||||
return { ok: false, retry: false, error: `the ${event.label.toLowerCase()} goes where the game puts it; only an airdrop and a patrol helicopter take a position` }
|
||||
}
|
||||
const where = location(params)
|
||||
if (!where.ok) return { ok: false, retry: false, error: where.error }
|
||||
serverId = where.serverId
|
||||
// A height means nothing to a call: the drop falls to the ground and the
|
||||
// helicopter flies at its own.
|
||||
const { y, ...flat } = where.wire
|
||||
wire = flat
|
||||
} else if (!serverId) {
|
||||
return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
|
||||
}
|
||||
|
||||
const found = await serverFor(serverId)
|
||||
if (!found.ok) return found
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await client.worldCall(found.server, {
|
||||
runId: String(runId),
|
||||
key: idempotencyKey,
|
||||
event: event.value,
|
||||
count,
|
||||
...wire,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
const permanent = result.status === 'not-configured' || result.status === 'no-token'
|
||||
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'world event call') }
|
||||
}
|
||||
|
||||
const data = result.data || {}
|
||||
|
||||
// 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,
|
||||
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
|
||||
error: pluginError(data, `${found.server.name || found.server.id} refused the ${event.label.toLowerCase()}`),
|
||||
}
|
||||
}
|
||||
|
||||
const called = Number(data.called) || 0
|
||||
return {
|
||||
ok: true,
|
||||
detail: {
|
||||
event: event.value,
|
||||
called,
|
||||
...(called < count ? { note: `the game started ${called} of the ${count} asked for` } : {}),
|
||||
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing new was called' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const ACTIONS = [worldCall]
|
||||
|
||||
module.exports = { EVENTS, MAX_CALLS_PER_RUN, PERMANENT, BUDGETS, ACTIONS, OPTION_SOURCES, hasPosition }
|
||||
97
server/guardedCrate.js
Normal file
97
server/guardedCrate.js
Normal 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 }
|
||||
@@ -59,6 +59,13 @@ module.exports = function register(ctx, api) {
|
||||
const eventLeases = require('./eventLeases')
|
||||
const eventWorld = require('./eventWorld')
|
||||
const eventRewards = require('./eventRewards')
|
||||
const eventWorldCall = require('./eventWorldCall')
|
||||
const eventDangerousTreasures = require('./eventDangerousTreasures')
|
||||
const eventGuardedCrate = require('./eventGuardedCrate')
|
||||
const eventRaidableBases = require('./eventRaidableBases')
|
||||
const eventPermRewards = require('./eventPermRewards')
|
||||
const eventCommands = require('./eventCommands')
|
||||
const eventTemplates = require('./eventTemplates')
|
||||
const boot = require('./boot')
|
||||
const commands = require('./commands')
|
||||
/* eslint-enable global-require */
|
||||
@@ -151,6 +158,23 @@ 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 public demo's seed (MODULE_API 1.18.0, DM15): the rig's server row and
|
||||
// demo-player's Steam link, written by this module from its part of the
|
||||
// fixture. Called only by `npm run seed:demo` on a DEMO_MODE deployment. The
|
||||
// demo's other locks are `demoLocks` in module.json and `demo.js`.
|
||||
const demoSeed = require('./demoSeed') // eslint-disable-line global-require
|
||||
api.onDemoSeed(demoSeed.onDemoSeed)
|
||||
|
||||
// The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server
|
||||
// and must give back. Core's `core.lease` is the verb; these are the values it
|
||||
// may name and the four callables each ships. Every lease is targeted and the
|
||||
@@ -168,8 +192,23 @@ module.exports = function register(ctx, api) {
|
||||
// and the chat line, with the two budgets they spend. Registered in the same
|
||||
// calls' neighbours, not merged into eventWorld's arrays, so each file keeps
|
||||
// its own statement of what it declares.
|
||||
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS])
|
||||
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS])
|
||||
//
|
||||
// Permissions as rewards (PLAN_REDESIGNS.md §12) beside them: any registered
|
||||
// permission, lent until the run ends or given for good, each on its own budget.
|
||||
//
|
||||
// Calling one of Rust's own world events (PLAN_REDESIGNS.md §14.3.1, protocol
|
||||
// 14) beside them: kept when the run ends, on a budget of its own. Starting a
|
||||
// Dangerous Treasures event (§14.3.4) and placing a guarded crate (§14.3.5)
|
||||
// likewise, each on its own budget; 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
|
||||
// template; what it builds is an ordinary draft.
|
||||
api.registerEventTemplates(eventTemplates.TEMPLATES)
|
||||
|
||||
// News in game chat (D104). Core enqueues every registered leg for every
|
||||
// published post, so the leg itself sends only to the servers whose switch an
|
||||
@@ -182,6 +221,11 @@ module.exports = function register(ctx, api) {
|
||||
...eventLeases.OPTION_SOURCES,
|
||||
...eventWorld.OPTION_SOURCES,
|
||||
...eventRewards.OPTION_SOURCES,
|
||||
...eventPermRewards.OPTION_SOURCES,
|
||||
...eventWorldCall.OPTION_SOURCES,
|
||||
...eventDangerousTreasures.OPTION_SOURCES,
|
||||
...eventGuardedCrate.OPTION_SOURCES,
|
||||
...eventRaidableBases.OPTION_SOURCES,
|
||||
])
|
||||
|
||||
// The slash commands (phase 16, R11). The handlers run HERE, in the website
|
||||
@@ -200,10 +244,10 @@ module.exports = function register(ctx, api) {
|
||||
streams: STREAMS.length,
|
||||
audiences: AUDIENCES.length,
|
||||
leases: eventLeases.LEASES.length,
|
||||
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length,
|
||||
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length,
|
||||
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length + eventDangerousTreasures.ACTIONS.length + eventGuardedCrate.ACTIONS.length + 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,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ const npcs = require('./model/npcs/npcs.model')
|
||||
const npcsDb = require('./model/npcs/npcs.db')
|
||||
const permissionsDb = require('./model/permissions/permissions.db')
|
||||
const sidecar = require('./sidecarClient')
|
||||
const worldEvents = require('./worldEvents')
|
||||
|
||||
const log = core.logger('ingest')
|
||||
|
||||
@@ -71,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
|
||||
|
||||
@@ -138,6 +141,17 @@ async function apply(serverId, item, server = null) {
|
||||
}
|
||||
|
||||
case 'player.tally': {
|
||||
// Protocol 14 (§11.2.2): the player's Economics balance, a VALUE written
|
||||
// over rather than added — unlike everything below it.
|
||||
const hasBalance = frame.balance !== undefined && frame.balance !== null
|
||||
if (hasBalance) await db.setBalance(at, frame.balance)
|
||||
|
||||
// A balance with no name is an offline player's: a payment or a staff
|
||||
// adjustment reaches somebody who is not there, and the plugin names only
|
||||
// a connected player. It says nothing about play, so it must not move
|
||||
// anybody's last-seen — which every writer below does.
|
||||
if (hasBalance && !frame.name) break
|
||||
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
await db.addStats(at, {
|
||||
npcKills: Number(frame.npcKills) || 0,
|
||||
@@ -257,6 +271,32 @@ async function apply(serverId, item, server = null) {
|
||||
break
|
||||
}
|
||||
|
||||
// ── Protocol 14: Rust's own world events (§14.3.1) ──────────────────────
|
||||
//
|
||||
// Stored like everything else, and read per viewer by the feed. The one
|
||||
// thing a world event COUNTS is a downed patrol helicopter, Chinook or
|
||||
// Bradley: the plugin sends every candidate and the server's rule picks
|
||||
// (D372, D379), so Rotorbane and Tankbreaker always agree with the line.
|
||||
// A protocol-13 plugin still sends `heliKills` / `apcKills` on its tally
|
||||
// instead, and the tally path above adds those as it always has.
|
||||
case 'world.event': {
|
||||
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 })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// ── Protocol 14: Economics wiped every balance (§11.2.2) ────────────────
|
||||
//
|
||||
// `ecowipe` puts everyone back at the start in the wipe in play, so none of
|
||||
// its balances is known any more. A new save's wipe (`newSave`) opens a new
|
||||
// map whose balances arrive as players connect; the last wipe's stay as the
|
||||
// record of how it ended.
|
||||
case 'economics.wiped':
|
||||
if (!frame.newSave) await db.clearBalances(serverId, wipeId)
|
||||
break
|
||||
|
||||
// ── Protocol 6: first-party clans ──────────────────────────────────────
|
||||
//
|
||||
// Each one is told to core as it happens (`ctx.teams.publish`) and written
|
||||
|
||||
95
server/liveStatus.js
Normal file
95
server/liveStatus.js
Normal 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 }
|
||||
36
server/model/backpacks/backpacks.db.js
Normal file
36
server/model/backpacks/backpacks.db.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// ── SQL for Backpacks (PLAN_REDESIGNS §11.3.2) ────────────────────────────
|
||||
//
|
||||
// What each server's last hello said about Backpacks. The backpacks themselves
|
||||
// are never stored (§11.3.2: live, never stored); this file only reads the
|
||||
// report the plugin makes about the plugin.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const STATE = 'rust_server_state'
|
||||
|
||||
/**
|
||||
* One server's report: `{ serverId, backpacks }`, with `backpacks` as the
|
||||
* driver hands JSON_EXTRACT back — text, or null for a server whose plugin
|
||||
* never said (no state yet, or a plugin before protocol 14).
|
||||
*/
|
||||
async function reportFor(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT server_id AS serverId,
|
||||
JSON_EXTRACT(raw, '$.integrations.backpacks') AS backpacks
|
||||
FROM ${STATE}
|
||||
WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** Every server's report, for the step's size list. */
|
||||
async function reports() {
|
||||
return core.query(
|
||||
`SELECT server_id AS serverId,
|
||||
JSON_EXTRACT(raw, '$.integrations.backpacks') AS backpacks
|
||||
FROM ${STATE}`,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { reportFor, reports }
|
||||
129
server/model/backpacks/backpacks.model.js
Normal file
129
server/model/backpacks/backpacks.model.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// ── Backpacks, as the site sees it (PLAN_REDESIGNS §11.3.2, D199, D337, D338) ─
|
||||
//
|
||||
// WhiteThunder's Backpacks is optional (D199): a server reports it in hello's
|
||||
// `integrations.backpacks`, with the sizes its `backpacks.size.N` permissions
|
||||
// offer, and every surface that needs it refuses with a reason where it is
|
||||
// absent. Two readers share this file:
|
||||
//
|
||||
// • the step (`rust.backpack.size`, in eventRewards.js), which leases one of
|
||||
// those sizes for a run (D337) and needs the list to check its param;
|
||||
// • the staff view and the player's own page, which read one backpack live.
|
||||
//
|
||||
// **Live, never stored.** Each view is one `backpacks.read`, and nothing about
|
||||
// a backpack is written to the database. The plugin only reads (R2).
|
||||
|
||||
const client = require('../../sidecarClient')
|
||||
const db = require('./backpacks.db')
|
||||
const servers = require('../servers/servers.model')
|
||||
const { pluginError, transportError } = require('../../eventLeases')
|
||||
|
||||
/** The sentence every surface says where a server has no Backpacks (D199). */
|
||||
const MISSING = 'Backpacks is not installed on this server'
|
||||
|
||||
/** JSON_EXTRACT comes back as text from the driver; an object passes through. */
|
||||
function parse(value) {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value === 'object') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A server's Backpacks as its last hello said: `{ loaded: false }`, or loaded
|
||||
* with its version and its sizes, ascending and whole.
|
||||
*/
|
||||
function shape(raw) {
|
||||
const b = parse(raw)
|
||||
if (!b || b.loaded !== true) return { loaded: false, sizes: [] }
|
||||
|
||||
const sizes = [...new Set((Array.isArray(b.sizes) ? b.sizes : []).map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
sizes.sort((x, y) => x - y)
|
||||
|
||||
return { loaded: true, version: typeof b.version === 'string' ? b.version : null, sizes }
|
||||
}
|
||||
|
||||
/** One server's report. `{ loaded: false }` for a server never heard from. */
|
||||
async function forServer(serverId) {
|
||||
const row = await db.reportFor(serverId)
|
||||
return shape(row && row.backpacks)
|
||||
}
|
||||
|
||||
/** Every server's report, keyed by id. */
|
||||
async function byServer() {
|
||||
const rows = await db.reports()
|
||||
return new Map(rows.map((r) => [r.serverId, shape(r.backpacks)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* The sizes a step may lease on a server: every size but the smallest, which
|
||||
* is the default everybody holds and so gives nobody anything.
|
||||
*/
|
||||
function leasable(report) {
|
||||
return report && report.loaded ? report.sizes.slice(1) : []
|
||||
}
|
||||
|
||||
/** One stack as the page draws it; nested `contents` keep the shape one level deep. */
|
||||
function stack(raw, nest) {
|
||||
const row = {
|
||||
slot: Number(raw.slot) || 0,
|
||||
shortname: typeof raw.shortname === 'string' ? raw.shortname : null,
|
||||
display: typeof raw.display === 'string' ? raw.display : null,
|
||||
amount: Number(raw.amount) || 0,
|
||||
}
|
||||
if (raw.itemId !== undefined) row.itemId = Number(raw.itemId)
|
||||
if (typeof raw.blueprint === 'string') row.blueprint = raw.blueprint
|
||||
// A skin id is past 2^53 more often than not; it is a label here, never a number to do sums with.
|
||||
if (raw.skin !== undefined && raw.skin !== null) row.skin = String(raw.skin)
|
||||
if (typeof raw.name === 'string' && raw.name) row.name = raw.name
|
||||
if (raw.overflow === true) row.overflow = true
|
||||
if (nest && Array.isArray(raw.contents) && raw.contents.length) row.contents = raw.contents.filter(Boolean).map((c) => stack(c, false))
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* One player's backpack on one server, read live.
|
||||
*
|
||||
* Resolves `{ ok: true, backpack }` — `{ serverId, server, steamId, exists,
|
||||
* capacity, pageSize, items }`, the last three only when it exists — or
|
||||
* `{ ok: false, status, message, reason? }`: 404 for no such server, 409 for a
|
||||
* server without Backpacks, 503 for one that could not be asked.
|
||||
*/
|
||||
async function read({ serverId, steamId }) {
|
||||
const server = await servers.getForCalling(serverId)
|
||||
if (!server) return { ok: false, status: 404, message: 'No such server' }
|
||||
|
||||
const name = server.name || server.id
|
||||
const result = await client.backpacksRead(server, String(steamId))
|
||||
if (!result.ok) return { ok: false, status: 503, message: transportError(server, result, 'backpacks') }
|
||||
|
||||
const data = result.data || {}
|
||||
|
||||
if (data.kind === 'backpacks.contents') {
|
||||
const base = { serverId: server.id, server: name, steamId: String(steamId) }
|
||||
if (data.exists !== true) return { ok: true, backpack: { ...base, exists: false } }
|
||||
return {
|
||||
ok: true,
|
||||
backpack: {
|
||||
...base,
|
||||
exists: true,
|
||||
capacity: Number(data.capacity) || 0,
|
||||
pageSize: Number(data.pageSize) || 48,
|
||||
items: (Array.isArray(data.items) ? data.items : []).filter(Boolean).map((s) => stack(s, true)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (data.reason === 'backpacks-missing') return { ok: false, status: 409, reason: data.reason, message: `${MISSING} (${name})` }
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: data.reason === 'bad-id' ? 400 : 502,
|
||||
reason: data.reason || null,
|
||||
message: pluginError(data, `${name} did not answer with a backpack`),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MISSING, shape, forServer, byServer, leasable, stack, read }
|
||||
@@ -43,8 +43,15 @@ const LOCKED_REASON = {
|
||||
*/
|
||||
const PATH_SHAPE = /^(?!.*\.\.)(?!\/)[A-Za-z0-9 _.\-()[\]]+(?:\/[A-Za-z0-9 _.\-()[\]]+)*\.json$/
|
||||
|
||||
/**
|
||||
* RaidableBases' profiles (PLAN_REDESIGNS §11.4.3, D205): the one named exception
|
||||
* to the editor never walking a data directory. The bridge lists them under this
|
||||
* virtual prefix, one file deep, and resolves it to that folder alone.
|
||||
*/
|
||||
const PROFILE_PATH = /^@data\/RaidableBases\/Profiles\/(?!.*\.\.)[A-Za-z0-9 _.\-()[\]]+\.json$/
|
||||
|
||||
function isPlausiblePath(path) {
|
||||
return typeof path === 'string' && path.length > 0 && path.length <= 255 && PATH_SHAPE.test(path)
|
||||
return typeof path === 'string' && path.length > 0 && path.length <= 255 && (PATH_SHAPE.test(path) || PROFILE_PATH.test(path))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +97,8 @@ function shapeCatalogue(catalogue) {
|
||||
modified: file.modified ? Number(file.modified) : null,
|
||||
editable: file.editable !== false,
|
||||
...(file.reason ? { reason: String(file.reason) } : {}),
|
||||
// A RaidableBases profile (D205): reloaded through the raids helper, never a plugin reload.
|
||||
...(file.profile === true ? { profile: true } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,11 +114,22 @@ function shapeCatalogue(catalogue) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin a path belongs to, by its first segment, or its file name for a
|
||||
* file at the root. A RaidableBases profile's virtual path names the plugin
|
||||
* after its `@data/` prefix, so the audit row says RaidableBases (W8).
|
||||
*/
|
||||
function pluginOf(path) {
|
||||
const text = String(path)
|
||||
const parts = text.split('/')
|
||||
if (parts[0] === '@data' && parts.length > 2) return parts[1]
|
||||
return parts.length > 1 ? parts[0] : text.replace(/\.json$/i, '')
|
||||
}
|
||||
|
||||
/** Whether this file is the bridge's own config, by the name the plugin gave. */
|
||||
function isBridgeConfig(path, self) {
|
||||
if (!self) return false
|
||||
const plugin = String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
|
||||
return plugin.toLowerCase() === String(self).toLowerCase()
|
||||
return pluginOf(path).toLowerCase() === String(self).toLowerCase()
|
||||
}
|
||||
|
||||
/** The locked keys for a file: three of them in our own config, none anywhere else. */
|
||||
@@ -154,6 +174,71 @@ function shapeFile(file, { self = null, maxDepth = 6 } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A shaped file with every credential-shaped value hidden, for the public demo
|
||||
* (DEMO_MODE.md §3.5, DM14). The form's mask is not a lock (DM10), so on the
|
||||
* demo the value never leaves the module:
|
||||
*
|
||||
* - in `text`, each flagged string or number is spliced out for the
|
||||
* placeholder, from the end backwards so the spans stay true;
|
||||
* - in `fields`, a flagged field carries the placeholder and is `locked`,
|
||||
* so the form shows it read-only, and the save refuses an edit to it anyway.
|
||||
*
|
||||
* "Flagged" is the editor's own reading (`configEdit.isSecretKey`, inherited by
|
||||
* everything under a flagged key), so the demo hides exactly what the form masks.
|
||||
* A file that does not parse cannot be read for its secrets, so its text is
|
||||
* withheld whole: the lock fails closed.
|
||||
*/
|
||||
function redactSecrets(shaped, placeholder) {
|
||||
if (!shaped) return shaped
|
||||
|
||||
let root
|
||||
try {
|
||||
root = configEdit.scan(shaped.text)
|
||||
} catch {
|
||||
return { ...shaped, text: '', fields: null, redacted: true, withheld: 'This file does not parse, so it is not shown on the demo site.' }
|
||||
}
|
||||
|
||||
const spans = []
|
||||
const visit = (node, secret) => {
|
||||
if (node.type === 'object') {
|
||||
for (const child of node.children) visit(child, secret || configEdit.isSecretKey(child.key))
|
||||
} else if (node.type === 'array') {
|
||||
for (const child of node.children) visit(child, secret)
|
||||
} else if (secret && (node.type === 'number' || (node.type === 'string' && node.value !== ''))) {
|
||||
spans.push(node)
|
||||
}
|
||||
}
|
||||
visit(root, false)
|
||||
|
||||
let text = shaped.text
|
||||
for (const node of [...spans].sort((a, b) => b.start - a.start)) {
|
||||
text = text.slice(0, node.start) + JSON.stringify(placeholder) + text.slice(node.end)
|
||||
}
|
||||
|
||||
const fields = Array.isArray(shaped.fields)
|
||||
? shaped.fields.map((field) => {
|
||||
if (!field.secret) return field
|
||||
const leaf = field.type === 'number' || field.type === 'string'
|
||||
const { raw, ...rest } = field
|
||||
return {
|
||||
...rest,
|
||||
...(leaf && !(field.type === 'string' && field.value === '') ? { value: placeholder } : {}),
|
||||
locked: true,
|
||||
}
|
||||
})
|
||||
: shaped.fields
|
||||
|
||||
return { ...shaped, text, fields, redacted: true }
|
||||
}
|
||||
|
||||
/** Whether any edit names a credential-shaped field, as `applyEdits` marks them. */
|
||||
function editsTouchSecret(edits) {
|
||||
return (edits || []).some(
|
||||
(edit) => Array.isArray(edit && edit.pointer) && edit.pointer.some((step) => typeof step === 'string' && configEdit.isSecretKey(step)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which locked keys differ between two versions of a document.
|
||||
*
|
||||
@@ -270,6 +355,9 @@ module.exports = {
|
||||
isPlausiblePath,
|
||||
shapeCatalogue,
|
||||
shapeFile,
|
||||
redactSecrets,
|
||||
editsTouchSecret,
|
||||
pluginOf,
|
||||
isBridgeConfig,
|
||||
lockedKeysFor,
|
||||
lockedChanges,
|
||||
|
||||
36
server/model/economics/economics.db.js
Normal file
36
server/model/economics/economics.db.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// ── SQL for Economics (PLAN_REDESIGNS §11.2.2) ────────────────────────────
|
||||
//
|
||||
// What each server's last hello said about Economics. The balances themselves
|
||||
// are rows of `rust_player_wipe_stats` and are written by ingest through
|
||||
// `events.db.js`; this file only reads the report the plugin makes about the
|
||||
// plugin.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const STATE = 'rust_server_state'
|
||||
|
||||
/**
|
||||
* Every server's report: `{ serverId, wipeId, economics }`, with `economics`
|
||||
* as the driver hands JSON_EXTRACT back — text, or null for a server whose
|
||||
* plugin never said (no state yet, or a plugin before protocol 14).
|
||||
*/
|
||||
async function reports() {
|
||||
return core.query(
|
||||
`SELECT server_id AS serverId, wipe_id AS wipeId,
|
||||
JSON_EXTRACT(raw, '$.integrations.economics') AS economics
|
||||
FROM ${STATE}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function reportFor(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT server_id AS serverId, wipe_id AS wipeId,
|
||||
JSON_EXTRACT(raw, '$.integrations.economics') AS economics
|
||||
FROM ${STATE}
|
||||
WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
module.exports = { reports, reportFor }
|
||||
222
server/model/economics/economics.model.js
Normal file
222
server/model/economics/economics.model.js
Normal file
@@ -0,0 +1,222 @@
|
||||
// ── Economics, as the site sees it (PLAN_REDESIGNS §11.2.2, D199, D335, D336) ─
|
||||
//
|
||||
// Wulf's Economics is optional (D199): a server reports it in hello's
|
||||
// `integrations.economics`, and every surface that needs it refuses with a
|
||||
// reason where it is absent, never quietly. Three readers share this file:
|
||||
//
|
||||
// • the step (`rust.coins.pay`, in eventRewards.js), which asks whether the
|
||||
// server has Economics before it pays anybody;
|
||||
// • the player's own page and the app, which read each linked account's
|
||||
// balance live and fall back to the last one the tally carried;
|
||||
// • staff, who deposit, withdraw or set one player's balance.
|
||||
//
|
||||
// The starting balance matters to D336: the richest column and the Magnate
|
||||
// title rank only balances above it, because Economics answers it for an
|
||||
// account it has never seen (§11.2.1). The plugin reads it from Economics'
|
||||
// config; when that file cannot be read the site assumes Economics' default,
|
||||
// 1000, and says so (`startingKnown: false`).
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const client = require('../../sidecarClient')
|
||||
const db = require('./economics.db')
|
||||
const eventsDb = require('../events/events.db')
|
||||
const servers = require('../servers/servers.model')
|
||||
const { pluginError, transportError } = require('../../eventLeases')
|
||||
const live = require('../../liveStatus')
|
||||
|
||||
const log = core.logger('economics')
|
||||
|
||||
/** Economics' own default starting balance, assumed when its config cannot be read. */
|
||||
const DEFAULT_STARTING_BALANCE = 1000
|
||||
|
||||
/** The sentence every surface says where a server has no Economics (D199). */
|
||||
const MISSING = 'Economics is not installed on this server'
|
||||
|
||||
const OPS = ['deposit', 'withdraw', 'set']
|
||||
|
||||
/** The plugin's bounds (PROTOCOL.md §20.3), refused here first so a form says why. */
|
||||
const AMOUNT_MIN = 0.01
|
||||
const AMOUNT_MAX = 1000000
|
||||
|
||||
/** JSON_EXTRACT comes back as text from the driver; an object passes through. */
|
||||
function parse(value) {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value === 'object') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A server's Economics as its last hello said: `{ loaded: false }`, or loaded
|
||||
* with its version, its starting balance (assumed where unread) and whether
|
||||
* balances wipe with the map.
|
||||
*/
|
||||
function shape(raw) {
|
||||
const e = parse(raw)
|
||||
if (!e || e.loaded !== true) return { loaded: false }
|
||||
|
||||
const start = Number(e.startingBalance)
|
||||
const known = e.startingBalance !== undefined && e.startingBalance !== null && Number.isFinite(start)
|
||||
|
||||
return {
|
||||
loaded: true,
|
||||
version: typeof e.version === 'string' ? e.version : null,
|
||||
startingBalance: known ? start : DEFAULT_STARTING_BALANCE,
|
||||
startingKnown: known,
|
||||
wipesWithMap: typeof e.wipesWithMap === 'boolean' ? e.wipesWithMap : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** One server's report, with the wipe it is on. `{ loaded: false }` for a server never heard from. */
|
||||
async function forServer(serverId) {
|
||||
const row = await db.reportFor(serverId)
|
||||
if (!row) return { loaded: false, wipeId: null }
|
||||
return { ...shape(row.economics), wipeId: row.wipeId || null }
|
||||
}
|
||||
|
||||
/** Every server's report, keyed by id. */
|
||||
async function byServer() {
|
||||
const rows = await db.reports()
|
||||
return new Map(rows.map((r) => [r.serverId, { ...shape(r.economics), wipeId: r.wipeId || null }]))
|
||||
}
|
||||
|
||||
/** An amount as a sentence says it: thousands separated, at most two places. */
|
||||
function coins(n) {
|
||||
return Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
/**
|
||||
* The balances of some Steam accounts on every enabled server: live where the
|
||||
* server answers, the last balance the tally carried where it does not, and
|
||||
* null where neither knows. A server without Economics is listed with the reason,
|
||||
* so a page can say why there is nothing rather than show nothing.
|
||||
*
|
||||
* @param {Array<{steamId, name}>} accounts the accounts to read, a user's links
|
||||
*/
|
||||
async function balancesOf(accounts) {
|
||||
const ids = [...new Set(accounts.map((a) => String(a.steamId)).filter(Boolean))]
|
||||
const [list, reports] = await Promise.all([servers.listForPolling(), byServer()])
|
||||
|
||||
return Promise.all(
|
||||
list.map(async (server) => {
|
||||
const econ = reports.get(server.id) || { loaded: false }
|
||||
const base = { serverId: server.id, server: server.name || server.id }
|
||||
|
||||
if (!econ.loaded) return { ...base, economics: false, reason: MISSING, accounts: [] }
|
||||
|
||||
const known = await eventsDb.balancesFor(server.id, econ.wipeId, ids)
|
||||
const live = new Map()
|
||||
let answered = false
|
||||
|
||||
if (ids.length) {
|
||||
const result = await client.economicsBalances(server, ids)
|
||||
const data = (result.ok && result.data) || {}
|
||||
if (data.kind === 'economics.balances') {
|
||||
answered = true
|
||||
for (const row of data.balances || []) {
|
||||
if (row && row.steamId && Number.isFinite(Number(row.balance))) live.set(String(row.steamId), Number(row.balance))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
economics: true,
|
||||
startingBalance: econ.startingBalance,
|
||||
startingKnown: econ.startingKnown,
|
||||
live: answered,
|
||||
accounts: accounts.map((a) => {
|
||||
const id = String(a.steamId)
|
||||
const source = live.has(id) ? 'live' : known.has(id) ? 'last-known' : null
|
||||
return {
|
||||
steamId: id,
|
||||
name: a.name || null,
|
||||
balance: source === 'live' ? live.get(id) : source ? known.get(id) : null,
|
||||
source,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Checks a staff adjustment's op and amount. `{ ok }` or `{ ok: false, message }`. */
|
||||
function checkAdjustment(op, amount) {
|
||||
if (!OPS.includes(op)) return { ok: false, message: `op is one of ${OPS.join(', ')}` }
|
||||
const value = Number(amount)
|
||||
const min = op === 'set' ? 0 : AMOUNT_MIN
|
||||
if (!Number.isFinite(value) || value < min || value > AMOUNT_MAX) {
|
||||
return { ok: false, message: `an amount is ${op === 'set' ? '0' : coins(AMOUNT_MIN)} to ${coins(AMOUNT_MAX)} coins` }
|
||||
}
|
||||
return { ok: true, value: Math.round(value * 100) / 100 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff deposit, withdraw or set one player's balance on one server.
|
||||
*
|
||||
* Resolves `{ ok: true, before, after, repeat, server }` or `{ ok: false,
|
||||
* status, message, reason? }`. The key makes a resent form harmless: the plugin
|
||||
* answers a key it has seen from the first reply, and moves nothing.
|
||||
*/
|
||||
async function adjust({ serverId, steamId, op, amount, key }) {
|
||||
const checked = checkAdjustment(op, amount)
|
||||
if (!checked.ok) return { ok: false, status: 400, message: checked.message }
|
||||
|
||||
const server = await servers.getForCalling(serverId)
|
||||
if (!server) return { ok: false, status: 404, message: 'No such server' }
|
||||
|
||||
// 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 })
|
||||
if (!result.ok) return { ok: false, status: 503, message: transportError(server, result, 'Economics') }
|
||||
|
||||
const data = result.data || {}
|
||||
const name = server.name || server.id
|
||||
|
||||
if (data.kind === 'economics.adjusted') {
|
||||
return { ok: true, before: Number(data.before), after: Number(data.after), repeat: Boolean(data.repeat), server: name, amount: checked.value }
|
||||
}
|
||||
|
||||
if (data.reason === 'insufficient') {
|
||||
// Economics takes all or nothing (§11.2.1), so the sentence is the balance.
|
||||
return { ok: false, status: 409, reason: 'insufficient', message: `They have only ${coins(data.balance)} coins on ${name}` }
|
||||
}
|
||||
|
||||
log.warn('Economics refused an adjustment', { server: server.id, op, reason: data.reason })
|
||||
// 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' || data.reason === 'at-limit' ? 409 : 400,
|
||||
reason: data.reason || null,
|
||||
message: pluginError(data, `${name} refused the adjustment`),
|
||||
}
|
||||
}
|
||||
|
||||
/** The activity row's sentence: "Deposited 500 coins to Wanderer on Main: 1,000 → 1,500". */
|
||||
function adjustmentSummary({ op, amount, name, server, before, after }) {
|
||||
const verb = { deposit: `Deposited ${coins(amount)} coins to`, withdraw: `Withdrew ${coins(amount)} coins from`, set: `Set the balance of` }[op]
|
||||
return `${verb} ${name} on ${server}${op === 'set' ? ` to ${coins(amount)}` : ''}: ${coins(before)} → ${coins(after)}`
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_STARTING_BALANCE,
|
||||
MISSING,
|
||||
OPS,
|
||||
AMOUNT_MIN,
|
||||
AMOUNT_MAX,
|
||||
shape,
|
||||
forServer,
|
||||
byServer,
|
||||
coins,
|
||||
balancesOf,
|
||||
checkAdjustment,
|
||||
adjust,
|
||||
adjustmentSummary,
|
||||
}
|
||||
@@ -148,6 +148,15 @@ const TITLE_SUMS = {
|
||||
rockets: 'rockets',
|
||||
explosives: 'explosives',
|
||||
missions: 'missions',
|
||||
// Protocol 14 (D356): Safecracker and Scavenger.
|
||||
cratesHacked: 'crates_hacked',
|
||||
dropsLooted: 'drops_looted',
|
||||
// Protocol 14 (D375): Treasure Hunter.
|
||||
treasuresWon: 'treasures_won',
|
||||
// Protocol 14 (D375): Warden.
|
||||
guardedWon: 'guarded_won',
|
||||
// Protocol 14 (§11.4, D202): Warlord.
|
||||
raidsCompleted: 'raids_completed',
|
||||
}
|
||||
|
||||
/** The two *best* fields and their columns: the longest single kill, never a sum. */
|
||||
@@ -193,6 +202,45 @@ async function addTitleStats({ serverId, wipeId, steamId }, frame = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
/** The largest balance a row holds: DECIMAL(14,2). Economics' own limit is far below it. */
|
||||
const BALANCE_MAX = 999999999999.99
|
||||
|
||||
/**
|
||||
* One player's Economics balance for the wipe (protocol 14, §11.2.2): a VALUE,
|
||||
* written over and never added. `last_seen` is left alone on purpose — a payment
|
||||
* or a staff adjustment reaches an offline player, and a balance is not play.
|
||||
*/
|
||||
async function setBalance({ serverId, wipeId, steamId }, balance) {
|
||||
const value = Number(balance)
|
||||
if (!serverId || !steamId || !Number.isFinite(value)) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${STATS} (server_id, wipe_id, steam_id, balance)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE balance = VALUES(balance)`,
|
||||
[serverId, wipeId || '', steamId, Math.max(-BALANCE_MAX, Math.min(Math.round(value * 100) / 100, BALANCE_MAX))],
|
||||
)
|
||||
}
|
||||
|
||||
/** `ecowipe`: every balance of the wipe is back at the start, so none is known. */
|
||||
async function clearBalances(serverId, wipeId) {
|
||||
if (!serverId) return
|
||||
await core.query(`UPDATE ${STATS} SET balance = NULL WHERE server_id = ? AND wipe_id = ?`, [serverId, wipeId || ''])
|
||||
}
|
||||
|
||||
/** The last known balances of some players in one wipe, as Steam id → number. */
|
||||
async function balancesFor(serverId, wipeId, steamIds) {
|
||||
if (!serverId || !steamIds.length) return new Map()
|
||||
const rows = await core.query(
|
||||
`SELECT steam_id AS steamId, balance
|
||||
FROM ${STATS}
|
||||
WHERE server_id = ? AND wipe_id = ? AND balance IS NOT NULL
|
||||
AND steam_id IN (${steamIds.map(() => '?').join(', ')})`,
|
||||
[serverId, wipeId || '', ...steamIds],
|
||||
)
|
||||
return new Map(rows.map((r) => [String(r.steamId), Number(r.balance)]))
|
||||
}
|
||||
|
||||
/** One weapon's credited kills (§5.3), added as a delta like a gathered resource. */
|
||||
async function addWeaponKills({ serverId, wipeId, steamId }, weapon, kills) {
|
||||
if (!serverId || !steamId || !weapon || !(kills > 0)) return
|
||||
@@ -291,9 +339,30 @@ async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
|
||||
* All-time is a SUM over the per-wipe rows rather than a separate set of
|
||||
* counters, which is what makes it impossible for the two to disagree — there
|
||||
* is only ever one number, added up differently.
|
||||
*
|
||||
* The balance is the exception (§11.2.2): a value per wipe, so it is read only
|
||||
* for one wipe, as that wipe's MAX — there is one row per player, so the max is
|
||||
* the value. `balanceFloor` is the server's starting balance, and only set when
|
||||
* the caller ranks by balance: then a row at or under it is not on the board
|
||||
* (D336), because "1,000" cannot tell a new account from one spent back to it.
|
||||
*/
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 }) {
|
||||
const column = { kills: 'kills', deaths: 'deaths', npcKills: 'npc_kills', playtime: 'playtime_sec' }[sort] || 'kills'
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25, balanceFloor = null }) {
|
||||
const byBalance = sort === 'balance' && wipeId && Number.isFinite(balanceFloor)
|
||||
const column = {
|
||||
kills: 'kills',
|
||||
deaths: 'deaths',
|
||||
npcKills: 'npc_kills',
|
||||
playtime: 'playtime_sec',
|
||||
// Protocol 14 (D356, D377): the columns Safecracker and Scavenger bring.
|
||||
cratesHacked: 'crates_hacked',
|
||||
dropsLooted: 'drops_looted',
|
||||
// Protocol 14 (D375): Dangerous Treasures won, Treasure Hunter's column.
|
||||
treasuresWon: 'treasures_won',
|
||||
// Protocol 14 (D375): guarded crates cleared, Warden's column.
|
||||
guardedWon: 'guarded_won',
|
||||
// Protocol 14 (§11.4, D202): RaidableBases bases completed, Warlord's column.
|
||||
raidsCompleted: 'raids_completed',
|
||||
}[sort] || 'kills'
|
||||
|
||||
const params = [serverId]
|
||||
let where = 's.server_id = ?'
|
||||
@@ -303,6 +372,10 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
|
||||
params.push(wipeId)
|
||||
}
|
||||
|
||||
const having = byBalance ? 'HAVING MAX(s.balance) > ?' : ''
|
||||
if (byBalance) params.push(balanceFloor)
|
||||
const order = byBalance ? 'MAX(s.balance)' : `SUM(s.${column})`
|
||||
|
||||
params.push(limit)
|
||||
|
||||
return core.query(
|
||||
@@ -313,12 +386,19 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
|
||||
SUM(s.npc_kills) AS npcKills,
|
||||
SUM(s.structures) AS structures,
|
||||
SUM(s.playtime_sec) AS playtimeSec,
|
||||
SUM(s.crates_hacked) AS cratesHacked,
|
||||
SUM(s.drops_looted) AS dropsLooted,
|
||||
SUM(s.treasures_won) AS treasuresWon,
|
||||
SUM(s.guarded_won) AS guardedWon,
|
||||
SUM(s.raids_completed) AS raidsCompleted,
|
||||
MAX(s.balance) AS balance,
|
||||
MAX(s.last_seen) AS lastSeen
|
||||
FROM ${STATS} s
|
||||
LEFT JOIN ${PLAYERS} p ON p.steam_id = s.steam_id
|
||||
WHERE ${where}
|
||||
GROUP BY s.steam_id, p.name
|
||||
ORDER BY SUM(s.${column}) DESC, MAX(s.last_seen) DESC
|
||||
${having}
|
||||
ORDER BY ${order} DESC, MAX(s.last_seen) DESC
|
||||
LIMIT ?`,
|
||||
params,
|
||||
)
|
||||
@@ -387,6 +467,9 @@ module.exports = {
|
||||
addGathered,
|
||||
addTitleStats,
|
||||
addWeaponKills,
|
||||
setBalance,
|
||||
clearBalances,
|
||||
balancesFor,
|
||||
replacePresence,
|
||||
pruneEvents,
|
||||
recentEvents,
|
||||
|
||||
@@ -13,7 +13,15 @@
|
||||
// type.
|
||||
|
||||
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')
|
||||
const weaponNames = require('../titles/weaponNames')
|
||||
|
||||
/** Hard ceiling on a page, whatever a caller asks for. */
|
||||
const MAX_LIMIT = 200
|
||||
@@ -51,7 +59,7 @@ function parseKinds(raw) {
|
||||
* refused: naming it in an error would confirm the kind exists, which is a small
|
||||
* thing to leak and a free one to avoid.
|
||||
*/
|
||||
async function recent({ serverId, admin = false, presence = false, kind = null, wipeId = null, limit }) {
|
||||
async function recent({ serverId, admin = false, staff = false, presence = false, kind = null, wipeId = null, limit }) {
|
||||
const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
|
||||
|
||||
// Every requested kind was refused. Answering with an empty list is right —
|
||||
@@ -65,7 +73,70 @@ async function recent({ serverId, admin = false, presence = false, kind = null,
|
||||
limit: boundedLimit(limit),
|
||||
})
|
||||
|
||||
return rows.map(shape)
|
||||
const shaped = rows.map(shape)
|
||||
if (!shaped.some((r) => r.kind === 'world.event' || dynamicPvp.KINDS.includes(r.kind) || dangerousTreasures.isKind(r.kind) || guardedCrate.isKind(r.kind) || 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, 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)
|
||||
}
|
||||
|
||||
/** What reading a world event for one viewer needs: the server's credit rule and map geometry. */
|
||||
async function worldView(serverId, { staff, presence }) {
|
||||
const [server, meta] = await Promise.all([
|
||||
serversDb.getServer(serverId).catch(() => null),
|
||||
mapDb.getMeta(serverId).catch(() => null),
|
||||
])
|
||||
return {
|
||||
staff,
|
||||
presence,
|
||||
rule: worldEvents.creditRuleOf(server && server.creditRule),
|
||||
geometry: meta
|
||||
? { worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** One Dynamic PVP row for a viewer (§14.3.3): its grid for everyone, its position for staff. */
|
||||
function pvpRow(row, view) {
|
||||
return { ...row, frame: dynamicPvp.shapeFor(row.frame, view) }
|
||||
}
|
||||
|
||||
/** One Dangerous Treasures row for a viewer (§14.3.4). The row's own Steam id, the winner's, is staff's. */
|
||||
function dtRow(row, view) {
|
||||
return { ...row, steamId: view.staff ? row.steamId : null, frame: dangerousTreasures.shapeFor(row.frame, view) }
|
||||
}
|
||||
|
||||
/** One Guarded Crate row for a viewer (§14.3.5). The row's own Steam id, the clearer's, is staff's. */
|
||||
function gcRow(row, view) {
|
||||
return { ...row, steamId: view.staff ? row.steamId : null, frame: guardedCrate.shapeFor(row.frame, view) }
|
||||
}
|
||||
|
||||
/** One 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)
|
||||
if (!frame) return null
|
||||
return { ...row, steamId: view.staff ? row.steamId : null, frame }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +156,13 @@ function shape(row) {
|
||||
frame = {}
|
||||
}
|
||||
|
||||
// The weapon as a page should print it (`model/titles/weaponNames.js`). Added
|
||||
// at read, so rows stored before this existed read the same as new ones, and
|
||||
// a later edit to the table renames every kill the feed still shows.
|
||||
if (typeof frame.weapon === 'string' && frame.weaponName === undefined) {
|
||||
frame.weaponName = weaponNames.nameOf(frame.weapon)
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(row.id),
|
||||
kind: row.kind,
|
||||
@@ -102,15 +180,20 @@ function shape(row) {
|
||||
* counters, so the two can never disagree — which is the whole reason R12's
|
||||
* "per-wipe detail plus all-time rollups" is one table and not two.
|
||||
*/
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false }) {
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false, economics = null }) {
|
||||
// The richest column (§11.2.2): only for one wipe — a balance is a value, and
|
||||
// there is no all-time one — and only where the server reports Economics.
|
||||
const withBalance = Boolean(economics && wipeId)
|
||||
const rows = await db.leaderboard({
|
||||
serverId,
|
||||
wipeId,
|
||||
sort,
|
||||
sort: sort === 'balance' && !withBalance ? 'kills' : sort,
|
||||
limit: boundedLimit(limit, 25),
|
||||
balanceFloor: withBalance && sort === 'balance' ? economics.startingBalance : null,
|
||||
})
|
||||
|
||||
return rows.map((r) => ({
|
||||
...(withBalance ? { balance: r.balance === null || r.balance === undefined ? null : Number(r.balance) } : {}),
|
||||
steamId: r.steamId,
|
||||
name: r.name || null,
|
||||
kills: Number(r.kills) || 0,
|
||||
@@ -118,6 +201,15 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, pre
|
||||
npcKills: Number(r.npcKills) || 0,
|
||||
structures: Number(r.structures) || 0,
|
||||
playtimeSec: Number(r.playtimeSec) || 0,
|
||||
// Protocol 14 (D356): Safecracker's and Scavenger's counts.
|
||||
cratesHacked: Number(r.cratesHacked) || 0,
|
||||
dropsLooted: Number(r.dropsLooted) || 0,
|
||||
// Protocol 14 (D375): Treasure Hunter's count.
|
||||
treasuresWon: Number(r.treasuresWon) || 0,
|
||||
// Protocol 14 (D375): Warden's count.
|
||||
guardedWon: Number(r.guardedWon) || 0,
|
||||
// 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
|
||||
@@ -163,4 +255,4 @@ async function online(serverId) {
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, MAX_LIMIT }
|
||||
module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, shape, MAX_LIMIT }
|
||||
|
||||
@@ -166,6 +166,28 @@ async function userIdsForSteamIds(steamIds) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked players of one server: every linked Steam account that has played
|
||||
* there, in any wipe (PLAN_REDESIGNS.md §2.5, the kit weekend's `linked`).
|
||||
*
|
||||
* "Has played there" is a stats row on that server, which every player who ever
|
||||
* connected has. A link is fleet-wide, so the server is what narrows it: a kit
|
||||
* weekend on one server rewards that server's players, not every account on the
|
||||
* site. Bounded at `limit`, ordered by most recently seen, so a caller with a
|
||||
* cap keeps the people who are actually around.
|
||||
*/
|
||||
async function linkedOnServer(serverId, limit = 5000) {
|
||||
return core.query(
|
||||
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name AS name, MAX(s.last_seen) AS lastSeen
|
||||
FROM ${LINKS} l
|
||||
JOIN rust_player_wipe_stats s ON s.steam_id = l.steam_id AND s.server_id = ?
|
||||
GROUP BY l.steam_id, l.user_id, l.name
|
||||
ORDER BY lastSeen DESC, l.steam_id
|
||||
LIMIT ?`,
|
||||
[serverId, Number(limit) || 5000],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The servers that handed out a link code in the last `windowSec` (PLAN_FIXES F5).
|
||||
*
|
||||
@@ -195,4 +217,5 @@ module.exports = {
|
||||
removeBySteamId,
|
||||
statsForSteamId,
|
||||
userIdsForSteamIds,
|
||||
linkedOnServer,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -7,15 +7,17 @@
|
||||
// viewer is SENT only the layers they may see — a hidden layer is absent from the
|
||||
// answer, never present and hidden by the page (§30.2).
|
||||
//
|
||||
// ── The four layers ───────────────────────────────────────────────────────
|
||||
// ── The five layers ───────────────────────────────────────────────────────
|
||||
//
|
||||
// world monuments, and the world's own events: cargo, the patrol
|
||||
// helicopter, the Chinook, Bradley, supply drops, locked crates
|
||||
// events what this site's events placed: zones, crates, NPCs (phase 13a)
|
||||
// players who is on the server and where, and the sleepers
|
||||
// bases tool cupboards and player vending machines, as positions only
|
||||
// pvp Dynamic PVP's own PvP zones, where they are now (protocol 14)
|
||||
// raids RaidableBases' live bases, and which a run made (protocol 14)
|
||||
//
|
||||
// Defaults: the first two public, the last two 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`).
|
||||
@@ -42,9 +44,13 @@ const visibilityDb = require('../visibility/visibility.db')
|
||||
|
||||
const log = core.logger('map')
|
||||
|
||||
const LAYERS = Object.freeze(['world', 'events', 'players', 'bases'])
|
||||
// `pvp` (protocol 14, PLAN_REDESIGNS §14.3.3) is Dynamic PVP's own zones, public by
|
||||
// default: the game draws them for everyone near one, and they name nobody.
|
||||
// `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' })
|
||||
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
|
||||
@@ -301,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
|
||||
@@ -329,6 +342,10 @@ function project(live, acc, ids = { own: new Set(), mates: new Set() }) {
|
||||
out.players = Array.isArray(live.players) ? live.players : []
|
||||
if (live.playersTruncated) out.playersTruncated = true
|
||||
}
|
||||
// Dynamic PVP's zones (§14.3.3): an older plugin's answer has no `pvp`, and it is empty.
|
||||
if (acc.layers.pvp.visible) out.pvp = Array.isArray(live.pvp) ? live.pvp : []
|
||||
// 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
|
||||
|
||||
@@ -124,7 +124,7 @@ function checkFactions(input) {
|
||||
}
|
||||
if (!RELATIONS.includes(relation)) return { ok: false, error: `factions[${i}]: '${relation}' is not hostile, neutral or allied` }
|
||||
const key = pairKey(a, b)
|
||||
if (seen.has(key)) return { ok: false, error: `factions[${i}]: ${a} and ${b} are given twice (one row is both ways, D268)` }
|
||||
if (seen.has(key)) return { ok: false, error: `factions[${i}]: ${a} and ${b} are given twice (one row is both ways)` }
|
||||
seen.add(key)
|
||||
rows.push(a < b ? { a, b, relation } : { a: b, b: a, relation })
|
||||
}
|
||||
@@ -173,7 +173,7 @@ function checkBody(input) {
|
||||
if (names.some((n) => n.length > DISPLAY_NAME_MAX)) return { ok: false, error: `names: each at most ${DISPLAY_NAME_MAX} characters` }
|
||||
|
||||
const kits = Array.isArray(b.kits) ? [...new Set(b.kits.map((k) => String(k === null || k === undefined ? '' : k).trim()).filter(Boolean))] : null
|
||||
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped (D217)' }
|
||||
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped' }
|
||||
if (kits.length > KITS_MAX) return { ok: false, error: `kits: at most ${KITS_MAX}` }
|
||||
|
||||
const prefab = String(b.prefab || '').trim()
|
||||
@@ -242,8 +242,8 @@ function checkBody(input) {
|
||||
const hasBoss = b.boss !== undefined && b.boss !== null
|
||||
const hasUse = b.use !== undefined && b.use !== null
|
||||
if (passive && hasBoss) return { ok: false, error: 'boss: a passive NPC never fights, so it cannot be a boss' }
|
||||
if (passive && !hasUse) return { ok: false, error: 'use: a passive NPC says what pressing E on it does (D278)' }
|
||||
if (hasUse && !passive) return { ok: false, error: 'use: only a passive NPC answers E (D278)' }
|
||||
if (passive && !hasUse) return { ok: false, error: 'use: a passive NPC says what pressing E on it does' }
|
||||
if (hasUse && !passive) return { ok: false, error: 'use: only a passive NPC answers E' }
|
||||
let use = null
|
||||
if (hasUse) {
|
||||
const checked = checkUse(b.use)
|
||||
|
||||
@@ -272,7 +272,7 @@ async function create(input, userId = null) {
|
||||
async function update(id, input) {
|
||||
const existing = await db.getProfile(id)
|
||||
if (!existing) throw new NpcError('no such profile', 404)
|
||||
if (existing.replaced) throw new NpcError('this profile is kept aside as replaced (D251): restore it before editing it', 409)
|
||||
if (existing.replaced) throw new NpcError('this profile is kept aside as replaced: restore it before editing it', 409)
|
||||
const clean = await validate(input, existing.id)
|
||||
await db.saveProfile({ ...clean, id: existing.id })
|
||||
const before = reach(existing)
|
||||
|
||||
@@ -322,6 +322,33 @@ async function insertGrant({ userId, permission, scope, source, note, grantedBy
|
||||
return { inserted: affected(result) > 0, id: result.insertId }
|
||||
}
|
||||
|
||||
/**
|
||||
* The grants some users already hold at one scope, with their source and note
|
||||
* (PLAN_REDESIGNS §12.5): `rust.perm.give` leaves a grant that is there alone,
|
||||
* and tells its own first attempt's grants from a staff member's by the note.
|
||||
*/
|
||||
async function listGrantsForUsers(userIds, scope) {
|
||||
const ids = [...new Set((userIds || []).map(Number).filter(Boolean))]
|
||||
if (!ids.length) return []
|
||||
return core.query(
|
||||
`SELECT user_id AS userId, permission, source, note
|
||||
FROM ${GRANTS}
|
||||
WHERE scope = ? AND user_id IN (${ids.map(() => '?').join(', ')})`,
|
||||
[scope, ...ids],
|
||||
)
|
||||
}
|
||||
|
||||
/** Add many grants at once, leaving any already there (§12.5). Returns how many were added. */
|
||||
async function insertGrants(rows) {
|
||||
if (!rows.length) return 0
|
||||
const result = await core.query(
|
||||
`INSERT IGNORE INTO ${GRANTS} (user_id, permission, scope, source, note, granted_by)
|
||||
VALUES ${placeholders(rows, 6)}`,
|
||||
rows.flatMap((r) => [r.userId, r.permission, r.scope, r.source, r.note || null, r.grantedBy || null]),
|
||||
)
|
||||
return affected(result)
|
||||
}
|
||||
|
||||
/** Delete a grant and the exceptions it carried, which no foreign key can reach. */
|
||||
async function deleteGrant(id) {
|
||||
const removed = affected(await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id])) > 0
|
||||
@@ -398,7 +425,8 @@ async function deleteExceptionsFor(holder, grantId) {
|
||||
async function listRunGrants() {
|
||||
return core.query(
|
||||
`SELECT run_id AS runId, step_id AS stepId, user_id AS userId, server_id AS serverId,
|
||||
steam_id AS steamId, permission, kit, credit
|
||||
steam_id AS steamId, permission, kit, credit,
|
||||
notice, notice_delivery AS noticeDelivery, notice_told_at AS noticeToldAt
|
||||
FROM ${RUN_GRANTS}`,
|
||||
)
|
||||
}
|
||||
@@ -416,8 +444,8 @@ async function insertRunGrants(rows) {
|
||||
if (!rows.length) return 0
|
||||
|
||||
const result = await core.query(
|
||||
`INSERT IGNORE INTO ${RUN_GRANTS} (run_id, step_id, idem_key, user_id, server_id, steam_id, permission, kit, credit)
|
||||
VALUES ${placeholders(rows, 9)}`,
|
||||
`INSERT IGNORE INTO ${RUN_GRANTS} (run_id, step_id, idem_key, user_id, server_id, steam_id, permission, kit, credit, notice, notice_delivery)
|
||||
VALUES ${placeholders(rows, 11)}`,
|
||||
rows.flatMap((r) => [
|
||||
String(r.runId),
|
||||
String(r.stepId),
|
||||
@@ -428,12 +456,36 @@ async function insertRunGrants(rows) {
|
||||
r.permission || '',
|
||||
r.kit,
|
||||
r.credit ? 1 : 0,
|
||||
r.notice || null,
|
||||
r.notice ? r.noticeDelivery || 'chat' : null,
|
||||
]),
|
||||
)
|
||||
|
||||
return affected(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark reward messages told (protocol 14, D333): the plugin listed their keys in
|
||||
* a sync report. A key is `<runId>:<stepId>:<userId>` (`noticeKey` in the
|
||||
* model); one that names no untold row — told already, or withdrawn — changes
|
||||
* nothing.
|
||||
*/
|
||||
async function markNoticesTold(serverId, keys) {
|
||||
let marked = 0
|
||||
for (const key of keys || []) {
|
||||
const [runId, stepId, userId] = String(key).split(':')
|
||||
if (!runId || !stepId || !Number(userId)) continue
|
||||
const result = await core.query(
|
||||
`UPDATE ${RUN_GRANTS} SET notice_told_at = NOW()
|
||||
WHERE server_id = ? AND run_id = ? AND step_id = ? AND user_id = ?
|
||||
AND notice IS NOT NULL AND notice_told_at IS NULL`,
|
||||
[serverId, runId, stepId, Number(userId)],
|
||||
)
|
||||
marked += affected(result)
|
||||
}
|
||||
return marked
|
||||
}
|
||||
|
||||
async function deleteRunGrantsForStep(runId, stepId) {
|
||||
return deleteRunGrantsWhere('run_id = ? AND step_id = ?', [String(runId), String(stepId)])
|
||||
}
|
||||
@@ -848,6 +900,8 @@ module.exports = {
|
||||
listGrants,
|
||||
getGrant,
|
||||
insertGrant,
|
||||
insertGrants,
|
||||
listGrantsForUsers,
|
||||
deleteGrant,
|
||||
listSteamGrants,
|
||||
getSteamGrant,
|
||||
@@ -858,6 +912,7 @@ module.exports = {
|
||||
deleteException,
|
||||
deleteExceptionsFor,
|
||||
listRunGrants,
|
||||
markNoticesTold,
|
||||
listRunGrantsForStep,
|
||||
insertRunGrants,
|
||||
deleteRunGrantsForStep,
|
||||
|
||||
@@ -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,
|
||||
@@ -342,8 +350,11 @@ function buildDesired(serverId, authored) {
|
||||
const permission = normaliseName(row.permission)
|
||||
if (!permission) continue
|
||||
|
||||
// A grant an event gave for good (PLAN_REDESIGNS.md §12.5) says so, with its
|
||||
// note, so the screen can show where it came from; staff delete it like any.
|
||||
const given = row.source === 'event' ? { origin: 'event', note: row.note || '' } : {}
|
||||
for (const steamId of steamIdsByUser.get(row.userId) || []) {
|
||||
addGrant(steamId, permission, { type: 'userGrant', id: row.id, userId: row.userId, scope: row.scope })
|
||||
addGrant(steamId, permission, { type: 'userGrant', id: row.id, userId: row.userId, scope: row.scope, ...given })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,6 +376,7 @@ function buildDesired(serverId, authored) {
|
||||
// every account the user has linked (D28). The CREDIT is one extra use on the
|
||||
// account that took part, while it is still linked to the winner.
|
||||
const credits = new Map()
|
||||
const notices = []
|
||||
|
||||
for (const row of runGrants) {
|
||||
if (row.serverId !== serverId) continue
|
||||
@@ -380,6 +392,19 @@ function buildDesired(serverId, authored) {
|
||||
const key = `${row.steamId}|${row.kit}`
|
||||
credits.set(key, (credits.get(key) || 0) + 1)
|
||||
}
|
||||
|
||||
// The reward's message (protocol 14, D333), to the account the reward
|
||||
// picked, while it is still linked to the winner — and only until the
|
||||
// plugin reports it said it. A withdrawn reward has no row, so nobody is
|
||||
// told about a reward that is no longer theirs.
|
||||
if (row.notice && !row.noticeToldAt && linked.includes(row.steamId)) {
|
||||
notices.push({
|
||||
key: noticeKey(row),
|
||||
steamId: row.steamId,
|
||||
message: String(row.notice),
|
||||
delivery: row.noticeDelivery === 'popup' ? 'popup' : 'chat',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── A group's BetterChat style (phase 17, D138) ───────────────────────
|
||||
@@ -415,11 +440,15 @@ function buildDesired(serverId, authored) {
|
||||
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({ steamId, permissions })),
|
||||
// Always sent, even empty (D103).
|
||||
credits: creditRows,
|
||||
// Always sent, even empty (D333): the whole untold set, so a notice the
|
||||
// site stops sending is one the plugin may forget.
|
||||
notices: notices.sort((a, b) => a.key.localeCompare(b.key)),
|
||||
}
|
||||
|
||||
const hashed = [
|
||||
...rows,
|
||||
...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
|
||||
...notices.map((n) => ({ kind: 'notice', subject: n.steamId, object: n.key, value: `${n.delivery}:${n.message}` })),
|
||||
]
|
||||
|
||||
return { payload, rows, hash: hashRows(hashed), sources }
|
||||
@@ -441,6 +470,15 @@ function isShared(group, serverRows) {
|
||||
* A digest of the desired set. Sorted before hashing, and a row's VALUE is in
|
||||
* it (a style field, a group's title, rank and parent): a change to one must push.
|
||||
*/
|
||||
/**
|
||||
* The key of one reward message (protocol 14, D333): `<runId>:<stepId>:<userId>`.
|
||||
* One reward step tells one person once, whichever of their linked accounts it
|
||||
* picked; `permissions.db.markNoticesTold` reads it back.
|
||||
*/
|
||||
function noticeKey(row) {
|
||||
return `${row.runId}:${row.stepId}:${row.userId}`
|
||||
}
|
||||
|
||||
function hashRows(rows) {
|
||||
const canonical = rows
|
||||
.map((row) => `${row.kind} ${row.subject} ${row.object}${row.value === undefined || row.value === null ? '' : `=${row.value}`}`)
|
||||
@@ -561,6 +599,7 @@ module.exports = {
|
||||
buildDesired,
|
||||
retirements,
|
||||
hashRows,
|
||||
noticeKey,
|
||||
rowKey,
|
||||
collapseGrants,
|
||||
shapeSync,
|
||||
|
||||
@@ -24,7 +24,7 @@ async function listServers({ enabledOnly = false } = {}) {
|
||||
return core.query(
|
||||
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
|
||||
protocol, enabled, sort_order AS sortOrder, announce_news AS announceNews,
|
||||
news_delivery AS newsDelivery,
|
||||
news_delivery AS newsDelivery, credit_rule AS creditRule,
|
||||
wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz,
|
||||
DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
@@ -37,7 +37,7 @@ async function listServers({ enabledOnly = false } = {}) {
|
||||
async function getServer(id) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
|
||||
protocol, enabled, sort_order AS sortOrder,
|
||||
protocol, enabled, sort_order AS sortOrder, credit_rule AS creditRule,
|
||||
wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz,
|
||||
DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
@@ -97,6 +97,15 @@ async function setSchedule(id, { wipeRule, wipeDay, wipeTime, wipeTz, wipeAnchor
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One server's credit rule (D372). Its own statement, like the schedule, and for
|
||||
* the same reason: written only when a save carries one, so a client that
|
||||
* predates the rule cannot reset it to the default.
|
||||
*/
|
||||
async function setCreditRule(id, rule) {
|
||||
await core.query(`UPDATE ${SERVERS} SET credit_rule = ? WHERE id = ?`, [rule, id])
|
||||
}
|
||||
|
||||
async function deleteServer(id) {
|
||||
await core.query(`DELETE FROM ${SERVERS} WHERE id = ?`, [id])
|
||||
}
|
||||
@@ -249,13 +258,95 @@ async function putState(state) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What one server's last status said about Dangerous Treasures and its console
|
||||
* (protocol 14, PLAN_REDESIGNS §14.3.4): `{ dangerousTreasures, console }`, each
|
||||
* as the driver hands JSON_EXTRACT back (text, or null for a plugin that never
|
||||
* said). Null for a server with no state yet.
|
||||
*/
|
||||
async function dtReportFor(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT JSON_EXTRACT(raw, '$.integrations.dangerousTreasures') AS dangerousTreasures,
|
||||
JSON_EXTRACT(raw, '$.console') AS console
|
||||
FROM ${STATE}
|
||||
WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* What one server's last status said about Guarded Crate (with NpcSpawn and its
|
||||
* event names) and its console (protocol 14, PLAN_REDESIGNS §14.3.5):
|
||||
* `{ guardedCrate, console }`, as `dtReportFor`'s are. Null for a server with no
|
||||
* state yet.
|
||||
*/
|
||||
async function gcReportFor(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT JSON_EXTRACT(raw, '$.integrations.guardedCrate') AS guardedCrate,
|
||||
JSON_EXTRACT(raw, '$.console') AS console
|
||||
FROM ${STATE}
|
||||
WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** `gcReportFor` for every enabled server, with its id and name: the event list a step offers. */
|
||||
async function gcReports() {
|
||||
return core.query(
|
||||
`SELECT s.id AS serverId, s.name AS name,
|
||||
JSON_EXTRACT(st.raw, '$.integrations.guardedCrate') AS guardedCrate,
|
||||
JSON_EXTRACT(st.raw, '$.console') AS console
|
||||
FROM ${SERVERS} s
|
||||
JOIN ${STATE} st ON st.server_id = s.id
|
||||
WHERE s.enabled = 1
|
||||
ORDER BY s.sort_order ASC, s.id ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
setSchedule,
|
||||
setCreditRule,
|
||||
deleteServer,
|
||||
listState,
|
||||
getState,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
const core = require('../../core')
|
||||
const db = require('./servers.db')
|
||||
const { nextWipe } = require('./nextWipe')
|
||||
const worldEvents = require('../../worldEvents')
|
||||
|
||||
const log = core.logger('servers')
|
||||
|
||||
@@ -59,6 +60,8 @@ function withToken(row) {
|
||||
announceNews: Boolean(Number(row.announceNews)),
|
||||
// D142: where that post goes — chat, or a popup.
|
||||
newsDelivery: row.newsDelivery === 'popup' ? 'popup' : 'chat',
|
||||
// D372: who a downed helicopter, Chinook or Bradley credits, for ingest.
|
||||
creditRule: worldEvents.creditRuleOf(row.creditRule),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +203,9 @@ async function listForAdmin(now = Date.now()) {
|
||||
// The public shape first, so the admin-only fields below cannot be
|
||||
// overwritten by a key the public shape happens to share.
|
||||
...shapePublic(row, state, now),
|
||||
sidecarBaseUrl: row.sidecarBaseUrl,
|
||||
// Left out on the public demo, so the screen gives nothing away that helps
|
||||
// someone reach the sidecar (DEMO_MODE.md §3.5); editing the link is locked.
|
||||
...(core.demoMode ? {} : { sidecarBaseUrl: row.sidecarBaseUrl }),
|
||||
hasToken: Boolean(row.sidecarTokenEnc),
|
||||
protocol: Number(row.protocol),
|
||||
enabled: Boolean(row.enabled),
|
||||
@@ -212,6 +217,8 @@ async function listForAdmin(now = Date.now()) {
|
||||
// bridge makes. Anything but `patched` is said on the servers page.
|
||||
zoneHelper: (state && state.zoneHelper) || null,
|
||||
schedule: scheduleOf(row),
|
||||
// D372: who a downed helicopter, Chinook or Bradley credits on this server.
|
||||
creditRule: worldEvents.creditRuleOf(row.creditRule),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,9 +76,27 @@ const COLUMN = /^[a-z_]+$/
|
||||
* @param {object} source a category's `source` from `titles.js`
|
||||
* @param {object} lists list name → the short names it holds (`lists.js`)
|
||||
*/
|
||||
async function standings({ serverId, wipeId, source, lists = {}, limit }) {
|
||||
async function standings({ serverId, wipeId, source, lists = {}, limit, floor = null }) {
|
||||
const params = [serverId, wipeId]
|
||||
|
||||
// The richest (§11.2.2): the balance is a value, one per row, ranked only
|
||||
// above `floor`, the server's starting balance (D336). No floor, no ranking —
|
||||
// the caller passes one only for a server that reports Economics.
|
||||
if (source.balance) {
|
||||
if (!COLUMN.test(source.balance)) throw new Error('not a stats column')
|
||||
if (!Number.isFinite(floor)) return []
|
||||
|
||||
return core.query(
|
||||
`SELECT s.steam_id AS steamId, MAX(s.${source.balance}) AS value
|
||||
FROM ${STATS} s
|
||||
WHERE s.server_id = ? AND s.wipe_id = ? AND s.${source.balance} > ?
|
||||
GROUP BY s.steam_id
|
||||
ORDER BY value DESC, MAX(s.last_seen) DESC
|
||||
LIMIT ?`,
|
||||
[...params, floor, limit],
|
||||
)
|
||||
}
|
||||
|
||||
if (source.sum || source.diff || source.best) {
|
||||
let value
|
||||
if (source.sum) value = `SUM(s.${source.sum})`
|
||||
|
||||
@@ -20,8 +20,12 @@ const crypto = require('node:crypto')
|
||||
|
||||
/**
|
||||
* The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three
|
||||
* conditions and their shipped titles, plus `playtime`, which predates them and
|
||||
* has no default title, so a playtime rule always carries its own text.
|
||||
* conditions and their shipped titles, the richest (§11.2.2, Magnate), the
|
||||
* two world-event counts (§14.3.1, Safecracker and Scavenger), Dangerous
|
||||
* Treasures won (§14.3.4, Treasure Hunter), guarded crates cleared (§14.3.5,
|
||||
* Warden), 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.
|
||||
*
|
||||
* `source` says where the standing is read from (`titles.db.js`):
|
||||
* `sum` a column of `rust_player_wipe_stats`, summed
|
||||
@@ -30,6 +34,9 @@ const crypto = require('node:crypto')
|
||||
* `best` a column's MAX: the longest single kill (D174)
|
||||
* `weapons` a named list's kills in `rust_weapon_kills` (§5.3)
|
||||
* `gathered` a named list's amount in `rust_gather_totals` (D159)
|
||||
* `balance` the Economics balance, a value per wipe (PLAN_REDESIGNS §11.2.2):
|
||||
* ranked only above the server's starting balance (D336), and
|
||||
* only on a server that reports Economics
|
||||
* `npcProfile` one site NPC profile's kills, counted as the profile says
|
||||
* (runicnpc stage 4, D247, D250). A rule on it names the profile,
|
||||
* and carries its own text, like playtime.
|
||||
@@ -61,6 +68,16 @@ const STATS = {
|
||||
rockets: { label: 'Rockets fired', title: 'Artillerist', source: { sum: 'rockets' } },
|
||||
explosives: { label: 'Explosives thrown', title: 'Demolitionist', source: { sum: 'explosives' } },
|
||||
missions: { label: 'Missions completed', title: 'Wayfarer', source: { sum: 'missions' } },
|
||||
// Protocol 14, Rust's own world events (D356): a hack counts for its starter, once per crate.
|
||||
crateshacked: { label: 'Locked crates hacked', title: 'Safecracker', source: { sum: 'crates_hacked' } },
|
||||
dropslooted: { label: 'Airdrops looted first', title: 'Scavenger', source: { sum: 'drops_looted' } },
|
||||
// Protocol 14, Dangerous Treasures (D375): a chest counts for the player who emptied it.
|
||||
treasureswon: { label: 'Dangerous Treasures won', title: 'Treasure Hunter', source: { sum: 'treasures_won' } },
|
||||
// Protocol 14, Guarded Crate (D375): a crate counts for the player who killed its last guard.
|
||||
guardedwon: { label: 'Guarded crates cleared', title: 'Warden', source: { sum: 'guarded_won' } },
|
||||
// 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 } },
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// the app's — so it is remembered briefly: the leaderboard is polled by every
|
||||
// open page, and each rule is a leaderboard query of its own.
|
||||
|
||||
const economics = require('../economics/economics.model')
|
||||
const serversDb = require('../servers/servers.db')
|
||||
const db = require('./titles.db')
|
||||
const lists = require('./lists')
|
||||
@@ -118,12 +119,21 @@ async function heldFor(serverId, { wipeId, now = Date.now() } = {}) {
|
||||
ruleBy.set(key, r)
|
||||
}
|
||||
|
||||
// The richest ranks above the server's starting balance (D336), and only
|
||||
// where the server reports Economics: a balance last known before it was
|
||||
// removed is not anybody's standing now.
|
||||
let floor = null
|
||||
if (depth.has('richest')) {
|
||||
const econ = await economics.forServer(serverId)
|
||||
floor = econ.loaded ? econ.startingBalance : null
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
[...depth.entries()].map(async ([key, limit]) => {
|
||||
const r = ruleBy.get(key)
|
||||
const rows = r.stat === 'profilekills'
|
||||
? await npcs.standing(serverId, r.profile, wipeId, limit)
|
||||
: await db.standings({ serverId, wipeId, source: titles.STATS[r.stat].source, lists, limit })
|
||||
: await db.standings({ serverId, wipeId, source: titles.STATS[r.stat].source, lists, limit, floor })
|
||||
// SUM() and MAX() come back as strings; the rules compare numbers.
|
||||
standings[key] = rows.map((row) => ({ steamId: row.steamId, value: Number(row.value) || 0 }))
|
||||
}),
|
||||
|
||||
89
server/model/titles/weaponNames.js
Normal file
89
server/model/titles/weaponNames.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// ── What a weapon is called on a page ──────────────────────────────────────
|
||||
//
|
||||
// The plugin names the weapon in a kill by the held entity's SHORT PREFAB NAME
|
||||
// (`ak47u.entity`) — the same name the title lists beside this file count by,
|
||||
// and the right key for counting. It is the wrong thing to print: the feed said
|
||||
// "with ak47u entity". So every event read gives the frame a `weaponName` from
|
||||
// here, and the site and the app print that, falling back to the prefab name
|
||||
// themselves only for a module too old to send one.
|
||||
//
|
||||
// The table is the ranged weapons — every gun, bow, crossbow, launcher and
|
||||
// flamethrower — named as the game names them. Read off the Oxide rig's own item
|
||||
// list on 2026-10-23 (Rust 26139947) by the same read-only walk the title lists
|
||||
// came from: each item whose held entity is an `AttackEntity`, its short prefab
|
||||
// name and its English display name. Melee, throwables and anything a later Rust
|
||||
// update adds fall back to `stripped()`, which drops the `.entity` / `.weapon`
|
||||
// ending and turns the rest into words ("salvaged sword"), so a new weapon reads
|
||||
// acceptably before anyone edits this list. To refresh, walk the list again.
|
||||
|
||||
const NAMES = new Map([
|
||||
['50cal.left.entity', 'Dual 50 Cal Turret'],
|
||||
['50cal.right.entity', 'Dual 50 Cal Turret'],
|
||||
['50cal.single.entity', '50 Cal Turret'],
|
||||
['ak47u.entity', 'Assault Rifle'],
|
||||
['ak47u.glass.blue.entity', 'Crystal Assault Rifle Sapphire'],
|
||||
['ak47u.glass.entity', 'Crystal Assault Rifle Diamond'],
|
||||
['ak47u.glass.green.entity', 'Crystal Assault Rifle Emerald'],
|
||||
['ak47u.glass.pink.entity', 'Crystal Assault Rifle Pink Diamond'],
|
||||
['ak47u.glass.red.entity', 'Crystal Assault Rifle Ruby'],
|
||||
['ak47u_diver.entity', 'Abyss Assault Rifle'],
|
||||
['ak47u_ice.entity', 'Ice Assault Rifle'],
|
||||
['ak47u_jungle.entity', 'Jungle Relic Assault Rifle'],
|
||||
['ak47u_med.entity', 'Medieval Assault Rifle'],
|
||||
['blunderbuss.entity', 'Blunderbuss'],
|
||||
['bolt_rifle.entity', 'Bolt Action Rifle'],
|
||||
['bow_hunting.entity', 'Hunting Bow'],
|
||||
['compound_bow.entity', 'Compound Bow'],
|
||||
['crossbow.entity', 'Crossbow'],
|
||||
['crossbow_bowless.entity', 'Bowless Crossbow'],
|
||||
['double_shotgun.entity', 'Double Barrel Shotgun'],
|
||||
['flamethrower.entity', 'Flame Thrower'],
|
||||
['glock.entity', 'Prototype 17'],
|
||||
['hc_revolver.entity', 'High Caliber Revolver'],
|
||||
['hmlmg.entity', 'HMLMG'],
|
||||
['krieg_shotgun.entity', 'Krieg Shotgun'],
|
||||
['l96.entity', 'L96 Rifle'],
|
||||
['legacybow.entity', 'Legacy Bow'],
|
||||
['lr300.entity', 'LR-300 Assault Rifle'],
|
||||
['lr300_space.entity', 'Space LR-300 Assault Rifle'],
|
||||
['m16a2.entity', 'M16A2'],
|
||||
['m249.entity', 'M249'],
|
||||
['m39.entity', 'M39 Rifle'],
|
||||
['m4_shotgun.entity', 'M4 Shotgun'],
|
||||
['m92.entity', 'M92 Pistol'],
|
||||
['mgl.entity', 'Multiple Grenade Launcher'],
|
||||
['militaryflamethrower.entity', 'Military Flame Thrower'],
|
||||
['mini_crossbow.entity', 'Mini Crossbow'],
|
||||
['mp5.entity', 'MP5A4'],
|
||||
['nailgun.entity', 'Nailgun'],
|
||||
['pistol_revolver.entity', 'Revolver'],
|
||||
['pistol_semiauto.a.m15.entity', 'M15 Semi-Automatic Pistol'],
|
||||
['pistol_semiauto.entity', 'Semi-Automatic Pistol'],
|
||||
['python.entity', 'Python Revolver'],
|
||||
['rocket_launcher.entity', 'Rocket Launcher'],
|
||||
['rocket_launcher_dragon.entity', 'Dragon Rocket Launcher'],
|
||||
['semi_auto_rifle.entity', 'Semi-Automatic Rifle'],
|
||||
['shotgun_pump.entity', 'Pump Shotgun'],
|
||||
['shotgun_waterpipe.entity', 'Waterpipe Shotgun'],
|
||||
['sks.entity', 'SKS'],
|
||||
['smg.entity', 'Custom SMG'],
|
||||
['spas12.entity', 'Spas-12 Shotgun'],
|
||||
['t1_smg.entity', 'Handmade SMG'],
|
||||
['thompson.entity', 'Thompson'],
|
||||
])
|
||||
|
||||
/** The endings a held entity's prefab name carries that no player says. */
|
||||
const ENDING = /[._](entity|weapon|deployed)$/
|
||||
|
||||
/** A prefab name as words: its ending dropped, its dots and underscores spaces. */
|
||||
function stripped(prefab) {
|
||||
return String(prefab).trim().replace(ENDING, '').replace(/[_.]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** What to print for a weapon's prefab name: the game's own name, or the stripped one. */
|
||||
function nameOf(prefab) {
|
||||
if (typeof prefab !== 'string' || !prefab.trim()) return null
|
||||
return NAMES.get(prefab.trim()) || stripped(prefab)
|
||||
}
|
||||
|
||||
module.exports = { nameOf, stripped, NAMES }
|
||||
@@ -99,15 +99,17 @@ async function deletePreset(id) {
|
||||
|
||||
/**
|
||||
* Each configured server, in the operator's order, with what its last hello said
|
||||
* about ZoneManager (`{ version, flags }`) and ZoneDomes (`{ loaded, version,
|
||||
* helper, ready }`). Either is null for a server that never said: no ZoneManager,
|
||||
* a plugin before protocol 13, or a server never reached.
|
||||
* about ZoneManager (`{ version, flags }`), ZoneDomes (`{ loaded, version,
|
||||
* helper, ready }`) and TruePVE (`{ loaded, version }`). Each is null for a server
|
||||
* that never said: no ZoneManager, a plugin before protocol 13 (14 for TruePVE), or
|
||||
* a server never reached.
|
||||
*/
|
||||
async function listZoneServers() {
|
||||
const rows = await core.query(
|
||||
`SELECT s.id, s.name, s.enabled,
|
||||
JSON_EXTRACT(st.raw, '$.zoneManager') AS zoneManager,
|
||||
JSON_EXTRACT(st.raw, '$.integrations.zoneDomes') AS zoneDomes
|
||||
JSON_EXTRACT(st.raw, '$.integrations.zoneDomes') AS zoneDomes,
|
||||
JSON_EXTRACT(st.raw, '$.integrations.truePve') AS truePve
|
||||
FROM ${SERVERS} s
|
||||
LEFT JOIN ${STATE} st ON st.server_id = s.id
|
||||
ORDER BY s.sort_order ASC, s.id ASC`,
|
||||
@@ -115,6 +117,7 @@ async function listZoneServers() {
|
||||
return rows.map((r) => {
|
||||
const zm = json(r.zoneManager, null)
|
||||
const zd = json(r.zoneDomes, null)
|
||||
const tp = json(r.truePve, null)
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -135,6 +138,9 @@ async function listZoneServers() {
|
||||
ready: zd.ready === true,
|
||||
}
|
||||
: null,
|
||||
// Protocol 14 (PLAN_REDESIGNS §14.3.2): null from a plugin older than TruePVE's support.
|
||||
truePve:
|
||||
tp && typeof tp === 'object' ? { loaded: tp.loaded === true, version: typeof tp.version === 'string' ? tp.version : null } : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ async function optionRows() {
|
||||
}))
|
||||
}
|
||||
|
||||
/** What one server's last hello said about ZoneManager and ZoneDomes, or null. */
|
||||
/** What one server's last hello said about ZoneManager, ZoneDomes and TruePVE, or null. */
|
||||
async function serverZoneMods(serverId) {
|
||||
return (await db.listZoneServers()).find((s) => s.id === serverId) || null
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -458,6 +469,11 @@ async function applyReport(server, { desired, hash, retire, styleRetired = [], r
|
||||
const revocations = await db.listRevocations(server.id)
|
||||
await db.deleteRevocations(revocations.map((row) => row.id))
|
||||
|
||||
// Protocol 14 (D333). The reward messages the plugin has said; they leave the
|
||||
// next sync's set, which is what lets the plugin forget them.
|
||||
const noticesTold = Array.isArray(report.noticesTold) ? report.noticesTold : []
|
||||
const marked = noticesTold.length ? await db.markNoticesTold(server.id, noticesTold) : 0
|
||||
|
||||
// What waits for a person: the reconcile's (D161's `adopt`, and an event's
|
||||
// grant removed in the game), and a style field somebody changed by hand (D138).
|
||||
await db.replaceDrift(server.id, [
|
||||
@@ -505,6 +521,7 @@ async function applyReport(server, { desired, hash, retire, styleRetired = [], r
|
||||
...(report.creditsApplied !== undefined
|
||||
? { creditsApplied: report.creditsApplied, creditsWithdrawn: report.creditsWithdrawn }
|
||||
: {}),
|
||||
...(marked ? { noticesTold: marked } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -520,6 +537,7 @@ module.exports = {
|
||||
syncOne,
|
||||
reasonToSync,
|
||||
applyReport,
|
||||
pushBody,
|
||||
withExpect,
|
||||
styleRetirements,
|
||||
withLock,
|
||||
|
||||
166
server/raidableBases.js
Normal file
166
server/raidableBases.js
Normal 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 }
|
||||
@@ -33,6 +33,7 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const configEdit = require('../../configEdit')
|
||||
const demo = require('../../demo')
|
||||
const db = require('../../model/config/config.db')
|
||||
const model = require('../../model/config/config.model')
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
@@ -87,7 +88,32 @@ async function listFiles(req, res) {
|
||||
return res.status(502).json({ message: refusalMessage(reply.data) })
|
||||
}
|
||||
|
||||
return res.json(model.shapeCatalogue(reply.data))
|
||||
return res.json(forDemo(model.shapeCatalogue(reply.data)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalogue without the bridge's own files, on the demo (DEMO_MODE.md §3.5,
|
||||
* DM14): they hold its link to the sidecar, so they are not listed, read or
|
||||
* written. Off the demo the catalogue is untouched.
|
||||
*/
|
||||
function forDemo(catalogue) {
|
||||
if (!demo.isDemo() || !catalogue) return catalogue
|
||||
return {
|
||||
...catalogue,
|
||||
plugins: catalogue.plugins
|
||||
.filter((group) => !demo.isBridgePlugin(group.plugin, catalogue.self))
|
||||
.map((group) => ({ ...group, files: group.files.filter((file) => !demo.isBridgePath(file.path, catalogue.self)) })),
|
||||
}
|
||||
}
|
||||
|
||||
/** A shaped file as the caller may see it: secrets hidden on the demo. */
|
||||
function visible(shaped) {
|
||||
return demo.isDemo() ? model.redactSecrets(shaped, demo.REDACTED) : shaped
|
||||
}
|
||||
|
||||
/** The demo's refusal for the bridge's config, in one place. */
|
||||
function refuseBridge(res) {
|
||||
return demo.refuse(res, 'The bridge plugin’s configuration is not shown on the demo site.')
|
||||
}
|
||||
|
||||
/** One file: its text, and the reading of it the form is drawn from. */
|
||||
@@ -98,6 +124,10 @@ async function readFile(req, res) {
|
||||
return res.status(400).json({ message: 'That is not a configuration path' })
|
||||
}
|
||||
|
||||
// Before the host is asked, by the bridge's own name; and again below, by the
|
||||
// name its catalogue gives, in case the two ever differ.
|
||||
if (demo.isDemo() && demo.isBridgePath(path, null)) return refuseBridge(res)
|
||||
|
||||
const server = await serverOr404(req, res)
|
||||
if (!server) return undefined
|
||||
|
||||
@@ -116,7 +146,8 @@ async function readFile(req, res) {
|
||||
}
|
||||
|
||||
const self = catalogueReply.ok && catalogueReply.data ? catalogueReply.data.self : null
|
||||
return res.json(model.shapeFile(fileReply.data, { self }))
|
||||
if (demo.isDemo() && demo.isBridgePath(path, self)) return refuseBridge(res)
|
||||
return res.json(visible(model.shapeFile(fileReply.data, { self })))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,6 +166,16 @@ async function writeFile(req, res) {
|
||||
return res.status(400).json({ message: 'That is not a configuration path' })
|
||||
}
|
||||
|
||||
if (demo.isDemo()) {
|
||||
if (demo.isBridgePath(path, null)) return refuseBridge(res)
|
||||
// A raw document sent back from the demo holds the placeholders, and would
|
||||
// write them over the real values. The form splices only what changed.
|
||||
if (tier === 'raw') return demo.refuse(res, 'Raw JSON saves are turned off on the demo site. Use the form.')
|
||||
if (model.editsTouchSecret(req.body.edits)) {
|
||||
return demo.refuse(res, 'Credential fields are read-only on the demo site.')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await serverOr404(req, res)
|
||||
if (!server) return undefined
|
||||
|
||||
@@ -151,6 +192,7 @@ async function writeFile(req, res) {
|
||||
|
||||
const onDisk = current.data
|
||||
const self = catalogue.ok && catalogue.data ? catalogue.data.self : null
|
||||
if (demo.isDemo() && demo.isBridgePath(path, self)) return refuseBridge(res)
|
||||
const locked = model.lockedKeysFor(path, self)
|
||||
|
||||
// The browser's version against what is on the host right now. The plugin
|
||||
@@ -160,7 +202,7 @@ async function writeFile(req, res) {
|
||||
if (String(req.body.version || '') !== String(onDisk.version)) {
|
||||
return res.status(409).json({
|
||||
message: 'That file changed on the server since you opened it',
|
||||
current: model.shapeFile(onDisk, { self }),
|
||||
current: visible(model.shapeFile(onDisk, { self })),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -287,7 +329,9 @@ async function writeFile(req, res) {
|
||||
/** What has been written to this server's configuration lately, and by whom. */
|
||||
async function history(req, res) {
|
||||
try {
|
||||
return res.json({ writes: await db.recentWrites(req.params.serverId, req.query.limit) })
|
||||
const writes = await db.recentWrites(req.params.serverId, req.query.limit)
|
||||
// The bridge's own writes are left out on the demo, as its files are (§3.5).
|
||||
return res.json({ writes: demo.isDemo() ? writes.filter((w) => !isBridgeWrite(w)) : writes })
|
||||
} catch (err) {
|
||||
log.error('failed to read the configuration history', { error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to read the configuration history' })
|
||||
@@ -305,6 +349,7 @@ async function writeStatus(req, res) {
|
||||
try {
|
||||
const write = await db.getWrite(req.params.serverId, Number(req.params.writeId))
|
||||
if (!write) return res.status(404).json({ message: 'No such configuration write' })
|
||||
if (demo.isDemo() && isBridgeWrite(write)) return refuseBridge(res)
|
||||
|
||||
return res.json({ write })
|
||||
} catch (err) {
|
||||
@@ -313,6 +358,11 @@ async function writeStatus(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an audit row is a write to the bridge's own config. */
|
||||
function isBridgeWrite(write) {
|
||||
return demo.isBridgePlugin(write.plugin, null) || demo.isBridgePath(write.path, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* One audit row, plus the activity entry core owns. Never lets a logging failure
|
||||
* fail a save. Returns the row's id, or null when it could not be written.
|
||||
@@ -323,7 +373,7 @@ async function record(req, row) {
|
||||
try {
|
||||
id = await db.recordWrite({
|
||||
...row,
|
||||
plugin: model.isBridgeConfig(row.path, row.self) ? row.self : pluginOf(row.path),
|
||||
plugin: model.isBridgeConfig(row.path, row.self) ? row.self : model.pluginOf(row.path),
|
||||
reloadTarget: row.reload,
|
||||
userId: req.user ? req.user.id : null,
|
||||
})
|
||||
@@ -347,10 +397,6 @@ async function record(req, row) {
|
||||
return id
|
||||
}
|
||||
|
||||
function pluginOf(path) {
|
||||
return String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
|
||||
}
|
||||
|
||||
/** A `config.error` frame as a sentence. */
|
||||
function refusalMessage(frame) {
|
||||
const reasons = {
|
||||
|
||||
@@ -113,6 +113,11 @@ async function putServer(req, res) {
|
||||
})
|
||||
if (schedule) await db.setSchedule(id, schedule)
|
||||
|
||||
// D372: written only when the save carries one, like the schedule. The router
|
||||
// has already refused a word this build does not know.
|
||||
const creditRule = req.body.creditRule === undefined ? null : req.body.creditRule
|
||||
if (creditRule) await db.setCreditRule(id, creditRule)
|
||||
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.server.save',
|
||||
@@ -125,6 +130,8 @@ async function putServer(req, res) {
|
||||
// The schedule as written, when the save carried one. Nothing in it is a
|
||||
// secret, and "who moved the wipe" is a question players will ask.
|
||||
...(schedule ? { schedule: { ...schedule, wipeOnceAt: schedule.wipeOnceAt ? schedule.wipeOnceAt.toISOString() : null } } : {}),
|
||||
// The credit rule as written, when the save carried one (D372).
|
||||
...(creditRule ? { creditRule } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -340,6 +347,9 @@ async function integrations(req, res) {
|
||||
ok: Boolean(data),
|
||||
status: result.status,
|
||||
integrations: data && data.integrations ? data.integrations : null,
|
||||
// Protocol 14 (D363): whether the framework runs another plugin's console
|
||||
// command from code, which a Dangerous Treasures step needs.
|
||||
console: data && data.console ? data.console : null,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to read integrations', { server: id, error: err.message })
|
||||
|
||||
@@ -22,6 +22,7 @@ const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const admin = require('./rust.controller')
|
||||
const worldEvents = require('../../worldEvents')
|
||||
const { requireRole, validate } = core.middleware
|
||||
const { body, param } = core.validator
|
||||
|
||||
@@ -63,7 +64,7 @@ adminRustRouter.put(
|
||||
'/servers/:id',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Create or update a Rust server'
|
||||
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. A 400 carries one sentence per problem in `errors`.'
|
||||
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. `creditRule` (protocol 14, D372) is who a downed patrol helicopter, Chinook or Bradley credits, for the feed line and for the Rotorbane and Tankbreaker counts alike: `blow` (the killing blow, the default; for the patrol helicopter the crippling hit, else the last hit), `last`, `most` (the most damage) or `all` (everyone who hurt it, one each). It is written only when present, and applies from then on. A 400 carries one sentence per problem in `errors`.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerSave" } } } } */
|
||||
/* #swagger.responses[204] = { description: 'Saved' } */
|
||||
/* #swagger.responses[400] = { description: 'Invalid body' } */
|
||||
@@ -91,6 +92,8 @@ adminRustRouter.put(
|
||||
body('wipeTz').optional({ values: 'null' }).isString().isLength({ max: 64 }),
|
||||
body('wipeAnchor').optional({ values: 'falsy' }).isISO8601({ strict: true }).isLength({ max: 10 }),
|
||||
body('wipeOnceAt').optional({ values: 'falsy' }).isISO8601({ strict: true }),
|
||||
// Protocol 14 (D372): who a downed helicopter, Chinook or Bradley credits.
|
||||
body('creditRule').optional().isIn(worldEvents.CREDIT_RULE_IDS),
|
||||
validate,
|
||||
admin.putServer,
|
||||
)
|
||||
@@ -206,7 +209,7 @@ adminRustRouter.get(
|
||||
'/servers/:id/integrations',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Which optional mods a server has loaded'
|
||||
// #swagger.description = 'Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why.'
|
||||
// #swagger.description = 'Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why. `console` (protocol 14, D363) says whether the server’s framework runs another plugin’s console command from code, which a Start a Dangerous Treasures event step needs.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'What the server has loaded', content: { "application/json": { schema: { $ref: "#/components/schemas/RustIntegrations" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
// every statement here is scoped by it, so a panel opened on one user cannot
|
||||
// read or write another's rows by editing a path segment.
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const backpacks = require('../../model/backpacks/backpacks.model')
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
const links = require('../../model/links/links.model')
|
||||
const permissionsDb = require('../../model/permissions/permissions.db')
|
||||
const permissions = require('../../model/permissions/permissions.model')
|
||||
@@ -208,4 +212,90 @@ async function removeGrant(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant }
|
||||
/** GET /admin/users/:id/rust/economics — this user's balances (§11.2.2), as the player sees them. */
|
||||
async function listEconomics(req, res) {
|
||||
try {
|
||||
const held = await links.listForUser(Number(req.params.id))
|
||||
res.json({ servers: await economics.balancesOf(held.map((l) => ({ steamId: l.steamId, name: l.name }))) })
|
||||
} catch (err) {
|
||||
log.error('failed to read a user’s balances', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read this user’s balances' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/users/:id/rust/economics — staff deposit, withdraw or set coins.
|
||||
*
|
||||
* **Scoped by the user**, like every write in this panel: the Steam id must be
|
||||
* one this user has linked, so a wrong id on the form moves nobody's coins
|
||||
* rather than a stranger's. The key is the form's, one per submission; a form
|
||||
* that sent none gets one, and is then only as safe as a single click.
|
||||
*/
|
||||
async function adjustEconomics(req, res) {
|
||||
const userId = Number(req.params.id)
|
||||
const { serverId, steamId, op, amount } = req.body
|
||||
const key = req.body.key ? `adj-${req.body.key}` : `adj-${crypto.randomUUID()}`
|
||||
|
||||
try {
|
||||
const held = await links.listForUser(userId)
|
||||
const link = held.find((l) => String(l.steamId) === String(steamId))
|
||||
if (!link) return res.status(404).json({ message: 'That Steam account is not linked to this user' })
|
||||
|
||||
const result = await economics.adjust({ serverId, steamId: String(steamId), op, amount, key })
|
||||
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
|
||||
|
||||
// A repeat moved nothing, so it is not a second row in the log.
|
||||
if (!result.repeat) {
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.economics.adjust',
|
||||
detail: {
|
||||
userId,
|
||||
serverId,
|
||||
steamId: String(steamId),
|
||||
op,
|
||||
amount: result.amount,
|
||||
before: result.before,
|
||||
after: result.after,
|
||||
summary: economics.adjustmentSummary({
|
||||
op,
|
||||
amount: result.amount,
|
||||
name: link.name || String(steamId),
|
||||
server: result.server,
|
||||
before: result.before,
|
||||
after: result.after,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({ before: result.before, after: result.after, repeat: result.repeat })
|
||||
} catch (err) {
|
||||
log.error('failed to adjust a balance', { userId, serverId, op, error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to adjust that balance' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/users/:id/rust/backpacks — one of this user's backpacks
|
||||
* (§11.3.2), read live. Scoped by the user like every route in this panel: a
|
||||
* Steam id they have not linked is a 404, never a stranger's backpack.
|
||||
*/
|
||||
async function readBackpack(req, res) {
|
||||
const userId = Number(req.params.id)
|
||||
const { serverId, steamId } = req.query
|
||||
try {
|
||||
const held = await links.listForUser(userId)
|
||||
if (!held.some((l) => String(l.steamId) === String(steamId))) {
|
||||
return res.status(404).json({ message: 'That Steam account is not linked to this user' })
|
||||
}
|
||||
const result = await backpacks.read({ serverId, steamId })
|
||||
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
|
||||
return res.json(result.backpack)
|
||||
} catch (err) {
|
||||
log.error('failed to read a backpack', { userId, serverId, error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to read that backpack' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant, listEconomics, adjustEconomics, readBackpack }
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const { body, param } = core.validator
|
||||
const { body, param, query } = core.validator
|
||||
|
||||
const usersRust = require('./usersRust.controller')
|
||||
const { validate } = core.middleware
|
||||
@@ -128,4 +128,72 @@ usersRustRouter.delete(
|
||||
usersRust.removeGrant,
|
||||
)
|
||||
|
||||
// ── Economics (PLAN_REDESIGNS §11.2.2): a person's coins, and staff's hand ──
|
||||
//
|
||||
// Beside their links, because "how many coins does this player have" is asked
|
||||
// about a person. The write is the only way coins come back after an event
|
||||
// paid them (D335): staff withdraw them by hand, and each adjustment is an
|
||||
// activity row with the balance before and after.
|
||||
|
||||
usersRustRouter.get(
|
||||
'/rust/economics',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s coin balances (admin only)'
|
||||
// #swagger.description = 'This user’s Economics balance on every server, per linked Steam account — live where the server answers, else the last balance it reported. A server without Economics is listed with the reason. The same shape as GET /player/rust/economics.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'The balances', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsBalances" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersRust.listEconomics,
|
||||
)
|
||||
|
||||
usersRustRouter.post(
|
||||
'/rust/economics',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Deposit, withdraw or set a user’s coins on one server (admin only)'
|
||||
// #swagger.description = 'Through the server’s Economics, for one Steam account this user has linked. A withdrawal above the balance is refused with the balance, because Economics takes all or nothing. `key` makes a resent form harmless: the game answers a key it has seen from the first reply and moves nothing. Recorded in the activity log with the balance before and after.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjust" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Adjusted', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjusted" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A bad op or amount', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or that Steam account is not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Economics is not installed there, 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 }),
|
||||
body('steamId').isString().matches(STEAM_ID_RE),
|
||||
body('op').isIn(['deposit', 'withdraw', 'set']),
|
||||
body('amount').isFloat({ min: 0, max: 1000000 }).toFloat(),
|
||||
body('key').optional().isString().matches(/^[A-Za-z0-9-]{8,64}$/),
|
||||
validate,
|
||||
usersRust.adjustEconomics,
|
||||
)
|
||||
|
||||
// ── Backpacks (PLAN_REDESIGNS §11.3.2): what a person carries, read live ──
|
||||
//
|
||||
// Read only (R2): there is no write beside it, here or in the game. Each open
|
||||
// of the panel is one read; nothing is stored.
|
||||
|
||||
usersRustRouter.get(
|
||||
'/rust/backpacks',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s backpack on one server (admin only)'
|
||||
// #swagger.description = 'One of this user’s linked Steam accounts’ Backpacks backpack on one server, read live from the game and never stored. The same shape as GET /player/rust/backpacks. Staff see the overflow too; only the owner’s own open of the backpack moves it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
// #swagger.parameters['serverId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'The server.' }
|
||||
// #swagger.parameters['steamId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'One of this user’s linked Steam ids.' }
|
||||
/* #swagger.responses[200] = { description: 'The backpack', content: { "application/json": { schema: { $ref: "#/components/schemas/RustBackpack" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or that Steam account is not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Backpacks is not installed on that server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
query('serverId').isString().isLength({ min: 1, max: 64 }),
|
||||
query('steamId').isString().matches(STEAM_ID_RE),
|
||||
validate,
|
||||
usersRust.readBackpack,
|
||||
)
|
||||
|
||||
module.exports = usersRustRouter
|
||||
|
||||
@@ -27,7 +27,7 @@ visibilityRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Who may see who is online, and who may see a clan roster'
|
||||
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world` and `events` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.'
|
||||
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world`, `events`, `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`) 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' } */
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const backpacks = require('../../model/backpacks/backpacks.model')
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
const links = require('../../model/links/links.model')
|
||||
const npcs = require('../../model/npcs/npcs.model')
|
||||
const npcsDb = require('../../model/npcs/npcs.db')
|
||||
@@ -55,6 +57,42 @@ async function ownNpcKills(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /player/rust/economics — the caller's coin balances (§11.2.2): every
|
||||
* server, each linked account, live where the server answers.
|
||||
*/
|
||||
async function ownEconomics(req, res) {
|
||||
try {
|
||||
const held = await links.listForUser(req.user.id)
|
||||
res.json({ servers: await economics.balancesOf(held.map((l) => ({ steamId: l.steamId, name: l.name }))) })
|
||||
} catch (err) {
|
||||
log.error('failed to read a player’s balances', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read your balances' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /player/rust/backpacks — one of the caller's own backpacks (§11.3.2),
|
||||
* read live. Only an account the caller has linked: anybody else's is a 404,
|
||||
* the same answer as an id nobody holds, so the route says nothing about
|
||||
* whose ids exist.
|
||||
*/
|
||||
async function ownBackpack(req, res) {
|
||||
const { serverId, steamId } = req.query
|
||||
try {
|
||||
const held = await links.listForUser(req.user.id)
|
||||
if (!held.some((l) => String(l.steamId) === String(steamId))) {
|
||||
return res.status(404).json({ message: 'That Steam account is not linked to you' })
|
||||
}
|
||||
const result = await backpacks.read({ serverId, steamId })
|
||||
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
|
||||
return res.json(result.backpack)
|
||||
} catch (err) {
|
||||
log.error('failed to read a player’s backpack', { serverId, error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to read your backpack' })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /player/rust/links — the Steam accounts the caller holds. */
|
||||
async function listLinks(req, res) {
|
||||
try {
|
||||
@@ -187,4 +225,4 @@ async function listPermissions(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills }
|
||||
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills, ownEconomics, ownBackpack }
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const { body, param } = core.validator
|
||||
const { body, param, query } = core.validator
|
||||
|
||||
const rust = require('./rust.controller')
|
||||
const { validate, rateLimit } = core.middleware
|
||||
@@ -67,6 +67,47 @@ playerRustRouter.get(
|
||||
rust.ownNpcKills,
|
||||
)
|
||||
|
||||
/**
|
||||
* A backpack read runs on the game's main thread (55–117 ms the first time
|
||||
* after a boot, §11.3.1), so a page held open on refresh is not allowed to
|
||||
* become a load on the server. Generous for a person, tight for a loop.
|
||||
*/
|
||||
const backpackLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 20,
|
||||
label: 'rust-backpack-read',
|
||||
message: 'Too many backpack reads. Please wait a minute.',
|
||||
})
|
||||
|
||||
playerRustRouter.get(
|
||||
'/backpacks',
|
||||
// #swagger.tags = ['Player · Rust']
|
||||
// #swagger.summary = 'Your backpack on one server'
|
||||
// #swagger.description = 'One of the caller’s own linked Steam accounts’ Backpacks backpack on one server, read live from the game and never stored (PLAN_REDESIGNS §11.3.2). `exists: false` for an account that has never had one. Each stack has its slot, shortname, the game’s English name (`display`), amount, and skin, custom name, blueprint target and nested contents where set; `overflow` marks a stack past the capacity, which moves to the owner’s inventory the next time they open the backpack. 404 for a Steam account the caller has not linked; 409 where the server has no Backpacks. Requires a session; 20 reads a minute.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['serverId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'The server.' }
|
||||
// #swagger.parameters['steamId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'One of the caller’s linked Steam ids.' }
|
||||
/* #swagger.responses[200] = { description: 'The backpack', content: { "application/json": { schema: { $ref: "#/components/schemas/RustBackpack" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or not one of your linked Steam accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Backpacks is not installed on that server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
backpackLimiter,
|
||||
query('serverId').isString().isLength({ min: 1, max: 64 }),
|
||||
query('steamId').isString().matches(STEAM_ID_RE),
|
||||
validate,
|
||||
rust.ownBackpack,
|
||||
)
|
||||
|
||||
playerRustRouter.get(
|
||||
'/economics',
|
||||
// #swagger.tags = ['Player · Rust']
|
||||
// #swagger.summary = 'Your coin balances'
|
||||
// #swagger.description = 'The caller’s Economics balance on every server, per linked Steam account (PLAN_REDESIGNS §11.2.2). Read live from each server; where a server does not answer, the last balance it reported (`source: last-known`), and null where neither knows. A server without Economics is listed with `economics: false` and the reason. `startingKnown: false` means the server’s Economics config could not be read and the starting balance shown is Economics’ default. Empty `accounts` with no linked account. Requires a session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The balances', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsBalances" } } } } */
|
||||
rust.ownEconomics,
|
||||
)
|
||||
|
||||
playerRustRouter.get(
|
||||
'/servers',
|
||||
// #swagger.tags = ['Player · Rust']
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
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')
|
||||
const mapDb = require('../../model/map/map.db')
|
||||
@@ -83,6 +87,9 @@ async function getServer(req, res) {
|
||||
* feed carries only what names nobody — a wipe, a start, a shutdown — and says
|
||||
* so with `presenceHidden`, so a page can explain a quiet feed instead of
|
||||
* implying a quiet server.
|
||||
*
|
||||
* `staff` is the same re-read rung, and reaches only what a world event carries
|
||||
* (protocol 14, D353): a Steam id and a position. It never widens the kinds.
|
||||
*/
|
||||
async function listEvents(req, res) {
|
||||
try {
|
||||
@@ -92,6 +99,7 @@ async function listEvents(req, res) {
|
||||
events: await events.recent({
|
||||
serverId: req.params.id,
|
||||
presence: presence.visible,
|
||||
staff: presence.level === 'staff',
|
||||
kind: req.query.kind,
|
||||
wipeId: req.query.wipe || null,
|
||||
limit: req.query.limit,
|
||||
@@ -108,13 +116,29 @@ async function listEvents(req, res) {
|
||||
async function listLeaderboard(req, res) {
|
||||
try {
|
||||
const presence = await visibility.canSeePresence(req, req.params.id)
|
||||
const wipeId = req.query.wipe || null
|
||||
|
||||
// The richest column (§11.2.2): one wipe at a time, on a server that
|
||||
// reports Economics. `economics` is null otherwise, and the rows carry no
|
||||
// `balance`, so a page shows the column exactly when there is one.
|
||||
const econ = await economics.forServer(req.params.id)
|
||||
// Treasure Hunter's column (D375): on a server that reports Dangerous
|
||||
// Treasures, and on any page whose rows already count a treasure won.
|
||||
const dt = dangerousTreasures.reportOf(await serversDb.dtReportFor(req.params.id).catch(() => null))
|
||||
// Warden's column (D375), likewise for Guarded Crate.
|
||||
const gc = guardedCrate.reportOf(await serversDb.gcReportFor(req.params.id).catch(() => null))
|
||||
// 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([
|
||||
events.leaderboard({
|
||||
serverId: req.params.id,
|
||||
wipeId: req.query.wipe || null,
|
||||
wipeId,
|
||||
sort: req.query.sort,
|
||||
limit: req.query.limit,
|
||||
presence: presence.visible,
|
||||
economics: withBalance,
|
||||
}),
|
||||
titles.currentFor(req.params.id),
|
||||
])
|
||||
@@ -125,6 +149,10 @@ async function listLeaderboard(req, res) {
|
||||
// this page is showing, because a title is what a player is, not what a
|
||||
// past wipe was.
|
||||
leaderboard: rows.map((row) => ({ ...row, titles: held.get(row.steamId) || [] })),
|
||||
economics: withBalance,
|
||||
dangerousTreasures: Boolean((dt.dangerousTreasures && dt.dangerousTreasures.loaded) || rows.some((r) => r.treasuresWon > 0)),
|
||||
guardedCrate: Boolean((gc.guardedCrate && gc.guardedCrate.loaded) || rows.some((r) => r.guardedWon > 0)),
|
||||
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 })
|
||||
|
||||
@@ -68,7 +68,7 @@ rustRouter.get(
|
||||
'/servers/:id/events',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'Recent events on one Rust server'
|
||||
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld.'
|
||||
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
@@ -82,10 +82,10 @@ rustRouter.get(
|
||||
'/servers/:id/leaderboard',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'The leaderboard for one Rust server'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules.'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336). Each row carries `treasuresWon` (Dangerous Treasures chests emptied, Treasure Hunter’s count, PLAN_REDESIGNS §14.3.4), and the answer carries `dangerousTreasures: true` where the server reports Dangerous Treasures or a row already counts one, which is when a page shows that column. Likewise `guardedWon` (guarded crates cleared, Warden’s count, PLAN_REDESIGNS §14.3.5) and `guardedCrate: true` where the server reports Guarded Crate or a row already counts one. 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 or playtime', 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,
|
||||
@@ -191,7 +191,7 @@ rustRouter.get(
|
||||
'/servers/:id/map',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'One Rust server’s map'
|
||||
// #swagger.description = 'Where the picture of the current map is, the geometry to draw it with (world size, the picture’s size and ocean margin in pixels, and the game’s own grid), the monuments when the viewer may see the world layer — only the labels drawn on this server, so a hidden label such as Substation is not sent at all (D196) — and which of the four layers — `world`, `events`, `players`, `bases` — this viewer gets. A hidden layer says which audience can see it and never what it holds. The players layer can never be wider than who may see who is online (`cappedByPresence`). `mates` says whether this viewer gets their own position and their online clan mates’. `picture` is null when the game has no picture of its map; `geometry` is null when the server has never described its map.'
|
||||
// #swagger.description = 'Where the picture of the current map is, the geometry to draw it with (world size, the picture’s size and ocean margin in pixels, and the game’s own grid), the monuments when the viewer may see the world layer — only the labels drawn on this server, so a hidden label such as Substation is not sent at all (D196) — and which of the five layers — `world`, `events`, `players`, `bases`, `pvp` — this viewer gets. A hidden layer says which audience can see it and never what it holds. The players layer can never be wider than who may see who is online (`cappedByPresence`). `mates` says whether this viewer gets their own position and their online clan mates’. `picture` is null when the game has no picture of its map; `geometry` is null when the server has never described its map.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'The map, as this viewer may see it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMap" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
|
||||
@@ -216,7 +216,7 @@ rustRouter.get(
|
||||
'/servers/:id/map/live',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'What moves on one Rust server’s map'
|
||||
// #swagger.description = 'The positions this viewer may see, asked of the game while somebody is looking and held for five seconds, so any number of viewers cost one ask. **A layer the viewer may not see is absent from the answer**, not empty: `world` (world events and locked crates), `events` (what the site’s events placed), `players` (online players and sleepers, with names) and `bases` (tool cupboards and vending machines, positions only). `mates` is the viewer’s own position and their online first-party clan mates’, for a linked viewer when the server allows it. `live: false` with a `reason` when the game did not answer. Positions are never stored.'
|
||||
// #swagger.description = 'The positions this viewer may see, asked of the game while somebody is looking and held for five seconds, so any number of viewers cost one ask. **A layer the viewer may not see is absent from the answer**, not empty: `world` (world events and locked crates), `events` (what the site’s events placed), `players` (online players and sleepers, with names) and `bases` (tool cupboards and vending machines, positions only) and `pvp` (Dynamic PVP’s own PvP zones, where each is now; protocol 14). `mates` is the viewer’s own position and their online first-party clan mates’, for a linked viewer when the server allows it. `live: false` with a `reason` when the game did not answer. Positions are never stored.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'The positions this viewer may see', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMapLive" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
|
||||
|
||||
@@ -69,7 +69,10 @@ const TIMEOUT_MS = 12000
|
||||
* `/map/render` and `/map/live`, its picture and what moves on it (PLAN.md
|
||||
* §30); **12** adds `/titles`, the chat titles BetterChat shows, and gives a
|
||||
* permission sync a group's BetterChat style and a chat line a delivery and a
|
||||
* voice (PLAN.md §33). The bump lands here in the same change as the emitters,
|
||||
* voice (PLAN.md §33); **13** is the first player walk's fixes and redesigns and
|
||||
* RunicNPC (PLAN_FIXES.md, PLAN_REDESIGNS.md §1–§6); **14** adds `/kits/uses`
|
||||
* and a permission sync's `notices` — the step editor's kit warning and reward
|
||||
* message (PLAN_REDESIGNS.md §2, D332, D333). The bump lands here in the same change as the emitters,
|
||||
* because the sidecar refuses a client declaring a different version with a
|
||||
* `409`: a module left on 2 would stop being able to read the server board it
|
||||
* has been reading all along. A constant that lags the deployment is not a safe
|
||||
@@ -79,7 +82,7 @@ const TIMEOUT_MS = 12000
|
||||
* deployment into a `409` naming both numbers instead of a parse failure three
|
||||
* layers further in.
|
||||
*/
|
||||
const PROTOCOL_VERSION = 13
|
||||
const PROTOCOL_VERSION = 14
|
||||
|
||||
/** What a caller gets back. Shaped once so every call site reads the same. */
|
||||
function reply(ok, status, data = null) {
|
||||
@@ -425,6 +428,59 @@ const worldOwned = (server, { runId } = {}) =>
|
||||
const worldZone = (server, body) => request(server, '/world/zone', { method: 'POST', body })
|
||||
const worldPlace = (server, body) => request(server, '/world/place', { method: 'POST', body })
|
||||
|
||||
/**
|
||||
* Run one of the game's own world events for a run (protocol 14, §20.5). `data.kind`
|
||||
* is `world.called` (with `called`, and `repeat: true` for a key the plugin has
|
||||
* seen) or `world.error` with a reason. What it starts is kept: there is no revert.
|
||||
*/
|
||||
const worldCall = (server, body) => request(server, '/world/call', { method: 'POST', body })
|
||||
|
||||
/**
|
||||
* Open one Dangerous Treasures event for a run's step (protocol 14, §20.8):
|
||||
* `{ key, level }`. `data.kind` is `dt.opened` (with `x`, `z`, `level`, and
|
||||
* `repeat: true` for a key the plugin has seen) or `dt.error` with a reason,
|
||||
* `dt-not-ready` among them (D365). DT despawns its own chest: there is no revert.
|
||||
*/
|
||||
const dtStart = (server, body) => request(server, '/dt/start', { method: 'POST', body })
|
||||
|
||||
/**
|
||||
* Place one guarded crate for a run's step (protocol 14, §20.9): `{ key, event }`
|
||||
* and a location as a world call takes one (`monument` and offsets, or `x` and
|
||||
* `z`). `data.kind` is `gc.placed` (with `event`, `x`, `z`, and `repeat: true`
|
||||
* for a key the plugin has seen) or `gc.error` with a reason, `npcspawn-missing`
|
||||
* among them (D364). The crate is kept when the run ends (D373): there is no revert.
|
||||
*/
|
||||
const gcPlace = (server, body) => request(server, '/gc/place', { method: 'POST', body })
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@@ -448,6 +504,36 @@ const tallyClose = (server, body) => request(server, '/tally/close', { method: '
|
||||
/** The kits this server's Kits plugin has, and the plugin's reward bounds. `kits.error` when Kits is not loaded. */
|
||||
const kits = (server) => request(server, '/kits')
|
||||
|
||||
/**
|
||||
* Protocol 14 (PLAN_REDESIGNS.md §2.6). Of these players, how many have used `kit`
|
||||
* up and how many are on its cooldown: `kits.uses` with `{ kit, max, cooldown,
|
||||
* asked, exhausted, onCooldown }`, counts only. `kits.error` when Kits is not
|
||||
* loaded or has no such kit.
|
||||
*/
|
||||
const kitsUses = (server, kit, steamIds) =>
|
||||
request(server, '/kits/uses', { method: 'POST', body: { kit, steamIds } })
|
||||
|
||||
/**
|
||||
* Economics (protocol 14, PLAN_REDESIGNS.md §11.2.2). `economics.paid` lists each
|
||||
* player's balance before and after; a key already paid answers `repeat: true`
|
||||
* and pays nobody, so a retry is safe. `economics.error` has a reason.
|
||||
*/
|
||||
const economicsPay = (server, body) => request(server, '/economics/pay', { method: 'POST', body })
|
||||
|
||||
/** Each player's balance, and the server's starting balance when its config says. */
|
||||
const economicsBalances = (server, steamIds) =>
|
||||
request(server, '/economics/balances', { method: 'POST', body: { steamIds } })
|
||||
|
||||
/** Staff: `{ key, steamId, op, amount }`, op deposit · withdraw · set. `economics.adjusted` has before and after. */
|
||||
const economicsAdjust = (server, body) => request(server, '/economics/adjust', { method: 'POST', body })
|
||||
|
||||
/**
|
||||
* One player's backpack (protocol 14, PLAN_REDESIGNS.md §11.3.2), read live.
|
||||
* `backpacks.contents` has `exists`, and with a backpack its capacity, page
|
||||
* size and stacks; `backpacks.error` has a reason. Nothing is written.
|
||||
*/
|
||||
const backpacksRead = (server, steamId) => request(server, '/backpacks/read', { method: 'POST', body: { steamId } })
|
||||
|
||||
/**
|
||||
* Say one line in the server's chat. `chat.ok` carries `said: false` when the
|
||||
* key was said in the last ten minutes — a retry, answered and not repeated.
|
||||
@@ -531,11 +617,22 @@ module.exports = {
|
||||
worldOwned,
|
||||
worldZone,
|
||||
worldPlace,
|
||||
worldCall,
|
||||
dtStart,
|
||||
gcPlace,
|
||||
raidSpawn,
|
||||
raidCheck,
|
||||
commandsRun,
|
||||
worldRevert,
|
||||
tallyOpen,
|
||||
tallySnapshot,
|
||||
tallyClose,
|
||||
kits,
|
||||
kitsUses,
|
||||
economicsPay,
|
||||
economicsBalances,
|
||||
economicsAdjust,
|
||||
backpacksRead,
|
||||
chat,
|
||||
titles,
|
||||
mapInfo,
|
||||
|
||||
@@ -109,6 +109,12 @@ module.exports = {
|
||||
wipeTz: { type: 'string', nullable: true, example: 'America/Chicago' },
|
||||
wipeAnchor: { type: 'string', format: 'date', nullable: true, example: null },
|
||||
wipeOnceAt: { type: 'string', format: 'date-time', nullable: true, example: null },
|
||||
creditRule: {
|
||||
type: 'string',
|
||||
enum: ['blow', 'last', 'most', 'all'],
|
||||
description: 'Who a downed patrol helicopter, Chinook or Bradley credits (D372): the killing blow, the last hit, the most damage, or everyone who hurt it. Written only when present.',
|
||||
example: 'blow',
|
||||
},
|
||||
},
|
||||
},
|
||||
RustAdminServerList: {
|
||||
@@ -139,6 +145,7 @@ module.exports = {
|
||||
},
|
||||
bootId: { type: 'string', nullable: true, example: 'boot-20260915T194502Z' },
|
||||
sidecarProtocol: { type: 'integer', nullable: true, example: 1 },
|
||||
creditRule: { type: 'string', enum: ['blow', 'last', 'most', 'all'], description: 'Who a downed patrol helicopter, Chinook or Bradley credits on this server (D372).', example: 'blow' },
|
||||
zoneHelper: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
@@ -244,8 +251,97 @@ module.exports = {
|
||||
type: 'object',
|
||||
properties: { loaded: { type: 'boolean', example: false } },
|
||||
},
|
||||
backpacks: {
|
||||
type: 'object',
|
||||
description: 'Protocol 14 (PLAN_REDESIGNS §11.3.2): `sizes` is every registered backpacks.size.N, ascending; a Bigger backpack step may lease any above the first.',
|
||||
properties: {
|
||||
loaded: { type: 'boolean', example: true },
|
||||
version: { type: 'string', example: '3.17.8' },
|
||||
sizes: { type: 'array', items: { type: 'integer' }, example: [6, 12, 18, 24, 30, 36, 42, 48, 96, 144] },
|
||||
},
|
||||
},
|
||||
truePve: {
|
||||
type: 'object',
|
||||
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.2): where loaded, a zone step may turn off PvP protection or name a TruePVE ruleset.',
|
||||
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '2.4.6' } },
|
||||
},
|
||||
dynamicPvp: {
|
||||
type: 'object',
|
||||
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.3): where loaded, its PvP zones reach the feed and the map’s pvp layer.',
|
||||
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '5.2.0' } },
|
||||
},
|
||||
dangerousTreasures: {
|
||||
type: 'object',
|
||||
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.4): where loaded, its treasure events reach the feed, the map and Treasure Hunter, and a step may start one (where `console.plugins` is true).',
|
||||
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '3.0.0' } },
|
||||
},
|
||||
guardedCrate: {
|
||||
type: 'object',
|
||||
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.5): where loaded, its guarded crates reach the feed, the map and Warden, and a step may place one (where NpcSpawn is loaded and `console.plugins` is true). `events` are the event names in its data file, absent when the file could not be read.',
|
||||
properties: {
|
||||
loaded: { type: 'boolean', example: true },
|
||||
version: { type: 'string', example: '2.0.17' },
|
||||
npcSpawn: {
|
||||
type: 'object',
|
||||
description: 'NpcSpawn, which its guards need (D364).',
|
||||
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '3.4.8' } },
|
||||
},
|
||||
events: { type: 'array', items: { type: 'string' }, example: ['Easy', 'Medium', 'Hard', 'Elite'] },
|
||||
},
|
||||
},
|
||||
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: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'Protocol 14 (D363): what the plugin’s console probe found at its load. Null from an older plugin, or when the game could not be asked.',
|
||||
properties: { plugins: { type: 'boolean', description: 'Whether another plugin’s console command runs from code here.', example: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
RustVoice: {
|
||||
@@ -562,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 },
|
||||
@@ -750,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'] },
|
||||
layers: { type: 'array', items: { type: 'string' }, example: ['world', 'events', 'players', 'bases', 'pvp', 'raids'] },
|
||||
minorLabels: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
@@ -764,6 +865,8 @@ module.exports = {
|
||||
events: { $ref: '#/components/schemas/RustAudience' },
|
||||
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 } },
|
||||
},
|
||||
@@ -901,6 +1004,8 @@ module.exports = {
|
||||
events: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
players: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
bases: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
pvp: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
raids: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
},
|
||||
},
|
||||
mates: {
|
||||
@@ -983,6 +1088,38 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
basesTruncated: { type: 'boolean', description: 'More than the plugin’s MapMaxBases.' },
|
||||
pvp: {
|
||||
type: 'array',
|
||||
description: 'Protocol 14 (§14.3.3): Dynamic PVP’s own PvP zones, where each is now (cargo’s moves with the ship). A cube zone’s radius is half its longer side.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
zoneId: { type: 'string', example: '1412203045' },
|
||||
event: { type: 'string', description: 'Dynamic PVP’s event name: one of its own (Helicopter, Bradley, CargoShip …), a monument’s, or an operator’s.', example: 'Helicopter' },
|
||||
radius: { type: 'number', example: 100 },
|
||||
shape: { type: 'string', enum: ['sphere', 'cube'], example: 'sphere' },
|
||||
x: { type: 'number' },
|
||||
z: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
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.',
|
||||
@@ -1286,6 +1423,80 @@ module.exports = {
|
||||
kills: { type: 'array', items: { type: 'object' }, example: [{ serverId: 'main', profile: 'warden', label: 'Warden', kills: 3 }] },
|
||||
},
|
||||
},
|
||||
RustEconomicsBalances: {
|
||||
type: 'object',
|
||||
description: 'Coin balances per server and linked Steam account (PLAN_REDESIGNS §11.2.2). `source` is `live`, `last-known` or null.',
|
||||
properties: {
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
serverId: { type: 'string', example: 'main' },
|
||||
server: { type: 'string', example: 'Main' },
|
||||
economics: { type: 'boolean', example: true },
|
||||
reason: { type: 'string', example: 'Economics is not installed on this server' },
|
||||
startingBalance: { type: 'number', example: 1000 },
|
||||
startingKnown: { type: 'boolean', example: true },
|
||||
live: { type: 'boolean', example: true },
|
||||
accounts: {
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
example: [{ steamId: '76561198000000001', name: 'Wanderer', balance: 1500, source: 'live' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
RustEconomicsAdjust: {
|
||||
type: 'object',
|
||||
required: ['serverId', 'steamId', 'op', 'amount'],
|
||||
properties: {
|
||||
serverId: { type: 'string', example: 'main' },
|
||||
steamId: { type: 'string', example: '76561198000000001' },
|
||||
op: { type: 'string', enum: ['deposit', 'withdraw', 'set'], example: 'deposit' },
|
||||
amount: { type: 'number', example: 500, description: '0.01 to 1,000,000; 0 is allowed for set.' },
|
||||
key: { type: 'string', example: '3f2a9c1e-7b4d-4e8a-9f10-2c6d5e8b7a41', description: 'One per submission, so a resend moves nothing.' },
|
||||
},
|
||||
},
|
||||
RustEconomicsAdjusted: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
before: { type: 'number', example: 1000 },
|
||||
after: { type: 'number', example: 1500 },
|
||||
repeat: { type: 'boolean', example: false },
|
||||
},
|
||||
},
|
||||
RustBackpackStack: {
|
||||
type: 'object',
|
||||
description: 'One stack in a backpack (PLAN_REDESIGNS §11.3.2). `display` is the game’s English name (D340); for a blueprint it is the name of what it makes, and `blueprint` that item’s shortname (D341). `shortname` is null, with `itemId`, for an item the game does not know.',
|
||||
properties: {
|
||||
slot: { type: 'integer', example: 2, description: 'Runs across pages: the first slot of page two is pageSize.' },
|
||||
shortname: { type: 'string', nullable: true, example: 'rifle.ak' },
|
||||
display: { type: 'string', nullable: true, example: 'Assault Rifle' },
|
||||
amount: { type: 'integer', example: 1 },
|
||||
itemId: { type: 'integer', example: 1545779598 },
|
||||
blueprint: { type: 'string', example: 'rifle.ak' },
|
||||
skin: { type: 'string', example: '10129', description: 'A workshop skin id, as text.' },
|
||||
name: { type: 'string', example: 'spike-a', description: 'A renamed item’s name.' },
|
||||
overflow: { type: 'boolean', example: true, description: 'Past the capacity: it moves to the owner’s inventory, or the ground, at their next open (D338).' },
|
||||
contents: { type: 'array', description: 'A weapon’s attachments or a container’s items, one level deep, without `contents` or `overflow` of their own.', items: { type: 'object' } },
|
||||
},
|
||||
},
|
||||
RustBackpack: {
|
||||
type: 'object',
|
||||
description: 'One Steam account’s backpack on one server, read live and never stored. `capacity`, `pageSize` and `items` are present only when `exists`.',
|
||||
properties: {
|
||||
serverId: { type: 'string', example: 'main' },
|
||||
server: { type: 'string', example: 'Main' },
|
||||
steamId: { type: 'string', example: '76561198000000001' },
|
||||
exists: { type: 'boolean', example: true, description: 'False for an account that has never had a backpack.' },
|
||||
capacity: { type: 'integer', example: 12 },
|
||||
pageSize: { type: 'integer', example: 48 },
|
||||
items: { type: 'array', items: { $ref: '#/components/schemas/RustBackpackStack' } },
|
||||
},
|
||||
},
|
||||
RustVisibilityUpdate: {
|
||||
type: 'object',
|
||||
description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',
|
||||
|
||||
@@ -104,6 +104,8 @@ function fakeCtx(overrides = {}) {
|
||||
accountChangeLimiter: (req, res, next) => next(),
|
||||
},
|
||||
site: { baseUrl: 'http://localhost:5173' },
|
||||
// MODULE_API 1.18.0: false unless a suite says it is the public demo.
|
||||
demoMode: false,
|
||||
...rest,
|
||||
}
|
||||
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
||||
@@ -160,10 +162,17 @@ function fakeApi() {
|
||||
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
||||
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
||||
// MODULE_API 1.13.0 (PLAN_REDESIGNS.md §2.5).
|
||||
registerEventTemplates(templates) { once('registerEventTemplates'); record.eventTemplates = templates },
|
||||
// Core's `once()` holds here too: a module's commands are one batch.
|
||||
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
// MODULE_API 1.17.0 (Steam sign-in, PLAN_REDESIGNS §15).
|
||||
onIdentityLinking(fn) { once('onIdentityLinking'); record.hooks.onIdentityLinking = fn },
|
||||
onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn },
|
||||
// MODULE_API 1.18.0 (the public demo's seed, DM15).
|
||||
onDemoSeed(fn) { once('onDemoSeed'); record.hooks.onDemoSeed = fn },
|
||||
}
|
||||
api.record = record
|
||||
return api
|
||||
|
||||
334
server/test/backpacks.test.js
Normal file
334
server/test/backpacks.test.js
Normal file
@@ -0,0 +1,334 @@
|
||||
// ── Backpacks (PLAN_REDESIGNS §11.3.2, protocol 14, D337, D338, D340, D341) ─
|
||||
//
|
||||
// Each test is one way the feature could look right and be wrong:
|
||||
//
|
||||
// the sizes a step may lease skip the smallest, which everybody holds
|
||||
// a server without Backpacks is refused with the reason, in the dry run too (D199)
|
||||
// a size the server does not offer is refused by name
|
||||
// the lease is a kit reward's row: the size's permission, no kit, no credit (D337)
|
||||
// an empty message is the take-back warning, never silence (D338)
|
||||
// the revert deletes the step's rows and marks the server for a push
|
||||
// a read answers what the game said, live; a stranger's id never reaches it
|
||||
// a stack keeps its name, blueprint, skin as text, and one level of contents
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
function withRecorder(rows = []) {
|
||||
const statements = []
|
||||
const ctx = fakeCtx({
|
||||
db: {
|
||||
query: (sql, params = []) => {
|
||||
statements.push({ sql, params })
|
||||
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
})
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
return { statements }
|
||||
}
|
||||
|
||||
withRecorder()
|
||||
|
||||
// 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')
|
||||
const linksDb = require('../model/links/links.db')
|
||||
const permDb = require('../model/permissions/permissions.db')
|
||||
const backpacksDb = require('../model/backpacks/backpacks.db')
|
||||
const backpacks = require('../model/backpacks/backpacks.model')
|
||||
const rewards = require('../eventRewards')
|
||||
|
||||
const ok = (data) => ({ ok: true, status: 'ok', data })
|
||||
|
||||
const A = '76561198000000001'
|
||||
const B = '76561198000000002'
|
||||
const C = '76561198000000003'
|
||||
|
||||
const SIZES = [6, 12, 18, 24, 30, 36, 42, 48, 96, 144]
|
||||
|
||||
// ── What a server reports ──────────────────────────────────────────────────
|
||||
|
||||
test('a server’s sizes are whole, ascending and unique; the smallest is never leased', () => {
|
||||
assert.deepStrictEqual(backpacks.shape(null), { loaded: false, sizes: [] })
|
||||
assert.deepStrictEqual(backpacks.shape(JSON.stringify({ loaded: false })), { loaded: false, sizes: [] })
|
||||
|
||||
const report = backpacks.shape(JSON.stringify({ loaded: true, version: '3.17.8', sizes: [48, 6, 12, 12, 'x', -3, 2.5] }))
|
||||
assert.deepStrictEqual(report, { loaded: true, version: '3.17.8', sizes: [6, 12, 48] })
|
||||
assert.deepStrictEqual(backpacks.leasable(report), [12, 48])
|
||||
assert.deepStrictEqual(backpacks.leasable({ loaded: false, sizes: [] }), [])
|
||||
})
|
||||
|
||||
// ── The step ───────────────────────────────────────────────────────────────
|
||||
|
||||
const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
|
||||
const lend = rewards.ACTIONS.find((a) => a.id === 'rust.backpack.size')
|
||||
|
||||
function stub(t, { loaded = true, sizes = SIZES, people = [], links = {}, existing = [] } = {}) {
|
||||
const calls = { inserted: [], dirty: [], deleted: [] }
|
||||
const saved = {
|
||||
getServer: serversDb.getServer,
|
||||
reportFor: backpacksDb.reportFor,
|
||||
tallySnapshot: client.tallySnapshot,
|
||||
userIdsForSteamIds: linksDb.userIdsForSteamIds,
|
||||
listRunGrantsForStep: permDb.listRunGrantsForStep,
|
||||
insertRunGrants: permDb.insertRunGrants,
|
||||
markDirty: permDb.markDirty,
|
||||
deleteRunGrantsForStep: permDb.deleteRunGrantsForStep,
|
||||
}
|
||||
|
||||
serversDb.getServer = async (id) => (id === 'main' ? ROW : null)
|
||||
backpacksDb.reportFor = async () => ({ serverId: 'main', backpacks: loaded ? JSON.stringify({ loaded: true, version: '3.17.8', sizes }) : null })
|
||||
client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 })
|
||||
linksDb.userIdsForSteamIds = async (ids) => ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
|
||||
permDb.listRunGrantsForStep = async () => existing
|
||||
permDb.insertRunGrants = async (rows) => {
|
||||
calls.inserted.push(...rows)
|
||||
return rows.length
|
||||
}
|
||||
permDb.markDirty = async (serverId) => calls.dirty.push(serverId)
|
||||
permDb.deleteRunGrantsForStep = async (runId, stepId) => {
|
||||
calls.deleted.push(`${runId}:${stepId}`)
|
||||
return ['main']
|
||||
}
|
||||
|
||||
t.after(() => Object.assign(serversDb, { getServer: saved.getServer }))
|
||||
t.after(() => Object.assign(backpacksDb, { reportFor: saved.reportFor }))
|
||||
t.after(() => Object.assign(client, { tallySnapshot: saved.tallySnapshot }))
|
||||
t.after(() => Object.assign(linksDb, { userIdsForSteamIds: saved.userIdsForSteamIds }))
|
||||
t.after(() =>
|
||||
Object.assign(permDb, {
|
||||
listRunGrantsForStep: saved.listRunGrantsForStep,
|
||||
insertRunGrants: saved.insertRunGrants,
|
||||
markDirty: saved.markDirty,
|
||||
deleteRunGrantsForStep: saved.deleteRunGrantsForStep,
|
||||
}),
|
||||
)
|
||||
return calls
|
||||
}
|
||||
|
||||
const PARAMS = { server: 'main', size: 48, recipients: 'everyone' }
|
||||
const person = (steamId, score) => ({ steamId, name: `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 })
|
||||
|
||||
test('a bigger backpack is a ledgered change, priced per recipient on its own budget', () => {
|
||||
assert.strictEqual(lend.risk, 'change')
|
||||
assert.strictEqual(lend.reversible, 'ledger')
|
||||
assert.deepStrictEqual(lend.cost({ recipients: 'top', count: 3 }), { 'rust.backpacks': 3 })
|
||||
assert.deepStrictEqual(lend.cost({ recipients: 'linked' }), { 'rust.backpacks': rewards.MAX_RECIPIENTS })
|
||||
})
|
||||
|
||||
test('a server without Backpacks is refused in the dry run, with the reason (D199)', async (t) => {
|
||||
stub(t, { loaded: false })
|
||||
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: true })
|
||||
assert.strictEqual(result.ok, false)
|
||||
assert.strictEqual(result.retry, false)
|
||||
assert.match(result.error, /Backpacks is not installed on this server \(Main\)/)
|
||||
})
|
||||
|
||||
test('a size the server does not offer, or its smallest, is refused by name', async (t) => {
|
||||
stub(t, { sizes: [6, 12, 48] })
|
||||
for (const size of [6, 96, 0, 'big']) {
|
||||
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, size }, verify: true })
|
||||
assert.strictEqual(result.ok, false, String(size))
|
||||
assert.strictEqual(result.retry, false)
|
||||
}
|
||||
const named = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, size: 96 }, verify: true })
|
||||
assert.match(named.error, /offers 12, 48 slots/)
|
||||
|
||||
const ok48 = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: PARAMS, verify: true })
|
||||
assert.deepStrictEqual(ok48, { ok: true })
|
||||
})
|
||||
|
||||
test('the lease is one row per website user: the size’s permission, no kit, no credit (D337)', async (t) => {
|
||||
const calls = stub(t, {
|
||||
// A and B are one person (user 1); C has linked nothing.
|
||||
people: [person(A, 9), person(B, 5), person(C, 3)],
|
||||
links: { [A]: 1, [B]: 1 },
|
||||
})
|
||||
|
||||
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
|
||||
|
||||
assert.strictEqual(result.ok, true)
|
||||
assert.strictEqual(calls.inserted.length, 1)
|
||||
const [row] = calls.inserted
|
||||
assert.strictEqual(row.permission, 'backpacks.size.48')
|
||||
assert.strictEqual(row.kit, '')
|
||||
assert.strictEqual(row.credit, false)
|
||||
assert.strictEqual(row.steamId, A)
|
||||
assert.strictEqual(row.userId, 1)
|
||||
assert.deepStrictEqual(calls.dirty, ['main'])
|
||||
assert.deepStrictEqual(result.resources, [{ kind: 'backpack-size', ref: 'main:7:4', payload: { serverId: 'main', permission: 'backpacks.size.48' } }])
|
||||
assert.strictEqual(result.detail.lent, 1)
|
||||
assert.deepStrictEqual(result.detail.missed, ['p3'])
|
||||
})
|
||||
|
||||
test('an empty message is the take-back warning, with the size in it (D338)', async (t) => {
|
||||
const calls = stub(t, { people: [person(A, 1)], links: { [A]: 1 } })
|
||||
await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: { ...PARAMS, message: ' ' }, verify: false })
|
||||
assert.match(calls.inserted[0].notice, /^Your backpack holds 48 until the event ends\./)
|
||||
assert.strictEqual(calls.inserted[0].noticeDelivery, 'chat')
|
||||
|
||||
assert.strictEqual(rewards.backpackNotice({}, 48).text, rewards.BACKPACK_NOTICE)
|
||||
assert.strictEqual(rewards.backpackNotice({ message: 'Big bag: {size}', delivery: 'popup' }, 96).delivery, 'popup')
|
||||
assert.strictEqual(rewards.backpackNotice({ message: 'x', delivery: 'carrier pigeon' }, 96).ok, false)
|
||||
assert.strictEqual(rewards.backpackNotice({ message: `${'x'.repeat(254)}{size}` }, 144).ok, false)
|
||||
})
|
||||
|
||||
test('a repeated key finds its rows and lends nothing new', async (t) => {
|
||||
const calls = stub(t, { existing: [{ userId: 1 }, { userId: 2 }], people: [person(A, 1)], links: { [A]: 1 } })
|
||||
const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
|
||||
assert.strictEqual(result.detail.repeat, true)
|
||||
assert.strictEqual(result.detail.lent, 2)
|
||||
assert.strictEqual(calls.inserted.length, 0)
|
||||
})
|
||||
|
||||
test('the revert deletes the step’s rows, and the next sync takes the size back', async (t) => {
|
||||
const calls = stub(t)
|
||||
const result = await lend.revert({ runId: 7, resources: [{ kind: 'backpack-size', ref: 'main:7:4' }], idempotencyKey: 'k-1' })
|
||||
assert.deepStrictEqual(result, { ok: true })
|
||||
assert.deepStrictEqual(calls.deleted, ['7:4'])
|
||||
assert.deepStrictEqual(calls.dirty, ['main'])
|
||||
})
|
||||
|
||||
test('the size list offers each 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' }, { id: 'off', name: 'Off' }]
|
||||
backpacksDb.reports = async () => [
|
||||
{ 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: 24, label: '24 slots — PvE' },
|
||||
{ value: 48, label: '48 slots — Main, PvE' },
|
||||
{ value: 96, label: '96 slots — Main' },
|
||||
])
|
||||
})
|
||||
|
||||
// ── Reading one ────────────────────────────────────────────────────────────
|
||||
|
||||
function stubRead(t, answer) {
|
||||
const sent = []
|
||||
const saved = { getForCalling: servers.getForCalling, backpacksRead: client.backpacksRead }
|
||||
t.after(() => Object.assign(servers, { getForCalling: saved.getForCalling }) && Object.assign(client, { backpacksRead: saved.backpacksRead }))
|
||||
servers.getForCalling = async (id) => (id === 'main' ? { id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' } : null)
|
||||
client.backpacksRead = async (server, steamId) => {
|
||||
sent.push(steamId)
|
||||
return typeof answer === 'function' ? answer(steamId) : answer
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
test('a backpack is read live and reduced to what the grid draws', async (t) => {
|
||||
const sent = stubRead(
|
||||
t,
|
||||
ok({
|
||||
kind: 'backpacks.contents',
|
||||
steamId: A,
|
||||
exists: true,
|
||||
capacity: 12,
|
||||
pageSize: 48,
|
||||
items: [
|
||||
{ slot: 0, shortname: 'wood', display: 'Wood', amount: 1000 },
|
||||
{ slot: 2, shortname: 'rifle.ak', display: 'Assault Rifle', amount: 1, contents: [{ slot: 0, shortname: 'weapon.mod.lasersight', display: 'Weapon Lasersight', amount: 1, contents: [{ slot: 0 }] }] },
|
||||
{ slot: 3, shortname: 'hoodie', display: 'Hoodie', amount: 1, skin: 10129 },
|
||||
{ slot: 5, shortname: 'blueprintbase', display: 'Assault Rifle', blueprint: 'rifle.ak', amount: 1 },
|
||||
{ slot: 14, shortname: 'note', display: 'Note', amount: 1, name: 'spike-a', overflow: true },
|
||||
{ slot: 15, shortname: null, itemId: 999, amount: 1, overflow: true },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await backpacks.read({ serverId: 'main', steamId: A })
|
||||
assert.deepStrictEqual(sent, [A])
|
||||
assert.strictEqual(result.ok, true)
|
||||
const bp = result.backpack
|
||||
assert.strictEqual(bp.server, 'Main')
|
||||
assert.strictEqual(bp.capacity, 12)
|
||||
assert.strictEqual(bp.pageSize, 48)
|
||||
assert.strictEqual(bp.items.length, 6)
|
||||
assert.strictEqual(bp.items[1].contents.length, 1)
|
||||
assert.strictEqual(bp.items[1].contents[0].contents, undefined, 'one level of contents, no deeper')
|
||||
assert.strictEqual(bp.items[2].skin, '10129', 'a skin id is text')
|
||||
assert.strictEqual(bp.items[3].blueprint, 'rifle.ak')
|
||||
assert.strictEqual(bp.items[4].name, 'spike-a')
|
||||
assert.strictEqual(bp.items[4].overflow, true)
|
||||
assert.strictEqual(bp.items[5].itemId, 999)
|
||||
})
|
||||
|
||||
test('a player who never had a backpack is exists: false, with no capacity', async (t) => {
|
||||
stubRead(t, ok({ kind: 'backpacks.contents', steamId: A, exists: false }))
|
||||
const result = await backpacks.read({ serverId: 'main', steamId: A })
|
||||
assert.deepStrictEqual(result.backpack, { serverId: 'main', server: 'Main', steamId: A, exists: false })
|
||||
})
|
||||
|
||||
test('a read says why it has nothing: no server, no Backpacks, or no answer', async (t) => {
|
||||
stubRead(t, ok({ kind: 'backpacks.error', reason: 'backpacks-missing', message: 'Backpacks is not installed on this server' }))
|
||||
const missing = await backpacks.read({ serverId: 'main', steamId: A })
|
||||
assert.strictEqual(missing.status, 409)
|
||||
assert.strictEqual(missing.message, 'Backpacks is not installed on this server (Main)')
|
||||
|
||||
assert.strictEqual((await backpacks.read({ serverId: 'nope', steamId: A })).status, 404)
|
||||
|
||||
client.backpacksRead = async () => ({ ok: false, status: 'http-503' })
|
||||
const down = await backpacks.read({ serverId: 'main', steamId: A })
|
||||
assert.strictEqual(down.status, 503)
|
||||
assert.match(down.message, /Main has no game connected/)
|
||||
})
|
||||
|
||||
// ── The routes ─────────────────────────────────────────────────────────────
|
||||
|
||||
function res() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
this.statusCode = code
|
||||
return this
|
||||
},
|
||||
json(body) {
|
||||
this.body = body
|
||||
return this
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('a backpack is read only for an account its user has linked', async (t) => {
|
||||
const links = require('../model/links/links.model')
|
||||
const saved = { listForUser: links.listForUser }
|
||||
t.after(() => Object.assign(links, saved))
|
||||
links.listForUser = async (userId) => (userId === 1 ? [{ steamId: A, name: 'pa' }] : [])
|
||||
const sent = stubRead(t, ok({ kind: 'backpacks.contents', steamId: A, exists: false }))
|
||||
|
||||
const player = require('../router/player/rust.controller')
|
||||
const staff = require('../router/admin/usersRust.controller')
|
||||
|
||||
const own = res()
|
||||
await player.ownBackpack({ user: { id: 1 }, query: { serverId: 'main', steamId: A } }, own)
|
||||
assert.strictEqual(own.statusCode, 200)
|
||||
assert.strictEqual(own.body.exists, false)
|
||||
|
||||
const stranger = res()
|
||||
await player.ownBackpack({ user: { id: 1 }, query: { serverId: 'main', steamId: B } }, stranger)
|
||||
assert.strictEqual(stranger.statusCode, 404)
|
||||
|
||||
const wrongUser = res()
|
||||
await staff.readBackpack({ params: { id: '2' }, query: { serverId: 'main', steamId: A } }, wrongUser)
|
||||
assert.strictEqual(wrongUser.statusCode, 404)
|
||||
|
||||
const viaStaff = res()
|
||||
await staff.readBackpack({ params: { id: '1' }, query: { serverId: 'main', steamId: A } }, viaStaff)
|
||||
assert.strictEqual(viaStaff.statusCode, 200)
|
||||
|
||||
assert.deepStrictEqual(sent, [A, A], 'a stranger’s id never reaches the game')
|
||||
})
|
||||
@@ -95,7 +95,7 @@ test('every kind is classified exactly once', () => {
|
||||
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
|
||||
})
|
||||
|
||||
test('the classification covers exactly the event kinds the protocol defines, through protocol 13', () => {
|
||||
test('the classification covers exactly the event kinds the protocol defines, through protocol 14', () => {
|
||||
// The spec lives in another repository, so the list is restated here rather
|
||||
// than parsed — and restating it is the point: adding a kind to the protocol
|
||||
// without deciding who may see it has to fail somewhere, and this is where.
|
||||
@@ -150,6 +150,33 @@ test('the classification covers exactly the event kinds the protocol defines, th
|
||||
'npc.boss.spawned',
|
||||
'npc.boss.phase',
|
||||
'npc.boss.died',
|
||||
// Protocol 14 (§20.5): Rust's own world events, one kind told apart by
|
||||
// `event`. Public as a kind; `worldEvents.js` filters per type and per viewer.
|
||||
'world.event',
|
||||
// Protocol 14 (§20.7): Dynamic PVP's zones. The opening is public and names
|
||||
// nobody; the closing is a staff line (PLAN_REDESIGNS §14.3.3).
|
||||
'pvp.zone.created',
|
||||
'pvp.zone.deleted',
|
||||
// Protocol 14 (§20.8): Dangerous Treasures. The opening and the win are
|
||||
// public, read per viewer by `dangerousTreasures.js`; the ending is staff's
|
||||
// (PLAN_REDESIGNS §14.3.4).
|
||||
'dt.started',
|
||||
'dt.won',
|
||||
'dt.ended',
|
||||
// Protocol 14 (§20.9): Guarded Crate. The landing and the clearing are
|
||||
// public, read per viewer by `guardedCrate.js`; the timeout is staff's
|
||||
// (PLAN_REDESIGNS §14.3.5).
|
||||
'gc.started',
|
||||
'gc.ended',
|
||||
'gc.failed',
|
||||
// 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())
|
||||
@@ -165,6 +192,25 @@ test('the classification covers exactly the event kinds the protocol defines, th
|
||||
for (const kind of ['npc.boss.spawned', 'npc.boss.died']) {
|
||||
assert.equal(catalogue.isPublic(kind), true, `${kind} is said to the whole server, so it is public (D286)`)
|
||||
}
|
||||
// D353, D376: the world event is public and names nobody below the presence
|
||||
// audience — the names come off per viewer, so the kind is not a presence kind.
|
||||
assert.equal(catalogue.isPublic('world.event'), true)
|
||||
assert.equal(catalogue.isPresence('world.event'), false)
|
||||
assert.equal(catalogue.isPublic('pvp.zone.created'), true)
|
||||
assert.equal(catalogue.isPresence('pvp.zone.created'), false)
|
||||
assert.equal(catalogue.isPublic('pvp.zone.deleted'), false, 'a zone closing is a staff line')
|
||||
// D376 for Dangerous Treasures: the win names its winner only to the presence
|
||||
// audience, per viewer, so neither public kind is a presence kind.
|
||||
for (const kind of ['dt.started', 'dt.won']) {
|
||||
assert.equal(catalogue.isPublic(kind), true)
|
||||
assert.equal(catalogue.isPresence(kind), false)
|
||||
}
|
||||
assert.equal(catalogue.isPublic('dt.ended'), false, 'a treasure event ending is a staff line')
|
||||
for (const kind of ['gc.started', 'gc.ended']) {
|
||||
assert.equal(catalogue.isPublic(kind), true)
|
||||
assert.equal(catalogue.isPresence(kind), false)
|
||||
}
|
||||
assert.equal(catalogue.isPublic('gc.failed'), false, 'a guarded crate timing out is a staff line')
|
||||
})
|
||||
|
||||
test('every kind that names a player who was on is behind the presence setting', () => {
|
||||
@@ -187,6 +233,11 @@ test('every kind that names a player who was on is behind the presence setting',
|
||||
// A presence kind is a subset of the public ones, never a staff kind widened.
|
||||
for (const kind of catalogue.PRESENCE_KINDS) assert.ok(catalogue.PUBLIC_KINDS.includes(kind))
|
||||
|
||||
// And what is left names nobody.
|
||||
assert.deepEqual(catalogue.kindsFor({}).sort(), ['npc.boss.spawned', 'server.initialized', 'server.shutdown', 'server.wipe'])
|
||||
// And what is left names nobody — a world event included, once `worldEvents.shapeFor`
|
||||
// has taken its names off for a viewer below the presence audience (D376).
|
||||
// Dynamic PVP's opening names nobody at all, and a Dangerous Treasures win names
|
||||
// nobody once `dangerousTreasures.shapeFor` has read it for such a viewer, nor
|
||||
// a guarded crate cleared once `guardedCrate.shapeFor` has, 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'])
|
||||
})
|
||||
|
||||
300
server/test/commandSteps.test.js
Normal file
300
server/test/commandSteps.test.js
Normal 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, /not on the server when the reward ran/)
|
||||
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 })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
320
server/test/dangerousTreasures.test.js
Normal file
320
server/test/dangerousTreasures.test.js
Normal file
@@ -0,0 +1,320 @@
|
||||
// ── Dangerous Treasures (PLAN_REDESIGNS §14.3.4, protocol 14) ──────────────
|
||||
//
|
||||
// Each test is one way the feature could look right and be wrong:
|
||||
//
|
||||
// the winner's name reaches a viewer below the presence audience (D376), or a
|
||||
// Steam id, an invader, a position or a step's key reaches anybody but staff
|
||||
// the step runs on a server that said it has no DT, or whose framework cannot
|
||||
// run DT's console command (D363), or a server that has not said is refused
|
||||
// DT's warm-up is not answered with core's ten-minute retry (D365, D371), or
|
||||
// a refusal DT would repeat is retried
|
||||
// a retry of a key opens a second chest
|
||||
// the tally's treasures won is dropped on the floor, or Treasure Hunter's
|
||||
// column and sort are missing
|
||||
// a trigger declares a variable its emitter never fills, or names a Steam id
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
/** Installs a core whose `db.query` records every statement and answers `rows`. */
|
||||
function withRecorder(rows = []) {
|
||||
const statements = []
|
||||
const ctx = fakeCtx({
|
||||
db: {
|
||||
query: (sql, params = []) => {
|
||||
statements.push({ sql, params })
|
||||
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
})
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
return { ctx, statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) }
|
||||
}
|
||||
|
||||
withRecorder()
|
||||
|
||||
// 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')
|
||||
|
||||
const A = '76561198000000001'
|
||||
const B = '76561198000000002'
|
||||
const NOW = Date.now()
|
||||
const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 }
|
||||
|
||||
const frame = (kind, over = {}) => ({ kind, type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', x: 812, z: -340.5, ...over })
|
||||
const won = (over = {}) => frame('dt.won', { steamId: A, name: 'Alice', invaders: [A, B], invaderCount: 2, ...over })
|
||||
|
||||
// ── Who may read what (D353, D376) ─────────────────────────────────────────
|
||||
|
||||
test('the public reads a treasure event’s grid, never its position or the step’s key', () => {
|
||||
const out = dt.shapeFor(frame('dt.started', { key: '7:1' }), { geometry: GEOMETRY })
|
||||
assert.strictEqual(out.grid, gridOf(GEOMETRY, 812, -340.5))
|
||||
assert.strictEqual(out.x, undefined)
|
||||
assert.strictEqual(out.z, undefined)
|
||||
assert.strictEqual(out.key, undefined)
|
||||
assert.strictEqual(out.kind, 'dt.started')
|
||||
})
|
||||
|
||||
test('a win names its winner only to the presence audience, and its Steam ids only to staff', () => {
|
||||
const signedOut = dt.shapeFor(won(), { geometry: GEOMETRY })
|
||||
assert.strictEqual(signedOut.name, undefined)
|
||||
assert.strictEqual(signedOut.steamId, undefined)
|
||||
assert.strictEqual(signedOut.invaders, undefined)
|
||||
assert.strictEqual(signedOut.invaderCount, 2, 'a count names nobody')
|
||||
|
||||
const member = dt.shapeFor(won(), { presence: true, geometry: GEOMETRY })
|
||||
assert.strictEqual(member.name, 'Alice')
|
||||
assert.strictEqual(member.steamId, undefined)
|
||||
assert.strictEqual(member.invaders, undefined)
|
||||
|
||||
const staff = dt.shapeFor(won({ invaders: [A, B, 'not-an-id'] }), { staff: true, geometry: GEOMETRY })
|
||||
assert.strictEqual(staff.name, 'Alice')
|
||||
assert.strictEqual(staff.steamId, A)
|
||||
assert.deepStrictEqual(staff.invaders, [A, B])
|
||||
assert.strictEqual(staff.x, 812)
|
||||
})
|
||||
|
||||
test('an older or partial win still counts its invaders, and something off the map has no grid', () => {
|
||||
assert.strictEqual(dt.shapeFor(won({ invaderCount: undefined }), {}).invaderCount, 2)
|
||||
assert.strictEqual(dt.shapeFor(frame('dt.started', { x: 9000 }), { geometry: GEOMETRY }).grid, undefined)
|
||||
assert.strictEqual(dt.shapeFor(frame('dt.started'), {}).grid, undefined, 'no geometry, no grid')
|
||||
const other = { kind: 'player.death', x: 1 }
|
||||
assert.strictEqual(dt.shapeFor(other, {}), other)
|
||||
})
|
||||
|
||||
test('the feed reads a stored win for its viewer, the row’s own Steam id staff’s', async () => {
|
||||
withRecorder((sql) => {
|
||||
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'dt.won', t: NOW, wipeId: 'w1', steamId: A, raw: JSON.stringify(won()) }]
|
||||
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
|
||||
return []
|
||||
})
|
||||
const events = require('../model/events/events.model')
|
||||
|
||||
const [signedOut] = await events.recent({ serverId: 'main', presence: false })
|
||||
assert.strictEqual(signedOut.steamId, null)
|
||||
assert.strictEqual(signedOut.frame.name, undefined)
|
||||
assert.ok(signedOut.frame.grid)
|
||||
|
||||
const [staff] = await events.recent({ serverId: 'main', admin: true })
|
||||
assert.strictEqual(staff.steamId, A)
|
||||
assert.deepStrictEqual(staff.frame.invaders, [A, B])
|
||||
})
|
||||
|
||||
test('a server’s report is read leniently: what it never said is null', () => {
|
||||
assert.deepStrictEqual(dt.reportOf(null), { dangerousTreasures: null, consolePlugins: null })
|
||||
assert.deepStrictEqual(dt.reportOf({ dangerousTreasures: '{"loaded":true,"version":"3.0.0"}', console: '{"plugins":false}' }), {
|
||||
dangerousTreasures: { loaded: true, version: '3.0.0' },
|
||||
consolePlugins: false,
|
||||
})
|
||||
assert.deepStrictEqual(dt.reportOf({ dangerousTreasures: 'not json', console: '{}' }), { dangerousTreasures: null, consolePlugins: null })
|
||||
})
|
||||
|
||||
// ── The step ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** The step against one server `main`, whose last report is `report`, and a sidecar answering `reply`. */
|
||||
function stubStart(t, { report = { dangerousTreasures: { loaded: true, version: '3.0.0' }, console: { plugins: true } }, reply } = {}) {
|
||||
withRecorder((sql) => {
|
||||
if (sql.includes('FROM rust_server_state')) {
|
||||
return report === null ? [] : [{ dangerousTreasures: JSON.stringify(report.dangerousTreasures ?? null), console: JSON.stringify(report.console ?? null) }]
|
||||
}
|
||||
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
|
||||
return []
|
||||
})
|
||||
const client = require('../sidecarClient')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const saved = { dtStart: client.dtStart, getServer: serversDb.getServer }
|
||||
const calls = []
|
||||
serversDb.getServer = async (id) => (id === 'main' ? { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' } : null)
|
||||
client.dtStart = async (server, body) => {
|
||||
calls.push({ server: server.id, body })
|
||||
return typeof reply === 'function' ? reply(body) : reply
|
||||
}
|
||||
t.after(() => {
|
||||
client.dtStart = saved.dtStart
|
||||
serversDb.getServer = saved.getServer
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
const startStep = () => require('../eventDangerousTreasures').ACTIONS[0]
|
||||
const ok = (data) => ({ ok: true, status: 'ok', data })
|
||||
const opened = (over = {}) => ok({ kind: 'dt.opened', key: '7:1', level: 1, repeat: false, x: 812, z: -340.5, ...over })
|
||||
|
||||
test('the step is kept, priced one event each, and offers DT’s three difficulties', async () => {
|
||||
const step = startStep()
|
||||
assert.strictEqual(step.id, 'rust.dt.start')
|
||||
assert.strictEqual(step.reversible, 'none')
|
||||
assert.strictEqual(step.risk, 'change')
|
||||
assert.strictEqual(step.revert, undefined)
|
||||
assert.deepStrictEqual(step.cost({ server: 'main', level: 2 }), { 'rust.dt.events': 1 })
|
||||
|
||||
const [levels] = require('../eventDangerousTreasures').OPTION_SOURCES
|
||||
assert.deepStrictEqual(await levels.resolve(), [
|
||||
{ value: 0, label: 'Easy' },
|
||||
{ value: 1, label: 'Medium' },
|
||||
{ value: 2, label: 'Hard' },
|
||||
])
|
||||
})
|
||||
|
||||
test('the dry run refuses a server without DT, or whose framework cannot run its command, and nothing else', async (t) => {
|
||||
const run = (params) => startStep().perform({ runId: 7, idempotencyKey: '7:1', params, verify: true })
|
||||
|
||||
stubStart(t, { report: { dangerousTreasures: { loaded: false }, console: { plugins: true } } })
|
||||
const absent = await run({ server: 'main', level: 1 })
|
||||
assert.strictEqual(absent.retry, false)
|
||||
assert.match(absent.error, /Dangerous Treasures is not installed on Main/)
|
||||
|
||||
stubStart(t, { report: { dangerousTreasures: { loaded: true }, console: { plugins: false } } })
|
||||
const carbon = await run({ server: 'main', level: 1 })
|
||||
assert.strictEqual(carbon.retry, false)
|
||||
assert.match(carbon.error, /cannot run Dangerous Treasures' console command/)
|
||||
assert.match(carbon.error, /Rust-Plugins#34/)
|
||||
|
||||
// A plugin before protocol 14, or a server never reached, has not said: the plugin answers.
|
||||
stubStart(t, { report: null })
|
||||
assert.ok((await run({ server: 'main', level: 1 })).ok)
|
||||
|
||||
const calls = stubStart(t)
|
||||
assert.ok((await run({ server: 'main' })).ok, 'blank is Easy')
|
||||
assert.match((await run({ server: 'main', level: 3 })).error, /0 \(Easy\), 1 \(Medium\) or 2 \(Hard\)/)
|
||||
assert.match((await run({ server: 'main', level: 'hard' })).error, /not "hard"/)
|
||||
assert.match((await run({ level: 1 })).error, /which server/)
|
||||
assert.strictEqual(calls.length, 0, 'the dry run starts nothing')
|
||||
})
|
||||
|
||||
test('a start sends the key and the level, and says where it opened', async (t) => {
|
||||
const calls = stubStart(t, { reply: opened() })
|
||||
const result = await startStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', level: '1' } })
|
||||
assert.ok(result.ok)
|
||||
assert.deepStrictEqual(calls, [{ server: 'main', body: { key: '7:1', level: 1 } }])
|
||||
assert.deepStrictEqual(result.detail, { level: 'Medium', grid: gridOf(GEOMETRY, 812, -340.5) })
|
||||
})
|
||||
|
||||
test('DT not ready is said in the plan’s words and retried once in ten minutes (D365, D371)', async (t) => {
|
||||
stubStart(t, { reply: ok({ kind: 'dt.error', reason: 'dt-not-ready', message: 'Dangerous Treasures is not ready yet: …' }) })
|
||||
const result = await startStep().perform({ runId: 7, idempotencyKey: '7:2', params: { server: 'main', level: 0 } })
|
||||
assert.strictEqual(result.ok, false)
|
||||
assert.strictEqual(result.retry, true)
|
||||
assert.strictEqual(result.retryAfterMs, 600000)
|
||||
assert.strictEqual(result.error, 'Dangerous Treasures is not ready yet on Main')
|
||||
})
|
||||
|
||||
test('no spot retries on core’s ordinary delay; a refusal DT would repeat is not retried', async (t) => {
|
||||
stubStart(t, {
|
||||
reply: (body) => ok(body.level === 2
|
||||
? { kind: 'dt.error', reason: 'dt-no-spot', message: 'Dangerous Treasures found no valid spot for an event' }
|
||||
: { kind: 'dt.error', reason: 'refused', message: 'Dangerous Treasures did not start: Invalid difficulty' }),
|
||||
})
|
||||
const step = startStep()
|
||||
|
||||
const noSpot = await step.perform({ runId: 7, idempotencyKey: '7:3', params: { server: 'main', level: 2 } })
|
||||
assert.strictEqual(noSpot.ok, false)
|
||||
assert.strictEqual(noSpot.retry, undefined)
|
||||
assert.strictEqual(noSpot.retryAfterMs, undefined)
|
||||
assert.match(noSpot.error, /no valid spot/)
|
||||
|
||||
const refused = await step.perform({ runId: 7, idempotencyKey: '7:4', params: { server: 'main', level: 1 } })
|
||||
assert.strictEqual(refused.retry, false)
|
||||
assert.match(refused.error, /Invalid difficulty/)
|
||||
|
||||
for (const reason of ['dt-missing', 'console-unavailable', 'bad-level', 'malformed', 'events-disabled']) {
|
||||
assert.ok(require('../eventDangerousTreasures').PERMANENT.has(reason), reason)
|
||||
}
|
||||
})
|
||||
|
||||
test('a game that is down is retried, and a repeat of a key opens nothing new', async (t) => {
|
||||
stubStart(t, { reply: { ok: false, status: 'http-503' } })
|
||||
const down = await startStep().perform({ runId: 7, idempotencyKey: '7:5', params: { server: 'main', level: 1 } })
|
||||
assert.strictEqual(down.retry, undefined)
|
||||
assert.match(down.error, /has no game connected/)
|
||||
|
||||
stubStart(t, { reply: opened({ repeat: true }) })
|
||||
const again = await startStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', level: 1 } })
|
||||
assert.ok(again.ok)
|
||||
assert.strictEqual(again.detail.repeat, true)
|
||||
})
|
||||
|
||||
// ── Treasure Hunter (D375) ─────────────────────────────────────────────────
|
||||
|
||||
test('the tally’s treasures won is added up', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
await apply('main', { id: 1, kind: 'player.tally', frame: { kind: 'player.tally', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', steamId: A, name: 'Alice', treasuresWon: 3 } })
|
||||
|
||||
const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('treasures_won'))
|
||||
assert.ok(titleAdd, 'the tally reached the title columns')
|
||||
const cols = titleAdd.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+)\)/)[1].split(', ')
|
||||
assert.strictEqual(titleAdd.params[3 + cols.indexOf('treasures_won')], 3)
|
||||
})
|
||||
|
||||
test('the leaderboard sorts by treasures won and carries the count on every row', async () => {
|
||||
const rec = withRecorder((sql) => (sql.includes('FROM rust_player_wipe_stats') ? [{ steamId: A, name: 'Alice', kills: 1, treasuresWon: '4' }] : []))
|
||||
const events = require('../model/events/events.model')
|
||||
|
||||
const [row] = await events.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'treasuresWon' })
|
||||
assert.strictEqual(row.treasuresWon, 4)
|
||||
const board = rec.touching('rust_player_wipe_stats').at(-1)
|
||||
assert.match(board.sql, /SUM\(s\.treasures_won\) AS treasuresWon/)
|
||||
assert.match(board.sql, /ORDER BY[^]*treasures_won/)
|
||||
})
|
||||
|
||||
// ── Triggers (D355, §14.3.9) ───────────────────────────────────────────────
|
||||
|
||||
test('a start and a win are public triggers, neither declaring a Steam id', () => {
|
||||
const found = TRIGGERS.filter((t) => t.id.startsWith('rust.dt.'))
|
||||
assert.deepStrictEqual(found.map((t) => t.id).sort(), ['rust.dt.started', 'rust.dt.won'])
|
||||
for (const t of found) {
|
||||
assert.strictEqual(t.ceiling, 'everyone', t.id)
|
||||
assert.strictEqual(t.subjectKey, 'subject', t.id)
|
||||
assert.ok(!t.variables.some((v) => /steam/i.test(v.name)), `${t.id} declares a Steam id`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a start and a win fire their triggers with what they declare; an ending fires nothing', async () => {
|
||||
const { ctx } = withRecorder((sql) => (sql.includes('FROM rust_map_images') ? [{ serverId: 'main', ...GEOMETRY }] : []))
|
||||
const emit = require('../engagement/emit')
|
||||
emit.reset()
|
||||
|
||||
const server = { id: 'main', name: 'Main' }
|
||||
await emit.onEvent(server, { id: 1, kind: 'dt.started', frame: frame('dt.started', { key: '7:1' }) })
|
||||
await emit.onEvent(server, { id: 2, kind: 'dt.won', frame: won({ invaderCount: 3 }) })
|
||||
await emit.onEvent(server, { id: 3, kind: 'dt.ended', frame: frame('dt.ended') })
|
||||
|
||||
const calls = ctx.events.emit.calls
|
||||
assert.strictEqual(calls.length, 2)
|
||||
|
||||
const [startedId, started] = calls[0]
|
||||
assert.strictEqual(startedId, 'rust.dt.started')
|
||||
assert.strictEqual(started.data.byEvent, true)
|
||||
assert.strictEqual(started.data.subject, 'main:dt.started')
|
||||
assert.match(started.data.intro, /A Dangerous Treasures chest opened at [A-Z]+\d+ on Main/)
|
||||
|
||||
const [wonId, winner] = calls[1]
|
||||
assert.strictEqual(wonId, 'rust.dt.won')
|
||||
assert.strictEqual(winner.data.player, 'Alice')
|
||||
assert.strictEqual(winner.data.invaders, 3)
|
||||
assert.match(winner.data.intro, /Alice won the treasure at [A-Z]+\d+ on Main, with 3 players fighting for it\./)
|
||||
|
||||
for (const [id, envelope] of calls) {
|
||||
const declared = new Set(TRIGGERS.find((x) => x.id === id).variables.map((v) => v.name))
|
||||
for (const name of Object.keys(envelope.data)) assert.ok(declared.has(name), `${id} fills ${name}, which it does not declare`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an old treasure tells nobody (D63)', async () => {
|
||||
const { ctx } = withRecorder()
|
||||
const emit = require('../engagement/emit')
|
||||
emit.reset()
|
||||
await emit.onEvent({ id: 'main', name: 'Main' }, { id: 1, kind: 'dt.won', frame: won({ t: NOW - 3 * 3600 * 1000 }) })
|
||||
assert.strictEqual(ctx.events.emit.calls.length, 0)
|
||||
})
|
||||
457
server/test/demo.test.js
Normal file
457
server/test/demo.test.js
Normal file
@@ -0,0 +1,457 @@
|
||||
// ── The public demo's module-side locks (website/DEMO_MODE.md §3.4, §3.5, §7.3) ──
|
||||
//
|
||||
// Everything a route list cannot lock, behind `ctx.demoMode` (MODULE_API
|
||||
// 1.18.0): the console-command deny list (DM11) at save through core's
|
||||
// `demoRefuses` (1.19.0, DM18) and at run; the bridge's config, neither read nor
|
||||
// written (DM14); secrets in every other config, redacted and read-only; the
|
||||
// sidecar URL left off the Servers view; and the seed hook (DM15). Each lock is
|
||||
// held twice: on the demo it refuses, and off it nothing changes.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
function withCore({ demo = true, select = () => [] } = {}) {
|
||||
const queries = []
|
||||
require('../core')._reset()
|
||||
require('../core').init(
|
||||
fakeCtx({
|
||||
demoMode: demo,
|
||||
db: {
|
||||
query: (sql, params) => {
|
||||
const flat = sql.trim().replace(/\s+/g, ' ')
|
||||
queries.push({ sql: flat, params })
|
||||
const verb = flat.split(' ')[0].toUpperCase()
|
||||
if (verb === 'SELECT') return Promise.resolve(select(flat, params))
|
||||
return Promise.resolve({ affectedRows: 1, insertId: 1 })
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
return queries
|
||||
}
|
||||
|
||||
function fakeRes() {
|
||||
const res = { statusCode: 200, body: null }
|
||||
res.status = (code) => {
|
||||
res.statusCode = code
|
||||
return res
|
||||
}
|
||||
res.json = (body) => {
|
||||
res.body = body
|
||||
return res
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// ── The console-command deny list (§3.4) ───────────────────────────────────
|
||||
|
||||
test('the deny list compares the first word, normalised', () => {
|
||||
withCore()
|
||||
const demo = require('../demo')
|
||||
for (const line of [
|
||||
'quit', 'QUIT', ' quit ', 'global.quit', 'restart 60 "bye"',
|
||||
'oxide.unload RunicGateway', 'o.unload RunicNPC', 'oxide.reload *', 'o.reload Kits', 'global.oxide.reload x',
|
||||
'rcon.password hunter2', 'RCON.web 0', 'fps.limit 5', 'server.tickrate 1',
|
||||
]) {
|
||||
assert.equal(demo.isDeniedCommand(line), true, line)
|
||||
}
|
||||
for (const line of [
|
||||
'say hello', 'server.writecfg', 'oxide.grant group default kits.use', 'kick someone', 'rbe 1',
|
||||
'quitter', 'restarts', 'oxide.load Foo', 'entity.spawn bear', 'weather.rain 1', '',
|
||||
]) {
|
||||
assert.equal(demo.isDeniedCommand(line), false, line)
|
||||
}
|
||||
assert.equal(demo.commandWord('Global.O.Unload X'), 'oxide.unload')
|
||||
})
|
||||
|
||||
test('the deny list refuses nothing off the demo', () => {
|
||||
withCore({ demo: false })
|
||||
assert.equal(require('../demo').deniedCommand(['quit']), null)
|
||||
})
|
||||
|
||||
test('both command actions refuse a denied command at save, through demoRefuses', () => {
|
||||
withCore()
|
||||
const { ACTIONS } = require('../eventCommands')
|
||||
const run = ACTIONS.find((a) => a.id === 'rust.command.run')
|
||||
const reward = ACTIONS.find((a) => a.id === 'rust.command.reward')
|
||||
|
||||
assert.equal(run.demoRefuses({ commands: ['say hi', 'quit'] }), '"quit" is turned off on the demo site.')
|
||||
// A teardown runs at the end of the event, so it is checked as well.
|
||||
assert.equal(run.demoRefuses({ commands: ['rbe 1'], teardown: ['o.unload RunicGateway'] }), '"o.unload RunicGateway" is turned off on the demo site.')
|
||||
assert.equal(run.demoRefuses({ commands: 'say hi' }), null)
|
||||
assert.equal(reward.demoRefuses({ commands: ['sr add {steamid} 100', 'rcon.password x'] }), '"rcon.password x" is turned off on the demo site.')
|
||||
assert.equal(reward.demoRefuses({ commands: ['sr add {steamid} 100'] }), null)
|
||||
})
|
||||
|
||||
test('a denied command is refused at run as well, permanently, before the rig is asked', async () => {
|
||||
withCore()
|
||||
const sidecar = require('../sidecarClient')
|
||||
let asked = 0
|
||||
sidecar.commandsRun = async () => { asked += 1; return { ok: true, data: { kind: 'command.ran', ran: 1 } } }
|
||||
const { ACTIONS } = require('../eventCommands')
|
||||
const run = ACTIONS.find((a) => a.id === 'rust.command.run')
|
||||
const reward = ACTIONS.find((a) => a.id === 'rust.command.reward')
|
||||
|
||||
const ran = await run.perform({ runId: 1, stepId: 's', idempotencyKey: 'k', params: { server: 'main', commands: ['quit'], confirm: true } })
|
||||
assert.deepEqual(ran, { ok: false, retry: false, error: '"quit" is turned off on the demo site.' })
|
||||
|
||||
const rewarded = await reward.perform({ runId: 1, stepId: 's', idempotencyKey: 'k', params: { server: 'main', recipients: 'linked', commands: ['restart'], confirm: true } })
|
||||
assert.deepEqual(rewarded, { ok: false, retry: false, error: '"restart" is turned off on the demo site.' })
|
||||
assert.equal(asked, 0)
|
||||
})
|
||||
|
||||
// ── The bridge's own config (§3.5) ─────────────────────────────────────────
|
||||
|
||||
test('a bridge path is recognised however it is spelled', () => {
|
||||
withCore()
|
||||
const demo = require('../demo')
|
||||
for (const p of ['RunicGateway.json', './RunicGateway.json', 'runicgateway.json', 'RunicGateway/extra.json', ' RunicGateway.json ', 'RunicGateway./x.json', 'Bridge.json']) {
|
||||
assert.equal(demo.isBridgePath(p, 'Bridge'), true, p)
|
||||
}
|
||||
for (const p of ['ZoneManager.json', 'Kits/RunicGateway.json', 'RunicGatewayExtra.json', '@data/RaidableBases/Profiles/a.json']) {
|
||||
assert.equal(demo.isBridgePath(p, 'Bridge'), false, p)
|
||||
}
|
||||
})
|
||||
|
||||
const SECRET_FILE = `{
|
||||
"Discord Webhook": "https://discord.example/hook/abc",
|
||||
"Api Key": 123456789,
|
||||
"Empty Token": "",
|
||||
"Use Auth": true,
|
||||
"Credentials": { "User": "admin", "List": ["one", "two"] },
|
||||
"Gather": { "Wood": 1.0 },
|
||||
"Enabled": true
|
||||
}`
|
||||
|
||||
function stubSidecar({ file = SECRET_FILE, self = 'RunicGateway' } = {}) {
|
||||
const sidecar = require('../sidecarClient')
|
||||
const calls = []
|
||||
sidecar.configFile = async (server, asked) => {
|
||||
calls.push(['read', asked])
|
||||
return { ok: true, status: 'ok', data: { kind: 'config.file', path: asked, text: file, version: `v-${asked}`, bytes: file.length } }
|
||||
}
|
||||
sidecar.configFiles = async () => {
|
||||
calls.push(['list'])
|
||||
return {
|
||||
ok: true,
|
||||
status: 'ok',
|
||||
data: {
|
||||
kind: 'config.catalogue',
|
||||
root: '/home/container/oxide/config',
|
||||
self,
|
||||
files: [
|
||||
{ path: 'Discord.json', bytes: 40, editable: true, plugin: 'Discord' },
|
||||
{ path: 'RunicGateway.json', bytes: 90, editable: true, plugin: 'RunicGateway' },
|
||||
],
|
||||
plugins: [{ name: 'Discord', title: 'Discord', version: '1.0.0' }, { name: 'RunicGateway', title: 'Runic Gateway', version: '1.0.0' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
sidecar.configWrite = async (server, body) => {
|
||||
calls.push(['write', body])
|
||||
return { ok: true, status: 'ok', data: { kind: 'config.report', ok: true, reloaded: true, files: [{ path: body.files[0].path, version: 'v-after' }] } }
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function stubServer() {
|
||||
const row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://x', sidecarTokenEnc: null, protocol: 14 }
|
||||
require('../model/servers/servers.db').getServer = async () => row
|
||||
require('../model/servers/servers.model').withToken = () => ({ id: row.id, baseUrl: row.sidecarBaseUrl, token: 't' })
|
||||
}
|
||||
|
||||
const controller = () => require('../router/admin/config.controller')
|
||||
|
||||
test('the file list leaves the bridge out on the demo, and keeps it off the demo', async () => {
|
||||
withCore()
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, res)
|
||||
assert.deepEqual(res.body.plugins.map((p) => p.plugin), ['Discord'])
|
||||
|
||||
withCore({ demo: false })
|
||||
const off = fakeRes()
|
||||
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, off)
|
||||
assert.deepEqual(off.body.plugins.map((p) => p.plugin), ['Discord', 'RunicGateway'])
|
||||
})
|
||||
|
||||
test('the bridge\'s config cannot be read on the demo, and the host is never asked for it', async () => {
|
||||
withCore()
|
||||
const calls = stubSidecar()
|
||||
stubServer()
|
||||
for (const path of ['RunicGateway.json', './RunicGateway.json']) {
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path } }, res)
|
||||
assert.equal(res.statusCode, 403, path)
|
||||
assert.equal(res.body.code, 'DEMO_LOCKED')
|
||||
}
|
||||
assert.deepEqual(calls, [])
|
||||
|
||||
// Named by the catalogue's own word for the plugin, when that differs.
|
||||
const renamed = stubSidecar({ self: 'Bridge' })
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Bridge.json' } }, res)
|
||||
assert.equal(res.statusCode, 403)
|
||||
assert.ok(renamed.some(([kind]) => kind === 'list'))
|
||||
})
|
||||
|
||||
test('another plugin\'s secrets are redacted in the text and the form, and made read-only', async () => {
|
||||
withCore()
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Discord.json' } }, res)
|
||||
|
||||
assert.equal(res.statusCode, 200)
|
||||
const { text, fields } = res.body
|
||||
assert.doesNotMatch(text, /discord\.example|123456789|"admin"|"one"/)
|
||||
assert.match(text, /"Discord Webhook": "\(hidden on the demo site\)"/)
|
||||
// Still JSON, and the values that are not credentials are untouched.
|
||||
const parsed = JSON.parse(text)
|
||||
assert.equal(parsed['Empty Token'], '')
|
||||
assert.equal(parsed['Use Auth'], true)
|
||||
assert.equal(parsed.Gather.Wood, 1)
|
||||
assert.match(text, /"Wood": 1\.0/)
|
||||
|
||||
const byPath = Object.fromEntries(fields.map((f) => [f.path, f]))
|
||||
assert.equal(byPath['Discord Webhook'].value, '(hidden on the demo site)')
|
||||
assert.equal(byPath['Discord Webhook'].locked, true)
|
||||
assert.equal(byPath['Api Key'].value, '(hidden on the demo site)')
|
||||
assert.equal('raw' in byPath['Api Key'], false)
|
||||
assert.equal(byPath['Credentials.List[0]'].value, '(hidden on the demo site)')
|
||||
assert.equal(byPath['Gather.Wood'].locked, false)
|
||||
assert.equal(res.body.redacted, true)
|
||||
})
|
||||
|
||||
test('off the demo the file is returned as it is', async () => {
|
||||
withCore({ demo: false })
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Discord.json' } }, res)
|
||||
assert.equal(res.body.text, SECRET_FILE)
|
||||
assert.equal(res.body.redacted, undefined)
|
||||
})
|
||||
|
||||
test('a file that does not parse is withheld whole on the demo', () => {
|
||||
withCore()
|
||||
const model = require('../model/config/config.model')
|
||||
const shaped = model.shapeFile({ path: 'Broken.json', version: 'v', text: '{ "Api Key": "abc", ' })
|
||||
const out = model.redactSecrets(shaped, '(hidden)')
|
||||
assert.equal(out.text, '')
|
||||
assert.match(out.withheld, /does not parse/)
|
||||
})
|
||||
|
||||
test('on the demo a save refuses the bridge, raw JSON and a credential field, before the host is asked', async () => {
|
||||
withCore()
|
||||
const calls = stubSidecar()
|
||||
stubServer()
|
||||
const save = async (body) => {
|
||||
const res = fakeRes()
|
||||
await controller().writeFile({ params: { serverId: 'main' }, body: { version: 'v-Discord.json', ...body } }, res)
|
||||
return res
|
||||
}
|
||||
|
||||
const bridge = await save({ path: 'RunicGateway.json', edits: [{ pointer: ['QueueCap'], raw: '1' }] })
|
||||
assert.equal(bridge.statusCode, 403)
|
||||
const raw = await save({ path: 'Discord.json', text: SECRET_FILE })
|
||||
assert.equal(raw.statusCode, 403)
|
||||
assert.match(raw.body.message, /Raw JSON saves/)
|
||||
const secret = await save({ path: 'Discord.json', edits: [{ pointer: ['Discord Webhook'], value: 'https://evil' }] })
|
||||
assert.equal(secret.statusCode, 403)
|
||||
const nested = await save({ path: 'Discord.json', edits: [{ pointer: ['Credentials', 'User'], value: 'x' }] })
|
||||
assert.equal(nested.statusCode, 403)
|
||||
for (const r of [bridge, raw, secret, nested]) assert.equal(r.body.code, 'DEMO_LOCKED')
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('on the demo a form edit to an ordinary field saves, and leaves the secrets on disk alone', async () => {
|
||||
withCore()
|
||||
const calls = stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().writeFile(
|
||||
{ params: { serverId: 'main' }, body: { path: 'Discord.json', version: 'v-Discord.json', edits: [{ pointer: ['Enabled'], value: false }] } },
|
||||
res,
|
||||
)
|
||||
assert.equal(res.statusCode, 200)
|
||||
const [, body] = calls.find(([kind]) => kind === 'write')
|
||||
assert.match(body.files[0].text, /discord\.example\/hook\/abc/)
|
||||
assert.match(body.files[0].text, /"Enabled": false/)
|
||||
})
|
||||
|
||||
test('a conflict\'s current file is redacted too', async () => {
|
||||
withCore()
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().writeFile(
|
||||
{ params: { serverId: 'main' }, body: { path: 'Discord.json', version: 'v-stale', edits: [{ pointer: ['Enabled'], value: false }] } },
|
||||
res,
|
||||
)
|
||||
assert.equal(res.statusCode, 409)
|
||||
assert.doesNotMatch(res.body.current.text, /discord\.example/)
|
||||
})
|
||||
|
||||
test('the write history leaves the bridge\'s writes out, and one of them cannot be read', async () => {
|
||||
const rows = [
|
||||
{ id: 2, serverId: 'main', path: 'RunicGateway.json', plugin: 'RunicGateway', outcome: 'applied', changes: null },
|
||||
{ id: 1, serverId: 'main', path: 'Discord.json', plugin: 'Discord', outcome: 'applied', changes: null },
|
||||
]
|
||||
withCore({ select: (sql, params) => (sql.includes('WHERE server_id = ? AND id = ?') ? rows.filter((r) => r.id === params[1]) : rows) })
|
||||
const res = fakeRes()
|
||||
await controller().history({ params: { serverId: 'main' }, query: {} }, res)
|
||||
assert.deepEqual(res.body.writes.map((w) => w.id), [1])
|
||||
|
||||
const one = fakeRes()
|
||||
const db = require('../model/config/config.db')
|
||||
db.getWrite = async () => rows[0]
|
||||
await controller().writeStatus({ params: { serverId: 'main', writeId: '2' } }, one)
|
||||
assert.equal(one.statusCode, 403)
|
||||
assert.equal(one.body.code, 'DEMO_LOCKED')
|
||||
})
|
||||
|
||||
// ── The Servers view (§3.5) ────────────────────────────────────────────────
|
||||
|
||||
test('the admin server list leaves the sidecar URL out on the demo, and keeps hasToken', async () => {
|
||||
const row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://10.0.0.5:7790', sidecarTokenEnc: 'enc:t', protocol: 14, enabled: 1, sortOrder: 0 }
|
||||
for (const demoMode of [true, false]) {
|
||||
withCore({ demo: demoMode })
|
||||
const db = require('../model/servers/servers.db')
|
||||
db.listServers = async () => [row]
|
||||
db.listState = async () => []
|
||||
const [shaped] = await require('../model/servers/servers.model').listForAdmin()
|
||||
assert.equal('sidecarBaseUrl' in shaped, !demoMode)
|
||||
assert.equal(shaped.hasToken, true)
|
||||
assert.equal(JSON.stringify(shaped).includes('10.0.0.5'), !demoMode)
|
||||
}
|
||||
})
|
||||
|
||||
// ── The seed hook (§7.3, DM15) ─────────────────────────────────────────────
|
||||
|
||||
const SECTION = {
|
||||
servers: [{ id: 'main', name: 'Demo rig', sidecarBaseUrl: 'http://rig:7790', sidecarToken: 'tok' }],
|
||||
links: [{ username: 'demo-player', steamId: '76561190000000001', serverId: 'main' }],
|
||||
}
|
||||
|
||||
test('the seed section is checked whole before anything is written', async () => {
|
||||
const queries = withCore()
|
||||
const seed = require('../demoSeed')
|
||||
const accounts = { 'demo-player': 3 }
|
||||
assert.deepEqual(seed.checkSection(SECTION, accounts), [])
|
||||
const problems = seed.checkSection({
|
||||
servers: [{ id: 'Main', name: '', sidecarBaseUrl: 'rig', sidecarToken: '' }],
|
||||
links: [{ username: 'someone', steamId: '123', serverId: 'Other' }],
|
||||
}, accounts)
|
||||
assert.equal(problems.length, 7)
|
||||
await assert.rejects(seed.onDemoSeed({ servers: 'x' }, { accounts }), /must be an array/)
|
||||
assert.deepEqual(queries, [])
|
||||
})
|
||||
|
||||
test('the seed upserts the server with an encrypted token, and links demo-player', async () => {
|
||||
const queries = withCore()
|
||||
await require('../demoSeed').onDemoSeed(SECTION, { accounts: { 'demo-player': 3 } })
|
||||
const upsert = queries.find((q) => q.sql.startsWith('INSERT INTO rust_servers'))
|
||||
assert.match(upsert.sql, /ON DUPLICATE KEY UPDATE/)
|
||||
assert.deepEqual(upsert.params.slice(0, 4), ['main', 'Demo rig', 'http://rig:7790', 'enc:tok'])
|
||||
const link = queries.find((q) => q.sql.startsWith('INSERT INTO rust_account_links'))
|
||||
assert.deepEqual(link.params, ['76561190000000001', 3, null, 'main'])
|
||||
})
|
||||
|
||||
test('a re-seed leaves a link the right owner holds, and moves one held by someone else', async () => {
|
||||
let owner = 3
|
||||
const queries = withCore({ select: (sql) => (sql.includes('rust_account_links') ? [{ steamId: '76561190000000001', userId: owner }] : []) })
|
||||
const seed = require('../demoSeed')
|
||||
await seed.onDemoSeed({ links: SECTION.links }, { accounts: { 'demo-player': 3 } })
|
||||
assert.equal(queries.some((q) => /^(INSERT|DELETE)/.test(q.sql)), false)
|
||||
|
||||
owner = 9
|
||||
await seed.onDemoSeed({ links: SECTION.links }, { accounts: { 'demo-player': 3 } })
|
||||
const writes = queries.filter((q) => /^(INSERT|DELETE)/.test(q.sql)).map((q) => q.sql.split(' ')[0])
|
||||
assert.deepEqual(writes, ['DELETE', 'INSERT'])
|
||||
})
|
||||
|
||||
// ── NPCs and chat styles from the seed (DM26) ──────────────────────────────
|
||||
|
||||
const NPC_SECTION = {
|
||||
npcs: {
|
||||
factions: [{ a: 'bandits', b: 'guards', relation: 'allied' }],
|
||||
profiles: [
|
||||
{ name: 'bandit', servers: ['main'], body: { kits: ['rnpc_raider'], role: 'roamer' } },
|
||||
{ name: 'guard', servers: ['main'], body: { kits: ['rnpc_campguard'], role: 'sentry' } },
|
||||
],
|
||||
},
|
||||
chat: [
|
||||
{ serverId: 'main', group: 'vip', style: { Title: '[VIP]', TitleColor: '#ffaa00' } },
|
||||
{ serverId: 'main', group: 'moderator', style: { Title: '[MOD]' } },
|
||||
],
|
||||
}
|
||||
|
||||
/** Stubs the models the seed writes NPCs and chat styles through; returns the calls. */
|
||||
function stubSeedModels({ profiles = [], groups = {} } = {}) {
|
||||
const calls = []
|
||||
const npcs = require('../model/npcs/npcs.model')
|
||||
const npcsDb = require('../model/npcs/npcs.db')
|
||||
const apply = require('../model/permissions/permissions.apply')
|
||||
const permDb = require('../model/permissions/permissions.db')
|
||||
npcs.setFactions = async (f) => calls.push(['setFactions', f])
|
||||
npcs.create = async (input) => calls.push(['create', input.name, input.servers])
|
||||
npcs.update = async (id, input) => calls.push(['update', id, input.name])
|
||||
npcsDb.listProfiles = async () => profiles
|
||||
apply.groupOn = async (name) => groups[name] || null
|
||||
permDb.insertGroup = async ({ name }) => {
|
||||
calls.push(['insertGroup', name])
|
||||
return 50
|
||||
}
|
||||
permDb.setGroupServers = async (id, s) => calls.push(['setGroupServers', id, s.servers])
|
||||
permDb.setGroupChat = async (id, style) => calls.push(['setGroupChat', id, style.Title, style.TitleColor, Object.keys(style).length])
|
||||
permDb.markDirty = async (scope) => calls.push(['markDirty', scope])
|
||||
return calls
|
||||
}
|
||||
|
||||
test('the NPC and chat parts of the seed section are checked before anything is written', async () => {
|
||||
withCore()
|
||||
const seed = require('../demoSeed')
|
||||
assert.deepEqual(seed.checkSection(NPC_SECTION, {}), [])
|
||||
const problems = seed.checkSection({
|
||||
npcs: { factions: [{ a: 'scientists', b: 'animals', relation: 'hostile' }], profiles: [{ name: 'Bad Name', body: [] }] },
|
||||
chat: [{ serverId: 'Main', group: '', style: { Title: '[VIP]', Nope: 1 } }],
|
||||
}, {})
|
||||
assert.equal(problems.length, 6, problems.join(' | '))
|
||||
assert.deepEqual(seed.checkSection({ npcs: [] }, {}), ['modules.rust.npcs must be an object'])
|
||||
const calls = stubSeedModels()
|
||||
await assert.rejects(seed.onDemoSeed({ chat: [{ serverId: 'main', group: 'vip', style: { Priority: 'x' } }] }, { accounts: {} }), /style/)
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('the seed sets the faction table, saves each profile, and styles each group', async () => {
|
||||
withCore()
|
||||
const calls = stubSeedModels({ profiles: [{ id: 7, name: 'bandit', replaced: false }], groups: { vip: { id: 13 } } })
|
||||
await require('../demoSeed').onDemoSeed(NPC_SECTION, { accounts: {} })
|
||||
assert.deepEqual(calls, [
|
||||
['setFactions', NPC_SECTION.npcs.factions],
|
||||
['update', 7, 'bandit'], // already there: updated, not created twice
|
||||
['create', 'guard', ['main']],
|
||||
['setGroupChat', 13, '[VIP]', '#ffaa00', 12], // all twelve fields, the defaults filled in
|
||||
['markDirty', 'main'],
|
||||
['insertGroup', 'moderator'], // the rig's import has not brought it in yet
|
||||
['setGroupServers', 50, ['main']],
|
||||
['setGroupChat', 50, '[MOD]', '#55aaff', 12],
|
||||
['markDirty', 'main'],
|
||||
])
|
||||
})
|
||||
|
||||
// ── The manifest (§3.2) ────────────────────────────────────────────────────
|
||||
|
||||
test('module.json locks the two link routes and needs a core that asks demoRefuses', () => {
|
||||
const manifest = require('../../module.json')
|
||||
assert.deepEqual(manifest.demoLocks, [
|
||||
{ method: 'PUT', path: '/admin/rust/servers/:id' },
|
||||
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
|
||||
])
|
||||
assert.equal(manifest.coreApi, '^1.19.0')
|
||||
})
|
||||
102
server/test/dynamicPvp.test.js
Normal file
102
server/test/dynamicPvp.test.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// ── Dynamic PVP's zones (PLAN_REDESIGNS §14.3.3, protocol 14) ──────────────
|
||||
//
|
||||
// Each test is one way the feature could look right and be wrong:
|
||||
//
|
||||
// a zone's position reaches a viewer below staff through the feed, round the
|
||||
// map's own audience (D111)
|
||||
// an operator's own event name, typed into Dynamic PVP's config, reaches a
|
||||
// public page
|
||||
// the feed shapes a zone line without its grid
|
||||
// a frame of another kind is changed by the zone reader
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
/** Installs a core whose `db.query` answers `rows`. */
|
||||
function withRows(rows = []) {
|
||||
const ctx = fakeCtx({
|
||||
db: { query: (sql, params = []) => Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows), pool: {} },
|
||||
})
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
}
|
||||
|
||||
withRows()
|
||||
|
||||
const dynamicPvp = require('../dynamicPvp')
|
||||
const { gridOf } = require('../worldEvents')
|
||||
|
||||
const NOW = Date.now()
|
||||
const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 }
|
||||
|
||||
const created = (over = {}) => ({
|
||||
kind: 'pvp.zone.created',
|
||||
type: 'event',
|
||||
t: NOW,
|
||||
serverId: 'main',
|
||||
wipeId: 'w1',
|
||||
zoneId: '1412203045',
|
||||
event: 'Helicopter',
|
||||
x: 812,
|
||||
z: -340.5,
|
||||
radius: 100,
|
||||
shape: 'sphere',
|
||||
durationSec: 600,
|
||||
...over,
|
||||
})
|
||||
|
||||
test('the public read the zone, its grid and how long it lasts, and never where it is', () => {
|
||||
const out = dynamicPvp.shapeFor(created(), { staff: false, geometry: GEOMETRY })
|
||||
assert.strictEqual(out.event, 'Helicopter')
|
||||
assert.strictEqual(out.grid, gridOf(GEOMETRY, 812, -340.5))
|
||||
assert.strictEqual(out.radius, 100)
|
||||
assert.strictEqual(out.durationSec, 600)
|
||||
for (const hidden of ['x', 'z', 'zoneId']) assert.strictEqual(hidden in out, false, `${hidden} is staff's`)
|
||||
})
|
||||
|
||||
test('staff read the position and the zone id too', () => {
|
||||
const out = dynamicPvp.shapeFor(created(), { staff: true, geometry: GEOMETRY })
|
||||
assert.strictEqual(out.x, 812)
|
||||
assert.strictEqual(out.z, -340.5)
|
||||
assert.strictEqual(out.zoneId, '1412203045')
|
||||
})
|
||||
|
||||
test("a monument's or an operator's own event name is staff's alone", () => {
|
||||
for (const event of ['Airfield', 'My secret arena']) {
|
||||
assert.strictEqual('event' in dynamicPvp.shapeFor(created({ event }), { staff: false, geometry: GEOMETRY }), false)
|
||||
assert.strictEqual(dynamicPvp.shapeFor(created({ event }), { staff: true, geometry: GEOMETRY }).event, event)
|
||||
}
|
||||
for (const event of dynamicPvp.GENERAL_EVENTS) assert.ok(dynamicPvp.isGeneral(event))
|
||||
})
|
||||
|
||||
test('without a map geometry a line has no grid, and off the map neither', () => {
|
||||
assert.strictEqual('grid' in dynamicPvp.shapeFor(created(), { geometry: null }), false)
|
||||
assert.strictEqual('grid' in dynamicPvp.shapeFor(created({ x: 9000 }), { geometry: GEOMETRY }), false)
|
||||
})
|
||||
|
||||
test('a frame of another kind is passed back untouched', () => {
|
||||
const other = { kind: 'world.event', event: 'heli.downed', x: 1, z: 2 }
|
||||
assert.strictEqual(dynamicPvp.shapeFor(other, { staff: false }), other)
|
||||
})
|
||||
|
||||
test('the feed reads a zone line for its viewer, with the map’s grid', async () => {
|
||||
const stored = created({ event: 'Airfield' })
|
||||
withRows((sql) => {
|
||||
if (sql.includes('FROM rust_events')) return [{ id: 11, kind: 'pvp.zone.created', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stored) }]
|
||||
if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', enabled: 1 }]
|
||||
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
|
||||
return []
|
||||
})
|
||||
const events = require('../model/events/events.model')
|
||||
|
||||
const [signedOut] = await events.recent({ serverId: 'main' })
|
||||
assert.strictEqual(signedOut.frame.grid, gridOf(GEOMETRY, 812, -340.5))
|
||||
assert.strictEqual('x' in signedOut.frame, false)
|
||||
assert.strictEqual('event' in signedOut.frame, false)
|
||||
|
||||
const [staff] = await events.recent({ serverId: 'main', staff: true })
|
||||
assert.strictEqual(staff.frame.x, 812)
|
||||
assert.strictEqual(staff.frame.event, 'Airfield')
|
||||
})
|
||||
330
server/test/economics.test.js
Normal file
330
server/test/economics.test.js
Normal file
@@ -0,0 +1,330 @@
|
||||
// ── Economics (PLAN_REDESIGNS §11.2.2, protocol 14, D335, D336) ───────────
|
||||
//
|
||||
// Each test is one way the feature could look right and be wrong:
|
||||
//
|
||||
// a balance is a VALUE, written over — summed, it would grow every minute
|
||||
// an offline player's balance moves nobody's last-seen
|
||||
// `ecowipe` clears the wipe's balances; a new save's wipe keeps the last one's
|
||||
// the richest ranks only above the starting balance, and only one wipe (D336)
|
||||
// a server without Economics is refused with the reason, in the dry run too (D199)
|
||||
// one payment per website user, with the key, so a retry never pays twice
|
||||
// a withdrawal above the balance is a sentence with the balance in it
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
/** Installs a core whose `db.query` records every statement and answers `rows`. */
|
||||
function withRecorder(rows = []) {
|
||||
const statements = []
|
||||
const ctx = fakeCtx({
|
||||
db: {
|
||||
query: (sql, params = []) => {
|
||||
statements.push({ sql, params })
|
||||
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
})
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
return { statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) }
|
||||
}
|
||||
|
||||
withRecorder()
|
||||
|
||||
// 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')
|
||||
const linksDb = require('../model/links/links.db')
|
||||
const economicsDb = require('../model/economics/economics.db')
|
||||
const economics = require('../model/economics/economics.model')
|
||||
const eventsDb = require('../model/events/events.db')
|
||||
const eventsModel = require('../model/events/events.model')
|
||||
const titlesDb = require('../model/titles/titles.db')
|
||||
const rewards = require('../eventRewards')
|
||||
|
||||
const ok = (data) => ({ ok: true, status: 'ok', data })
|
||||
const frame = (over = {}) => ({ type: 'event', t: 1, serverId: 'main', wipeId: 'w1', ...over })
|
||||
const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...over }) })
|
||||
|
||||
const A = '76561198000000001'
|
||||
const B = '76561198000000002'
|
||||
const C = '76561198000000003'
|
||||
const D = '76561198000000004'
|
||||
|
||||
// ── What a server reports ──────────────────────────────────────────────────
|
||||
|
||||
test('a server whose Economics config cannot be read is assumed at 1000, and says so', () => {
|
||||
assert.deepStrictEqual(economics.shape(null), { loaded: false })
|
||||
assert.deepStrictEqual(economics.shape({ loaded: false }), { loaded: false })
|
||||
|
||||
const unread = economics.shape(JSON.stringify({ loaded: true, version: '3.9.2' }))
|
||||
assert.strictEqual(unread.startingBalance, 1000)
|
||||
assert.strictEqual(unread.startingKnown, false)
|
||||
|
||||
const read = economics.shape({ loaded: true, version: '3.9.2', startingBalance: 0, wipesWithMap: true })
|
||||
assert.strictEqual(read.startingBalance, 0)
|
||||
assert.strictEqual(read.startingKnown, true)
|
||||
assert.strictEqual(read.wipesWithMap, true)
|
||||
})
|
||||
|
||||
// ── Ingest ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a tally’s balance is written over, and an offline one moves nobody’s last-seen', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
// No name: the plugin names only a connected player.
|
||||
await apply('main', item('player.tally', { steamId: A, balance: 1500.456 }))
|
||||
|
||||
const stats = rec.touching('rust_player_wipe_stats')
|
||||
assert.strictEqual(stats.length, 1)
|
||||
assert.match(stats[0].sql, /balance = VALUES\(balance\)/)
|
||||
assert.doesNotMatch(stats[0].sql, /balance \+/)
|
||||
assert.doesNotMatch(stats[0].sql, /last_seen/)
|
||||
assert.deepStrictEqual(stats[0].params, ['main', 'w1', A, 1500.46])
|
||||
assert.strictEqual(rec.touching('rust_players ').length, 0, 'no player row is touched')
|
||||
|
||||
// A connected player's tally carries the balance AND its play.
|
||||
const live = withRecorder()
|
||||
await apply('main', item('player.tally', { steamId: A, name: 'Wanderer', balance: 900, npcKills: 2 }))
|
||||
assert.ok(live.touching('rust_player_wipe_stats').some((s) => /balance = VALUES\(balance\)/.test(s.sql)))
|
||||
assert.ok(live.touching('rust_player_wipe_stats').some((s) => /npc_kills = npc_kills \+/.test(s.sql)))
|
||||
})
|
||||
|
||||
test('ecowipe clears the wipe’s balances; a new save keeps the last wipe’s as its record', async () => {
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
const rec = withRecorder()
|
||||
await apply('main', item('economics.wiped', { newSave: false }))
|
||||
const cleared = rec.touching('rust_player_wipe_stats')
|
||||
assert.strictEqual(cleared.length, 1)
|
||||
assert.match(cleared[0].sql, /SET balance = NULL WHERE server_id = \? AND wipe_id = \?/)
|
||||
assert.deepStrictEqual(cleared[0].params, ['main', 'w1'])
|
||||
|
||||
const kept = withRecorder()
|
||||
await apply('main', item('economics.wiped', { newSave: true }))
|
||||
assert.strictEqual(kept.touching('rust_player_wipe_stats').length, 0)
|
||||
})
|
||||
|
||||
// ── The richest (D336) ─────────────────────────────────────────────────────
|
||||
|
||||
test('the richest column ranks one wipe, above the starting balance', async () => {
|
||||
const rec = withRecorder()
|
||||
await eventsDb.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'balance', limit: 25, balanceFloor: 1000 })
|
||||
const [q] = rec.statements
|
||||
assert.match(q.sql, /HAVING MAX\(s\.balance\) > \?/)
|
||||
assert.match(q.sql, /ORDER BY MAX\(s\.balance\) DESC/)
|
||||
assert.deepStrictEqual(q.params, ['main', 'w1', 1000, 25])
|
||||
|
||||
// All time has no balance: the sort falls back, and no row carries one.
|
||||
const all = withRecorder([{ steamId: A, kills: 3, balance: 5000 }])
|
||||
const rows = await eventsModel.leaderboard({ serverId: 'main', wipeId: null, sort: 'balance', economics: { startingBalance: 1000 } })
|
||||
assert.doesNotMatch(all.statements[0].sql, /HAVING/)
|
||||
assert.match(all.statements[0].sql, /ORDER BY SUM\(s\.kills\) DESC/)
|
||||
assert.ok(!('balance' in rows[0]))
|
||||
|
||||
// One wipe on a server with Economics: every row carries its balance, null when none.
|
||||
withRecorder([{ steamId: A, balance: '2500.00' }, { steamId: B, balance: null }])
|
||||
const wipe = await eventsModel.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'kills', economics: { startingBalance: 1000 } })
|
||||
assert.strictEqual(wipe[0].balance, 2500)
|
||||
assert.strictEqual(wipe[1].balance, null)
|
||||
})
|
||||
|
||||
test('Magnate ranks nobody without a floor, and only balances above it with one', async () => {
|
||||
const none = withRecorder()
|
||||
assert.deepStrictEqual(await titlesDb.standings({ serverId: 'main', wipeId: 'w1', source: { balance: 'balance' }, limit: 3 }), [])
|
||||
assert.strictEqual(none.statements.length, 0)
|
||||
|
||||
const rec = withRecorder()
|
||||
await titlesDb.standings({ serverId: 'main', wipeId: 'w1', source: { balance: 'balance' }, limit: 3, floor: 1000 })
|
||||
assert.match(rec.statements[0].sql, /s\.balance > \?/)
|
||||
assert.deepStrictEqual(rec.statements[0].params, ['main', 'w1', 1000, 3])
|
||||
})
|
||||
|
||||
// ── The step ───────────────────────────────────────────────────────────────
|
||||
|
||||
const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
|
||||
const pay = rewards.ACTIONS.find((a) => a.id === 'rust.coins.pay')
|
||||
|
||||
function stub(t, { loaded = true, people = [], links = {}, payAnswer } = {}) {
|
||||
const calls = { pay: [] }
|
||||
const saved = {
|
||||
getServer: serversDb.getServer,
|
||||
reportFor: economicsDb.reportFor,
|
||||
tallySnapshot: client.tallySnapshot,
|
||||
economicsPay: client.economicsPay,
|
||||
userIdsForSteamIds: linksDb.userIdsForSteamIds,
|
||||
}
|
||||
|
||||
serversDb.getServer = async (id) => (id === 'main' ? ROW : null)
|
||||
economicsDb.reportFor = async () => ({ serverId: 'main', wipeId: 'w1', economics: loaded ? JSON.stringify({ loaded: true, startingBalance: 1000 }) : null })
|
||||
client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 })
|
||||
client.economicsPay = async (server, body) => {
|
||||
calls.pay.push(body)
|
||||
return payAnswer ? payAnswer(body) : ok({ kind: 'economics.paid', key: body.key, repeat: false, paid: [], failed: [] })
|
||||
}
|
||||
linksDb.userIdsForSteamIds = async (ids) => ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
|
||||
|
||||
t.after(() => {
|
||||
serversDb.getServer = saved.getServer
|
||||
economicsDb.reportFor = saved.reportFor
|
||||
client.tallySnapshot = saved.tallySnapshot
|
||||
client.economicsPay = saved.economicsPay
|
||||
linksDb.userIdsForSteamIds = saved.userIdsForSteamIds
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
const PARAMS = { server: 'main', amount: 500, recipients: 'everyone', message: 'You earned {amount} coins', delivery: 'popup' }
|
||||
|
||||
test('pay coins is irreversible and kept: no ledger, no revert (D335)', () => {
|
||||
assert.strictEqual(pay.risk, 'irreversible')
|
||||
assert.strictEqual(pay.reversible, 'none')
|
||||
assert.strictEqual(pay.revert, undefined)
|
||||
})
|
||||
|
||||
test('a server without Economics is refused in the dry run, with the reason (D199)', async (t) => {
|
||||
stub(t, { loaded: false })
|
||||
const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: true })
|
||||
assert.strictEqual(result.ok, false)
|
||||
assert.strictEqual(result.retry, false)
|
||||
assert.match(result.error, /Economics is not installed on this server/)
|
||||
})
|
||||
|
||||
test('coins go once to each website user, with the key, the amount and the message', async (t) => {
|
||||
const person = (steamId, score) => ({ steamId, name: `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 })
|
||||
const calls = stub(t, {
|
||||
// A and B are one person (user 1); C has linked nothing; D is user 2.
|
||||
people: [person(A, 9), person(B, 5), person(C, 3), person(D, 1)],
|
||||
links: { [A]: 1, [B]: 1, [D]: 2 },
|
||||
payAnswer: (body) =>
|
||||
ok({
|
||||
kind: 'economics.paid',
|
||||
key: body.key,
|
||||
repeat: false,
|
||||
paid: [{ steamId: A, before: 1000, after: 1500 }],
|
||||
failed: [{ steamId: D, reason: 'at-limit' }],
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
|
||||
|
||||
assert.strictEqual(result.ok, true)
|
||||
assert.deepStrictEqual(calls.pay, [
|
||||
{ key: 'k-1', amount: 500, steamIds: [A, D], message: 'You earned {amount} coins', delivery: 'popup' },
|
||||
])
|
||||
assert.strictEqual(result.detail.paid, 1)
|
||||
assert.strictEqual(result.detail.total, 500)
|
||||
assert.deepStrictEqual(result.detail.received, [{ name: 'p1', received: 500 }])
|
||||
assert.deepStrictEqual(result.detail.failed, [{ name: 'p4', reason: 'at-limit' }])
|
||||
assert.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: [] }) })
|
||||
const repeat = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
|
||||
assert.strictEqual(repeat.ok, true)
|
||||
assert.strictEqual(repeat.detail.repeat, true)
|
||||
|
||||
client.economicsPay = async () => ({ ok: false, status: 'timeout' })
|
||||
const lost = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
|
||||
assert.strictEqual(lost.ok, false)
|
||||
assert.notStrictEqual(lost.retry, false, 'a timeout is retried, and the key keeps it to one payment')
|
||||
|
||||
client.economicsPay = async () => ok({ kind: 'economics.error', reason: 'economics-missing', message: 'Economics is not installed on this server' })
|
||||
const missing = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
|
||||
assert.strictEqual(missing.retry, false)
|
||||
})
|
||||
|
||||
test('a payment is priced at the most it could pay, and its message is measured with the amount in it', () => {
|
||||
assert.deepStrictEqual(pay.cost({ amount: 500, recipients: 'top', count: 3 }), { 'rust.coins': 1500 })
|
||||
assert.deepStrictEqual(pay.cost({ amount: 2.5, recipients: 'everyone' }), { 'rust.coins': 250 })
|
||||
|
||||
assert.strictEqual(rewards.coinsNotice({ message: '' }, 500).text, null)
|
||||
assert.strictEqual(rewards.coinsNotice({ message: `${'x'.repeat(250)}{amount}` }, 1000000).ok, false)
|
||||
assert.strictEqual(rewards.coinsNotice({ message: 'Got {amount}', delivery: 'carrier pigeon' }, 5).ok, false)
|
||||
|
||||
const key = rewards.paymentKey('k'.repeat(300), 7, 4)
|
||||
assert.ok(key.startsWith('coins:') && key.length <= 120)
|
||||
assert.strictEqual(rewards.paymentKey('k-1', 7, 4), 'k-1')
|
||||
})
|
||||
|
||||
test('an amount outside 0.01 to 1,000,000 is refused before anything is asked', async (t) => {
|
||||
stub(t)
|
||||
for (const amount of [0, -5, 1000001, 'lots']) {
|
||||
const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, amount }, verify: true })
|
||||
assert.strictEqual(result.ok, false, String(amount))
|
||||
assert.strictEqual(result.retry, false)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Staff ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('an adjustment checks its op and amount; only a set may be 0', () => {
|
||||
assert.strictEqual(economics.checkAdjustment('steal', 5).ok, false)
|
||||
assert.strictEqual(economics.checkAdjustment('deposit', 0).ok, false)
|
||||
assert.strictEqual(economics.checkAdjustment('set', 0).ok, true)
|
||||
assert.strictEqual(economics.checkAdjustment('withdraw', 1000001).ok, false)
|
||||
assert.strictEqual(economics.checkAdjustment('deposit', 12.345).value, 12.35)
|
||||
})
|
||||
|
||||
test('a withdrawal above the balance is refused with the balance', async (t) => {
|
||||
const saved = { getForCalling: servers.getForCalling, reportFor: economicsDb.reportFor, economicsAdjust: client.economicsAdjust }
|
||||
t.after(() => Object.assign(servers, { getForCalling: saved.getForCalling }) && Object.assign(economicsDb, { reportFor: saved.reportFor }) && Object.assign(client, { economicsAdjust: saved.economicsAdjust }))
|
||||
|
||||
const sent = []
|
||||
servers.getForCalling = async () => ({ id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' })
|
||||
economicsDb.reportFor = async () => ({ serverId: 'main', wipeId: 'w1', economics: { loaded: true } })
|
||||
client.economicsAdjust = async (server, body) => {
|
||||
sent.push(body)
|
||||
return ok({ kind: 'economics.error', reason: 'insufficient', balance: 340 })
|
||||
}
|
||||
|
||||
const result = await economics.adjust({ serverId: 'main', steamId: A, op: 'withdraw', amount: 500, key: 'adj-1' })
|
||||
assert.strictEqual(result.ok, false)
|
||||
assert.strictEqual(result.status, 409)
|
||||
assert.match(result.message, /only 340 coins on Main/)
|
||||
assert.deepStrictEqual(sent, [{ key: 'adj-1', steamId: A, op: 'withdraw', amount: 500 }])
|
||||
|
||||
// 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)
|
||||
assert.strictEqual(missing.message, economics.MISSING)
|
||||
})
|
||||
|
||||
test('the activity row says what moved', () => {
|
||||
assert.strictEqual(
|
||||
economics.adjustmentSummary({ op: 'deposit', amount: 500, name: 'Wanderer', server: 'Main', before: 1000, after: 1500 }),
|
||||
'Deposited 500 coins to Wanderer on Main: 1,000 → 1,500',
|
||||
)
|
||||
assert.strictEqual(
|
||||
economics.adjustmentSummary({ op: 'set', amount: 0, name: 'Wanderer', server: 'Main', before: 1000, after: 0 }),
|
||||
'Set the balance of Wanderer on Main to 0: 1,000 → 0',
|
||||
)
|
||||
})
|
||||
@@ -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+/)
|
||||
@@ -156,18 +163,37 @@ test('the event verbs are registered, and every budget has a verb that spends it
|
||||
const budgets = api.record.eventBudgets
|
||||
assert.deepStrictEqual(actions.map((a) => a.id).sort(), [
|
||||
'rust.announce',
|
||||
'rust.backpack.size',
|
||||
'rust.coins.pay',
|
||||
'rust.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',
|
||||
])
|
||||
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
|
||||
'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',
|
||||
])
|
||||
|
||||
@@ -178,7 +204,8 @@ test('the event verbs are registered, and every budget has a verb that spends it
|
||||
// operator. The two tally verbs spend nothing: counting people costs no loot.
|
||||
const spent = new Set()
|
||||
for (const a of actions) {
|
||||
const example = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
|
||||
// A list param (MODULE_API 1.14.0) is priced as core prices it: its one example as a list of one.
|
||||
const example = Object.fromEntries(a.params.map((p) => [p.name, p.multiple ? [p.example] : p.example]))
|
||||
const dims = Object.keys(a.cost(example)).filter((id) => a.cost(example)[id] > 0)
|
||||
if (a.id.startsWith('rust.participation.')) {
|
||||
assert.strictEqual(dims.length, 0, `${a.id} prices ${dims.join(', ')}`)
|
||||
@@ -189,17 +216,22 @@ test('the event verbs are registered, and every budget has a verb that spends it
|
||||
}
|
||||
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
|
||||
|
||||
// What a teardown can give back is declared honestly: a line said and a
|
||||
// collect filed are not undone.
|
||||
const once = new Set(['rust.announce', 'rust.participation.collect'])
|
||||
// What a teardown can give back is declared honestly: a line said, a
|
||||
// collect filed, coins paid (D335) and permissions given for good (D346) are
|
||||
// not undone, and nor is a world event called (D354): a cargo ship or a drop
|
||||
// may already hold players or their loot; nor a Dangerous Treasures event
|
||||
// started, which DT despawns itself (§14.3.4); nor a guarded crate placed,
|
||||
// which Guarded Crate cannot end alone (§14.3.5, D373); 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)
|
||||
if (once.has(a.id)) continue
|
||||
assert.strictEqual(typeof a.revert, 'function')
|
||||
assert.strictEqual(typeof a.reconcile, 'function')
|
||||
// Every param source is one this module registers.
|
||||
const sources = new Set(api.record.eventOptionSources.map((s) => s.id))
|
||||
for (const p of a.params) if (p.source) assert.ok(sources.has(p.source), `${a.id}.${p.name} names ${p.source}`)
|
||||
if (once.has(a.id)) continue
|
||||
assert.strictEqual(typeof a.revert, 'function')
|
||||
assert.strictEqual(typeof a.reconcile, 'function')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
372
server/test/guardedCrate.test.js
Normal file
372
server/test/guardedCrate.test.js
Normal 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)
|
||||
})
|
||||
@@ -45,6 +45,10 @@ test('the player tier serves the identity routes, the entitlement read, and noth
|
||||
routes.map((r) => `${r.method} ${r.path}`).sort(),
|
||||
[
|
||||
'DELETE /links/:steamId',
|
||||
// Protocol 14 (§11.3.2): the caller's own backpack, read live. Read-only.
|
||||
'GET /backpacks',
|
||||
// Protocol 14 (§11.2.2): the caller's own coin balances. Read-only.
|
||||
'GET /economics',
|
||||
'GET /links',
|
||||
// Phase 8: what the site has given the caller in game. Read-only on this
|
||||
// tier by construction — the authoring routes are all admin.
|
||||
@@ -88,8 +92,13 @@ test('the admin.users.detail router merges the parent’s params and keeps its o
|
||||
'DELETE /rust/links/:steamId',
|
||||
// Phase 7 filled the same panel with what this person may do in game.
|
||||
'DELETE /rust/permissions/grants/:grantId',
|
||||
// Protocol 14 (§11.3.2): what they carry, read live. There is no write.
|
||||
'GET /rust/backpacks',
|
||||
// Protocol 14 (§11.2.2): their coins, and staff's hand on them.
|
||||
'GET /rust/economics',
|
||||
'GET /rust/links',
|
||||
'GET /rust/permissions',
|
||||
'POST /rust/economics',
|
||||
'POST /rust/permissions/grants',
|
||||
])
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
113
server/test/liveStatus.test.js
Normal file
113
server/test/liveStatus.test.js
Normal 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 })
|
||||
})
|
||||
@@ -84,17 +84,19 @@ const LIVE = {
|
||||
{ steamId: '7005', name: 'My alt, asleep', x: 5, z: 5, sleeping: true, online: false },
|
||||
],
|
||||
bases: [{ kind: 'tc', x: 9, z: 9 }],
|
||||
pvp: [{ zoneId: '1412203045', event: 'Helicopter', x: 30, z: 40, radius: 100, shape: 'sphere' }],
|
||||
}
|
||||
|
||||
test('a fresh install shows the world and events to anybody, and positions to staff only', async (t) => {
|
||||
settings(t)
|
||||
assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', mates: true, markers: {} })
|
||||
assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', pvp: 'public', raids: 'public', mates: true, markers: {} })
|
||||
|
||||
const acc = await map.access({}, 'main')
|
||||
assert.equal(acc.layers.world.visible, true)
|
||||
assert.equal(acc.layers.events.visible, true)
|
||||
assert.equal(acc.layers.players.visible, false)
|
||||
assert.equal(acc.layers.bases.visible, false)
|
||||
assert.equal(acc.layers.pvp.visible, true, 'Dynamic PVP zones name nobody (§14.3.3)')
|
||||
assert.equal(acc.mates.visible, false, 'an anonymous viewer has no accounts to be shown')
|
||||
})
|
||||
|
||||
@@ -158,6 +160,19 @@ test('a hidden layer is absent from the answer, not an empty array', async (t) =
|
||||
}
|
||||
})
|
||||
|
||||
test('§14.3.3: the pvp layer passes Dynamic PVP zones through, is empty from an older plugin, and can be hidden', async (t) => {
|
||||
settings(t)
|
||||
assert.deepStrictEqual(map.project(LIVE, await map.access({}, 'main')).pvp, LIVE.pvp)
|
||||
const { pvp, ...older } = LIVE
|
||||
assert.deepStrictEqual(map.project(older, await map.access({}, 'main')).pvp, [])
|
||||
})
|
||||
|
||||
test('§14.3.3: a pvp layer set to staff is absent for the public', async (t) => {
|
||||
settings(t, { stored: { 'map.layer.pvp.audience': 'staff' } })
|
||||
const out = map.project(LIVE, await map.access({}, 'main'))
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(out, 'pvp'), false)
|
||||
})
|
||||
|
||||
test('staff get every layer', async (t) => {
|
||||
settings(t, { viewer: { level: 'staff', userId: 1 } })
|
||||
const out = map.project({ ...LIVE, basesTruncated: true }, await map.access({}, 'main'))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user