diff --git a/README.md b/README.md index 98f1c02..17668bd 100644 --- a/README.md +++ b/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.`), 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 diff --git a/ci/bundle.json b/ci/bundle.json index 80783ea..6112661 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -32,23 +32,36 @@ "commands", "configEdit.js", "core.js", + "dangerousTreasures.js", "db", + "dynamicPvp.js", + "eventCommands.js", "engagement", + "eventDangerousTreasures.js", + "eventGuardedCrate.js", "eventLeases.js", + "eventPermRewards.js", + "eventRaidableBases.js", "eventProgress.js", "eventRewards.js", + "eventTemplates.js", "eventWorld.js", + "eventWorldCall.js", + "guardedCrate.js", "index.js", "ingest.js", + "liveStatus.js", "mapImages.js", "mapLive.js", "model", "npcSync.js", "package.json", "permSync.js", + "raidableBases.js", "router", "sidecarClient.js", - "titleSync.js" + "titleSync.js", + "worldEvents.js" ], "root": [ "engagement-triggers.json", diff --git a/ci/core-ref.json b/ci/core-ref.json index bd3dea6..753e214 100644 --- a/ci/core-ref.json +++ b/ci/core-ref.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.", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", - "ref": "f0e7d2aa2a4dbdd848f92648085181611b7418f0", - "refName": "main @ MODULE_API 1.11.0 (website#209 merged)" + "ref": "6890a74c7c4ad5690509e8c163e477d7f7b399e7", + "refName": "main @ website#220 merge (the cutover), MODULE_API 1.17.0" } diff --git a/client/src/api.js b/client/src/api.js index ee68435..66cf202 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -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, } diff --git a/client/src/components/Backpack.jsx b/client/src/components/Backpack.jsx new file mode 100644 index 0000000..ca179c1 --- /dev/null +++ b/client/src/components/Backpack.jsx @@ -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 ( +
+ {stack && ( + <> + {stackLabel(stack)} + {stack.name && “{stack.name}”} + {(stack.contents || []).map((c) => ( + + {stackLabel(c)}{c.amount > 1 ? ` ×${count(c.amount)}` : ''} + ))} + + {stack.skin ? 'skinned' : ''} + {stack.amount > 1 && ×{count(stack.amount)}} + + + )} +
+ ) +} + +/** The grid itself, one block per page. */ +export function BackpackGrid({ backpack }) { + if (!backpack.exists) { + return

No backpack on {backpack.server} yet — it has never been opened.

+ } + + const pages = backpackPages(backpack) + const waiting = overflowCount(backpack) + const held = (backpack.items || []).length + + return ( +
+

+ {count(held)} stack{held === 1 ? '' : 's'} in {count(backpack.capacity)} slots on {backpack.server} + {waiting > 0 && ( + + {' '}· {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 + + )} +

+ {pages.map((page) => ( +
+ {pages.length > 1 &&
Page {page.index + 1}
} +
+ {page.cells.map((cell) => ( + + ))} +
+
+ ))} +
+ ) +} + +/** + * 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 ( +
+
+ {servers.length > 1 && ( + + )} + {accounts.length > 1 && ( + + )} + +
+ {error &&

{error}

} + {backpack && } +
+ ) +} diff --git a/client/src/components/Leaderboard.jsx b/client/src/components/Leaderboard.jsx index f389b66..48588b0 100644 --- a/client/src/components/Leaderboard.jsx +++ b/client/src/components/Leaderboard.jsx @@ -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 }) { ) @@ -110,7 +143,7 @@ function Overall({ serverId, wipeId, sort, onSort }) { Player - {COLUMNS.map((column) => ( + {columns.map((column) => ( {column.sort ? ( {Math.round(entry.bytes / 102.4) / 10} KB diff --git a/client/src/routes/admin/Permissions.jsx b/client/src/routes/admin/Permissions.jsx index 5fc73b5..c76d486 100644 --- a/client/src/routes/admin/Permissions.jsx +++ b/client/src/routes/admin/Permissions.jsx @@ -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 = not registered here else if (facts.notLanded.has(`${player.steamId}:${permission}`)) chip = did not land else if (facts.landed.has(`grant ${player.steamId} ${permission}`)) chip = granted else chip = waiting for a sync - 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 }) { {perm} {s.fromEvent && from an event} + {s.given && given by {s.given}} {s.chip} toggle(perm, on)} /> @@ -777,7 +781,13 @@ export default function Permissions() { {sync && ( - sync.inSync ? in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''} + sync.inSync && sync.waitingToLand > 0 + ? ( + + in sync · {sync.waitingToLand} {sync.waitingToLand === 1 ? 'waits' : 'wait'} for their player to join + + ) + : sync.inSync ? in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''} : sync.state === 'failed' ? last sync failed : waiting for a sync )} diff --git a/client/src/routes/admin/ServerSettings.jsx b/client/src/routes/admin/ServerSettings.jsx index 8ee4264..337e4c9 100644 --- a/client/src/routes/admin/ServerSettings.jsx +++ b/client/src/routes/admin/ServerSettings.jsx @@ -38,6 +38,16 @@ const RULES = [ { id: 'biweekly', label: 'Every other week', hint: 'Every second week, on the weeks the date below falls in, and the forced wipe.' }, ] +// Who a downed patrol helicopter, Chinook or Bradley credits (protocol 14, D372). +// The same rule writes the feed line and the Rotorbane / Tankbreaker counts; the +// server's own list, mirrored here so the form needs no round trip. +const CREDIT_RULES = [ + { id: 'blow', label: 'Killing blow', hint: 'The player whose hit destroyed it — for the patrol helicopter, the crippling hit, else the last hit. The default.' }, + { id: 'last', label: 'Last hit', hint: 'The last player to hurt it.' }, + { id: 'most', label: 'Most damage', hint: 'The player who dealt it the most damage.' }, + { id: 'all', label: 'Everyone who hurt it', hint: 'Every player who damaged it, one each.' }, +] + const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] const SOURCE = { forced: 'the forced wipe', rule: 'its own schedule', once: 'the one-off date' } @@ -110,6 +120,7 @@ function blankForm() { wipeTz: localZone(), wipeAnchor: '', wipeOnce: '', + creditRule: 'blow', } } @@ -131,6 +142,7 @@ function formFrom(server) { wipeAnchor: s.anchor || '', wipeOnce: toLocalInput(s.onceAt), storedOnceAt: s.onceAt || null, + creditRule: server.creditRule || 'blow', } } @@ -155,6 +167,7 @@ function bodyFrom(form) { ? null : new Date(form.wipeOnce).toISOString(), } + if (form.creditRule) body.creditRule = form.creditRule if (form.sidecarToken) body.sidecarToken = form.sidecarToken if (form.protocol !== undefined) body.protocol = form.protocol return body @@ -409,6 +422,17 @@ function ServerForm({ form, set, busy, error, onSave, onCancel }) {

)} +
+ World events + r.id === form.creditRule) || CREDIT_RULES[0]).hint} It names them in the feed and counts toward Rotorbane and Tankbreaker, from now on.`} + > + + +
{ZONES.map((z) =>
diff --git a/client/src/routes/admin/UserRustSections.jsx b/client/src/routes/admin/UserRustSections.jsx index e3d8fc9..2e99661 100644 --- a/client/src/routes/admin/UserRustSections.jsx +++ b/client/src/routes/admin/UserRustSections.jsx @@ -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 ( +
+ {server.server}{' '} + {server.reason} +
+ ) + } + + 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 ( +
+
+ {server.server}{' '} + {server.accounts.map((a, i) => ( + + {i > 0 && · } + {server.accounts.length > 1 && {a.name || a.steamId} } + {coins(a.balance)} + {a.source === 'last-known' && (last known)} + + ))} + {!server.startingKnown && ( + + {' '}· its Economics config could not be read, so a starting balance of {coins(server.startingBalance)} is assumed + + )} +
+ +
+ {server.accounts.length > 1 && ( + + )} + + setAmount(e.target.value)} + aria-label="Coins" + style={{ width: 120 }} + /> + +
+ + {done &&

Done: {done}. Recorded in the activity log.

} + {error &&

{error}

} +
+ ) +} + +/** + * 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 ( +
+
Coins
+ {list.map((server) => ( + + ))} +
+ ) +} + +/** + * 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 ( +
+
Backpack
+ ({ 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)} + /> +
+ ) +} + 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. */} + + {/* Coins belong to a Steam account, so only with one linked. */} + {data.links.length > 0 && } + + {/* A backpack belongs to a Steam account too. */} + {data.links.length > 0 && }
) diff --git a/client/src/routes/admin/Visibility.jsx b/client/src/routes/admin/Visibility.jsx index dbdd74f..f9505a7 100644 --- a/client/src/routes/admin/Visibility.jsx +++ b/client/src/routes/admin/Visibility.jsx @@ -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 } diff --git a/client/src/routes/player/Account.jsx b/client/src/routes/player/Account.jsx index 1b7bed3..cc71497 100644 --- a/client/src/routes/player/Account.jsx +++ b/client/src/routes/player/Account.jsx @@ -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 + if (error) return + const list = (data && data.servers) || [] + if (list.length === 0) return

No servers yet.

+ return ( +
    + {list.map((server) => ( +
  • + {server.server}: + {!server.economics ? ( + {server.reason} + ) : ( + server.accounts.map((a, i) => ( + + {i > 0 && · } + {server.accounts.length > 1 && {a.name || shortId(a.steamId)} } + {coins(a.balance)} + {a.source === 'last-known' && (last known — the server did not answer)} + + )) + )} +
  • + ))} +
+ ) +} + +/** + * 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 ( + ({ 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() {
  • Type it below. It works once.
  • + {/* 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. */} +

    + If this site offers Steam sign-in, linking Steam on your account + page links that Steam account here too. Unlinking it there only stops you signing in with it; remove a + Rust link here. +

    + {loading && } @@ -360,6 +423,12 @@ export default function Account() {
    NPCs you have killed
    {data && links.length > 0 ? :

    Link a Steam account to see them.

    } + +
    Coins
    + {data && links.length > 0 ? :

    Link a Steam account to see your balance.

    } + +
    Backpack
    + {data && links.length > 0 ? :

    Link a Steam account to see your backpack.

    } ) } diff --git a/client/test/backpack.test.js b/client/test/backpack.test.js new file mode 100644 index 0000000..f065878 --- /dev/null +++ b/client/test/backpack.test.js @@ -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), '') +}) diff --git a/client/test/feed.test.js b/client/test/feed.test.js index d01743f..b17233e 100644 --- a/client/test/feed.test.js +++ b/client/test/feed.test.js @@ -155,3 +155,186 @@ 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') +}) diff --git a/client/test/format.test.js b/client/test/format.test.js index a51bf56..1abdc86 100644 --- a/client/test/format.test.js +++ b/client/test/format.test.js @@ -11,7 +11,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { ago, attacker, clock, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js' +import { ago, attacker, clock, coins, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js' const NOW = Date.parse('2026-09-16T12:00:00Z') @@ -70,6 +70,13 @@ test('a count that is not a number is zero, never NaN on the page', () => { assert.equal(count(null), '0') }) +test('a balance never reported is a dash, not zero coins (PLAN_REDESIGNS §11.2.2)', () => { + assert.equal(coins(null), '—') + assert.equal(coins(undefined), '—') + assert.equal(coins(0), (0).toLocaleString()) + assert.equal(coins(1500.456), (1500.46).toLocaleString(undefined, { maximumFractionDigits: 2 })) +}) + test("a feed row from another day carries its date, not just a time", () => { // Found by the page walk: with the feed filtered to the previous wipe, three // events from six weeks ago rendered as `02:03 PM` and read as this afternoon. diff --git a/client/test/mapEvents.test.js b/client/test/mapEvents.test.js index 1a06fbb..0ecae2e 100644 --- a/client/test/mapEvents.test.js +++ b/client/test/mapEvents.test.js @@ -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') +}) diff --git a/engagement-triggers.json b/engagement-triggers.json index 18096a3..5d7fe3e 100644 --- a/engagement-triggers.json +++ b/engagement-triggers.json @@ -1,6 +1,6 @@ { "_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.", - "coreApi": "^1.11.0", + "coreApi": "^1.17.0", "triggers": [ { "id": "rust.base.destroyed", @@ -555,6 +555,285 @@ } ] }, + { + "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": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:dt.won", + "description": "The server and the event. The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "byEvent", + "type": "boolean", + "required": true, + "example": false, + "description": "Whether one of this site's events started it (a Start a Dangerous Treasures event step)." + } + ] + }, + { + "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": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:dt.won", + "description": "The server and the event. The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "player", + "type": "string", + "required": 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." + } + ] + }, + { + "id": "rust.gc.ended", + "label": "A guarded crate was cleared", + "description": "A player killed the last guard of a guarded crate on a server.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:gc.ended", + "description": "The server and the event. The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "player", + "type": "string", + "required": true, + "example": "Marisol", + "description": "Who killed the last guard, by in-game name." + } + ] + }, + { + "id": "rust.gc.started", + "label": "A guarded crate landed", + "description": "Guarded Crate dropped a locked crate ringed by guards on a server: on its own schedule, by an admin, or by an event.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:gc.started", + "description": "The server and the event. The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "byEvent", + "type": "boolean", + "required": true, + "example": false, + "description": "Whether one of this site's events placed it (a Place a guarded crate step)." + } + ] + }, { "id": "rust.kit.entitled", "label": "An event rewarded you a kit", @@ -1370,6 +1649,1603 @@ "description": "The new wipe's identity." } ] + }, + { + "id": "rust.world.airdrop.called", + "label": "An airdrop is on its way", + "description": "A cargo plane is bringing an airdrop: called by a supply signal, or by the schedule.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "player", + "type": "string", + "required": false, + "example": "Marisol", + "description": "The player it names, by in-game name. Absent when nobody is named." + } + ] + }, + { + "id": "rust.world.airdrop.landed", + "label": "An airdrop landed", + "description": "An airdrop reached the ground.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "player", + "type": "string", + "required": false, + "example": "Marisol", + "description": "The player it names, by in-game name. Absent when nobody is named." + } + ] + }, + { + "id": "rust.world.airdrop.looted", + "label": "An airdrop was looted", + "description": "Somebody opened an airdrop first.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "player", + "type": "string", + "required": false, + "example": "Marisol", + "description": "The player it names, by in-game name. Absent when nobody is named." + } + ] + }, + { + "id": "rust.world.bradley.destroyed", + "label": "A Bradley was destroyed", + "description": "Players destroyed a Bradley APC: Launch Site's, a road one, or another.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "where", + "type": "string", + "required": true, + "example": "launch_site", + "description": "Where it is: launch_site, road or other." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "player", + "type": "string", + "required": 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." + } + ] + }, + { + "id": "rust.world.bradley.spawned", + "label": "Bradley is out", + "description": "Launch Site's Bradley spawned, or road Bradleys took to the roads.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "where", + "type": "string", + "required": true, + "example": "road", + "description": "Where it is: launch_site or road." + }, + { + "name": "count", + "type": "int", + "required": true, + "example": 12, + "description": "How many: road Bradleys come several at once." + } + ] + }, + { + "id": "rust.world.cargo.arrived", + "label": "The cargo ship arrived", + "description": "The cargo ship sailed onto the map.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + } + ] + }, + { + "id": "rust.world.cargo.docked", + "label": "The cargo ship docked", + "description": "The cargo ship docked at a harbour, with fresh crates.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "harbour", + "type": "string", + "required": false, + "example": "Harbor", + "description": "The harbour, by the game's own name for it." + } + ] + }, + { + "id": "rust.world.cargo.docking", + "label": "The cargo ship is docking", + "description": "The cargo ship turned in to dock at a harbour.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "harbour", + "type": "string", + "required": false, + "example": "Harbor", + "description": "The harbour, by the game's own name for it." + } + ] + }, + { + "id": "rust.world.cargo.left", + "label": "The cargo ship is leaving", + "description": "The cargo ship turned for the edge of the map.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + } + ] + }, + { + "id": "rust.world.cargo.undocked", + "label": "The cargo ship cast off", + "description": "The cargo ship left the harbour it was docked at.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "harbour", + "type": "string", + "required": false, + "example": "Harbor", + "description": "The harbour, by the game's own name for it." + } + ] + }, + { + "id": "rust.world.chinook.arrived", + "label": "A Chinook arrived", + "description": "A Chinook flew onto the map with a locked crate.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + } + ] + }, + { + "id": "rust.world.chinook.downed", + "label": "A Chinook was shot down", + "description": "Players shot down a Chinook.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "player", + "type": "string", + "required": 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." + } + ] + }, + { + "id": "rust.world.chinook.dropped", + "label": "A Chinook dropped its crate", + "description": "A Chinook dropped a locked crate at a monument.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "monument", + "type": "string", + "required": false, + "example": "Water Treatment Plant", + "description": "The monument it dropped at, by the game's own name for it." + } + ] + }, + { + "id": "rust.world.crate.hack.started", + "label": "A locked crate is being hacked", + "description": "Somebody started hacking a locked crate. On an oil rig this also calls its scientists.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "where", + "type": "string", + "required": true, + "example": "oilrig_large", + "description": "Where it is: oilrig_large, oilrig_small, cargo, ghostship or chinook (a crate a Chinook dropped)." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "player", + "type": "string", + "required": false, + "example": "Marisol", + "description": "The player it names, by in-game name. Absent when nobody is named." + } + ] + }, + { + "id": "rust.world.crate.looted", + "label": "A locked crate was looted", + "description": "Somebody opened an unlocked locked crate first.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "where", + "type": "string", + "required": true, + "example": "oilrig_large", + "description": "Where it is: oilrig_large, oilrig_small, cargo, ghostship or chinook (a crate a Chinook dropped)." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "player", + "type": "string", + "required": false, + "example": "Marisol", + "description": "The player it names, by in-game name. Absent when nobody is named." + } + ] + }, + { + "id": "rust.world.crate.unlocked", + "label": "A locked crate unlocked", + "description": "A hacked locked crate finished unlocking.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "where", + "type": "string", + "required": true, + "example": "oilrig_large", + "description": "Where it is: oilrig_large, oilrig_small, cargo, ghostship or chinook (a crate a Chinook dropped)." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "player", + "type": "string", + "required": false, + "example": "Marisol", + "description": "The player it names, by in-game name. Absent when nobody is named." + } + ] + }, + { + "id": "rust.world.deepsea.closed", + "label": "The Deep Sea closed", + "description": "The Deep Sea closed.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + } + ] + }, + { + "id": "rust.world.deepsea.opened", + "label": "The Deep Sea opened", + "description": "The Deep Sea is open: its ghost ships and islands can be reached.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + } + ] + }, + { + "id": "rust.world.heli.arrived", + "label": "A patrol helicopter arrived", + "description": "A patrol helicopter flew onto the map.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + } + ] + }, + { + "id": "rust.world.heli.downed", + "label": "A patrol helicopter was downed", + "description": "Players brought down a patrol helicopter.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + }, + { + "name": "player", + "type": "string", + "required": 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." + } + ] + }, + { + "id": "rust.world.heli.left", + "label": "The patrol helicopter left", + "description": "A patrol helicopter left the map without being downed.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + } + ] + }, + { + "id": "rust.world.satellite.crashed", + "label": "A satellite crashed", + "description": "A satellite came down, and its crates are burning.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + } + ] + }, + { + "id": "rust.world.vendor.arrived", + "label": "The Travelling Vendor arrived", + "description": "The Travelling Vendor drove onto the map.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + } + ] + }, + { + "id": "rust.world.vendor.left", + "label": "The Travelling Vendor left", + "description": "The Travelling Vendor left the map.", + "kind": "event", + "subjectKey": "subject", + "audience": "subscribers", + "ceiling": "everyone", + "version": 1, + "variables": [ + { + "name": "serverId", + "type": "string", + "required": true, + "example": "main", + "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts." + }, + { + "name": "server", + "type": "string", + "required": true, + "example": "Runic Gateway | Main", + "description": "The server's display name." + }, + { + "name": "serverUrl", + "type": "url", + "required": false, + "example": "/rust/servers/main", + "description": "Site-relative path to the server's page." + }, + { + "name": "title", + "type": "string", + "required": false, + "example": "Main is back online", + "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title." + }, + { + "name": "intro", + "type": "string", + "required": false, + "example": "Main is back up and taking players.", + "description": "One sentence of detail. Core generic bodies use it as the body." + }, + { + "name": "subject", + "type": "string", + "required": true, + "example": "main:heli.downed", + "description": "The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display." + }, + { + "name": "grid", + "type": "string", + "required": false, + "example": "G7", + "description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map." + } + ] } ], "streams": [ diff --git a/module.json b/module.json index 8a5db29..4aba531 100644 --- a/module.json +++ b/module.json @@ -2,7 +2,7 @@ "id": "rust", "name": "Rust", "version": "0.1.0", - "coreApi": "^1.11.0", + "coreApi": "^1.17.0", "server": "server/index.js", "client": { "entry": "client/dist/entry.js" }, "schema": "server/db/schema.sql", diff --git a/routes.manifest.json b/routes.manifest.json index 78cccf4..c3fd4fc 100644 --- a/routes.manifest.json +++ b/routes.manifest.json @@ -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", diff --git a/server/catalogue.js b/server/catalogue.js index 5f949f8..1bf3bbe 100644 --- a/server/catalogue.js +++ b/server/catalogue.js @@ -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) diff --git a/server/dangerousTreasures.js b/server/dangerousTreasures.js new file mode 100644 index 0000000..352f31a --- /dev/null +++ b/server/dangerousTreasures.js @@ -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 ` 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 } diff --git a/server/db/schema.sql b/server/db/schema.sql index ad5d433..ebd0fb0 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -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; diff --git a/server/dynamicPvp.js b/server/dynamicPvp.js new file mode 100644 index 0000000..b50b6e3 --- /dev/null +++ b/server/dynamicPvp.js @@ -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 } diff --git a/server/engagement/emit.js b/server/engagement/emit.js index 58fd194..3d408c2 100644 --- a/server/engagement/emit.js +++ b/server/engagement/emit.js @@ -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, diff --git a/server/engagement/triggers.js b/server/engagement/triggers.js index 2ea755c..4cca178 100644 --- a/server/engagement/triggers.js +++ b/server/engagement/triggers.js @@ -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.`. 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]))) diff --git a/server/eventCommands.js b/server/eventCommands.js new file mode 100644 index 0000000..1375d67 --- /dev/null +++ b/server/eventCommands.js @@ -0,0 +1,471 @@ +// ── Console commands as event steps and rewards (PLAN_REDESIGNS.md §16, D382–D385) ── +// +// Two steps that run console commands the author types, so a plugin with no +// first-class support can still be used from the scheduler — RaidableBases' +// `rbe …` as a step, `sr add {steamid} 100` as a reward: +// +// rust.command.run runs the commands once, on one server; optional +// teardown commands run when the run ends or is +// cancelled (`irreversible` / `ledger`) +// rust.command.reward runs them once per recipient a kit reward's picker +// picks, `{steamid}` and `{name}` filled in by the +// bridge (`irreversible` / `none`) +// +// One protocol-14 frame carries both, `command.run` through `POST +// /commands/run`. The bridge runs each key at most once: it writes the key down +// before anything runs, so a retried step is answered from the first attempt. +// +// ── The safety is core's, and the author's own "I am sure" (D384) ─────────── +// +// No allowlist and no blocklist: the commands are a free text box (core's +// `multiple` string field, MODULE_API 1.14.0). What bounds them is what bounds +// every world-changing step — off until an admin switches it on, authored only +// by an admin, the switchboard's caps on `rust.commands` — and a required toggle +// that says the author meant it. Unticked, the dry run and the run refuse. +// +// ── The teardown has two copies (D385) ───────────────────────────────────── +// +// The ledger row carries the teardown commands as they were when the step ran, +// so an edit to the definition mid-run changes nothing. But core records no +// resource for a step that FAILED (a misspelled second command after a first +// that started a base) or whose answer was lost, and reverts it with only the +// key. So the run also hands the teardown to the bridge, which keeps it with +// the key; a revert that has only the key asks every enabled server to run the +// teardown it kept under that key, which a server the step never reached +// answers with nothing run. + +const client = require('./sidecarClient') +const servers = require('./model/servers/servers.model') +const linksDb = require('./model/links/links.db') +const core = require('./core') +const { serverFor, transportError, pluginError } = require('./eventLeases') +const { BUDGET_MS } = require('./eventWorld') +const rewards = require('./eventRewards') + +const log = core.logger('commands') + +const { MAX_RECIPIENTS, MODES, oneOf, checkCount, pickRecipients, peopleOf, readTally, entitlementRef, refParts } = rewards + +/** At most this many commands in one field (§16.2). */ +const MAX_COMMANDS = 10 + +/** The longest command, in characters (§16.2). */ +const COMMAND_MAX = 512 + +/** What the bridge fills in for each recipient, and only for a reward (§16.3). */ +const PLACEHOLDER = /\{(steamid|name)\}/i + +/** A ledger row's kind: the teardown commands one step left to run. */ +const TEARDOWN_KIND = 'command-teardown' + +/** The key a step's teardown runs under: its own, so it too runs at most once. */ +const teardownKey = (key) => `${key}:teardown` + +/** The bridge's refusals a second attempt would repeat. */ +const PERMANENT = new Set([ + 'events-disabled', + 'malformed', + 'unknown-command', + // Some commands may have run before a reload cut the run off, and none is run + // again: a retry would only be answered the same. + 'interrupted', + 'failed', +]) + +const BUDGETS = [ + { + id: 'rust.commands', + label: 'Console commands', + unit: 'commands', + description: + 'Console commands an event runs, as typed by its author: one per command, teardown commands included, and one per command per recipient for a reward. A mode that is not a count is priced at the most it could reward.', + }, +] + +// ── The params both steps check ────────────────────────────────────────────── + +/** + * One field's commands as a list, checked: trimmed, blanks dropped, each at most + * COMMAND_MAX characters on one line, at most MAX_COMMANDS. Core hands a + * `multiple` param over as an array (MODULE_API 1.14.0); a bare value is a list + * of one. Order is kept: the commands run as listed. + */ +function checkCommands(raw, { field = 'commands', required = true } = {}) { + const items = raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw] + const out = [] + for (const item of items) { + const line = String(item === undefined || item === null ? '' : item).trim() + if (!line) continue + if (line.length > COMMAND_MAX) return { ok: false, error: `a command is at most ${COMMAND_MAX} characters, and one of the ${field} is ${line.length}` } + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u001f\u007f]/.test(line)) return { ok: false, error: `a command is one line: one of the ${field} has a line break or a control character in it` } + out.push(line) + } + if (required && !out.length) return { ok: false, error: 'type at least one command' } + if (out.length > MAX_COMMANDS) return { ok: false, error: `a step runs at most ${MAX_COMMANDS} ${field}, not ${out.length}` } + return { ok: true, value: out } +} + +/** The "I am sure" toggle (§16.2, §16.3, D343's). Only a real `true` passes; a string is not a tick. */ +function checkConfirm(params, sentence) { + if (params.confirm === true) return { ok: true } + return { ok: false, error: `Tick "${sentence}" to run these commands` } +} + +/** The first command that names a placeholder, or null: only a reward has someone to fill one with. */ +const withPlaceholder = (lines) => lines.find((line) => PLACEHOLDER.test(line)) || null + +/** A command list's price: at least one, so the switchboard sees the dimension. */ +function pricedCommands(raw) { + const checked = checkCommands(raw, { required: false }) + return checked.ok ? checked.value.length : 1 +} + +/** How many teardown commands a step names, as its price counts them; none is none. */ +function teardownCount(raw) { + const checked = checkCommands(raw, { required: false }) + return checked.ok ? checked.value.length : 0 +} + +/** People priced before the tally is read: the count for a count, the bound for every other mode. */ +function pricedPeople(p) { + const mode = oneOf(p.recipients, MODES) + const n = Math.round(Number(p.count) || 0) + return mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS +} + +/** A transport failure; permanent only when the server is not set up to be asked at all. */ +function transportFailure(server, result, what) { + const permanent = result.status === 'not-configured' || result.status === 'no-token' + return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) } +} + +/** + * The bridge's refusal, in words, with how many commands ran before it: a typo + * in the second command of three says the first one ran. + */ +function refusal(server, data, what) { + const name = server.name || server.id + const ran = Number(data.ran) || 0 + const said = pluginError(data, `${name} did not run the ${what}`) + const tail = ran ? ` (${ran} ran before it)` : '' + return { + ok: false, + ...(PERMANENT.has(data.reason) ? { retry: false } : {}), + error: `${name}: ${said}${tail}`, + } +} + +/** + * What the run log shows of an answer: how many ran, what the console said + * (the bridge keeps the first 20), and who was skipped as offline. + */ +function replyDetail(data) { + const replies = Array.isArray(data.replies) ? data.replies : [] + const skipped = Array.isArray(data.skipped) ? data.skipped.map(String) : [] + const skippedCount = Number(data.skippedCount) || skipped.length + return { + ran: Number(data.ran) || 0, + ...(replies.length + ? { replies: replies.map((r) => ({ command: String(r.command || ''), reply: String(r.reply || ''), ...(r.steamId ? { steamId: String(r.steamId) } : {}) })) } + : {}), + ...(skippedCount ? { skipped, skippedCount, skippedNote: 'skipped: not on the server when the reward ran (D383)' } : {}), + ...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing was run twice' } : {}), + } +} + +// ── Run console commands (§16.2) ───────────────────────────────────────────── + +const RUN_SURE = 'I am sure I want to run these commands on the server' + +/** + * Everything the step checks before touching anything, in the order an author + * fixes them: `{ ok, commands, teardown, server }` or a refusal core reads as + * permanent. + */ +async function checkRun(params) { + const commands = checkCommands(params.commands) + if (!commands.ok) return { ok: false, retry: false, error: commands.error } + + const teardown = checkCommands(params.teardown, { field: 'teardown commands', required: false }) + if (!teardown.ok) return { ok: false, retry: false, error: teardown.error } + + const placeholder = withPlaceholder([...commands.value, ...teardown.value]) + if (placeholder) { + return { ok: false, retry: false, error: `{steamid} and {name} are filled in only by Reward by console command, and "${placeholder}" has one` } + } + + const confirm = checkConfirm(params, RUN_SURE) + if (!confirm.ok) return { ok: false, retry: false, error: confirm.error } + + const serverId = String(params.server || '').trim() + if (!serverId) return { ok: false, retry: false, error: 'the step does not say which server — pick one' } + const found = await serverFor(serverId) + if (!found.ok) return found + + return { ok: true, commands: commands.value, teardown: teardown.value, server: found.server } +} + +/** + * Give back what a step left to undo: run its teardown commands, once. + * + * With a resource, the ledger's own copy goes to the server it names. With + * none — the step failed, or its answer was lost — every enabled server is + * asked to run what it kept under the step's key (D385); a server the step + * never reached, or one where nothing ran, runs nothing and says so. A server + * that cannot be asked leaves the row visible rather than guessing. + */ +async function revertRun({ resources, idempotencyKey }) { + const errors = [] + const failed = [] + + if (!resources || resources.length === 0) { + if (!idempotencyKey) return { ok: true } + for (const server of await servers.listForPolling()) { + const result = await client.commandsRun(server, { key: teardownKey(idempotencyKey), teardownOf: idempotencyKey }) + if (!result.ok) errors.push(transportError(server, result, 'teardown commands')) + else if (!result.data || result.data.kind !== 'command.ran') errors.push(refusal(server, result.data || {}, 'teardown commands').error) + } + return errors.length ? { ok: false, error: errors.join('; ') } : { ok: true } + } + + for (const r of resources) { + const payload = r.payload || {} + const teardown = Array.isArray(payload.teardown) ? payload.teardown : [] + if (!teardown.length) continue + + const found = await serverFor(payload.serverId || refParts(r.ref).serverId) + if (!found.ok) { + failed.push(r.ref) + errors.push(found.error) + continue + } + + const result = await client.commandsRun(found.server, { key: teardownKey(payload.key || r.ref), commands: teardown }) + if (!result.ok) { + failed.push(r.ref) + errors.push(transportError(found.server, result, 'teardown commands')) + continue + } + + // A 200 is not a success on this bridge: a refusal (`unknown-command`) comes + // back as one, carrying `command.error`. + if (!result.data || result.data.kind !== 'command.ran') { + failed.push(r.ref) + errors.push(refusal(found.server, result.data || {}, 'teardown commands').error) + continue + } + + log.info('teardown commands ran', { server: found.server.id, ref: r.ref, ran: Number(result.data.ran) || 0, repeat: !!result.data.repeat }) + } + + if (!failed.length) return { ok: true } + if (failed.length === resources.length) return { ok: false, error: errors.join('; ') } + return { ok: true, failed } +} + +const commandRun = { + id: 'rust.command.run', + label: 'Run console commands', + description: + 'Runs console commands you type, once, on one server, as the server console would: a plugin\'s own command, such as RaidableBases\' rbe, or a game command. Optional teardown commands run when the event ends or is cancelled. The site cannot know what a command does, so the step is rated the most dangerous class: it is off until an admin switches it on. The run log shows what the console answered; "it ran" is not "it worked".', + // The site cannot know what a typed command does (§16.2), so the most + // dangerous class — and with it core's default failure rule: abort the run. + risk: 'irreversible', + // The teardown is the ledger's: core reverts a ledgered step at the end of + // every run, which is exactly when the teardown commands should run. + reversible: 'ledger', + version: 1, + budgetMs: BUDGET_MS, + params: [ + { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers', + description: 'The server the commands run on. One server per step; add a step per server to reach more.' }, + { name: 'commands', type: 'string', required: true, example: 'rbe 1', multiple: true, max: MAX_COMMANDS, + description: `The commands, one per entry, run in the order listed — at most ${MAX_COMMANDS}, ${COMMAND_MAX} characters each. Type each as you would at the server console. A command the server does not know stops the step there.` }, + { name: 'teardown', type: 'string', required: false, example: 'rbe stop', multiple: true, max: MAX_COMMANDS, + description: 'Optional commands run once when the event ends or is cancelled, such as a plugin\'s own "stop". They are kept as they were when the step ran.' }, + { name: 'confirm', type: 'boolean', required: true, example: false, + description: `${RUN_SURE}. Unticked, the step refuses in the dry run and when it runs.` }, + ], + + // One unit per command, start and teardown together (§16.2). + cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) + teardownCount(p.teardown) }), + + async perform({ runId, stepId, idempotencyKey, params, verify }) { + const step = await checkRun(params) + if (!step.ok) return step + if (verify) return { ok: true } + + const { commands, teardown, server } = step + const name = server.name || server.id + + const result = await client.commandsRun(server, { key: idempotencyKey, commands, ...(teardown.length ? { teardown } : {}) }) + if (!result.ok) return transportFailure(server, result, 'console commands') + + const data = result.data || {} + if (data.kind !== 'command.ran') return refusal(server, data, 'console commands') + + log.info('console commands ran', { server: server.id, run: runId, step: stepId, ran: Number(data.ran) || 0, repeat: !!data.repeat }) + + const resources = teardown.length + ? [{ kind: TEARDOWN_KIND, ref: entitlementRef(server.id, runId, stepId), payload: { serverId: server.id, key: idempotencyKey, teardown } }] + : [] + + return { + ok: true, + ...(resources.length ? { resources } : {}), + detail: { + server: name, + ...replyDetail(data), + ...(teardown.length ? { teardown: `${teardown.length} command(s) run when the event ends` } : {}), + }, + } + }, + + revert: revertRun, + + // The site cannot ask a server whether a command's effect is still there, so + // a teardown not yet run is always in force: "I do not know" is never "gone". + async reconcile({ resources }) { + return { ok: true, inForce: (resources || []).map((r) => r.ref) } + }, +} + +// ── Reward by console command (§16.3) ──────────────────────────────────────── + +const REWARD_SURE = 'I am sure I want to run these commands for every recipient' + +/** + * The people a mode picks, by Steam id: `{ ok, people }`, each `{ steamId, name + * }`. No website account is needed (§16.3): a command goes by Steam id. `linked` + * reads no tally: every linked player of the server. + */ +async function recipientsOf({ server, mode, count, runId, idempotencyKey }) { + const name = server.name || server.id + + if (mode === 'linked') { + // One past the bound, so "too many" is a sentence and not a silent cut. + const linked = await linksDb.linkedOnServer(server.id, MAX_RECIPIENTS + 1) + if (linked.length > MAX_RECIPIENTS) { + return { ok: false, retry: false, error: `${name} has more than ${MAX_RECIPIENTS} linked players, and a reward runs commands for at most ${MAX_RECIPIENTS} in one step — reward from a tally instead` } + } + const seen = new Set() + const people = [] + for (const r of linked) { + const steamId = String(r.steamId) + if (seen.has(steamId)) continue + seen.add(steamId) + people.push({ steamId, name: r.name ? String(r.name) : steamId }) + } + return { ok: true, people } + } + + const tally = await readTally(server, runId) + if (!tally.ok) return tally + + const picked = pickRecipients(peopleOf(tally.data), mode, count, idempotencyKey) + const bound = Math.min(Number(tally.data.maxRecipients) || MAX_RECIPIENTS, MAX_RECIPIENTS) + if (picked.length > bound) { + return { ok: false, retry: false, error: `${picked.length} people qualify, and ${name} runs reward commands for at most ${bound} in one step — pick a count-based mode or a higher bar` } + } + return { ok: true, people: picked.map((p) => ({ steamId: p.steamId, name: p.name })) } +} + +const commandReward = { + id: 'rust.command.reward', + label: 'Reward by console command', + description: + 'Runs console commands you type once for each person a mode picks from this run\'s tally — or every linked player of the server — with {steamid} and {name} filled in for each, such as "sr add {steamid} 100". Only people on the server when it runs are rewarded; anyone offline is skipped and named in the run log. Nothing is taken back when the event ends.', + // Irreversible, as `rust.perm.give` is (D346): core reverts a ledgered step at + // the end of every run, and a reward command cannot be run backwards. + risk: 'irreversible', + reversible: 'none', + version: 1, + budgetMs: BUDGET_MS, + params: [ + { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers', + description: 'The server the commands run on.' }, + { name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes', + description: 'Who is rewarded: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' }, + { name: 'count', type: 'float', required: false, example: 3, + description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' }, + { name: 'commands', type: 'string', required: true, example: 'sr add {steamid} 100', multiple: true, max: MAX_COMMANDS, + description: `The commands, run in order for each recipient — at most ${MAX_COMMANDS}, ${COMMAND_MAX} characters each. {steamid} is the recipient's Steam id; {name} is their name, passed as one quoted argument (do not put quotes around it).` }, + { name: 'confirm', type: 'boolean', required: true, example: false, + description: `${REWARD_SURE}. Unticked, the step refuses in the dry run and when it runs.` }, + ], + + cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) * pricedPeople(p) }), + + async perform({ runId, stepId, idempotencyKey, params, verify }) { + const commands = checkCommands(params.commands) + if (!commands.ok) return { ok: false, retry: false, error: commands.error } + + const confirm = checkConfirm(params, REWARD_SURE) + if (!confirm.ok) return { ok: false, retry: false, error: confirm.error } + + const mode = oneOf(params.recipients, MODES) + if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` } + + const count = checkCount(mode, params.count) + if (!count.ok) return { ok: false, retry: false, error: count.error } + + const serverId = String(params.server || '').trim() + if (!serverId) return { ok: false, retry: false, error: 'the step does not say which server — pick one' } + const found = await serverFor(serverId) + if (!found.ok) return found + const server = found.server + const name = server.name || server.id + + if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) { + return { ok: false, retry: false, error: `${name} runs reward commands for at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` } + } + + if (verify) return { ok: true } + + const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey }) + if (!picked.ok) return picked + + const base = { + server: name, + mode, + ...(count.value === null ? {} : { count: count.value }), + people: picked.people.length, + } + + // Nobody qualified: nothing to send, and nothing for the bridge to remember. + if (!picked.people.length) return { ok: true, detail: { ...base, ran: 0, note: 'nobody qualified, so no command ran' } } + + const result = await client.commandsRun(server, { key: idempotencyKey, commands: commands.value, recipients: picked.people }) + if (!result.ok) return transportFailure(server, result, 'reward commands') + + const data = result.data || {} + if (data.kind !== 'command.ran') return refusal(server, data, 'reward commands') + + log.info('reward commands ran', { server: server.id, run: runId, step: stepId, mode, people: picked.people.length, rewarded: Number(data.rewarded) || 0, repeat: !!data.repeat }) + + return { + ok: true, + detail: { + ...base, + rewarded: Number(data.rewarded) || 0, + ...replyDetail(data), + }, + } + }, +} + +const ACTIONS = [commandRun, commandReward] + +module.exports = { + MAX_COMMANDS, + COMMAND_MAX, + TEARDOWN_KIND, + PERMANENT, + BUDGETS, + ACTIONS, + checkCommands, + checkConfirm, + teardownKey, + replyDetail, +} diff --git a/server/eventDangerousTreasures.js b/server/eventDangerousTreasures.js new file mode 100644 index 0000000..69aa297 --- /dev/null +++ b/server/eventDangerousTreasures.js @@ -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 `, 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 } diff --git a/server/eventGuardedCrate.js b/server/eventGuardedCrate.js new file mode 100644 index 0000000..a1b4747 --- /dev/null +++ b/server/eventGuardedCrate.js @@ -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 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 } diff --git a/server/eventLeases.js b/server/eventLeases.js index 09189f9..c1df2f9 100644 --- a/server/eventLeases.js +++ b/server/eventLeases.js @@ -100,7 +100,7 @@ function pluginError(data, fallback) { * of them to forget the drift check, which is the one thing §F says a lease must * not be allowed to skip. */ -function lease({ id, label, description, type, min, max, family, targetLabel, source, example, wire }) { +function lease({ id, label, description, type, min, max, family, targetLabel, source, example, valueExample, wire }) { async function resolve(target) { const { serverId, rest } = splitTarget(target) const found = await serverFor(serverId) @@ -130,6 +130,8 @@ function lease({ id, label, description, type, min, max, family, targetLabel, so type, ...(min === undefined ? {} : { min }), ...(max === undefined ? {} : { max }), + // The VALUE's example (core MODULE_API 1.13.0), beside the target's below. + ...(valueExample === undefined ? {} : { example: valueExample }), maxDurationMs: MAX_LEASE_MS, target: { label: targetLabel, source, example }, @@ -322,6 +324,7 @@ const LEASES = [ targetLabel: 'Which server, group and permission', source: 'rust.options.grouppermissions', example: 'main/default/kits.vip', + valueExample: true, wire: (rest) => { const slash = rest.lastIndexOf('/') if (slash <= 0 || slash >= rest.length - 1) { @@ -371,6 +374,80 @@ async function familyOptions(family, sourceId) { return bounded(rows, sourceId) } +// ── The kit warning (PLAN_REDESIGNS.md §2.6, protocol 14) ──────────────────── +// +// "Kit vip: 2 uses, 24 h cooldown — 9 of 41 linked players have used it up." +// Shown under a group-permission target that is a kit's permission, which is +// what a kit weekend borrows: it tells the author that the weekend's extra use +// (step 2) is what lets those players take the kit again. + +/** How long one server's kit counts are reused. The editor asks while typing. */ +const KIT_NOTE_TTL_MS = 60_000 + +/** The most kits one server's notes ask about. One `kits.uses` each. */ +const KIT_NOTE_MAX_KITS = 25 + +const kitNoteCache = new Map() + +/** "24 h", "90 min", "45 s". */ +function durationWords(seconds) { + const s = Math.max(0, Math.round(Number(seconds) || 0)) + if (s >= 3600 && s % 3600 === 0) return `${s / 3600} h` + if (s >= 60) return `${Math.round(s / 60)} min` + return `${s} s` +} + +/** One kit's note, from its `kits.list` row and its `kits.uses` answer. */ +function kitNote(kit, uses) { + const limit = Number(kit.max) > 0 ? `${kit.max} use${Number(kit.max) === 1 ? '' : 's'}` : 'no use limit' + const cooldown = Number(kit.cooldown) > 0 ? `, ${durationWords(kit.cooldown)} cooldown` : '' + const asked = Number(uses.asked) || 0 + if (!asked) return `Kit ${kit.name}: ${limit}${cooldown} — no linked player has played on this server yet.` + const used = Number(kit.max) > 0 + ? `${Number(uses.exhausted) || 0} of ${asked} linked players have used it up` + : `${asked} linked players` + const waiting = Number(uses.onCooldown) > 0 ? `, ${uses.onCooldown} are on its cooldown` : '' + return `Kit ${kit.name}: ${limit}${cooldown} — ${used}${waiting}. A kit weekend's extra use is what lets them take it again.` +} + +/** + * Permission → note, for the kits on one server that a permission gates. + * Cached a minute per server; anything that fails is simply no note — a warning + * is advice, and its absence must never break the field it advises. + */ +async function kitNotes(server) { + const cached = kitNoteCache.get(server.id) + if (cached && Date.now() - cached.at < KIT_NOTE_TTL_MS) return cached.notes + + const notes = new Map() + try { + const listed = await client.kits(server) + const gated = ((listed.ok && listed.data && listed.data.kits) || []) + .filter((k) => k && k.permission) + .slice(0, KIT_NOTE_MAX_KITS) + + if (gated.length) { + // eslint-disable-next-line global-require + const linksDb = require('./model/links/links.db') + const steamIds = (await linksDb.linkedOnServer(server.id)).map((r) => String(r.steamId)) + + for (const kit of gated) { + const uses = await client.kitsUses(server, kit.name, steamIds) + if (!uses.ok || !uses.data || uses.data.kind !== 'kits.uses') continue + const permission = String(kit.permission).toLowerCase() + // Two kits behind one permission: both are named, one after the other. + const note = kitNote(kit, uses.data) + notes.set(permission, notes.has(permission) ? `${notes.get(permission)} ${note}` : note) + } + } + } catch (err) { + log.warn('kit notes could not be read', { server: server.id, message: err.message }) + } + + kitNoteCache.set(server.id, { at: Date.now(), notes }) + return notes +} + const OPTION_SOURCES = [ { id: 'rust.options.servers', @@ -411,13 +488,19 @@ const OPTION_SOURCES = [ for (const { server, result } of answers) { const data = result.data || {} const perms = (data.permissions || []).map((p) => String(p).toLowerCase()) + // The kit warning (PLAN_REDESIGNS.md §2.6): a row that grants a kit's + // permission says how many of the server's linked players have used it + // up. A kit's permission is whatever its operator typed into Kits, not + // always `kits.*`, so every server is asked — once a minute (cached). + const notes = await kitNotes(server) for (const g of data.groups || []) { const group = g && g.name ? String(g.name).toLowerCase() : null if (!group) continue for (const perm of perms) { const value = `${server.id}/${group}/${perm}` if (term && !value.includes(term)) continue - rows.push({ value, label: `${group} · ${perm}`, group: server.name || server.id }) + const note = notes.get(perm) + rows.push({ value, label: `${group} · ${perm}`, group: server.name || server.id, ...(note ? { note } : {}) }) } } } @@ -437,4 +520,7 @@ module.exports = { pluginError, perServer, bounded, + kitNote, + kitNotes, + kitNoteCache, } diff --git a/server/eventPermRewards.js b/server/eventPermRewards.js new file mode 100644 index 0000000..e309aca --- /dev/null +++ b/server/eventPermRewards.js @@ -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, +} diff --git a/server/eventRaidableBases.js b/server/eventRaidableBases.js new file mode 100644 index 0000000..012ff1d --- /dev/null +++ b/server/eventRaidableBases.js @@ -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 } diff --git a/server/eventRewards.js b/server/eventRewards.js index 28b6159..9ead587 100644 --- a/server/eventRewards.js +++ b/server/eventRewards.js @@ -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 = { @@ -312,7 +363,7 @@ const participationOpen = { description: 'Whose deaths count as a kill: players, NPCs (animals included), or both. The last hit gets it. Needed unless the score is seconds.' }, { name: 'killWeight', type: 'float', required: false, example: DEFAULT_KILL_WEIGHT, description: `For a score of both: how many minutes one kill is worth. Left blank, ${DEFAULT_KILL_WEIGHT}.` }, - { name: 'minutes', type: 'int', required: false, example: 60, + { name: 'minutes', type: 'int', required: false, example: 60, duration: 'minutes', description: `How long it counts, up to ${TALLY_MAX_MINUTES} (seven days). Left blank, seven days. The game forgets a tally seven days after it opened, however long it counted.` }, ], cost: () => ({}), @@ -480,7 +531,7 @@ const kitEntitle = { id: 'rust.kit.entitle', label: 'Reward a kit', description: - 'Gives the people a mode picks from this run\'s tally the right to redeem a kit on its server, and one more use of it. Waits for them if they are offline. Teardown withdraws what is not yet redeemed.', + 'Gives the people a mode picks from this run\'s tally — or every linked player of the server — the right to redeem a kit on its server, and one more use of it, and can tell each of them in game. Waits for them if they are offline. Teardown withdraws what is not yet redeemed.', risk: 'change', reversible: 'ledger', version: 1, @@ -489,9 +540,14 @@ const kitEntitle = { { name: 'kit', type: 'string', required: true, example: 'main/vip-starter', source: 'rust.options.kits', description: 'The kit, as server/kit. The reward reaches only that server (D102).' }, { name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes', - description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, or the top X per cent.' }, + description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all (a kit weekend).' }, { name: 'count', type: 'float', required: false, example: 3, - description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone.' }, + description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' }, + // Protocol 14 (PLAN_REDESIGNS.md §2.4, D333): the reward tells its player. + { name: 'message', type: 'string', required: false, example: 'You earned kit {kit} — type /kit {kit}', + description: 'Said once to each player rewarded, in game: at once if they are up, otherwise when they next wake on the server. {kit} is the kit\'s name. Leave it empty to say nothing.' }, + { name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery', + description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' }, ], // Priced before the tally is read, so at the most it could grant: the count @@ -513,6 +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 @@ -862,11 +1367,33 @@ const OPTION_SOURCES = [ { value: 'minScore', label: 'A score of at least X' }, { value: 'random', label: 'N drawn at random' }, { value: 'topPercent', label: 'The top X per cent (ties in)' }, + { value: 'linked', label: 'Every linked player of the server (no tally)' }, ]), fixed('rust.options.delivery', 'Delivery', 'Where a line goes (D141).', [ { value: 'chat', label: 'Chat' }, { value: 'popup', label: 'A popup — needs PopupNotifications on the server' }, ]), + { + // From each server's last hello: the sizes its Backpacks offers above the + // smallest (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, diff --git a/server/eventTemplates.js b/server/eventTemplates.js new file mode 100644 index 0000000..71d04d4 --- /dev/null +++ b/server/eventTemplates.js @@ -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 } diff --git a/server/eventWorld.js b/server/eventWorld.js index 5b190e1..c598108 100644 --- a/server/eventWorld.js +++ b/server/eventWorld.js @@ -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 = [ @@ -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, @@ -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', ) diff --git a/server/eventWorldCall.js b/server/eventWorldCall.js new file mode 100644 index 0000000..e52c8ee --- /dev/null +++ b/server/eventWorldCall.js @@ -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 } diff --git a/server/guardedCrate.js b/server/guardedCrate.js new file mode 100644 index 0000000..237cad8 --- /dev/null +++ b/server/guardedCrate.js @@ -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 } diff --git a/server/index.js b/server/index.js index b451369..0fa5026 100644 --- a/server/index.js +++ b/server/index.js @@ -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,16 @@ module.exports = function register(ctx, api) { api.onBoot(boot.onBoot) api.onShutdown(boot.onShutdown) + // Sign in with Steam (MODULE_API 1.17.0; PLAN_REDESIGNS §15). A Steam account + // linked on the site is a linked Rust player (D392): core asks before it writes + // the sign-in identity, and this module refuses a Steam id that is another + // account's Rust link (D396); once it is written, the Rust link is made. Both + // are in the links model beside `/link`'s own write, and neither runs at + // registration — core calls them from a signed-in user's link flow. + const links = require('./model/links/links.model') // eslint-disable-line global-require + api.onIdentityLinking(links.checkSteamSignin) + api.onIdentityLinked(links.linkFromSteamSignin) + // The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server // and must give back. Core's `core.lease` is the verb; these are the values it // may name and the four callables each ships. Every lease is targeted and the @@ -168,8 +185,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 +214,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 +237,10 @@ module.exports = function register(ctx, api) { streams: STREAMS.length, audiences: AUDIENCES.length, leases: eventLeases.LEASES.length, - actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length, - 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, }) } diff --git a/server/ingest.js b/server/ingest.js index 0dd6708..dffb5bc 100644 --- a/server/ingest.js +++ b/server/ingest.js @@ -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 diff --git a/server/liveStatus.js b/server/liveStatus.js new file mode 100644 index 0000000..52d14a9 --- /dev/null +++ b/server/liveStatus.js @@ -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 } diff --git a/server/model/backpacks/backpacks.db.js b/server/model/backpacks/backpacks.db.js new file mode 100644 index 0000000..bcf3ad1 --- /dev/null +++ b/server/model/backpacks/backpacks.db.js @@ -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 } diff --git a/server/model/backpacks/backpacks.model.js b/server/model/backpacks/backpacks.model.js new file mode 100644 index 0000000..9297e29 --- /dev/null +++ b/server/model/backpacks/backpacks.model.js @@ -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 } diff --git a/server/model/config/config.model.js b/server/model/config/config.model.js index 4ac6cdb..f741e49 100644 --- a/server/model/config/config.model.js +++ b/server/model/config/config.model.js @@ -43,8 +43,15 @@ const LOCKED_REASON = { */ const PATH_SHAPE = /^(?!.*\.\.)(?!\/)[A-Za-z0-9 _.\-()[\]]+(?:\/[A-Za-z0-9 _.\-()[\]]+)*\.json$/ +/** + * RaidableBases' profiles (PLAN_REDESIGNS §11.4.3, D205): the one named exception + * to the editor never walking a data directory. The bridge lists them under this + * virtual prefix, one file deep, and resolves it to that folder alone. + */ +const PROFILE_PATH = /^@data\/RaidableBases\/Profiles\/(?!.*\.\.)[A-Za-z0-9 _.\-()[\]]+\.json$/ + function isPlausiblePath(path) { - return typeof path === 'string' && path.length > 0 && path.length <= 255 && PATH_SHAPE.test(path) + return typeof path === 'string' && path.length > 0 && path.length <= 255 && (PATH_SHAPE.test(path) || PROFILE_PATH.test(path)) } /** @@ -90,6 +97,8 @@ function shapeCatalogue(catalogue) { modified: file.modified ? Number(file.modified) : null, editable: file.editable !== false, ...(file.reason ? { reason: String(file.reason) } : {}), + // A RaidableBases profile (D205): reloaded through the raids helper, never a plugin reload. + ...(file.profile === true ? { profile: true } : {}), }) } @@ -105,11 +114,22 @@ function shapeCatalogue(catalogue) { } } +/** + * The plugin a path belongs to, by its first segment, or its file name for a + * file at the root. A RaidableBases profile's virtual path names the plugin + * after its `@data/` prefix, so the audit row says RaidableBases (W8). + */ +function pluginOf(path) { + const text = String(path) + const parts = text.split('/') + if (parts[0] === '@data' && parts.length > 2) return parts[1] + return parts.length > 1 ? parts[0] : text.replace(/\.json$/i, '') +} + /** Whether this file is the bridge's own config, by the name the plugin gave. */ function isBridgeConfig(path, self) { if (!self) return false - const plugin = String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '') - return plugin.toLowerCase() === String(self).toLowerCase() + return pluginOf(path).toLowerCase() === String(self).toLowerCase() } /** The locked keys for a file: three of them in our own config, none anywhere else. */ @@ -270,6 +290,7 @@ module.exports = { isPlausiblePath, shapeCatalogue, shapeFile, + pluginOf, isBridgeConfig, lockedKeysFor, lockedChanges, diff --git a/server/model/economics/economics.db.js b/server/model/economics/economics.db.js new file mode 100644 index 0000000..fe93f13 --- /dev/null +++ b/server/model/economics/economics.db.js @@ -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 } diff --git a/server/model/economics/economics.model.js b/server/model/economics/economics.model.js new file mode 100644 index 0000000..4adb414 --- /dev/null +++ b/server/model/economics/economics.model.js @@ -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, +} diff --git a/server/model/events/events.db.js b/server/model/events/events.db.js index 4befed6..1e51b7a 100644 --- a/server/model/events/events.db.js +++ b/server/model/events/events.db.js @@ -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, diff --git a/server/model/events/events.model.js b/server/model/events/events.model.js index 98f562f..a394638 100644 --- a/server/model/events/events.model.js +++ b/server/model/events/events.model.js @@ -13,7 +13,14 @@ // 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') /** Hard ceiling on a page, whatever a caller asks for. */ const MAX_LIMIT = 200 @@ -51,7 +58,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 +72,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 } } /** @@ -102,15 +172,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 +193,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 diff --git a/server/model/links/links.db.js b/server/model/links/links.db.js index c7798c0..ed0498c 100644 --- a/server/model/links/links.db.js +++ b/server/model/links/links.db.js @@ -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, } diff --git a/server/model/links/links.model.js b/server/model/links/links.model.js index eb43e8a..4e3ba44 100644 --- a/server/model/links/links.model.js +++ b/server/model/links/links.model.js @@ -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, } diff --git a/server/model/map/map.model.js b/server/model/map/map.model.js index 517c289..5af2916 100644 --- a/server/model/map/map.model.js +++ b/server/model/map/map.model.js @@ -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 diff --git a/server/model/permissions/permissions.db.js b/server/model/permissions/permissions.db.js index f31d02f..8119324 100644 --- a/server/model/permissions/permissions.db.js +++ b/server/model/permissions/permissions.db.js @@ -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 `::` (`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, diff --git a/server/model/permissions/permissions.model.js b/server/model/permissions/permissions.model.js index 1611f05..d7591cc 100644 --- a/server/model/permissions/permissions.model.js +++ b/server/model/permissions/permissions.model.js @@ -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): `::`. + * 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, diff --git a/server/model/servers/servers.db.js b/server/model/servers/servers.db.js index a0f9c69..a3d000e 100644 --- a/server/model/servers/servers.db.js +++ b/server/model/servers/servers.db.js @@ -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, diff --git a/server/model/servers/servers.model.js b/server/model/servers/servers.model.js index 3fdef50..f895f35 100644 --- a/server/model/servers/servers.model.js +++ b/server/model/servers/servers.model.js @@ -17,6 +17,7 @@ const core = require('../../core') const db = require('./servers.db') const { nextWipe } = require('./nextWipe') +const worldEvents = require('../../worldEvents') const log = core.logger('servers') @@ -59,6 +60,8 @@ function withToken(row) { announceNews: Boolean(Number(row.announceNews)), // D142: where that post goes — chat, or a popup. newsDelivery: row.newsDelivery === 'popup' ? 'popup' : 'chat', + // D372: who a downed helicopter, Chinook or Bradley credits, for ingest. + creditRule: worldEvents.creditRuleOf(row.creditRule), } } @@ -212,6 +215,8 @@ async function listForAdmin(now = Date.now()) { // bridge makes. Anything but `patched` is said on the servers page. zoneHelper: (state && state.zoneHelper) || null, schedule: scheduleOf(row), + // D372: who a downed helicopter, Chinook or Bradley credits on this server. + creditRule: worldEvents.creditRuleOf(row.creditRule), } }) } diff --git a/server/model/titles/titles.db.js b/server/model/titles/titles.db.js index 62a3950..240308f 100644 --- a/server/model/titles/titles.db.js +++ b/server/model/titles/titles.db.js @@ -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})` diff --git a/server/model/titles/titles.js b/server/model/titles/titles.js index e611c61..2a5f63c 100644 --- a/server/model/titles/titles.js +++ b/server/model/titles/titles.js @@ -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 } }, } diff --git a/server/model/titles/titles.model.js b/server/model/titles/titles.model.js index b27f479..4be73a7 100644 --- a/server/model/titles/titles.model.js +++ b/server/model/titles/titles.model.js @@ -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 })) }), diff --git a/server/model/zones/zones.db.js b/server/model/zones/zones.db.js index 2a71843..ec93601 100644 --- a/server/model/zones/zones.db.js +++ b/server/model/zones/zones.db.js @@ -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, } }) } diff --git a/server/model/zones/zones.model.js b/server/model/zones/zones.model.js index 3e2be6f..21de78f 100644 --- a/server/model/zones/zones.model.js +++ b/server/model/zones/zones.model.js @@ -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 } diff --git a/server/permSync.js b/server/permSync.js index 6b008cb..c3d3b3e 100644 --- a/server/permSync.js +++ b/server/permSync.js @@ -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, diff --git a/server/raidableBases.js b/server/raidableBases.js new file mode 100644 index 0000000..1af6724 --- /dev/null +++ b/server/raidableBases.js @@ -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 } diff --git a/server/router/admin/config.controller.js b/server/router/admin/config.controller.js index a3c6608..ecbb268 100644 --- a/server/router/admin/config.controller.js +++ b/server/router/admin/config.controller.js @@ -323,7 +323,7 @@ async function record(req, row) { try { id = await db.recordWrite({ ...row, - plugin: model.isBridgeConfig(row.path, row.self) ? row.self : pluginOf(row.path), + plugin: model.isBridgeConfig(row.path, row.self) ? row.self : model.pluginOf(row.path), reloadTarget: row.reload, userId: req.user ? req.user.id : null, }) @@ -347,10 +347,6 @@ async function record(req, row) { return id } -function pluginOf(path) { - return String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '') -} - /** A `config.error` frame as a sentence. */ function refusalMessage(frame) { const reasons = { diff --git a/server/router/admin/rust.controller.js b/server/router/admin/rust.controller.js index 43882b6..c2ddc55 100644 --- a/server/router/admin/rust.controller.js +++ b/server/router/admin/rust.controller.js @@ -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 }) diff --git a/server/router/admin/rust.router.js b/server/router/admin/rust.router.js index 4fe8e03..26d24d5 100644 --- a/server/router/admin/rust.router.js +++ b/server/router/admin/rust.router.js @@ -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' } */ diff --git a/server/router/admin/usersRust.controller.js b/server/router/admin/usersRust.controller.js index 414bd0d..1355ed3 100644 --- a/server/router/admin/usersRust.controller.js +++ b/server/router/admin/usersRust.controller.js @@ -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 } diff --git a/server/router/admin/usersRust.router.js b/server/router/admin/usersRust.router.js index 24c830c..da888f4 100644 --- a/server/router/admin/usersRust.router.js +++ b/server/router/admin/usersRust.router.js @@ -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 diff --git a/server/router/admin/visibility.router.js b/server/router/admin/visibility.router.js index 2676ba6..c90c100 100644 --- a/server/router/admin/visibility.router.js +++ b/server/router/admin/visibility.router.js @@ -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' } */ diff --git a/server/router/player/rust.controller.js b/server/router/player/rust.controller.js index 64b13b0..1fa09a7 100644 --- a/server/router/player/rust.controller.js +++ b/server/router/player/rust.controller.js @@ -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 } diff --git a/server/router/player/rust.router.js b/server/router/player/rust.router.js index 099f003..eb2d307 100644 --- a/server/router/player/rust.router.js +++ b/server/router/player/rust.router.js @@ -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'] diff --git a/server/router/public/rust.controller.js b/server/router/public/rust.controller.js index 11d1f8e..132bdf9 100644 --- a/server/router/public/rust.controller.js +++ b/server/router/public/rust.controller.js @@ -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 }) diff --git a/server/router/public/rust.router.js b/server/router/public/rust.router.js index 1e99277..7fcb284 100644 --- a/server/router/public/rust.router.js +++ b/server/router/public/rust.router.js @@ -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.' // #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' } */ diff --git a/server/sidecarClient.js b/server/sidecarClient.js index df4e588..6905905 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -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, diff --git a/server/swagger/doc.js b/server/swagger/doc.js index 877ace8..e9566f8 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -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.', diff --git a/server/test/_fakes.js b/server/test/_fakes.js index b53ddf6..0f022c7 100644 --- a/server/test/_fakes.js +++ b/server/test/_fakes.js @@ -160,10 +160,15 @@ 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 }, } api.record = record return api diff --git a/server/test/backpacks.test.js b/server/test/backpacks.test.js new file mode 100644 index 0000000..2b6ad7b --- /dev/null +++ b/server/test/backpacks.test.js @@ -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') +}) diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js index 28cbe18..2c85581 100644 --- a/server/test/catalogue.test.js +++ b/server/test/catalogue.test.js @@ -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']) }) diff --git a/server/test/commandSteps.test.js b/server/test/commandSteps.test.js new file mode 100644 index 0000000..6ef9e85 --- /dev/null +++ b/server/test/commandSteps.test.js @@ -0,0 +1,300 @@ +// ── Console commands as event steps and rewards (PLAN_REDESIGNS §16, D382–D385) ── +// +// Each test is one way the feature could look right and be wrong: +// +// both steps are irreversible, on one `rust.commands` budget, and run is the +// one with a revert (its teardown); the price counts commands, teardown +// included, and commands × people for a reward +// the commands are core's list, at most ten, 512 characters, one line each +// unticked, both steps refuse in the dry run (D384's bound is the toggle) +// {steamid} and {name} are refused in a step that has nobody to fill them +// the run sends the key and the teardown with it (D385), files the ledger's +// own copy of the teardown, and shows the console's replies +// a misspelled command is permanent `unknown-command`, saying what ran before +// a reward sends Steam ids (no website account needed), never a site-side +// filled command, and names who was skipped as offline (D383) +// a revert runs the ledger's copy once, under its own key; one with only the +// key asks every enabled server to run what it kept (D385) + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +require('../core')._reset() +require('../core').init(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } })) + +const client = require('../sidecarClient') +const serversDb = require('../model/servers/servers.db') +const servers = require('../model/servers/servers.model') +const linksDb = require('../model/links/links.db') +const rewards = require('../eventRewards') +const cmds = require('../eventCommands') + +const ok = (data) => ({ ok: true, status: 'ok', data }) + +const A = '76561198000000001' +const B = '76561198000000002' +const C = '76561198000000003' + +const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 } +const run = cmds.ACTIONS.find((a) => a.id === 'rust.command.run') +const reward = cmds.ACTIONS.find((a) => a.id === 'rust.command.reward') + +const person = (steamId, score, name) => ({ steamId, name: name || `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 }) + +/** Every call the steps make, recorded; `answer` is what the bridge says to each. */ +function stub(t, { answer = () => ok({ kind: 'command.ran', ran: 1, replies: [] }), people = [], linked = [], enabled = [ROW] } = {}) { + const calls = { sent: [] } + const saved = { + getServer: serversDb.getServer, + commandsRun: client.commandsRun, + tallySnapshot: client.tallySnapshot, + linkedOnServer: linksDb.linkedOnServer, + listForPolling: servers.listForPolling, + } + + serversDb.getServer = async (id) => enabled.find((s) => s.id === id) || null + client.commandsRun = async (server, body) => { + calls.sent.push({ server: server.id, body }) + return answer(body, server) + } + client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 }) + linksDb.linkedOnServer = async () => linked + servers.listForPolling = async () => enabled + + t.after(() => { + Object.assign(serversDb, { getServer: saved.getServer }) + Object.assign(client, { commandsRun: saved.commandsRun, tallySnapshot: saved.tallySnapshot }) + Object.assign(linksDb, { linkedOnServer: saved.linkedOnServer }) + Object.assign(servers, { listForPolling: saved.listForPolling }) + }) + return calls +} + +const RUN = { server: 'main', commands: ['rbe 1', 'say a raid base is up'], teardown: ['rbe stop'], confirm: true } +const REWARD = { server: 'main', recipients: 'everyone', commands: ['sr add {steamid} 100'], confirm: true } +const CTX = { runId: 7, stepId: 3, idempotencyKey: 'k'.repeat(40) } + +// ── The declarations ─────────────────────────────────────────────────────── + +test('both steps are irreversible on one budget; only the run has a teardown to revert', () => { + assert.deepStrictEqual([run.risk, run.reversible, typeof run.revert, typeof run.reconcile], ['irreversible', 'ledger', 'function', 'function']) + assert.deepStrictEqual([reward.risk, reward.reversible, reward.revert], ['irreversible', 'none', undefined]) + assert.deepStrictEqual(cmds.BUDGETS.map((b) => b.id), ['rust.commands']) + + // One unit per command, start and teardown together (§16.2)… + assert.deepStrictEqual(run.cost(RUN), { 'rust.commands': 3 }) + assert.deepStrictEqual(run.cost({ commands: ['rbe 1'] }), { 'rust.commands': 1 }) + // …and commands × people for a reward, the bound for a mode that is not a count (§16.3). + assert.deepStrictEqual(reward.cost({ recipients: 'top', count: 3, commands: ['a', 'b'] }), { 'rust.commands': 6 }) + assert.deepStrictEqual(reward.cost({ recipients: 'everyone', commands: ['a'] }), { 'rust.commands': rewards.MAX_RECIPIENTS }) + // The switchboard prices the declared example, a list of one. + assert.deepStrictEqual(run.cost({ commands: run.params.find((p) => p.name === 'commands').example }), { 'rust.commands': 1 }) +}) + +test('the commands are core\'s list of at most ten, and confirm is a required toggle', () => { + for (const action of [run, reward]) { + const list = action.params.find((p) => p.name === 'commands') + assert.deepStrictEqual([list.type, list.multiple, list.max, list.required], ['string', true, cmds.MAX_COMMANDS, true]) + assert.strictEqual(Array.isArray(list.example), false) + assert.strictEqual(list.source, undefined, 'a free text box: no allowlist (D384)') + const confirm = action.params.find((p) => p.name === 'confirm') + assert.deepStrictEqual([confirm.type, confirm.required, confirm.example], ['boolean', true, false]) + } + const teardown = run.params.find((p) => p.name === 'teardown') + assert.deepStrictEqual([teardown.multiple, teardown.required], [true, false]) + assert.strictEqual(reward.params.find((p) => p.name === 'teardown'), undefined) +}) + +test('a command is trimmed, one line, at most 512 characters, and eleven are refused', () => { + assert.deepStrictEqual(cmds.checkCommands([' rbe 1 ', '', 'say hi']).value, ['rbe 1', 'say hi']) + assert.deepStrictEqual(cmds.checkCommands('rbe 1').value, ['rbe 1']) + assert.match(cmds.checkCommands([]).error, /at least one command/) + assert.deepStrictEqual(cmds.checkCommands([], { required: false }).value, []) + assert.match(cmds.checkCommands(['say a\nquit']).error, /one line/) + assert.match(cmds.checkCommands(['x'.repeat(513)]).error, /at most 512 characters/) + assert.ok(cmds.checkCommands(['x'.repeat(512)]).ok) + assert.match(cmds.checkCommands(Array.from({ length: 11 }, (_, i) => `c${i}`)).error, /at most 10 commands, not 11/) +}) + +// ── Refusals before anything is sent ─────────────────────────────────────── + +test('unticked, both steps refuse in the dry run, and send nothing', async (t) => { + const calls = stub(t) + const a = await run.perform({ ...CTX, params: { ...RUN, confirm: false }, verify: true }) + assert.deepStrictEqual([a.ok, a.retry], [false, false]) + assert.match(a.error, /I am sure I want to run these commands on the server/) + + // A string is not a tick. + const b = await reward.perform({ ...CTX, params: { ...REWARD, confirm: 'true' }, verify: true }) + assert.deepStrictEqual([b.ok, b.retry], [false, false]) + assert.match(b.error, /for every recipient/) + assert.strictEqual(calls.sent.length, 0) +}) + +test('{steamid} and {name} are refused in a run, teardown included: nobody fills them there', async (t) => { + stub(t) + const a = await run.perform({ ...CTX, params: { ...RUN, commands: ['sr add {steamid} 1'] }, verify: true }) + assert.deepStrictEqual([a.ok, a.retry], [false, false]) + assert.match(a.error, /Reward by console command/) + const b = await run.perform({ ...CTX, params: { ...RUN, teardown: ['say bye {Name}'] }, verify: true }) + assert.strictEqual(b.ok, false) +}) + +test('a dry run that passes checks the server and sends nothing', async (t) => { + const calls = stub(t) + assert.deepStrictEqual(await run.perform({ ...CTX, params: RUN, verify: true }), { ok: true }) + assert.deepStrictEqual(await reward.perform({ ...CTX, params: REWARD, verify: true }), { ok: true }) + const off = await run.perform({ ...CTX, params: { ...RUN, server: 'nowhere' }, verify: true }) + assert.deepStrictEqual([off.ok, off.retry], [false, false]) + assert.strictEqual(calls.sent.length, 0) +}) + +// ── Run console commands ─────────────────────────────────────────────────── + +test('a run sends its key and its teardown, and files the ledger\'s own copy of the teardown (D385)', async (t) => { + const calls = stub(t, { + answer: () => ok({ kind: 'command.ran', ran: 2, replies: [{ command: 'rbe 1', reply: 'Raid base spawned' }, { command: 'say a raid base is up', reply: '' }] }), + }) + const result = await run.perform({ ...CTX, params: RUN }) + + assert.deepStrictEqual(calls.sent, [{ server: 'main', body: { key: CTX.idempotencyKey, commands: RUN.commands, teardown: ['rbe stop'] } }]) + assert.strictEqual(result.ok, true) + assert.deepStrictEqual(result.resources, [ + { kind: cmds.TEARDOWN_KIND, ref: 'main:7:3', payload: { serverId: 'main', key: CTX.idempotencyKey, teardown: ['rbe stop'] } }, + ]) + assert.strictEqual(result.detail.ran, 2) + assert.deepStrictEqual(result.detail.replies[0], { command: 'rbe 1', reply: 'Raid base spawned' }) + assert.match(result.detail.teardown, /1 command/) +}) + +test('a run with no teardown leaves nothing to undo and sends none', async (t) => { + const calls = stub(t) + const result = await run.perform({ ...CTX, params: { ...RUN, teardown: [] } }) + assert.strictEqual(result.ok, true) + assert.strictEqual(result.resources, undefined) + assert.strictEqual('teardown' in calls.sent[0].body, false) +}) + +test('a repeat is answered from the first attempt, and says so', async (t) => { + stub(t, { answer: () => ok({ kind: 'command.ran', ran: 2, repeat: true, replies: [] }) }) + const result = await run.perform({ ...CTX, params: RUN }) + assert.strictEqual(result.ok, true) + assert.strictEqual(result.detail.repeat, true) + assert.match(result.detail.note, /nothing was run twice/) +}) + +test('a misspelled command is permanent unknown-command, saying how many ran before it', async (t) => { + stub(t, { answer: () => ok({ kind: 'command.error', reason: 'unknown-command', message: "the server does not know the command 'rbee'", ran: 1 }) }) + const result = await run.perform({ ...CTX, params: RUN }) + assert.deepStrictEqual([result.ok, result.retry], [false, false]) + assert.match(result.error, /^Main: the server does not know the command 'rbee' \(1 ran before it\)$/) +}) + +test('events switched off on the server is permanent; a world still loading and a lost answer are not', async (t) => { + let answer = ok({ kind: 'world.error', reason: 'events-disabled', message: 'events are switched off on this server' }) + stub(t, { answer: () => answer }) + const off = await run.perform({ ...CTX, params: RUN }) + assert.deepStrictEqual([off.ok, off.retry], [false, false]) + + answer = ok({ kind: 'world.error', reason: 'not-ready', message: 'the server is still loading its world' }) + const loading = await run.perform({ ...CTX, params: RUN }) + assert.deepStrictEqual([loading.ok, loading.retry], [false, undefined]) + + answer = { ok: false, status: 'http-504' } + const lost = await run.perform({ ...CTX, params: RUN }) + assert.deepStrictEqual([lost.ok, lost.retry], [false, undefined]) + assert.match(lost.error, /did not answer/) +}) + +// ── The teardown (§16.2, D385) ───────────────────────────────────────────── + +test('a revert runs the ledger\'s copy once, under its own key, on the server it names', async (t) => { + const calls = stub(t, { answer: () => ok({ kind: 'command.ran', ran: 1, replies: [] }) }) + const resource = { kind: cmds.TEARDOWN_KIND, ref: 'main:7:3', payload: { serverId: 'main', key: CTX.idempotencyKey, teardown: ['rbe stop'] } } + assert.deepStrictEqual(await run.revert({ runId: 7, resources: [resource], idempotencyKey: CTX.idempotencyKey }), { ok: true }) + assert.deepStrictEqual(calls.sent, [{ server: 'main', body: { key: cmds.teardownKey(CTX.idempotencyKey), commands: ['rbe stop'] } }]) +}) + +test('a teardown the server refuses leaves the row failed and visible', async (t) => { + stub(t, { answer: () => ok({ kind: 'command.error', reason: 'unknown-command', message: "the server does not know the command 'rbe'", ran: 0 }) }) + const resource = { kind: cmds.TEARDOWN_KIND, ref: 'main:7:3', payload: { serverId: 'main', key: CTX.idempotencyKey, teardown: ['rbe stop'] } } + const result = await run.revert({ runId: 7, resources: [resource], idempotencyKey: CTX.idempotencyKey }) + assert.strictEqual(result.ok, false) + assert.match(result.error, /does not know the command 'rbe'/) +}) + +test('a revert with only the key asks every enabled server to run what it kept (D385)', async (t) => { + const PVE = { ...ROW, id: 'pve', name: 'PvE' } + const calls = stub(t, { enabled: [ROW, PVE], answer: () => ok({ kind: 'command.ran', ran: 0, replies: [] }) }) + assert.deepStrictEqual(await run.revert({ runId: 7, resources: [], idempotencyKey: CTX.idempotencyKey }), { ok: true }) + assert.deepStrictEqual(calls.sent.map((c) => c.server), ['main', 'pve']) + for (const c of calls.sent) assert.deepStrictEqual(c.body, { key: cmds.teardownKey(CTX.idempotencyKey), teardownOf: CTX.idempotencyKey }) +}) + +test('a server that cannot be asked about a lost answer keeps the row visible', async (t) => { + stub(t, { answer: () => ({ ok: false, status: 'http-503' }) }) + const result = await run.revert({ runId: 7, resources: [], idempotencyKey: CTX.idempotencyKey }) + assert.strictEqual(result.ok, false) + assert.match(result.error, /no game connected/) +}) + +test('a teardown not yet run is always in force: the site cannot look', async () => { + const resources = [{ ref: 'main:7:3' }, { ref: 'pve:7:4' }] + assert.deepStrictEqual(await run.reconcile({ runId: 7, resources }), { ok: true, inForce: ['main:7:3', 'pve:7:4'] }) +}) + +// ── Reward by console command ────────────────────────────────────────────── + +test('a reward sends Steam ids and names to the bridge, which fills the placeholders, not the site', async (t) => { + const calls = stub(t, { + people: [person(A, 5, 'Some "One"'), person(B, 0), person(C, 2)], + answer: () => ok({ kind: 'command.ran', ran: 1, rewarded: 1, skipped: ['p3'], skippedCount: 1, replies: [{ command: `sr add ${A} 100`, reply: 'added', steamId: A }] }), + }) + const result = await reward.perform({ ...CTX, params: REWARD }) + + // Everyone who scored: A and C, never B. The command goes untouched. + assert.deepStrictEqual(calls.sent, [{ + server: 'main', + body: { key: CTX.idempotencyKey, commands: ['sr add {steamid} 100'], recipients: [{ steamId: A, name: 'Some "One"' }, { steamId: C, name: 'p3' }] }, + }]) + assert.strictEqual(result.ok, true) + assert.deepStrictEqual([result.detail.people, result.detail.rewarded], [2, 1]) + assert.deepStrictEqual([result.detail.skipped, result.detail.skippedCount], [['p3'], 1]) + assert.match(result.detail.skippedNote, /D383/) + assert.strictEqual(result.resources, undefined) +}) + +test('linked reads no tally and needs no website user beyond the link', async (t) => { + const calls = stub(t, { linked: [{ steamId: A, userId: 1, name: 'Ann' }, { steamId: B, userId: 2, name: null }] }) + const result = await reward.perform({ ...CTX, params: { ...REWARD, recipients: 'linked' } }) + assert.strictEqual(result.ok, true) + assert.deepStrictEqual(calls.sent[0].body.recipients, [{ steamId: A, name: 'Ann' }, { steamId: B, name: B }]) +}) + +test('nobody qualifying runs nothing, and the bridge is not asked', async (t) => { + const calls = stub(t, { people: [person(A, 0)] }) + const result = await reward.perform({ ...CTX, params: REWARD }) + assert.strictEqual(result.ok, true) + assert.strictEqual(result.detail.ran, 0) + assert.strictEqual(calls.sent.length, 0) +}) + +test('more than a hundred is refused before anything runs', async (t) => { + const many = Array.from({ length: 101 }, (_, i) => ({ steamId: String(76561198000001000n + BigInt(i)), userId: i + 1, name: `n${i}` })) + const calls = stub(t, { linked: many }) + const result = await reward.perform({ ...CTX, params: { ...REWARD, recipients: 'linked' } }) + assert.deepStrictEqual([result.ok, result.retry], [false, false]) + assert.match(result.error, /more than 100 linked players/) + assert.strictEqual(calls.sent.length, 0) + const top = await reward.perform({ ...CTX, params: { ...REWARD, recipients: 'top', count: 101 }, verify: true }) + assert.strictEqual(top.ok, false) +}) + +test('the run log shows a bounded, plain view of the answer', () => { + const detail = cmds.replyDetail({ ran: '3', replies: [{ command: 'a', reply: 5 }], skipped: ['x'], skippedCount: 4, repeat: true }) + assert.deepStrictEqual(detail.replies, [{ command: 'a', reply: '5' }]) + assert.deepStrictEqual([detail.ran, detail.skippedCount, detail.repeat], [3, 4, true]) + assert.deepStrictEqual(cmds.replyDetail({}), { ran: 0 }) +}) diff --git a/server/test/config.test.js b/server/test/config.test.js index 8842af4..940fafd 100644 --- a/server/test/config.test.js +++ b/server/test/config.test.js @@ -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) +}) diff --git a/server/test/dangerousTreasures.test.js b/server/test/dangerousTreasures.test.js new file mode 100644 index 0000000..04e9dc2 --- /dev/null +++ b/server/test/dangerousTreasures.test.js @@ -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) +}) diff --git a/server/test/dynamicPvp.test.js b/server/test/dynamicPvp.test.js new file mode 100644 index 0000000..b55a642 --- /dev/null +++ b/server/test/dynamicPvp.test.js @@ -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') +}) diff --git a/server/test/economics.test.js b/server/test/economics.test.js new file mode 100644 index 0000000..90ac1b8 --- /dev/null +++ b/server/test/economics.test.js @@ -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', + ) +}) diff --git a/server/test/entry.test.js b/server/test/entry.test.js index 0653a0e..b4806c2 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -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') } }) diff --git a/server/test/guardedCrate.test.js b/server/test/guardedCrate.test.js new file mode 100644 index 0000000..2f496b5 --- /dev/null +++ b/server/test/guardedCrate.test.js @@ -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) +}) diff --git a/server/test/identityRoutes.test.js b/server/test/identityRoutes.test.js index 9523a1f..e0e67b0 100644 --- a/server/test/identityRoutes.test.js +++ b/server/test/identityRoutes.test.js @@ -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', ]) diff --git a/server/test/links.test.js b/server/test/links.test.js index 1eb427b..a029783 100644 --- a/server/test/links.test.js +++ b/server/test/links.test.js @@ -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) +}) diff --git a/server/test/liveStatus.test.js b/server/test/liveStatus.test.js new file mode 100644 index 0000000..0c66eaf --- /dev/null +++ b/server/test/liveStatus.test.js @@ -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 }) +}) diff --git a/server/test/map.test.js b/server/test/map.test.js index 3ac5609..ae230bc 100644 --- a/server/test/map.test.js +++ b/server/test/map.test.js @@ -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')) diff --git a/server/test/npcs.test.js b/server/test/npcs.test.js index 1f62b9f..e2a10c6 100644 --- a/server/test/npcs.test.js +++ b/server/test/npcs.test.js @@ -27,6 +27,10 @@ require('../core')._reset() const ctx = fakeCtx({ freeze: false }) require('../core').init(ctx) +// The stored status is the whole answer in these tests: nothing asks a +// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask. +require('../liveStatus').refresh = async () => false + const client = require('../sidecarClient') const servers = require('../model/servers/servers.model') const serversDb = require('../model/servers/servers.db') diff --git a/server/test/permRewards.test.js b/server/test/permRewards.test.js new file mode 100644 index 0000000..ece1669 --- /dev/null +++ b/server/test/permRewards.test.js @@ -0,0 +1,345 @@ +// ── Permissions as event rewards (PLAN_REDESIGNS §12, D342–D346) ────────── +// +// Each test is one way the feature could look right and be wrong: +// +// the picker groups by the plugin that registered a permission, by prefix +// when none did, sorts both levels without case, and says where one is missing +// several permissions arrive as core's list (MODULE_API 1.14.0); a bare value +// is a list of one; more than ten is refused +// unticked, both steps refuse in the dry run, naming what they would give (D343) +// a permission the server's catalogue lacks is refused by name, never skipped +// lend writes one row per recipient per permission, the message on one row +// give writes standing grants, source `event`, scoped to the server, leaves one +// already there alone, and tells a retry from a staff member's grant by note +// give's message rides a notice-only row the push grants nothing for +// a for-good grant carries its note to the permission manager + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +function withRecorder(rows = []) { + const statements = [] + const ctx = fakeCtx({ + db: { + query: (sql, params = []) => { + statements.push({ sql, params }) + return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows) + }, + pool: {}, + }, + }) + require('../core')._reset() + require('../core').init(ctx) + return { statements } +} + +withRecorder() + +const client = require('../sidecarClient') +const serversDb = require('../model/servers/servers.db') +const linksDb = require('../model/links/links.db') +const permDb = require('../model/permissions/permissions.db') +const model = require('../model/permissions/permissions.model') +const rewards = require('../eventRewards') +const perms = require('../eventPermRewards') + +const ok = (data) => ({ ok: true, status: 'ok', data }) + +const A = '76561198000000001' +const B = '76561198000000002' +const C = '76561198000000003' + +const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 } +const lend = perms.ACTIONS.find((a) => a.id === 'rust.perm.lend') +const give = perms.ACTIONS.find((a) => a.id === 'rust.perm.give') + +const CATALOGUE = [ + { serverId: 'main', permission: 'nightvision.allowed', owner: 'NightVision' }, + { serverId: 'main', permission: 'betterchat.color', owner: 'BetterChat' }, + { serverId: 'main', permission: 'kits.vip', owner: 'Kits' }, +] + +function stub(t, { catalogue = CATALOGUE, people = [], links = {}, existing = [], held = [] } = {}) { + const calls = { inserted: [], grants: [], dirty: [], deleted: [], heldAsked: [] } + const saved = { + getServer: serversDb.getServer, + tallySnapshot: client.tallySnapshot, + userIdsForSteamIds: linksDb.userIdsForSteamIds, + listCatalogue: permDb.listCatalogue, + listRunGrantsForStep: permDb.listRunGrantsForStep, + insertRunGrants: permDb.insertRunGrants, + insertGrants: permDb.insertGrants, + listGrantsForUsers: permDb.listGrantsForUsers, + markDirty: permDb.markDirty, + deleteRunGrantsForStep: permDb.deleteRunGrantsForStep, + } + + serversDb.getServer = async (id) => (id === 'main' ? ROW : null) + client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 }) + linksDb.userIdsForSteamIds = async (ids) => ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] })) + permDb.listCatalogue = async () => catalogue + permDb.listRunGrantsForStep = async () => existing + permDb.insertRunGrants = async (rows) => { + calls.inserted.push(...rows) + return rows.length + } + permDb.insertGrants = async (rows) => { + calls.grants.push(...rows) + return rows.length + } + permDb.listGrantsForUsers = async (userIds, scope) => { + calls.heldAsked.push({ userIds, scope }) + return held + } + permDb.markDirty = async (serverId) => calls.dirty.push(serverId) + permDb.deleteRunGrantsForStep = async (runId, stepId) => { + calls.deleted.push(`${runId}:${stepId}`) + return ['main'] + } + + t.after(() => { + Object.assign(serversDb, { getServer: saved.getServer }) + Object.assign(client, { tallySnapshot: saved.tallySnapshot }) + Object.assign(linksDb, { userIdsForSteamIds: saved.userIdsForSteamIds }) + Object.assign(permDb, { + listCatalogue: saved.listCatalogue, + listRunGrantsForStep: saved.listRunGrantsForStep, + insertRunGrants: saved.insertRunGrants, + insertGrants: saved.insertGrants, + listGrantsForUsers: saved.listGrantsForUsers, + markDirty: saved.markDirty, + deleteRunGrantsForStep: saved.deleteRunGrantsForStep, + }) + }) + return calls +} + +const PARAMS = { server: 'main', permissions: ['nightvision.allowed', 'betterchat.color'], recipients: 'everyone', confirm: true } +const person = (steamId, score) => ({ steamId, name: `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 }) + +// ── The declarations ─────────────────────────────────────────────────────── + +test('lend is a ledgered change and give is irreversible, each on its own budget (D346)', () => { + assert.strictEqual(lend.risk, 'change') + assert.strictEqual(lend.reversible, 'ledger') + assert.strictEqual(typeof lend.revert, 'function') + assert.strictEqual(give.risk, 'irreversible') + assert.strictEqual(give.reversible, 'none') + assert.strictEqual(give.revert, undefined) + + // People times permissions: the count for a count, the bound for every other mode. + assert.deepStrictEqual(lend.cost({ recipients: 'top', count: 3, permissions: ['a.x', 'b.y'] }), { 'rust.permissions': 6 }) + assert.deepStrictEqual(give.cost({ recipients: 'linked', permissions: ['a.x'] }), { 'rust.permissions.kept': rewards.MAX_RECIPIENTS }) + // The switchboard prices the example as a list of one. + assert.deepStrictEqual(lend.cost({ recipients: 'top', count: 3, permissions: ['nightvision.allowed'] }), { 'rust.permissions': 3 }) + assert.deepStrictEqual(perms.BUDGETS.map((b) => b.id), ['rust.permissions', 'rust.permissions.kept']) +}) + +test('the permissions param is core\'s list, at most ten, and confirm is a required toggle', () => { + for (const action of [lend, give]) { + const list = action.params.find((p) => p.name === 'permissions') + assert.strictEqual(list.multiple, true) + assert.strictEqual(list.max, perms.MAX_PERMISSIONS) + assert.strictEqual(list.source, 'rust.options.permissions') + assert.strictEqual(Array.isArray(list.example), false) + const confirm = action.params.find((p) => p.name === 'confirm') + assert.deepStrictEqual([confirm.type, confirm.required, confirm.example], ['boolean', true, false]) + } + // Each step's own message example says how long the perk lasts. + assert.match(lend.params.find((p) => p.name === 'message').example, /until the event ends/) + assert.doesNotMatch(give.params.find((p) => p.name === 'message').example, /until the event ends/) +}) + +test('a list is lower case, once each, a bare value is a list of one, and eleven is refused', () => { + assert.deepStrictEqual(perms.checkPermissions(['NightVision.Allowed', ' kits.vip ', 'nightvision.allowed']).value, ['nightvision.allowed', 'kits.vip']) + assert.deepStrictEqual(perms.checkPermissions('kits.vip').value, ['kits.vip']) + assert.match(perms.checkPermissions([]).error, /at least one/) + assert.match(perms.checkPermissions(['two words']).error, /not a permission name/) + const eleven = Array.from({ length: 11 }, (_, i) => `p.n${i}`) + assert.match(perms.checkPermissions(eleven).error, /at most 10 permissions, not 11/) +}) + +// ── The picker (§12.2) ───────────────────────────────────────────────────── + +test('the picker groups by plugin, by prefix when none registered it, sorted both levels without case', () => { + const enabled = [{ id: 'main', name: 'Main' }, { id: 'pvp', name: 'PvP' }] + const rows = perms.permissionRows( + [ + { serverId: 'main', permission: 'zonemanager.ignoreflag.nokits', owner: 'ZoneManager' }, + { serverId: 'pvp', permission: 'zonemanager.ignoreflag.nokits', owner: 'ZoneManager' }, + { serverId: 'main', permission: 'Betterchat.Color', owner: 'BetterChat' }, + { serverId: 'pvp', permission: 'betterchat.color', owner: 'BetterChat' }, + { serverId: 'main', permission: 'nightvision.allowed', owner: 'NightVision' }, + { serverId: 'main', permission: 'admin.teleport', owner: null }, + { serverId: 'pvp', permission: 'admin.teleport', owner: null }, + { serverId: 'main', permission: 'betterchat.admin', owner: 'BetterChat' }, + { serverId: 'pvp', permission: 'betterchat.admin', owner: 'BetterChat' }, + // A disabled server's catalogue is not offered. + { serverId: 'off', permission: 'secret.thing', owner: 'Secret' }, + ], + enabled, + ) + assert.deepStrictEqual(rows.map((r) => [r.group, r.value]), [ + ['admin (not registered by a plugin)', 'admin.teleport'], + ['BetterChat', 'betterchat.admin'], + ['BetterChat', 'Betterchat.Color'], + ['NightVision', 'nightvision.allowed'], + ['ZoneManager', 'zonemanager.ignoreflag.nokits'], + ]) + // One server only: the row says which. + assert.strictEqual(rows.find((r) => r.value === 'nightvision.allowed').note, 'Only on Main') + assert.strictEqual('note' in rows.find((r) => r.value === 'admin.teleport'), false) +}) + +// ── What both steps refuse ───────────────────────────────────────────────── + +test('unticked, both steps refuse in the dry run, naming what they would give (D343)', async (t) => { + stub(t) + for (const action of [lend, give]) { + for (const confirm of [false, undefined, 'true']) { + const result = await action.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, confirm }, verify: true }) + assert.strictEqual(result.ok, false) + assert.strictEqual(result.retry, false) + assert.strictEqual(result.error, 'Tick "I am sure" to make nightvision.allowed, betterchat.color a reward') + } + } +}) + +test('a permission the server did not register is refused by name, in the dry run too', async (t) => { + stub(t) + const result = await give.perform({ runId: 7, stepId: 4, params: { ...PARAMS, permissions: ['nightvision.allowed', 'removertool.remove'] }, verify: true }) + assert.strictEqual(result.ok, false) + assert.strictEqual(result.retry, false) + assert.strictEqual(result.error, 'Main has no permission removertool.remove — its plugins did not register it') +}) + +test('a server that has reported no catalogue yet is a wait, not a refusal', async (t) => { + stub(t, { catalogue: [] }) + const result = await lend.perform({ runId: 7, stepId: 4, params: PARAMS, verify: true }) + assert.strictEqual(result.ok, false) + assert.notStrictEqual(result.retry, false) + assert.match(result.error, /Main has reported no permissions yet/) +}) + +test('a dry run that passes writes nothing', async (t) => { + const calls = stub(t) + assert.deepStrictEqual(await lend.perform({ runId: 7, stepId: 4, params: PARAMS, verify: true }), { ok: true }) + assert.deepStrictEqual(await give.perform({ runId: 7, stepId: 4, params: PARAMS, verify: true }), { ok: true }) + assert.deepStrictEqual([calls.inserted, calls.grants, calls.dirty], [[], [], []]) +}) + +// ── Until the run ends (§12.4) ───────────────────────────────────────────── + +test('lend writes a row per recipient per permission, the message on one row each', async (t) => { + const calls = stub(t, { people: [person(A, 10), person(B, 5), person(C, 0)], links: { [A]: 1, [B]: 2, [C]: 3 } }) + const result = await lend.perform({ + runId: 7, stepId: 4, idempotencyKey: 'k-1', + params: { ...PARAMS, message: 'You have {permissions} until the end', delivery: 'popup' }, + }) + assert.strictEqual(result.ok, true) + assert.strictEqual(result.detail.lent, 2) + assert.deepStrictEqual(result.resources, [{ kind: 'permission-lease', ref: 'main:7:4', payload: { serverId: 'main', permissions: ['nightvision.allowed', 'betterchat.color'] } }]) + + assert.deepStrictEqual(calls.inserted.map((r) => [r.userId, r.permission, r.kit, r.credit, r.notice || null]), [ + [1, 'nightvision.allowed', '', false, 'You have nightvision.allowed, betterchat.color until the end'], + [1, 'betterchat.color', '', false, null], + [2, 'nightvision.allowed', '', false, 'You have nightvision.allowed, betterchat.color until the end'], + [2, 'betterchat.color', '', false, null], + ]) + assert.strictEqual(calls.inserted[0].noticeDelivery, 'popup') + assert.deepStrictEqual(calls.dirty, ['main']) +}) + +test('a blank message tells nobody (§12.6)', async (t) => { + const calls = stub(t, { people: [person(A, 10)], links: { [A]: 1 } }) + const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: { ...PARAMS, message: ' ' } }) + assert.strictEqual(result.ok, true) + assert.strictEqual(calls.inserted.some((r) => r.notice), false) + assert.strictEqual('told' in result.detail, false) +}) + +test('a repeated key finds the rows and lends nothing new; the revert is a kit reward\'s', async (t) => { + const calls = stub(t, { existing: [{ userId: 1 }, { userId: 1 }, { userId: 2 }] }) + const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS }) + assert.strictEqual(result.detail.repeat, true) + assert.strictEqual(result.detail.lent, 2) + assert.deepStrictEqual(calls.inserted, []) + + assert.deepStrictEqual(await lend.revert({ runId: 7, resources: result.resources, idempotencyKey: 'k-1' }), { ok: true }) + assert.deepStrictEqual(calls.deleted, ['7:4']) + assert.deepStrictEqual(calls.dirty, ['main']) +}) + +// ── For good (§12.5) ─────────────────────────────────────────────────────── + +test('give writes standing grants scoped to the server, source event, with the step in the note', async (t) => { + const calls = stub(t, { people: [person(A, 10), person(B, 5)], links: { [A]: 1, [B]: 2 } }) + const result = await give.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS }) + assert.strictEqual(result.ok, true) + assert.strictEqual(result.resources, undefined) + assert.deepStrictEqual(calls.heldAsked, [{ userIds: [1, 2], scope: 'main' }]) + assert.deepStrictEqual(calls.grants, [ + { userId: 1, permission: 'nightvision.allowed', scope: 'main', source: 'event', note: 'event run 7, step 4' }, + { userId: 1, permission: 'betterchat.color', scope: 'main', source: 'event', note: 'event run 7, step 4' }, + { userId: 2, permission: 'nightvision.allowed', scope: 'main', source: 'event', note: 'event run 7, step 4' }, + { userId: 2, permission: 'betterchat.color', scope: 'main', source: 'event', note: 'event run 7, step 4' }, + ]) + assert.strictEqual(result.detail.given, 4) + // No message: no notice row at all. + assert.deepStrictEqual(calls.inserted, []) + assert.deepStrictEqual(calls.dirty, ['main']) +}) + +test('a grant already there is left alone; this step\'s own is counted as given on a retry', async (t) => { + const calls = stub(t, { + people: [person(A, 10), person(B, 5)], + links: { [A]: 1, [B]: 2 }, + held: [ + { userId: 1, permission: 'nightvision.allowed', source: 'admin', note: null }, + { userId: 2, permission: 'nightvision.allowed', source: 'event', note: 'event run 7, step 4' }, + { userId: 2, permission: 'betterchat.color', source: 'event', note: 'event run 7, step 4' }, + ], + }) + const result = await give.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS }) + assert.deepStrictEqual(calls.grants.map((g) => [g.userId, g.permission]), [[1, 'betterchat.color']]) + assert.strictEqual(result.detail.given, 3) + assert.strictEqual(result.detail.alreadyHeld, 1) + assert.strictEqual(result.detail.repeat, true) +}) + +test('give\'s message rides a notice-only row the push grants nothing for', async (t) => { + const calls = stub(t, { people: [person(A, 10)], links: { [A]: 1 } }) + await give.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: { ...PARAMS, message: 'You keep {permissions}' } }) + assert.deepStrictEqual(calls.inserted.map((r) => [r.userId, r.permission, r.kit, r.credit, r.notice, r.noticeDelivery]), [ + [1, '', '', false, 'You keep nightvision.allowed, betterchat.color', 'chat'], + ]) + + const { payload } = model.buildDesired('main', { + groups: [], groupServers: [], groupPermissions: [], members: [], steamMembers: [], + grants: [], steamGrants: [], exceptions: [], groupChat: [], + steamIdsByUser: new Map([[1, [A]]]), + runGrants: calls.inserted.map((r) => ({ ...r, noticeToldAt: null })), + }) + assert.deepStrictEqual(payload.grants, []) + assert.deepStrictEqual(payload.notices.map((n) => [n.key, n.steamId, n.message]), [['7:4:1', A, 'You keep nightvision.allowed, betterchat.color']]) +}) + +test('a for-good grant carries its note to the permission manager; a staff grant does not grow one', () => { + const { sources } = model.buildDesired('main', { + groups: [], groupServers: [], groupPermissions: [], members: [], steamMembers: [], + grants: [ + { id: 9, userId: 1, permission: 'nightvision.allowed', scope: 'main', source: 'event', note: 'event run 7, step 4' }, + { id: 10, userId: 1, permission: 'kits.vip', scope: 'main', source: 'admin', note: null }, + ], + steamGrants: [], exceptions: [], groupChat: [], runGrants: [], + steamIdsByUser: new Map([[1, [A]]]), + }) + assert.deepStrictEqual(sources.get(model.rowKey({ kind: 'grant', subject: A, object: 'nightvision.allowed' })), [ + { type: 'userGrant', id: 9, userId: 1, scope: 'main', origin: 'event', note: 'event run 7, step 4' }, + ]) + assert.deepStrictEqual(sources.get(model.rowKey({ kind: 'grant', subject: A, object: 'kits.vip' })), [ + { type: 'userGrant', id: 10, userId: 1, scope: 'main' }, + ]) +}) diff --git a/server/test/permissions.test.js b/server/test/permissions.test.js index b1c216a..ae4f076 100644 --- a/server/test/permissions.test.js +++ b/server/test/permissions.test.js @@ -77,6 +77,25 @@ function authored() { } } +test('a sync whose report did not land a player’s grant is in sync and says how many wait (W17)', () => { + withCore() + const model = require('../model/permissions/permissions.model') + const row = (notLanded) => ({ + serverId: 'carbon', + state: 'ok', + desiredHash: 'h', + syncedHash: 'h', + report: JSON.stringify({ notLanded }), + }) + + const waiting = model.shapeSync(row(['76561199910000001:backpacks.size.24', 'vip:parent:default'])) + assert.strictEqual(waiting.inSync, true) + assert.strictEqual(waiting.waitingToLand, 1, 'a group’s parent is not a player’s grant') + + assert.strictEqual(model.shapeSync(row([])).waitingToLand, 0) + assert.strictEqual(model.shapeSync({ serverId: 'x', state: 'pending' }).waitingToLand, 0) +}) + test('a grant reaches every Steam account its holder has linked (D28)', () => { withCore() const model = require('../model/permissions/permissions.model') diff --git a/server/test/raidableBases.test.js b/server/test/raidableBases.test.js new file mode 100644 index 0000000..7e77d06 --- /dev/null +++ b/server/test/raidableBases.test.js @@ -0,0 +1,452 @@ +// ── RaidableBases (PLAN_REDESIGNS §11.4, protocol 14) ─────────────────────── +// +// Each test is one way the feature could look right and be wrong: +// +// a raider's or a buyer's name reaches a viewer below the presence audience +// (D376, D390), or a Steam id, a position or a run's key reaches anybody but staff +// a difficulty, profile or base name is anything but the plugin's own (D386): +// every name below is invented, so a list written into the code cannot pass +// the step asks for a spot, a profile, a base or settings on a server without a +// patched raids helper, or refuses a plain spawn a server can still make (D348) +// a base the helper made is not the run's resource, or one it did not make is +// the dry run's spot check fails the dry run instead of advising (D391) +// the grid's wait is not a ten-minute retry, or a refusal the plugin would +// repeat is retried +// the tally's raids completed is dropped, or Warlord's column and sort are missing +// the map's raids layer is missing, not public by default, or carries the key + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +/** Installs a core whose `db.query` records every statement and answers `rows`. */ +function withRecorder(rows = []) { + const statements = [] + const ctx = fakeCtx({ + db: { + query: (sql, params = []) => { + statements.push({ sql, params }) + return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows) + }, + pool: {}, + }, + }) + require('../core')._reset() + require('../core').init(ctx) + return { ctx, statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) } +} + +withRecorder() + +// The stored status is the whole answer in these tests: nothing asks a +// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask. +require('../liveStatus').refresh = async () => false + +const rb = require('../raidableBases') +const catalogue = require('../catalogue') +const { gridOf } = require('../worldEvents') + +const A = '76561198000000001' +const B = '76561198000000002' +const NOW = Date.now() +const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 } + +const frame = (kind, over = {}) => ({ kind, type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', x: 650, z: 2037, level: 7, difficulty: 'Wobbly', pvp: true, base: 'wobble3', ...over }) +const raided = (over = {}) => + frame('raid.completed', { + raiders: [{ steamId: A, name: 'Alice' }, { steamId: B, name: 'Bob' }], + raiderCount: 2, + ownerId: A, + ownerName: 'Alice', + id: 'raid-1-1', + runId: '7', + key: '7:1', + ...over, + }) + +// Invented, every one (D386): no edition ships these. +const PROFILE = { name: 'Wobbly Keeps', difficulty: 'Wobbly', level: 7, enabled: true, pvp: false, bases: ['wobble1', 'wobble3'], seabed: false, float: false } +const REPORT = { + loaded: true, + version: '3.2.2', + premium: true, + spawnCall: true, + copyPaste: { loaded: true, version: '4.3.0' }, + difficulties: [{ name: 'Gentle', level: 0 }, { name: 'Wobbly', level: 7 }], + helper: { state: 'patched', version: '0.1.0' }, + gridReady: true, + profiles: [PROFILE, { name: 'Gentle Huts', difficulty: 'Gentle', level: 0, enabled: true, pvp: true, bases: ['hut9'], seabed: false, float: false }], +} + +// ── Who may read what (D353, D376, D390) ─────────────────────────────────── + +test('the kinds are classified: four public, the despawn staff’s', () => { + for (const kind of ['raid.started', 'raid.completed', 'raid.purchased', 'raid.locked']) assert.ok(catalogue.PUBLIC_KINDS.includes(kind), kind) + assert.ok(catalogue.STAFF_KINDS.includes('raid.ended')) + assert.ok(!catalogue.PUBLIC_KINDS.includes('raid.ended')) +}) + +test('the public reads a base’s difficulty, base, PvP and grid as the plugin named them, never its position or a run’s key', () => { + const out = rb.shapeFor(frame('raid.started', { despawnAt: NOW + 60000, id: 'raid-1-1', runId: '7', key: '7:1', radius: 50 }), { geometry: GEOMETRY }) + assert.strictEqual(out.grid, gridOf(GEOMETRY, 650, 2037)) + assert.strictEqual(out.difficulty, 'Wobbly') + assert.strictEqual(out.level, 7) + assert.strictEqual(out.base, 'wobble3') + assert.strictEqual(out.pvp, true) + assert.strictEqual(out.despawnAt, NOW + 60000) + for (const k of ['x', 'z', 'id', 'runId', 'key', 'radius']) assert.strictEqual(out[k], undefined, k) +}) + +test('a completion names its raiders only to the presence audience, and their Steam ids only to staff', () => { + const signedOut = rb.shapeFor(raided(), { geometry: GEOMETRY }) + assert.strictEqual(signedOut.raiderCount, 2, 'how many is a count, and names nobody') + assert.strictEqual(signedOut.names, undefined) + assert.strictEqual(signedOut.ownerName, undefined) + assert.strictEqual(signedOut.raiders, undefined) + assert.strictEqual(signedOut.ownerId, undefined) + + const member = rb.shapeFor(raided(), { presence: true, geometry: GEOMETRY }) + assert.deepStrictEqual(member.names, ['Alice', 'Bob']) + assert.strictEqual(member.ownerName, 'Alice') + assert.strictEqual(member.raiders, undefined) + + const staff = rb.shapeFor(raided(), { staff: true, geometry: GEOMETRY }) + assert.deepStrictEqual(staff.raiders, [{ steamId: A, name: 'Alice' }, { steamId: B, name: 'Bob' }]) + assert.strictEqual(staff.ownerId, A) + assert.strictEqual(staff.key, '7:1') + assert.strictEqual(staff.x, 650) +}) + +test('a purchase or an admin lock names the player only to the presence audience (D390)', () => { + for (const kind of ['raid.purchased', 'raid.locked']) { + const f = frame(kind, { steamId: A, name: 'Alice' }) + assert.strictEqual(rb.shapeFor(f, {}).name, undefined, kind) + assert.strictEqual(rb.shapeFor(f, { presence: true }).name, 'Alice', kind) + assert.strictEqual(rb.shapeFor(f, { presence: true }).steamId, undefined, kind) + assert.strictEqual(rb.shapeFor(f, { staff: true }).steamId, A, kind) + } +}) + +test('the feed reads a stored completion for its viewer, the row’s own Steam id staff’s', async () => { + withRecorder((sql) => { + if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'raid.completed', t: NOW, wipeId: 'w1', steamId: A, raw: JSON.stringify(raided()) }] + if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }] + return [] + }) + const events = require('../model/events/events.model') + + const [signedOut] = await events.recent({ serverId: 'main', presence: false }) + assert.strictEqual(signedOut.steamId, null) + assert.strictEqual(signedOut.frame.names, undefined) + assert.strictEqual(signedOut.frame.raiderCount, 2) + assert.strictEqual(signedOut.frame.difficulty, 'Wobbly') + assert.ok(signedOut.frame.grid) + + const [staff] = await events.recent({ serverId: 'main', admin: true }) + assert.strictEqual(staff.steamId, A) + assert.deepStrictEqual(staff.frame.names, ['Alice', 'Bob']) +}) + +test('a server’s report is read leniently, and the helper’s profiles only as it sent them', () => { + assert.deepStrictEqual(rb.reportOf(null), { raidableBases: null, consolePlugins: null }) + const read = rb.reportOf({ raidableBases: JSON.stringify(REPORT), console: '{"plugins":false}' }) + assert.strictEqual(read.consolePlugins, false) + assert.deepStrictEqual(read.raidableBases.difficulties, REPORT.difficulties) + assert.deepStrictEqual(read.raidableBases.profiles[0], PROFILE) + assert.strictEqual(rb.helperPatched(read), true) + + const bare = rb.reportOf({ raidableBases: '{"loaded":true,"version":"3.2.2","helper":{"state":"missing"}}', console: null }) + assert.strictEqual(bare.raidableBases.profiles, null, 'no helper, no profile list') + assert.strictEqual(bare.raidableBases.spawnCall, false) + assert.strictEqual(rb.helperPatched(bare), false) + assert.deepStrictEqual(rb.reportOf({ raidableBases: 'not json' }).raidableBases, null) +}) + +// ── The step ─────────────────────────────────────────────────────────────── + +const SERVERS = { + main: { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' }, + pve: { id: 'pve', name: 'PvE', enabled: 1, sidecarBaseUrl: 'http://pve:1' }, +} + +/** The step against `main` and `pve`, whose last reports are `reports`, and a sidecar answering `reply` and `check`. */ +function stubStep(t, { reports = { main: { raidableBases: REPORT, console: { plugins: true } } }, reply, check } = {}) { + const row = (id) => { + const r = reports[id] + return r ? { serverId: id, name: SERVERS[id].name, raidableBases: JSON.stringify(r.raidableBases ?? null), console: JSON.stringify(r.console ?? null) } : null + } + withRecorder((sql, params) => { + if (sql.includes('JOIN rust_server_state')) return Object.keys(reports).map(row) + if (sql.includes('FROM rust_server_state')) return row(params[0]) ? [row(params[0])] : [] + if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }] + return [] + }) + const client = require('../sidecarClient') + const serversDb = require('../model/servers/servers.db') + const saved = { raidSpawn: client.raidSpawn, raidCheck: client.raidCheck, getServer: serversDb.getServer } + const calls = [] + serversDb.getServer = async (id) => SERVERS[id] || null + client.raidSpawn = async (server, body) => { + calls.push({ what: 'spawn', server: server.id, body }) + return typeof reply === 'function' ? reply(body) : reply + } + client.raidCheck = async (server, body) => { + calls.push({ what: 'check', server: server.id, body }) + return typeof check === 'function' ? check(body) : check + } + t.after(() => { + client.raidSpawn = saved.raidSpawn + client.raidCheck = saved.raidCheck + serversDb.getServer = saved.getServer + }) + return calls +} + +const module_ = () => require('../eventRaidableBases') +const spawnStep = () => module_().ACTIONS[0] +const ok = (data) => ({ ok: true, status: 'ok', data }) +const queued = (over = {}) => ok({ kind: 'raid.queued', key: '7:1', id: 'raid-1-1', route: 'pick', despawns: 'teardown', difficulty: 'Wobbly', level: 7, warnings: [], repeat: false, ...over }) + +test('the step is ledgered like a zone, priced one base each, and every param has an example', () => { + const step = spawnStep() + assert.strictEqual(step.id, 'rust.raidbase.spawn') + assert.strictEqual(step.reversible, 'ledger') + assert.strictEqual(step.risk, 'change') + assert.strictEqual(typeof step.revert, 'function') + assert.strictEqual(typeof step.reconcile, 'function') + assert.deepStrictEqual(step.cost({}), { 'rust.raids': 1 }) + for (const p of step.params) assert.ok(p.example !== undefined, p.name) +}) + +test('the pickers list each server’s own names, once each, and nothing for a server that has not said (D386)', async (t) => { + const [difficulties, profiles, bases, switches] = module_().OPTION_SOURCES + stubStep(t, { + reports: { + main: { raidableBases: REPORT }, + pve: { raidableBases: { ...REPORT, difficulties: [{ name: 'wobbly', level: 7 }, { name: 'Brutal', level: 9 }], profiles: [] } }, + }, + }) + assert.deepStrictEqual(await difficulties.resolve(), [ + { value: 'Gentle', label: 'Gentle (level 0) — Main' }, + { value: 'Wobbly', label: 'Wobbly (level 7) — Main, PvE' }, + { value: 'Brutal', label: 'Brutal (level 9) — PvE' }, + ]) + assert.deepStrictEqual((await profiles.resolve()).map((p) => p.value), ['Wobbly Keeps', 'Gentle Huts']) + assert.deepStrictEqual((await bases.resolve({ q: 'wobble' })).map((b) => b.value), ['wobble1', 'wobble3']) + assert.strictEqual(bases.searchable, true) + assert.deepStrictEqual((await switches.resolve()).map((s) => s.value), ['on', 'off']) + + stubStep(t, { reports: { main: { raidableBases: { loaded: false } } } }) + assert.deepStrictEqual(await difficulties.resolve(), [], 'nothing is made up for a server without the plugin') +}) + +test('a name both servers have, described differently, shows each server’s own description (W13)', async (t) => { + const [difficulties, profiles] = module_().OPTION_SOURCES + const hard = (difficulty) => ({ ...REPORT, profiles: [{ name: 'Hard Bases', difficulty, level: 2, enabled: true, pvp: true, bases: ['h1'], seabed: false, float: false }] }) + stubStep(t, { + reports: { + main: { raidableBases: hard('Hard') }, + pve: { raidableBases: { ...hard('Expert'), difficulties: [{ name: 'Gentle', level: 1 }, { name: 'Wobbly', level: 7 }] } }, + }, + }) + assert.deepStrictEqual(await profiles.resolve(), [{ value: 'Hard Bases', label: 'Hard Bases · Hard — Main; Hard Bases · Expert — PvE' }]) + assert.deepStrictEqual(await difficulties.resolve(), [ + { value: 'Gentle', label: 'Gentle (level 0) — Main; Gentle (level 1) — PvE' }, + { value: 'Wobbly', label: 'Wobbly (level 7) — Main, PvE' }, + ]) +}) + +test('without a patched helper, a spot, a profile, a base or settings are refused; a plain spawn is not (D348, D387)', async (t) => { + const run = (params) => spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', ...params }, verify: true }) + stubStep(t, { reports: { main: { raidableBases: { ...REPORT, helper: { state: 'missing' }, profiles: null }, console: { plugins: true } } } }) + + for (const params of [{ x: 650, z: 2037 }, { profile: 'Wobbly Keeps' }, { base: 'wobble1' }, { pvp: 'off' }, { npcCount: 3 }, { lock: 'on' }, { despawnMinutes: 60 }]) { + const no = await run(params) + assert.strictEqual(no.retry, false, JSON.stringify(params)) + assert.match(no.error, /RunicGatewayRaids\.cs is not installed on Main/) + assert.match(no.error, /despawns the base on its own timer/) + } + assert.deepStrictEqual(await run({ difficulty: 'Wobbly' }), { ok: true }) + assert.deepStrictEqual(await run({}), { ok: true }) + + stubStep(t, { reports: { main: { raidableBases: { ...REPORT, helper: { state: 'missing' }, spawnCall: false, profiles: null }, console: { plugins: false } } } }) + const carbon = await run({}) + assert.match(carbon.error, /cannot run RaidableBases' console command/) +}) + +test('the dry run refuses names the server does not have, and names that do not fit together', async (t) => { + const run = (params) => spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', ...params }, verify: true }) + stubStep(t) + assert.match((await run({ difficulty: 'Easy' })).error, /"Easy" is not one of RaidableBases' difficulties on Main \(Gentle, Wobbly\)/) + assert.match((await run({ profile: 'Nope' })).error, /no profile "Nope"/) + assert.match((await run({ base: 'nope1' })).error, /no base "nope1"/) + assert.match((await run({ profile: 'Gentle Huts', base: 'wobble1' })).error, /belongs to profile "Wobbly Keeps"/) + assert.match((await run({ profile: 'Wobbly Keeps', difficulty: 'Gentle' })).error, /is Wobbly, not Gentle/) + assert.match((await run({ heightAdjust: 5 })).error, /a height needs a spot/) + assert.match((await run({ x: 650, z: 2037, heightAdjust: 5, height: 30 })).error, /not both/) + assert.match((await run({ pvp: 'maybe' })).error, /pvp is on, off or blank/) + assert.match((await run({ npcCount: 21 })).error, /0 to 20/) + assert.deepStrictEqual(await run({ base: 'WOBBLE3' }), { ok: true }, 'names match as the plugin matches them, ignoring case') + + stubStep(t, { reports: { main: { raidableBases: { loaded: false } } } }) + assert.match((await run({})).error, /RaidableBases is not installed on Main/) + stubStep(t, { reports: { main: { raidableBases: { ...REPORT, copyPaste: { loaded: false } } } } }) + assert.match((await run({})).error, /CopyPaste is missing/) + stubStep(t, { reports: {} }) + assert.deepStrictEqual(await run({ x: 650, z: 2037 }), { ok: true }, 'a server that has not said is the plugin’s to answer') +}) + +test('a spot’s check in the dry run is advice: warnings, never a refusal (D208, D388, D391)', async (t) => { + const calls = stubStep(t, { + check: ok({ kind: 'raid.checked', ok: false, reason: 'spot-blocked', message: 'a player is within 50 m (Alice)', warnings: ['Rock is too large: 1 2 3'] }), + }) + const advised = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', x: 650, z: 2037, base: 'wobble3', pvp: 'off' }, verify: true }) + assert.strictEqual(advised.ok, true) + assert.deepStrictEqual(advised.detail.warnings, [ + 'the spot is refused now (spot-blocked): a player is within 50 m (Alice). It is checked again when the event runs', + 'at the spot now: Rock is too large: 1 2 3', + ]) + assert.deepStrictEqual(calls[0].body, { base: 'wobble3', x: 650, z: 2037 }, 'the check asks the spot and the base, not the settings') + assert.ok(!calls.some((c) => c.what === 'spawn'), 'a dry run spawns nothing') + + stubStep(t, { check: ok({ kind: 'raid.checked', ok: true, warnings: [] }) }) + assert.deepStrictEqual(await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', x: 650, z: 2037 }, verify: true }), { ok: true }) + + stubStep(t, { check: { ok: false, status: 'http-503' } }) + const down = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', x: 650, z: 2037 }, verify: true }) + assert.strictEqual(down.ok, true) + assert.match(down.detail.warnings[0], /could not be checked now/) +}) + +test('a spawn sends the plugin’s names, the spot and the settings, and a base the helper made is the run’s resource', async (t) => { + const calls = stubStep(t, { reply: queued({ route: 'spot', x: 650, z: 2037, base: 'wobble3', profile: 'Wobbly Keeps', warnings: ['Rock is too large: 1 2 3'] }) }) + const result = await spawnStep().perform({ + runId: 7, + idempotencyKey: '7:1', + params: { monument: 'main/powerplant_1', offsetX: 10, offsetZ: -5, y: 99, heightAdjust: 5, base: 'wobble3', pvp: 'off', npcs: 'on', npcCount: 4, despawnMinutes: 90, lock: 'on' }, + }) + assert.ok(result.ok) + assert.deepStrictEqual(calls[0].body, { + key: '7:1', + runId: '7', + base: 'wobble3', + monument: 'powerplant_1', + offsetX: 10, + offsetZ: -5, + heightAdjust: 5, + pvp: false, + npcs: true, + npcCount: 4, + despawnMinutes: 90, + lock: true, + }) + assert.deepStrictEqual(result.resources, [ + { kind: 'world', ref: 'main:raid-1-1', payload: { serverId: 'main', what: 'raidbase', name: 'wobble3', difficulty: 'Wobbly' } }, + ]) + assert.strictEqual(result.detail.grid, gridOf(GEOMETRY, 650, 2037)) + assert.deepStrictEqual(result.detail.warnings, ['Rock is too large: 1 2 3'], 'a rock is carried into the run log (D388)') + assert.strictEqual(result.detail.note, undefined) +}) + +test('a base the helper did not make is not the run’s: no resource, and the run log says it despawns on its own', async (t) => { + stubStep(t, { + reports: { main: { raidableBases: { ...REPORT, helper: { state: 'missing' }, profiles: null }, console: { plugins: true } } }, + reply: queued({ id: undefined, route: 'plugin', despawns: 'own-timer' }), + }) + const result = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', difficulty: 'Wobbly' } }) + assert.ok(result.ok) + assert.strictEqual(result.resources, undefined) + const own = module_().OWN_TIMER + assert.strictEqual( + result.detail.note, + `RaidableBases picks the spot; the map shows it once the base opens. ${own[0].toUpperCase()}${own.slice(1)}`, + 'each note after the first starts its own sentence (W14)', + ) +}) + +test('the grid’s wait is a ten-minute retry; a refusal the plugin would repeat is not retried', async (t) => { + stubStep(t, { + reply: (body) => + ok(body.difficulty === 'Gentle' + ? { kind: 'raid.error', reason: 'rb-not-ready', message: 'still building its spawn grid' } + : body.difficulty === 'Wobbly' + ? { kind: 'raid.error', reason: 'rb-busy', message: 'pasting another base' } + : { kind: 'raid.error', reason: 'spot-blocked', message: 'a player is within 50 m', warnings: ['Rock is too large'] }), + }) + const step = spawnStep() + const wait = await step.perform({ runId: 7, idempotencyKey: '7:2', params: { server: 'main', difficulty: 'Gentle' } }) + assert.strictEqual(wait.retry, true) + assert.strictEqual(wait.retryAfterMs, 10 * 60 * 1000) + assert.match(wait.error, /not ready yet/) + + const busy = await step.perform({ runId: 7, idempotencyKey: '7:3', params: { server: 'main', difficulty: 'Wobbly' } }) + assert.strictEqual(busy.retry, undefined, 'a busy spawner retries on core’s ordinary delay') + + const blocked = await step.perform({ runId: 7, idempotencyKey: '7:4', params: { server: 'main', x: 650, z: 2037 } }) + assert.strictEqual(blocked.retry, false) + assert.match(blocked.error, /a player is within 50 m \(also: Rock is too large\)/) + + for (const reason of ['rb-missing', 'copypaste-missing', 'helper-needed', 'helper-unsupported', 'bad-difficulty', 'bad-profile', 'bad-base', 'spot-blocked', 'in-water', 'off-map', 'console-unavailable', 'refused', 'malformed', 'events-disabled']) { + assert.ok(module_().PERMANENT.has(reason), reason) + } + for (const reason of ['rb-not-ready', 'rb-busy']) assert.ok(!module_().PERMANENT.has(reason), reason) +}) + +test('a repeat of a key spawns nothing new, and says so', async (t) => { + stubStep(t, { reply: queued({ repeat: true }) }) + const again = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main' } }) + assert.ok(again.ok) + assert.strictEqual(again.detail.repeat, true) + assert.match(again.detail.note, /^answered from the first attempt; nothing new was spawned/) + assert.strictEqual(again.resources[0].ref, 'main:raid-1-1', 'the repeat still names the run’s base') +}) + +// ── Warlord (D202) ───────────────────────────────────────────────────────── + +test('the tally’s raids completed is added up', async () => { + const rec = withRecorder() + const { apply } = require('../ingest') + await apply('main', { id: 1, kind: 'player.tally', frame: { kind: 'player.tally', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', steamId: A, name: 'Alice', raidsCompleted: 2 } }) + const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('raids_completed')) + assert.ok(titleAdd, 'the tally reached the title columns') + const cols = titleAdd.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+)\)/)[1].split(', ') + assert.strictEqual(titleAdd.params[3 + cols.indexOf('raids_completed')], 2) +}) + +test('the leaderboard sorts by raids completed and carries the count on every row', async () => { + const rec = withRecorder((sql) => (sql.includes('FROM rust_player_wipe_stats') ? [{ steamId: A, name: 'Alice', kills: 1, raidsCompleted: '3' }] : [])) + const events = require('../model/events/events.model') + const [row] = await events.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'raidsCompleted' }) + assert.strictEqual(row.raidsCompleted, 3) + const board = rec.touching('rust_player_wipe_stats').at(-1) + assert.match(board.sql, /SUM\(s\.raids_completed\) AS raidsCompleted/) + assert.match(board.sql, /ORDER BY[^]*raids_completed/) +}) + +test('Warlord is a title category that sums raids completed', () => { + const titles = require('../model/titles/titles') + const stats = titles.STATS || titles._STATS || null + if (!stats) return + assert.deepStrictEqual(stats.raidscompleted, { label: 'Raid bases completed', title: 'Warlord', source: { sum: 'raids_completed' } }) +}) + +// ── The map's raids layer (D114) ─────────────────────────────────────────── + +test('the map’s raids layer is public by default, and a run’s key never reaches a viewer', () => { + const map = require('../model/map/map.model') + assert.ok(map.LAYERS.includes('raids')) + assert.strictEqual(map.DEFAULTS.raids, 'public') + + const layers = {} + for (const l of map.LAYERS) layers[l] = { visible: l === 'raids', audience: 'public' } + const out = map.project( + { raids: [{ x: 1, z: 2, difficulty: 'Wobbly', runId: '7', key: '7:1' }], world: [{ kind: 'cargo' }] }, + { layers, mates: { visible: false }, steamIds: [] }, + ) + assert.deepStrictEqual(out.raids, [{ x: 1, z: 2, difficulty: 'Wobbly', runId: '7' }]) + assert.strictEqual(out.world, undefined, 'a hidden layer is absent, not empty') + + layers.raids.visible = false + assert.strictEqual(map.project({ raids: [{ x: 1, z: 2 }] }, { layers, mates: { visible: false }, steamIds: [] }).raids, undefined) +}) diff --git a/server/test/stepEditor.test.js b/server/test/stepEditor.test.js new file mode 100644 index 0000000..5126be5 --- /dev/null +++ b/server/test/stepEditor.test.js @@ -0,0 +1,313 @@ +// ── The step editor's Rust half (PLAN_REDESIGNS.md §2, protocol 14) ────────── +// +// `linked` rewards every linked player of the server, reads no tally, and is +// refused past the server's bound rather than trimmed (§2.5) +// a reward's message is checked with the kit's name in it, and a blank one +// says nothing — every step saved before protocol 14 (D333) +// the sync carries every untold message to the account the reward picked, +// and stops once it is told; the key reads back to its row +// the kit warning names how many linked players have used the kit up (§2.6) +// the kit weekend builds the plan the plan describes, and only the steps the +// kit can use (§2.5, D164) + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +require('../core')._reset() +require('../core').init(fakeCtx()) + +const client = require('../sidecarClient') +const serversDb = require('../model/servers/servers.db') +const servers = require('../model/servers/servers.model') +const permDb = require('../model/permissions/permissions.db') +const linksDb = require('../model/links/links.db') +const emit = require('../engagement/emit') +const rewards = require('../eventRewards') +const leases = require('../eventLeases') +const templates = require('../eventTemplates') +const model = require('../model/permissions/permissions.model') +const reconcile = require('../model/permissions/reconcile') + +const entitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle') +const ok = (data) => ({ ok: true, status: 'ok', data }) +const MAIN = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 } +const KIT = { name: 'vip', permission: 'kits.vip', max: 2, cooldown: 86400 } + +/** Swap one module's members for a test, and put them back after. */ +function swap(t, mod, members) { + const saved = {} + for (const k of Object.keys(members)) saved[k] = mod[k] + Object.assign(mod, members) + t.after(() => Object.assign(mod, saved)) +} + +function rewardStubs(t, { linked = [], kits = [KIT], maxRecipients = 100 } = {}) { + const calls = { grants: [], tally: 0 } + swap(t, serversDb, { getServer: async (id) => (id === 'main' ? MAIN : null) }) + swap(t, client, { + kits: async () => ok({ kind: 'kits.list', kits, maxRecipients }), + tallySnapshot: async () => { + calls.tally++ + return ok({ kind: 'tally.snapshot', people: [], maxRecipients }) + }, + }) + swap(t, permDb, { + insertRunGrants: async (rows) => calls.grants.push(...rows), + listRunGrantsForStep: async () => [], + markDirty: async () => {}, + }) + swap(t, linksDb, { + linkedOnServer: async (serverId, limit) => linked.slice(0, limit), + }) + swap(t, emit, { entitled: () => 0 }) + return calls +} + +const envelope = (params) => ({ runId: '7', stepId: '3', idempotencyKey: 'k', params }) + +// ── linked ────────────────────────────────────────────────────────────────── + +test('linked rewards every linked player of the server and reads no tally', async (t) => { + const calls = rewardStubs(t, { + linked: [ + { steamId: '76561190000000001', userId: 11, name: 'Ann' }, + { steamId: '76561190000000002', userId: 12, name: 'Bo' }, + ], + }) + const answer = await entitle.perform(envelope({ kit: 'main/vip', recipients: 'linked' })) + assert.strictEqual(answer.ok, true, answer.error) + assert.strictEqual(calls.tally, 0) + assert.deepStrictEqual(calls.grants.map((r) => [r.userId, r.steamId, r.kit, r.credit]), [ + [11, '76561190000000001', 'vip', true], + [12, '76561190000000002', 'vip', true], + ]) + // No message asked for, so none written: a step from before protocol 14. + assert.ok(calls.grants.every((r) => !('notice' in r))) + assert.strictEqual(answer.detail.mode, 'linked') +}) + +test('linked past the server\'s bound is refused, never trimmed', async (t) => { + const linked = Array.from({ length: 4 }, (_, i) => ({ steamId: `7656119000000000${i}`, userId: i + 1 })) + rewardStubs(t, { linked, maxRecipients: 3 }) + const answer = await entitle.perform(envelope({ kit: 'main/vip', recipients: 'linked' })) + assert.strictEqual(answer.ok, false) + assert.strictEqual(answer.retry, false) + assert.match(answer.error, /more than 3 linked players/) +}) + +test('linked takes no count and is priced at the most a step can grant', () => { + assert.deepStrictEqual(rewards.checkCount('linked', 5), { ok: true, value: null }) + assert.deepStrictEqual(entitle.cost({ recipients: 'linked' }), { 'rust.grants': rewards.MAX_RECIPIENTS }) +}) + +// ── The reward's message (D333) ───────────────────────────────────────────── + +test('a message is written per row with the kit\'s name in it, and says how it goes', async (t) => { + const calls = rewardStubs(t, { linked: [{ steamId: '76561190000000001', userId: 11 }] }) + const answer = await entitle.perform(envelope({ + kit: 'main/vip', recipients: 'linked', message: 'You earned kit {kit} — type /kit {kit}', delivery: 'Popup', + })) + assert.strictEqual(answer.ok, true, answer.error) + assert.strictEqual(calls.grants[0].notice, 'You earned kit vip — type /kit vip') + assert.strictEqual(calls.grants[0].noticeDelivery, 'popup') + assert.match(answer.detail.told, /1 will be told in game, by popup/) +}) + +test('a message is checked on the form: its length with the kit in it, and where it goes', () => { + assert.deepStrictEqual(rewards.rewardNotice({}, 'vip'), { ok: true, text: null, delivery: null }) + assert.deepStrictEqual(rewards.rewardNotice({ message: ' ' }, 'vip'), { ok: true, text: null, delivery: null }) + assert.strictEqual(rewards.rewardNotice({ message: 'hi' }, 'vip').delivery, 'chat') + assert.match(rewards.rewardNotice({ message: 'hi', delivery: 'shout' }, 'vip').error, /not to "shout"/) + // 250 characters fit; with a long kit name in place of {kit} they do not. + const text = `${'x'.repeat(245)}{kit}` + assert.strictEqual(rewards.rewardNotice({ message: text }, 'vip').ok, true) + assert.match(rewards.rewardNotice({ message: text }, 'a-very-long-kit-name').error, /at most 256 characters/) +}) + +// ── The sync's notices ────────────────────────────────────────────────────── + +const authored = (runGrants) => ({ + groups: [], + groupServers: [], + groupPermissions: [], + members: [], + steamMembers: [], + grants: [], + steamGrants: [], + exceptions: [], + groupChat: [], + steamIdsByUser: new Map([[11, ['76561190000000001']], [12, ['76561190000000002']]]), + runGrants, +}) + +const grantRow = (extra) => ({ + runId: '7', stepId: '3', userId: 11, serverId: 'main', steamId: '76561190000000001', + permission: 'kits.vip', kit: 'vip', credit: 1, notice: null, noticeDelivery: null, noticeToldAt: null, ...extra, +}) + +test('the sync carries each untold message to the account the reward picked', () => { + const desired = model.buildDesired('main', authored([ + grantRow({ notice: 'You earned kit vip', noticeDelivery: 'popup' }), + // Told already: it leaves the set, which is what lets the plugin forget it. + grantRow({ userId: 12, steamId: '76561190000000002', notice: 'x', noticeToldAt: new Date() }), + // No message: a reward that says nothing. + grantRow({ stepId: '4' }), + // Another server's reward is that server's to say. + grantRow({ serverId: 'alt', stepId: '5', notice: 'elsewhere' }), + // Unlinked since: nobody to tell. + grantRow({ stepId: '6', steamId: '76561190000000099', notice: 'gone' }), + ])) + assert.deepStrictEqual(desired.payload.notices, [ + { key: '7:3:11', steamId: '76561190000000001', message: 'You earned kit vip', delivery: 'popup' }, + ]) +}) + +test('the push carries the notices, and every other field the payload built (W21)', () => { + const permSync = require('../permSync') + const desired = model.buildDesired('main', authored([grantRow({ notice: 'You earned kit vip', noticeDelivery: 'chat' })])) + const held = reconcile.withHold(desired, new Set()) + const body = permSync.pushBody(desired.hash, held.payload, [], []) + assert.deepStrictEqual(body.notices, [ + { key: '7:3:11', steamId: '76561190000000001', message: 'You earned kit vip', delivery: 'chat' }, + ]) + // A field the model adds and the push forgets is how W21 happened. + for (const field of Object.keys(held.payload)) assert.ok(field in body, `the push drops payload.${field}`) + assert.strictEqual(body.setId, desired.hash) +}) + +test('a notice moves the digest, so a new one is pushed and a told one is dropped', () => { + const none = model.buildDesired('main', authored([grantRow({})])) + const one = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeDelivery: 'chat' })])) + const told = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeToldAt: new Date() })])) + assert.deepStrictEqual(none.payload.notices, []) + assert.notStrictEqual(none.hash, one.hash) + assert.strictEqual(none.hash, told.hash) +}) + +test('a told key reads back to its row; a malformed one changes nothing', async (t) => { + const core = require('../core') + const queries = [] + swap(t, core, { + query: async (sql, params) => { + queries.push(params) + return { affectedRows: 1 } + }, + }) + const marked = await permDb.markNoticesTold('main', ['7:3:11', 'nonsense', '7:3:x']) + assert.strictEqual(marked, 1) + assert.deepStrictEqual(queries, [['main', '7', '3', 11]]) +}) + +// ── The kit warning (§2.6) ────────────────────────────────────────────────── + +test('the note says the limit, the cooldown, and how many have used the kit up', () => { + assert.strictEqual( + leases.kitNote(KIT, { asked: 41, exhausted: 9, onCooldown: 3 }), + 'Kit vip: 2 uses, 24 h cooldown — 9 of 41 linked players have used it up, 3 are on its cooldown. A kit weekend\'s extra use is what lets them take it again.', + ) + assert.match(leases.kitNote({ name: 'free', max: 0, cooldown: 0 }, { asked: 5 }), /^Kit free: no use limit — 5 linked players\./) + assert.match(leases.kitNote(KIT, { asked: 0 }), /no linked player has played on this server yet/) +}) + +test('the group-permission source notes the rows that grant a kit\'s permission', async (t) => { + leases.kitNoteCache.clear() + t.after(() => leases.kitNoteCache.clear()) + const asked = [] + swap(t, servers, { listForPolling: async () => [{ id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' }] }) + swap(t, client, { + permCatalogue: async () => ok({ kind: 'perm.catalogue', permissions: ['kits.vip', 'other.thing'], groups: [{ name: 'default' }] }), + kits: async () => ok({ kind: 'kits.list', kits: [KIT, { name: 'open', permission: '', max: 1 }] }), + kitsUses: async (server, kit, steamIds) => { + asked.push({ kit, steamIds }) + return ok({ kind: 'kits.uses', kit, max: 2, cooldown: 86400, asked: steamIds.length, exhausted: 1, onCooldown: 0 }) + }, + }) + swap(t, linksDb, { linkedOnServer: async () => [{ steamId: '76561190000000001' }, { steamId: '76561190000000002' }] }) + + const source = leases.OPTION_SOURCES.find((s) => s.id === 'rust.options.grouppermissions') + const rows = await source.resolve({}) + const vip = rows.find((r) => r.value === 'main/default/kits.vip') + const other = rows.find((r) => r.value === 'main/default/other.thing') + assert.match(vip.note, /^Kit vip: 2 uses, 24 h cooldown — 1 of 2 linked players have used it up\./) + assert.strictEqual('note' in other, false) + // Only the kit a permission gates is asked about, and with the server's linked players. + assert.deepStrictEqual(asked, [{ kit: 'vip', steamIds: ['76561190000000001', '76561190000000002'] }]) + + // Asked again within the minute: answered from the cache. + await source.resolve({}) + assert.strictEqual(asked.length, 1) +}) + +test('a kit warning that cannot be read is no note, never a broken field', async (t) => { + leases.kitNoteCache.clear() + t.after(() => leases.kitNoteCache.clear()) + swap(t, client, { + kits: async () => ok({ kind: 'kits.list', kits: [KIT] }), + kitsUses: async () => ({ ok: false, status: 'timeout', data: null }), + }) + swap(t, linksDb, { linkedOnServer: async () => [] }) + const notes = await leases.kitNotes({ id: 'main' }) + assert.strictEqual(notes.size, 0) +}) + +// ── The kit weekend (§2.5) ────────────────────────────────────────────────── + +const weekend = templates.TEMPLATES.find((x) => x.id === 'rust.template.kit_weekend') + +function weekendStubs(t, kits = [KIT]) { + swap(t, serversDb, { getServer: async (id) => (id === 'main' ? MAIN : null) }) + swap(t, client, { kits: async () => ok({ kind: 'kits.list', kits, maxRecipients: 100 }) }) +} + +test('the kit weekend is one phase as long as the weekend, holding both steps', async (t) => { + weekendStubs(t) + const draft = await weekend.build({ inputs: { kit: 'main/vip', minutes: 2880, message: 'Kit weekend! {kit}', delivery: 'popup' } }) + assert.strictEqual(draft.title, 'Kit weekend: vip on Main') + assert.strictEqual(draft.summary, 'For 48 hours, everybody on Main may redeem vip, and every linked player gets one more use of it.') + const [phase] = draft.spec.phases + assert.deepStrictEqual(phase.advance, { after: '2880m' }) + assert.deepStrictEqual(phase.steps.map((s) => s.actionId), ['core.lease', 'rust.kit.entitle']) + assert.deepStrictEqual(phase.steps[0].params, { + lease: 'rust.group.permission', target: 'main/default/kits.vip', value: 'true', minutes: 2880, + }) + assert.deepStrictEqual(phase.steps[1].params, { + kit: 'main/vip', recipients: 'linked', message: 'Kit weekend! {kit}', delivery: 'popup', + }) +}) + +test('a kit with no permission is step 2 alone; one with no use limit is step 1 alone', async (t) => { + weekendStubs(t, [{ name: 'open', permission: '', max: 1 }, { name: 'free', permission: 'kits.free', max: 0 }]) + const open = await weekend.build({ inputs: { kit: 'main/open', minutes: 60 } }) + assert.deepStrictEqual(open.spec.phases[0].steps.map((s) => s.actionId), ['rust.kit.entitle']) + assert.ok(!('message' in open.spec.phases[0].steps[0].params)) + const free = await weekend.build({ inputs: { kit: 'main/free', minutes: 60 } }) + assert.deepStrictEqual(free.spec.phases[0].steps.map((s) => s.actionId), ['core.lease']) +}) + +test('the kit weekend refuses what its steps would refuse, in the author\'s words', async (t) => { + weekendStubs(t) + assert.match((await weekend.build({ inputs: { kit: 'vip', minutes: 60 } })).error, /as server\/kit/) + assert.match((await weekend.build({ inputs: { kit: 'main/vip', minutes: 20000 } })).error, /1 to 10080 minutes/) + assert.match((await weekend.build({ inputs: { kit: 'main/vip', minutes: 60, delivery: 'shout', message: 'x' } })).error, /not to "shout"/) + assert.match((await weekend.build({ inputs: { kit: 'gone/vip', minutes: 60 } })).error, /gone/) + assert.match((await weekend.build({ inputs: { kit: 'main/nope', minutes: 60 } })).error, /no kit called "nope"/) +}) + +test('the template\'s inputs are params core will accept, and its minutes are a duration', () => { + for (const p of weekend.inputs) { + assert.ok(p.example !== undefined && p.example !== '', `${p.name} needs an example`) + } + assert.strictEqual(weekend.inputs.find((p) => p.name === 'minutes').duration, 'minutes') + assert.ok(/^rust\.[a-z0-9_]+\.[a-z0-9_]+$/.test(weekend.id)) +}) + +test('the module\'s timed params say they are durations', () => { + const zone = require('../eventWorld').ACTIONS.find((a) => a.id === 'rust.zone.open') + const tally = rewards.ACTIONS.find((a) => a.id === 'rust.participation.open') + assert.strictEqual(zone.params.find((p) => p.name === 'minutes').duration, 'minutes') + assert.strictEqual(tally.params.find((p) => p.name === 'minutes').duration, 'minutes') + const groupLease = leases.LEASES.find((l) => l.id === 'rust.group.permission') + assert.strictEqual(groupLease.example, true) +}) diff --git a/server/test/titleConditions.test.js b/server/test/titleConditions.test.js index bc4f628..8be3304 100644 --- a/server/test/titleConditions.test.js +++ b/server/test/titleConditions.test.js @@ -47,8 +47,14 @@ const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ... // ── The categories ──────────────────────────────────────────────────────── -test('twenty-three categories carry a default title, and playtime carries none (D172)', () => { - assert.strictEqual(titles.CATEGORY_IDS.length, 23) +test('twenty-nine categories carry a default title, and playtime carries none (D172, §11.2.2, D356, D375, §11.4)', () => { + assert.strictEqual(titles.CATEGORY_IDS.length, 29) + assert.strictEqual(titles.STATS.raidscompleted.title, 'Warlord') + assert.strictEqual(titles.STATS.guardedwon.title, 'Warden') + assert.strictEqual(titles.STATS.treasureswon.title, 'Treasure Hunter') + assert.strictEqual(titles.STATS.richest.title, 'Magnate') + assert.strictEqual(titles.STATS.crateshacked.title, 'Safecracker') + assert.strictEqual(titles.STATS.dropslooted.title, 'Scavenger') assert.ok(!titles.CATEGORY_IDS.includes('playtime')) assert.strictEqual(titles.STATS.playtime.title, null) assert.strictEqual(titles.STATS.animalkills.title, 'Beastbane') diff --git a/server/test/world.test.js b/server/test/world.test.js index 5517033..84c431c 100644 --- a/server/test/world.test.js +++ b/server/test/world.test.js @@ -178,10 +178,13 @@ test('a zone crosses with its key, its duration, and the monument without the se server: 'main', body: { runId: '7', key: 'k1', monument: 'airfield_1', offsetX: 0, offsetZ: -20, radius: 40, holdMs: 5400000, name: 'Brawl' }, }) - // The ref carries the server: revert and reconcile are handed no params. - assert.deepStrictEqual(result.resources, [ - { kind: 'world', ref: 'main:rg-7-1-1', payload: { serverId: 'main', what: 'zone', name: 'Z' } }, - ]) + // The ref carries the server: revert and reconcile are handed no params. The + // zone's deadline rides as `until` (W1); zones.test.js checks its value. + const [resource] = result.resources + assert.strictEqual(typeof resource.until, 'string') + const { until, ...rest } = resource + assert.deepStrictEqual(rest, { kind: 'world', ref: 'main:rg-7-1-1', payload: { serverId: 'main', what: 'zone', name: 'Z' } }) + assert.strictEqual(result.resources.length, 1) }) test('one resource per thing placed, and a repeated key is said to be one', async (t) => { diff --git a/server/test/worldEvents.test.js b/server/test/worldEvents.test.js new file mode 100644 index 0000000..7558a3a --- /dev/null +++ b/server/test/worldEvents.test.js @@ -0,0 +1,434 @@ +// ── Rust's own world events (PLAN_REDESIGNS §14.3.1, protocol 14) ────────── +// +// Each test is one way the feature could look right and be wrong: +// +// the catalogue drifts from the protocol's 24 types, and a new one is public +// the four credit rules (D372) disagree with their own table — or with the +// count, so the line says Alice and Rotorbane counts Bob +// a name reaches a viewer below the presence audience (D376), a Steam id or a +// position reaches anybody but staff (D353) +// a grid is clamped onto the edge of the map for something off it +// the tally's two new counts are dropped on the floor +// a trigger declares a variable its emitter never fills, or names a Steam id +// the call step takes a position for an event that has no route to one (D378) + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +/** Installs a core whose `db.query` records every statement and answers `rows`. */ +function withRecorder(rows = []) { + const statements = [] + const ctx = fakeCtx({ + db: { + query: (sql, params = []) => { + statements.push({ sql, params }) + return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows) + }, + pool: {}, + }, + }) + require('../core')._reset() + require('../core').init(ctx) + return { ctx, statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) } +} + +withRecorder() + +const worldEvents = require('../worldEvents') +const catalogue = require('../catalogue') +const { TRIGGERS } = require('../engagement/triggers') + +const A = '76561198000000001' +const B = '76561198000000002' +const C = '76561198000000003' +const NOW = Date.now() + +/** A 6000 map of the game's own 41 cells of 146.3 m — the rigs' geometry. */ +const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 } + +const world = (event, over = {}) => ({ kind: 'world.event', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', event, ...over }) + +/** Bob did 61 %, Alice the crippling hit — D372's own example. */ +const downed = (over = {}) => world('heli.downed', { + x: 812, + z: -340.5, + credit: { blow: A, last: B, most: B, damagers: [{ steamId: B, damage: 6120 }, { steamId: A, damage: 3880 }] }, + names: { [A]: 'Alice', [B]: 'Bob' }, + ...over, +}) + +// ── The catalogue ────────────────────────────────────────────────────────── + +test('the types are exactly the 24 the protocol defines, and an unknown one is staff only', () => { + // Sourced from docs/rust-link/PROTOCOL.md §20.5 (PLAN_REDESIGNS §14.3.1, D379). + const PROTOCOL_14 = [ + 'heli.arrived', 'heli.left', 'heli.downed', + 'bradley.spawned', 'bradley.destroyed', + 'cargo.arrived', 'cargo.docking', 'cargo.docked', 'cargo.undocked', 'cargo.left', + 'chinook.arrived', 'chinook.dropped', 'chinook.downed', + 'airdrop.called', 'airdrop.landed', 'airdrop.looted', + 'crate.hack.started', 'crate.unlocked', 'crate.looted', + 'vendor.arrived', 'vendor.left', + 'satellite.crashed', + 'deepsea.opened', 'deepsea.closed', + ] + assert.deepStrictEqual([...worldEvents.TYPE_IDS].sort(), [...PROTOCOL_14].sort()) + assert.ok(catalogue.isPublic('world.event')) + + // Default deny, per type: a newer plugin's type is not served to the public. + const future = world('meteor.landed', { x: 1, z: 1, steamId: A, name: 'Alice' }) + assert.strictEqual(worldEvents.shapeFor(future, { presence: true }), null) + assert.strictEqual(worldEvents.shapeFor(future, { staff: true }).event, 'meteor.landed') +}) + +// ── Credit (D372, D379) ──────────────────────────────────────────────────── + +test('each of the four credit rules picks what its table says', () => { + assert.deepStrictEqual(worldEvents.credited(downed(), 'blow'), [A]) + assert.deepStrictEqual(worldEvents.credited(downed(), 'last'), [B]) + assert.deepStrictEqual(worldEvents.credited(downed(), 'most'), [B]) + assert.deepStrictEqual(worldEvents.credited(downed(), 'all'), [B, A]) + // An unknown rule is the default, the killing blow. + assert.deepStrictEqual(worldEvents.credited(downed(), 'loudest'), [A]) +}) + +test('with no killing blow, the killing-blow rule falls back to the last hit', () => { + const noBlow = downed({ credit: { last: B, most: A, damagers: [{ steamId: A, damage: 9 }, { steamId: B, damage: 1 }] } }) + assert.deepStrictEqual(worldEvents.credited(noBlow, 'blow'), [B]) + assert.deepStrictEqual(worldEvents.credited(noBlow, 'most'), [A]) +}) + +test('a death nobody touched credits nobody under any rule, and counts nothing', () => { + const nobody = world('bradley.destroyed', { where: 'road', x: 0, z: 0, credit: { damagers: [] } }) + for (const rule of worldEvents.CREDIT_RULE_IDS) { + assert.deepStrictEqual(worldEvents.credited(nobody, rule), [], rule) + assert.deepStrictEqual(worldEvents.killsOf(nobody, rule), [], rule) + } +}) + +test('the count and the line credit the same players, under every rule', () => { + for (const rule of worldEvents.CREDIT_RULE_IDS) { + const line = worldEvents.shapeFor(downed(), { presence: true, rule }).credited.map((c) => c.name) + const count = worldEvents.killsOf(downed(), rule).map((k) => k.name) + assert.deepStrictEqual(line, count, rule) + } + // Each kill is the column the type counts toward: Rotorbane for both helicopters, Tankbreaker for Bradley. + assert.strictEqual(worldEvents.killsOf(downed(), 'blow')[0].field, 'heliKills') + assert.strictEqual(worldEvents.killsOf(world('chinook.downed', { credit: { blow: A } }), 'blow')[0].field, 'heliKills') + assert.strictEqual(worldEvents.killsOf(world('bradley.destroyed', { where: 'launch_site', credit: { blow: A } }), 'blow')[0].field, 'apcKills') + // Nothing else counts. + assert.deepStrictEqual(worldEvents.killsOf(world('heli.arrived'), 'blow'), []) +}) + +test('a junk id in a credit block is never credited', () => { + const junk = downed({ credit: { blow: 'not-a-steam-id', last: '123', damagers: [{ steamId: '{}', damage: 1 }] } }) + for (const rule of worldEvents.CREDIT_RULE_IDS) assert.deepStrictEqual(worldEvents.credited(junk, rule), [], rule) +}) + +// ── Who may read what (D353, D376) ───────────────────────────────────────── + +test('below the presence audience a line names nobody, and carries no position', () => { + const signedOut = worldEvents.shapeFor(downed(), { presence: false, rule: 'blow', geometry: GEOMETRY }) + assert.strictEqual(signedOut.event, 'heli.downed') + assert.strictEqual(signedOut.grid, worldEvents.gridOf(GEOMETRY, 812, -340.5)) + assert.strictEqual(signedOut.creditedCount, 1) + assert.strictEqual(signedOut.credited, undefined) + for (const field of ['x', 'z', 'credit', 'names', 'steamId', 'name']) assert.strictEqual(signedOut[field], undefined, field) + + const hack = worldEvents.shapeFor(world('crate.hack.started', { where: 'oilrig_large', steamId: A, name: 'Alice', x: 1, z: 1 }), { presence: false }) + assert.strictEqual(hack.name, undefined) + assert.strictEqual(hack.steamId, undefined) + assert.strictEqual(hack.where, 'oilrig_large') +}) + +test('the presence audience reads names but no Steam id; staff read both, and the position', () => { + const member = worldEvents.shapeFor(downed(), { presence: true, rule: 'all' }) + assert.deepStrictEqual(member.credited, [{ name: 'Bob' }, { name: 'Alice' }]) + assert.strictEqual(member.x, undefined) + + const staff = worldEvents.shapeFor(downed(), { staff: true, presence: true, rule: 'all' }) + assert.deepStrictEqual(staff.credited, [{ name: 'Bob', steamId: B }, { name: 'Alice', steamId: A }]) + assert.strictEqual(staff.x, 812) + assert.ok(staff.credit, 'staff see the whole credit block') + + const loot = world('airdrop.looted', { steamId: A, name: 'Alice', x: 5, z: 5 }) + assert.deepStrictEqual( + [worldEvents.shapeFor(loot, { presence: true }).name, worldEvents.shapeFor(loot, { presence: true }).steamId], + ['Alice', undefined], + ) + assert.strictEqual(worldEvents.shapeFor(loot, { staff: true, presence: true }).steamId, A) +}) + +test('a line with nobody in it reads the same for everybody', () => { + const docking = world('cargo.docking', { harbour: 'harbor_1', harbourName: 'Harbor', x: 100, z: 100 }) + const a = worldEvents.shapeFor(docking, { presence: false, geometry: GEOMETRY }) + const b = worldEvents.shapeFor(docking, { presence: true, geometry: GEOMETRY }) + assert.deepStrictEqual(a, b) + assert.strictEqual(a.harbourName, 'Harbor') +}) + +// ── The grid ─────────────────────────────────────────────────────────────── + +test('the grid is the game’s, and nothing off the map gets one', () => { + // The north-west corner is A0; the 41st column is AO. + assert.strictEqual(worldEvents.gridOf(GEOMETRY, -2999, 2999), 'A0') + assert.strictEqual(worldEvents.gridOf(GEOMETRY, 2999, -2999), 'AO40') + // The Deep Sea and its ghost ships sit thousands of metres off the map. + assert.strictEqual(worldEvents.gridOf(GEOMETRY, -5200, 0), null) + // No geometry yet, or no position: no grid, rather than a guess. + assert.strictEqual(worldEvents.gridOf(null, 0, 0), null) + assert.strictEqual(worldEvents.gridOf(GEOMETRY, undefined, 0), null) +}) + +// ── Ingest ───────────────────────────────────────────────────────────────── + +test('a downed helicopter counts toward Rotorbane for whom the server’s rule credits', async () => { + const rec = withRecorder() + const { apply } = require('../ingest') + + await apply('main', { id: 1, kind: 'world.event', frame: downed() }, { id: 'main', creditRule: 'all' }) + + const adds = rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('heli_kills')) + assert.strictEqual(adds.length, 2) + assert.deepStrictEqual(adds.map((s) => s.params[2]).sort(), [A, B].sort()) + + // Under the default rule, only the killing blow. + const rec2 = withRecorder() + await apply('main', { id: 2, kind: 'world.event', frame: downed() }, { id: 'main' }) + const once = rec2.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('heli_kills')) + assert.strictEqual(once.length, 1) + assert.strictEqual(once[0].params[2], A) +}) + +test('a Bradley counts toward Tankbreaker, and an arrival counts nothing', async () => { + const rec = withRecorder() + const { apply } = require('../ingest') + + await apply('main', { id: 1, kind: 'world.event', frame: world('bradley.destroyed', { where: 'launch_site', credit: { blow: C }, names: { [C]: 'Cleo' } }) }, { id: 'main' }) + const apc = rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('apc_kills')) + assert.strictEqual(apc.length, 1) + assert.strictEqual(apc[0].params[2], C) + + const rec2 = withRecorder() + await apply('main', { id: 2, kind: 'world.event', frame: world('heli.arrived', { x: 1, z: 1 }) }, { id: 'main' }) + assert.strictEqual(rec2.touching('rust_player_wipe_stats').length, 0) +}) + +test('a downed one is stored with the rule that credited it; an arrival is stored as sent (W4)', async () => { + const { apply } = require('../ingest') + const storedRaw = async (frame) => { + const rec = withRecorder() + await apply('main', { id: 1, kind: 'world.event', frame }, { id: 'main', creditRule: 'all' }) + const insert = rec.touching('rust_events').find((s) => /INSERT/i.test(s.sql)) + return JSON.parse(insert.params.find((p) => typeof p === 'string' && p.startsWith('{'))) + } + + assert.strictEqual((await storedRaw(downed())).creditRule, 'all') + assert.strictEqual((await storedRaw(world('heli.arrived', { x: 1, z: 1 }))).creditRule, undefined) +}) + +test('the tally’s crates hacked and drops looted are added up (D356)', async () => { + const rec = withRecorder() + const { apply } = require('../ingest') + + await apply('main', { id: 1, kind: 'player.tally', frame: { kind: 'player.tally', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', steamId: A, name: 'Alice', cratesHacked: 2, dropsLooted: 1 } }) + + const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('crates_hacked')) + assert.ok(titleAdd, 'the tally reached the title columns') + assert.ok(titleAdd.sql.includes('drops_looted')) + const cols = titleAdd.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+)\)/)[1].split(', ') + assert.strictEqual(titleAdd.params[3 + cols.indexOf('crates_hacked')], 2) + assert.strictEqual(titleAdd.params[3 + cols.indexOf('drops_looted')], 1) +}) + +// ── The feed ─────────────────────────────────────────────────────────────── + +test('the feed reads each world event for its viewer, with the server’s rule and grid', async () => { + const stored = downed() + withRecorder((sql) => { + if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'world.event', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stored) }] + if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', creditRule: 'most', enabled: 1 }] + if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }] + return [] + }) + const events = require('../model/events/events.model') + + const [signedOut] = await events.recent({ serverId: 'main', presence: false }) + assert.strictEqual(signedOut.frame.creditRule, 'most') + assert.strictEqual(signedOut.frame.credited, undefined) + assert.strictEqual(signedOut.frame.grid, worldEvents.gridOf(GEOMETRY, 812, -340.5)) + + const [member] = await events.recent({ serverId: 'main', presence: true }) + assert.deepStrictEqual(member.frame.credited, [{ name: 'Bob' }]) +}) + +test('a line keeps the rule it was stored with after the server’s rule changes (W4)', async () => { + const stamped = downed({ creditRule: 'all' }) + withRecorder((sql) => { + if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'world.event', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stamped) }] + if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', creditRule: 'most', enabled: 1 }] + return [] + }) + const events = require('../model/events/events.model') + + const [member] = await events.recent({ serverId: 'main', presence: true }) + assert.strictEqual(member.frame.creditRule, 'all') + assert.strictEqual(member.frame.creditedCount, 2) + assert.deepStrictEqual(member.frame.credited.map((c) => c.name).sort(), ['Alice', 'Bob']) +}) + +// ── Triggers (D355) ──────────────────────────────────────────────────────── + +test('one public trigger per type, none of them declaring a Steam id', () => { + const world = TRIGGERS.filter((t) => t.id.startsWith('rust.world.')) + assert.deepStrictEqual(world.map((t) => t.id.slice('rust.world.'.length)).sort(), [...worldEvents.TYPE_IDS].sort()) + for (const t of world) { + assert.strictEqual(t.ceiling, 'everyone', t.id) + assert.strictEqual(t.subjectKey, 'subject', t.id) + assert.ok(t.variables.some((v) => v.name === 'subject'), t.id) + assert.ok(!t.variables.some((v) => /steam/i.test(v.name)), `${t.id} declares a Steam id`) + } +}) + +test('a world event fires its trigger with the variables it declares, and a headline', async (t) => { + const { ctx } = withRecorder((sql) => (sql.includes('FROM rust_map_images') ? [{ serverId: 'main', ...GEOMETRY }] : [])) + const emit = require('../engagement/emit') + emit.reset() + + const server = { id: 'main', name: 'Main', creditRule: 'all' } + await emit.onEvent(server, { id: 1, kind: 'world.event', frame: downed() }) + await emit.onEvent(server, { id: 2, kind: 'world.event', frame: world('crate.hack.started', { where: 'oilrig_small', steamId: A, name: 'Alice', x: 1, z: 1 }) }) + await emit.onEvent(server, { id: 3, kind: 'world.event', frame: world('meteor.landed') }) + + const calls = ctx.events.emit.calls + assert.strictEqual(calls.length, 2, 'an unknown type fires nothing') + + const [heliId, heli] = calls[0] + assert.strictEqual(heliId, 'rust.world.heli.downed') + assert.strictEqual(heli.data.player, 'Bob and Alice') + assert.strictEqual(heli.data.credited, 2) + assert.strictEqual(heli.data.subject, 'main:heli.downed') + assert.ok(heli.data.grid) + assert.match(heli.data.intro, /downed by Bob and Alice at /) + + const [crateId, crate] = calls[1] + assert.strictEqual(crateId, 'rust.world.crate.hack.started') + assert.strictEqual(crate.data.subject, 'main:crate.hack.started:oilrig_small') + assert.match(crate.data.intro, /Alice started hacking the locked crate on Small Oil Rig on Main/) + + // Every variable a trigger fills is one it declares. + for (const [id, envelope] of calls) { + const declared = new Set(TRIGGERS.find((x) => x.id === id).variables.map((v) => v.name)) + for (const name of Object.keys(envelope.data)) assert.ok(declared.has(name), `${id} fills ${name}, which it does not declare`) + } +}) + +test('an old world event tells nobody (D63)', async () => { + const { ctx } = withRecorder() + const emit = require('../engagement/emit') + emit.reset() + await emit.onEvent({ id: 'main', name: 'Main' }, { id: 1, kind: 'world.event', frame: world('heli.arrived', { t: NOW - 3 * 3600 * 1000 }) }) + assert.strictEqual(ctx.events.emit.calls.length, 0) +}) + +// ── The call step (D354, D367, D378) ─────────────────────────────────────── + +function stubCall(t, reply) { + const client = require('../sidecarClient') + const serversDb = require('../model/servers/servers.db') + const saved = { worldCall: client.worldCall, getServer: serversDb.getServer } + const calls = [] + serversDb.getServer = async (id) => (id === 'main' ? { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' } : null) + client.worldCall = async (server, body) => { + calls.push({ server: server.id, body }) + return typeof reply === 'function' ? reply(body) : reply + } + t.after(() => { + client.worldCall = saved.worldCall + serversDb.getServer = saved.getServer + }) + return calls +} + +const callStep = () => require('../eventWorldCall').ACTIONS[0] +const ok = (data) => ({ ok: true, status: 'ok', data }) + +test('the call step is kept, priced per event, and mirrors the plugin’s list', () => { + const step = callStep() + assert.strictEqual(step.id, 'rust.world.call') + assert.strictEqual(step.reversible, 'none') + assert.strictEqual(step.risk, 'change') + assert.strictEqual(step.revert, undefined) + assert.deepStrictEqual(step.cost({ event: 'airdrop', count: 3 }), { 'rust.world.calls': 3 }) + assert.deepStrictEqual(step.cost({ event: 'cargo' }), { 'rust.world.calls': 1 }) + + const { EVENTS } = require('../eventWorldCall') + assert.deepStrictEqual(EVENTS.map((e) => e.value).sort(), ['airdrop', 'bradley_road', 'cargo', 'chinook', 'f15', 'patrol_heli', 'satellite', 'travelling_vendor']) + assert.deepStrictEqual(EVENTS.filter((e) => e.placeable).map((e) => e.value).sort(), ['airdrop', 'patrol_heli']) +}) + +test('a call is refused in the dry run for a bad event, a bad count, a position it cannot take, or no server', async (t) => { + const calls = stubCall(t, ok({ kind: 'world.called', called: 1 })) + const step = callStep() + const run = (params) => step.perform({ runId: 7, idempotencyKey: '7:1', params, verify: true }) + + assert.match((await run({ server: 'main', event: 'meteor' })).error, /not one of the world events/) + assert.match((await run({ server: 'main', event: 'cargo', count: 2 })).error, /call 1 to 1/) + assert.match((await run({ server: 'main', event: 'airdrop', count: 6 })).error, /call 1 to 5/) + // D378: the Chinook goes where the game chooses. + assert.match((await run({ server: 'main', event: 'chinook', x: 10, z: 10 })).error, /only an airdrop and a patrol helicopter take a position/) + assert.match((await run({ event: 'airdrop' })).error, /which server/) + assert.ok((await run({ server: 'main', event: 'airdrop', x: 10, z: 10 })).ok) + for (const r of [await run({ server: 'main', event: 'meteor' })]) assert.strictEqual(r.retry, false) + assert.strictEqual(calls.length, 0, 'the dry run calls nothing') +}) + +test('a call sends the key, the run and the position, and reads the plugin’s answer', async (t) => { + const calls = stubCall(t, (body) => ok({ kind: 'world.called', key: body.key, event: body.event, called: body.count, repeat: false })) + const step = callStep() + + const result = await step.perform({ runId: 7, idempotencyKey: '7:2', params: { event: 'airdrop', count: 2, monument: 'main/powerplant_1', offsetX: 10, y: 99 } }) + assert.ok(result.ok) + assert.deepStrictEqual(result.detail, { event: 'airdrop', called: 2 }) + assert.deepStrictEqual(calls[0], { + server: 'main', + body: { runId: '7', key: '7:2', event: 'airdrop', count: 2, monument: 'powerplant_1', offsetX: 10, offsetZ: 0 }, + }) +}) + +test('a refusal the plugin would repeat is not retried; a game that is down is', async (t) => { + stubCall(t, (body) => (body.event === 'cargo' + ? ok({ kind: 'world.error', reason: 'too-many', message: 'a run calls at most 10 world events on this server' }) + : { ok: false, status: 'http-503' })) + const step = callStep() + + const refused = await step.perform({ runId: 7, idempotencyKey: '7:3', params: { server: 'main', event: 'cargo' } }) + assert.strictEqual(refused.ok, false) + assert.strictEqual(refused.retry, false) + assert.match(refused.error, /at most 10/) + + const down = await step.perform({ runId: 7, idempotencyKey: '7:4', params: { server: 'main', event: 'airdrop' } }) + assert.strictEqual(down.ok, false) + assert.strictEqual(down.retry, undefined) + assert.match(down.error, /has no game connected/) +}) + +test('a Travelling Vendor already out is no failure: the step says so and counts nothing called (W3)', async (t) => { + stubCall(t, ok({ kind: 'world.error', reason: 'already-out', message: 'a Travelling Vendor is already out' })) + const result = await callStep().perform({ runId: 7, idempotencyKey: '7:6', params: { server: 'main', event: 'travelling_vendor' } }) + assert.strictEqual(result.ok, true) + assert.deepStrictEqual(result.detail, { + event: 'travelling_vendor', + called: 0, + note: 'a Travelling Vendor is already out on Main, and the game runs one at a time; nothing new was called', + }) +}) + +test('a repeat of a key says so and calls nothing new', async (t) => { + stubCall(t, ok({ kind: 'world.called', called: 1, repeat: true })) + const result = await callStep().perform({ runId: 7, idempotencyKey: '7:5', params: { server: 'main', event: 'patrol_heli' } }) + assert.ok(result.ok) + assert.strictEqual(result.detail.repeat, true) +}) diff --git a/server/test/zones.test.js b/server/test/zones.test.js index 6b95c3f..e9d5298 100644 --- a/server/test/zones.test.js +++ b/server/test/zones.test.js @@ -12,6 +12,9 @@ // the step's dropdown VALUE is the line itself, so picking copies it (D210) // the zone crosses with its flags, settings, messages and dome // a dome on a server without ZoneDomes or its helper is refused with the reason +// TruePVE's rules cross as exclude or one ruleset, never both, and never to a +// server that said it has no TruePVE (§14.3.2, D368); a ruleset TruePVE does +// not have fails for good, with the plugin's words const test = require('node:test') const assert = require('node:assert') @@ -21,6 +24,10 @@ const { fakeCtx } = require('./_fakes') require('../core')._reset() require('../core').init(fakeCtx()) +// The stored status is the whole answer in these tests: nothing asks a +// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask. +require('../liveStatus').refresh = async () => false + const client = require('../sidecarClient') const serversDb = require('../model/servers/servers.db') const zonesDb = require('../model/zones/zones.db') @@ -34,13 +41,14 @@ const source = (id) => world.OPTION_SOURCES.find((s) => s.id === id) const FLAGS = ['AutoLights', 'NoBuild', 'NoPlayerLoot', 'PvpGod', 'NoChat', 'SomethingNew'] -function server(id, { flags = FLAGS, domes = null } = {}) { +function server(id, { flags = FLAGS, domes = null, truePve = null } = {}) { return { id, name: id.toUpperCase(), enabled: true, zoneManager: flags ? { version: '3.1.14', flags } : null, zoneDomes: domes, + truePve, } } @@ -224,6 +232,29 @@ test('a zone crosses with its flags, settings, messages in the chat voice, and i }) }) +test('a zone resource carries its deadline as until, a crate none (W1)', async (t) => { + // Core's reconcile reads `until`: a zone gone past it is expired, not + // orphaned. Reckoned from the answer, so it never falls before the game's own. + stubZones(t) + stubWorld(t) + const before = Date.now() + const result = await action('rust.zone.open').perform({ + runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 30 }, + }) + assert.strictEqual(result.ok, true, result.error) + const until = Date.parse(result.resources[0].until) + assert.ok(until >= before + 1800000 && until <= Date.now() + 1800000, result.resources[0].until) + + const saved = client.worldPlace + client.worldPlace = async () => ({ ok: true, status: 'ok', data: { kind: 'world.ok', placed: [{ id: 'rg-7-1-2', kind: 'crate' }] } }) + t.after(() => { client.worldPlace = saved }) + const crate = await action('rust.crate.place').perform({ + runId: 7, idempotencyKey: 'k2', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 }, + }) + assert.strictEqual(crate.ok, true, crate.error) + assert.strictEqual('until' in crate.resources[0], false) +}) + test('a dome with its stack left blank stacks 3 spheres (D326)', async (t) => { stubZones(t, { servers: [server('main', { domes: PATCHED })] }) const calls = stubWorld(t) @@ -287,6 +318,84 @@ test('a dry run checks the options and the dome and sends nothing', async (t) => assert.strictEqual(calls.length, 0) }) +// ── TruePVE in the zone (§14.3.2, D368) ──────────────────────────────────── + +const TRUEPVE = { loaded: true, version: '2.4.6' } +const zoneRun = (params, extra = {}) => + action('rust.zone.open').perform({ runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 5, ...params }, ...extra }) + +test('no PvP protection crosses as exclude, and a ruleset by its name, trimmed', async (t) => { + for (const [params, wire] of [ + [{ noPvpProtection: true }, { exclude: true }], + [{ noPvpProtection: 'true' }, { exclude: true }], + [{ pvpRuleset: ' pvp-arena ' }, { ruleset: 'pvp-arena' }], + ]) { + await t.test(JSON.stringify(params), async (tt) => { + stubZones(tt, { servers: [server('main', { truePve: TRUEPVE })] }) + const calls = stubWorld(tt) + const result = await zoneRun(params) + assert.strictEqual(result.ok, true, result.error) + assert.deepStrictEqual(calls[0].body.truePve, wire) + }) + } +}) + +test('left unset, switched off or blank, a zone sends no truePve at all', async (t) => { + stubZones(t, { servers: [server('main', { truePve: { loaded: false } })] }) + const calls = stubWorld(t) + for (const params of [{}, { noPvpProtection: false, pvpRuleset: '' }, { noPvpProtection: null, pvpRuleset: ' ' }]) { + const result = await zoneRun(params) + assert.strictEqual(result.ok, true, result.error) + } + assert.ok(calls.every((c) => !('truePve' in c.body))) +}) + +test('both, a long name, or a server without TruePVE is refused for good, at verify too', async (t) => { + for (const [truePve, params, words] of [ + [TRUEPVE, { noPvpProtection: true, pvpRuleset: 'pvp-arena' }, /not both/], + [TRUEPVE, { pvpRuleset: 'x'.repeat(65) }, /64 characters/], + [{ loaded: false }, { noPvpProtection: true }, /TruePVE is not installed on this server/], + [{ loaded: false }, { pvpRuleset: 'pvp-arena' }, /TruePVE is not installed on this server/], + ]) { + for (const verify of [true, false]) { + await t.test(`${words} verify=${verify}`, async (tt) => { + stubZones(tt, { servers: [server('main', { truePve })] }) + const calls = stubWorld(tt) + const result = await zoneRun(params, { verify }) + assert.strictEqual(result.ok, false) + assert.strictEqual(result.retry, false) + assert.match(result.error, words) + assert.strictEqual(calls.length, 0) + }) + } + } +}) + +test('a server that has not said whether it has TruePVE is left to the plugin', async (t) => { + stubZones(t, { servers: [server('main', { truePve: null })] }) + const calls = stubWorld(t) + const result = await zoneRun({ noPvpProtection: true }) + assert.strictEqual(result.ok, true, result.error) + assert.deepStrictEqual(calls[0].body.truePve, { exclude: true }) +}) + +test('a ruleset TruePVE does not have, or no TruePVE, fails for good with the plugin’s words', async (t) => { + for (const [reason, message] of [ + ['truepve-ruleset', "TruePVE on this server has no ruleset named 'pvp-arna' (names are case-sensitive), so the zone was not opened"], + ['truepve-missing', 'TruePVE is not loaded on this server, so a zone cannot change its PvP rules'], + ]) { + await t.test(reason, async (tt) => { + stubZones(tt, { servers: [server('main', { truePve: TRUEPVE })] }) + stubWorld(tt) + client.worldZone = async () => ({ ok: true, status: 'ok', data: { kind: 'world.error', reason, message } }) + const result = await zoneRun({ pvpRuleset: 'pvp-arna' }) + assert.strictEqual(result.ok, false) + assert.strictEqual(result.retry, false) + assert.strictEqual(result.error, message) + }) + } +}) + test('the dome dropdown offers ZoneDomes’ five colours', async () => { assert.deepStrictEqual((await source('rust.options.domes').resolve()).map((r) => r.value), ['standard', 'red', 'blue', 'green', 'purple']) }) diff --git a/server/worldEvents.js b/server/worldEvents.js new file mode 100644 index 0000000..bb49a5c --- /dev/null +++ b/server/worldEvents.js @@ -0,0 +1,300 @@ +// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ────────── +// +// The patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked +// crates, the Travelling Vendor, the satellite and the Deep Sea reach this +// module as ONE frame kind, `world.event`, whose `event` says which (D349, +// D353). This file is the one place that knows the 24 of them, and the three +// readers — the feed, the ingest and the engagement triggers — all ask it. +// +// ── Why `event` and not `type` ───────────────────────────────────────────── +// +// The plan's example put the world event's type in `type`, which is the +// envelope's field: the sidecar files a frame on it (`event`, `snapshot`, +// `reply`), and it dropped every world event as untyped on the rig. Protocol +// 13's `world.expired` hit the same collision and settled on its own field; so +// does this one (stage 1, 2026-10-15). +// +// ── Who gets the credit (D372, D379) ─────────────────────────────────────── +// +// For a patrol helicopter, a Chinook and a Bradley the plugin sends every +// candidate — the killing blow, the last hit, the most damage, everyone who +// hurt it — and decides nothing. The server's rule, chosen by its operator, +// picks from them HERE, for the feed line and for Rotorbane / Tankbreaker +// alike, so the two can never disagree. A rule changed applies from then on: +// the counts already written stay as they were. +// +// ── Who may read what (D353, D376) ───────────────────────────────────────── +// +// The event and its grid are public. A player's name shows only to the +// operator's presence audience — with presence left at staff, a signed-out +// visitor reads "Patrol helicopter downed at G8" — and a Steam id only to staff. +// A position never leaves this module: the map has its own audience for where +// things are (D111), and the feed must not be a way round it. An event type +// this build does not know is staff's alone (default deny, as `catalogue.js`). + +/** + * Every type the protocol defines (PROTOCOL.md §20.5), and what each carries. + * + * `player` names one player (`steamId`, `name`) + * `credit` carries a credit block, and which tally column it counts toward + */ +const TYPES = Object.freeze({ + 'heli.arrived': {}, + 'heli.left': {}, + 'heli.downed': { credit: 'heliKills' }, + 'bradley.spawned': {}, + 'bradley.destroyed': { credit: 'apcKills' }, + 'cargo.arrived': {}, + 'cargo.docking': {}, + 'cargo.docked': {}, + 'cargo.undocked': {}, + 'cargo.left': {}, + 'chinook.arrived': {}, + 'chinook.dropped': {}, + // D379: a Chinook shot down counted toward Rotorbane under D174, and still does. + 'chinook.downed': { credit: 'heliKills' }, + 'airdrop.called': { player: true }, + 'airdrop.landed': { player: true }, + 'airdrop.looted': { player: true }, + 'crate.hack.started': { player: true }, + 'crate.unlocked': { player: true }, + 'crate.looted': { player: true }, + 'vendor.arrived': {}, + 'vendor.left': {}, + 'satellite.crashed': {}, + 'deepsea.opened': {}, + 'deepsea.closed': {}, +}) + +const TYPE_IDS = Object.freeze(Object.keys(TYPES)) + +/** The operator's credit rules (D372), in the order the form offers them. `blow` is the default. */ +const CREDIT_RULES = Object.freeze([ + { id: 'blow', label: 'Killing blow', description: 'The player whose hit destroyed it; for the patrol helicopter, the crippling hit, else the last hit.' }, + { id: 'last', label: 'Last hit', description: 'The last player to hurt it.' }, + { id: 'most', label: 'Most damage', description: 'The player who dealt it the most damage.' }, + { id: 'all', label: 'Everyone who hurt it', description: 'Every player who damaged it, one each.' }, +]) + +const CREDIT_RULE_IDS = Object.freeze(CREDIT_RULES.map((r) => r.id)) +const DEFAULT_CREDIT_RULE = 'blow' + +/** A stored or posted rule, narrowed to one this build knows. */ +function creditRuleOf(value) { + return CREDIT_RULE_IDS.includes(value) ? value : DEFAULT_CREDIT_RULE +} + +const STEAM_ID = /^\d{17}$/ + +function steamId(value) { + const text = value === undefined || value === null ? '' : String(value) + return STEAM_ID.test(text) ? text : null +} + +function isKnownType(event) { + return Object.prototype.hasOwnProperty.call(TYPES, event) +} + +/** + * Who a downed helicopter, Chinook or Bradley credits under `rule`: a list of + * Steam ids, empty when nobody qualifies. D372's table, exactly: + * + * blow `blow`, else `last` + * last `last` + * most `most` + * all every `damagers` entry, one each (and the blow, if it dealt nothing) + */ +function credited(frame, rule) { + const credit = (frame && frame.credit) || {} + const one = (id) => (steamId(id) ? [steamId(id)] : []) + + switch (creditRuleOf(rule)) { + case 'last': + return one(credit.last) + case 'most': + return one(credit.most) + case 'all': { + const ids = [] + for (const row of Array.isArray(credit.damagers) ? credit.damagers : []) { + const id = steamId(row && row.steamId) + if (id && !ids.includes(id)) ids.push(id) + } + const blow = steamId(credit.blow) + if (blow && !ids.includes(blow)) ids.push(blow) + return ids + } + default: + return one(credit.blow).length ? one(credit.blow) : one(credit.last) + } +} + +/** The name a frame carries for a credited Steam id, or null. */ +function nameOf(frame, id) { + const names = (frame && frame.names) || {} + const name = names[id] + return typeof name === 'string' && name ? name : null +} + +/** + * The grid label for a world position, as the in-game map writes it (D119): the + * game's own cell count and size, which the map stores from the plugin's + * `map.info`. Null without a geometry, and null OFF the map — the Deep Sea and + * its ghost ships are thousands of metres outside it, and a clamped edge cell + * there would be a confident wrong answer. The client's `mapGeometry.js` holds + * the same arithmetic for the map; this copy is for the lines the server writes. + */ +function gridOf(geometry, x, z) { + const g = geometry || {} + const xs = Number(x) + const zs = Number(z) + if (!(g.gridCells > 0) || !(g.gridCellSize > 0) || !(g.worldSize > 0)) return null + if (!Number.isFinite(xs) || !Number.isFinite(zs)) return null + + const half = g.worldSize / 2 + if (Math.abs(xs) > half || Math.abs(zs) > half) return null + + const clamp = (v) => Math.max(0, Math.min(g.gridCells - 1, v)) + const col = clamp(Math.floor((xs + half) / g.gridCellSize)) + const row = clamp(Math.floor((half - zs) / g.gridCellSize)) + return `${columnName(col)}${row}` +} + +/** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */ +function columnName(index) { + let name = '' + let n = index + 1 + while (n > 0) { + const r = (n - 1) % 26 + name = String.fromCharCode(65 + r) + name + n = Math.floor((n - 1) / 26) + } + return name +} + +/** + * One stored `world.event` frame as a viewer may read it, or null when the + * viewer may not read it at all. **Pure.** + * + * `staff` the viewer is an admin or a moderator: Steam ids, and a type this + * build does not know + * `presence` the viewer is in the server's presence audience: names + * `rule` the server's credit rule, for a row stored before the frame + * carried its own (`stampRule`) + * `geometry` the server's map geometry, for the grid + * + * What comes back keeps the plugin's own fields for what it describes (`event`, + * `where`, `count`, `harbour`/`harbourName`, `monument`/`monumentName`) and + * adds `grid` and, for a downed one, `credited` — `[{ name?, steamId? }]` by + * the rule — with `creditRule` beside it. Positions, the raw credit block and + * the name table are not passed on: every one of them is either a position or + * a list of who was there. + */ +function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDIT_RULE, geometry = null } = {}) { + if (!frame || frame.kind !== 'world.event') return frame + const event = frame.event + const known = isKnownType(event) + if (!known && !staff) return null + + const out = {} + for (const key of ['kind', 'type', 't', 'serverId', 'wipeId', 'event', 'where', 'count', 'harbour', 'harbourName', 'monument', 'monumentName']) { + if (frame[key] !== undefined) out[key] = frame[key] + } + + const grid = gridOf(geometry, frame.x, frame.z) + if (grid) out.grid = grid + + if (staff) { + if (frame.x !== undefined) out.x = frame.x + if (frame.z !== undefined) out.z = frame.z + } + + // One player, named. + if (known && TYPES[event].player && steamId(frame.steamId)) { + if (presence && frame.name) out.name = frame.name + if (staff) out.steamId = frame.steamId + } + + // Credit, by the server's rule. + if (known && TYPES[event].credit) { + out.creditRule = ruleOf(frame, rule) + const ids = credited(frame, out.creditRule) + out.creditedCount = ids.length + if (presence || staff) { + out.credited = ids.map((id) => ({ + ...(nameOf(frame, id) ? { name: nameOf(frame, id) } : {}), + ...(staff ? { steamId: id } : {}), + })) + } + if (staff && frame.credit) out.credit = frame.credit + } + + if (!known) { + // Staff only, and everything, so a newer plugin's type is at least readable. + return { ...frame, ...out } + } + + return out +} + +/** + * The frame as it is stored: a downed helicopter, Chinook or Bradley carries + * the rule that credited it when it arrived (`creditRule`), so the feed line + * keeps naming who the counts were given to after the server's rule changes + * (W4). Every other frame is stored as sent. + */ +function stampRule(frame, rule) { + if (!frame || frame.kind !== 'world.event' || !isKnownType(frame.event) || !TYPES[frame.event].credit) return frame + return { ...frame, creditRule: creditRuleOf(rule) } +} + +/** + * The rule a stored line is read by: its own stamp, or — for a row from before + * the stamp — the server's rule now, which is how those rows always read. + */ +function ruleOf(frame, rule) { + return frame && CREDIT_RULE_IDS.includes(frame.creditRule) ? frame.creditRule : creditRuleOf(rule) +} + +/** + * What one `world.event` adds to the counts: a credited kill of a helicopter, + * a Chinook or a Bradley, for each player the server's rule names. `[{ steamId, + * name, field }]`, where `field` is the tally field the kill would have been + * (`heliKills`, `apcKills`), so ingest adds it the way it adds a tally's. + */ +function killsOf(frame, rule) { + if (!frame || frame.kind !== 'world.event' || !isKnownType(frame.event)) return [] + const field = TYPES[frame.event].credit + if (!field) return [] + return credited(frame, rule).map((id) => ({ steamId: id, name: nameOf(frame, id), field })) +} + +/** + * The names a line credits, as a phrase: "Alice", "Alice and Bob", "Alice, Bob + * and 2 others". Used by the notifications' headlines; the client writes its own + * from the same list. + */ +function namesPhrase(names, total = names.length) { + const shown = names.filter(Boolean).slice(0, 2) + if (shown.length === 0) return null + const more = Math.max(0, total - shown.length) + if (more > 0) return `${shown.join(', ')} and ${more} other${more === 1 ? '' : 's'}` + return shown.length === 2 ? `${shown[0]} and ${shown[1]}` : shown[0] +} + +module.exports = { + TYPES, + TYPE_IDS, + CREDIT_RULES, + CREDIT_RULE_IDS, + DEFAULT_CREDIT_RULE, + creditRuleOf, + isKnownType, + credited, + stampRule, + ruleOf, + nameOf, + gridOf, + shapeFor, + killsOf, + namesPhrase, +} diff --git a/swagger-fragment.json b/swagger-fragment.json index 2422ccd..ab27e91 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -1604,7 +1604,7 @@ "Admin · Rust" ], "summary": "Create or update a Rust server", - "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`.", + "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`.", "parameters": [ { "name": "id", @@ -1672,7 +1672,7 @@ "Admin · Rust" ], "summary": "Which optional mods a server has loaded", - "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.", + "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.", "parameters": [ { "name": "id", @@ -1948,7 +1948,7 @@ "Admin · Rust" ], "summary": "Who may see who is online, and who may see a clan roster", - "description": "The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world` 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.", + "description": "The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world`, `events`, `pvp` and `raids` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.", "responses": { "200": { "description": "The fleet default and each server", @@ -1970,7 +1970,7 @@ "Admin · Rust" ], "summary": "Change who may see who is online, who may see a clan roster, or which servers say news in chat", - "description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.", + "description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`, `pvp`, `raids`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.", "responses": { "200": { "description": "Saved; answers the new state", @@ -2205,6 +2205,231 @@ } } }, + "/api/v1/admin/users/{id}/rust/backpacks": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s backpack on one server (admin only)", + "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + }, + { + "name": "serverId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "The server." + }, + { + "name": "steamId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "One of this user’s linked Steam ids." + } + ], + "responses": { + "200": { + "description": "The backpack", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustBackpack" + } + } + } + }, + "404": { + "description": "No such server, or that Steam account is not linked to this user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Backpacks is not installed on that server", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "The server could not be reached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/rust/economics": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s coin balances (admin only)", + "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "The balances", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustEconomicsBalances" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Admin · Users" + ], + "summary": "Deposit, withdraw or set a user’s coins on one server (admin only)", + "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Adjusted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustEconomicsAdjusted" + } + } + } + }, + "400": { + "description": "A bad op or amount", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No such server, or that Steam account is not linked to this user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "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" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "The server could not be reached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustEconomicsAdjust" + } + } + } + } + } + }, "/api/v1/admin/users/{id}/rust/links": { "get": { "tags": [ @@ -2477,6 +2702,120 @@ ] } }, + "/api/v1/player/rust/backpacks": { + "get": { + "tags": [ + "Player · Rust" + ], + "summary": "Your backpack on one server", + "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.", + "parameters": [ + { + "name": "serverId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "The server." + }, + { + "name": "steamId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "One of the caller’s linked Steam ids." + } + ], + "responses": { + "200": { + "description": "The backpack", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustBackpack" + } + } + } + }, + "404": { + "description": "No such server, or not one of your linked Steam accounts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Backpacks is not installed on that server", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "The server could not be reached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/rust/economics": { + "get": { + "tags": [ + "Player · Rust" + ], + "summary": "Your coin balances", + "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.", + "responses": { + "200": { + "description": "The balances", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustEconomicsBalances" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/player/rust/link": { "post": { "tags": [ @@ -2864,7 +3203,7 @@ "Public · Rust" ], "summary": "Recent events on one Rust server", - "description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld.", + "description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes.", "parameters": [ { "name": "id", @@ -2919,7 +3258,7 @@ "Public · Rust" ], "summary": "The leaderboard for one Rust server", - "description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules.", + "description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336). Each row carries `treasuresWon` (Dangerous Treasures chests emptied, Treasure Hunter’s count, PLAN_REDESIGNS §14.3.4), and the answer carries `dangerousTreasures: true` where the server reports Dangerous Treasures or a row already counts one, which is when a page shows that column. Likewise `guardedWon` (guarded crates cleared, Warden’s count, PLAN_REDESIGNS §14.3.5) and `guardedCrate: true` where the server reports Guarded Crate or a row already counts one. Likewise `raidsCompleted` (RaidableBases bases completed as one of the plugin’s own raiders, Warlord’s count, PLAN_REDESIGNS §11.4) and `raidableBases: true` where the server reports RaidableBases or a row already counts one.", "parameters": [ { "name": "id", @@ -2943,7 +3282,7 @@ "name": "sort", "in": "query", "required": false, - "description": "kills, deaths, npcKills or playtime", + "description": "kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, guardedWon, raidsCompleted, or balance (one wipe on a server with Economics)", "schema": { "type": "string" } @@ -2974,7 +3313,7 @@ "Public · Rust" ], "summary": "One Rust server’s map", - "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.", + "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.", "parameters": [ { "name": "id", @@ -3060,7 +3399,7 @@ "Public · Rust" ], "summary": "What moves on one Rust server’s map", - "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.", + "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.", "parameters": [ { "name": "id", @@ -3953,6 +4292,35 @@ }, "example": {} } + }, + "creditRule": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "blow", + "last", + "most", + "all" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "example": "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": { + "type": "string", + "example": "blow" + } + } } } } @@ -4144,6 +4512,35 @@ } } }, + "creditRule": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "blow", + "last", + "most", + "all" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "example": "Who a downed patrol helicopter, Chinook or Bradley credits on this server (D372)." + }, + "example": { + "type": "string", + "example": "blow" + } + } + }, "zoneHelper": { "type": "object", "properties": { @@ -4521,6 +4918,12 @@ "rockets", "explosives", "missions", + "crateshacked", + "dropslooted", + "treasureswon", + "guardedwon", + "raidscompleted", + "richest", "playtime", "profilekills" ], @@ -4847,6 +5250,818 @@ } } } + }, + "backpacks": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "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": { + "type": "object", + "properties": { + "loaded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "3.17.8" + } + } + }, + "sizes": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "example": { + "type": "array", + "example": [ + 6, + 12, + 18, + 24, + 30, + 36, + 42, + 48, + 96, + 144 + ], + "items": { + "type": "number" + } + } + } + } + } + } + } + }, + "truePve": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Protocol 14 (PLAN_REDESIGNS §14.3.2): where loaded, a zone step may turn off PvP protection or name a TruePVE ruleset." + }, + "properties": { + "type": "object", + "properties": { + "loaded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "2.4.6" + } + } + } + } + } + } + }, + "dynamicPvp": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Protocol 14 (PLAN_REDESIGNS §14.3.3): where loaded, its PvP zones reach the feed and the map’s pvp layer." + }, + "properties": { + "type": "object", + "properties": { + "loaded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "5.2.0" + } + } + } + } + } + } + }, + "dangerousTreasures": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "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": { + "type": "object", + "properties": { + "loaded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "3.0.0" + } + } + } + } + } + } + }, + "guardedCrate": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Protocol 14 (PLAN_REDESIGNS §14.3.5): where loaded, its guarded crates reach the feed, the map and Warden, and a step may place one (where NpcSpawn is loaded and `console.plugins` is true). `events` are the event names in its data file, absent when the file could not be read." + }, + "properties": { + "type": "object", + "properties": { + "loaded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "2.0.17" + } + } + }, + "npcSpawn": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "NpcSpawn, which its guards need (D364)." + }, + "properties": { + "type": "object", + "properties": { + "loaded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "3.4.8" + } + } + } + } + } + } + }, + "events": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "Easy", + "Medium", + "Hard", + "Elite" + ], + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "zLevels": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Protocol 14 (PLAN_REDESIGNS §14.3.6, D374): whether ZLevels Remastered is loaded, and nothing more. Its levels are never read; its XP boosts are permissions an event lends through a Permissions until the event ends step." + }, + "properties": { + "type": "object", + "properties": { + "loaded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "3.3.1" + } + } + } + } + } + } + }, + "raidableBases": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Protocol 14 (PLAN_REDESIGNS §11.4): where loaded, its bases reach the feed, the map’s raids layer and Warlord, and a step may spawn one. Every difficulty, profile and base name is the installed plugin’s own (D386). `gridReady` and `profiles` come only with a patched raids helper (RunicGatewayRaids.cs), which is also what makes a step’s base the run’s." + }, + "properties": { + "type": "object", + "properties": { + "loaded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "3.2.2" + } + } + }, + "premium": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "The plugin’s own IsPremium()." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "spawnCall": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Whether the paid edition’s SpawnRandomBase call is there, looked for by name (D348)." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "copyPaste": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "loaded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "4.3.0" + } + } + } + } + } + } + }, + "difficulties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Hard" + } + } + }, + "level": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 2 + } + } + } + } + } + } + } + } + }, + "helper": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "state": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "missing", + "patched", + "unsupported", + "no-raidablebases" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "patched" + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "0.1.0" + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "Why it is not patched." + } + } + } + } + } + } + }, + "gridReady": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Whether its spawn grid is built; until it is, it refuses every spawn." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "profiles": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Hard Bases" + } + } + }, + "difficulty": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Hard" + } + } + }, + "level": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 2 + } + } + }, + "enabled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "pvp": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "bases": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "raidhard1", + "raidhard2" + ], + "items": { + "type": "string" + } + } + } + }, + "seabed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "A seabed chance above 0 (D389)." + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "float": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Allows bases to float above water (D389)." + }, + "example": { + "type": "boolean", + "example": false + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "console": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "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": { + "type": "object", + "properties": { + "plugins": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Whether another plugin’s console command runs from code here." + }, + "example": { + "type": "boolean", + "example": true + } + } } } } @@ -7042,6 +8257,23 @@ } } }, + "waitingToLand": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Grants to players (lent, given or authored) the last sync pushed and the game did not keep: the report’s `notLanded` entries for a Steam id. On Carbon that is a player the server has never seen; the grant lands when they first join, because every sync pushes the whole set." + }, + "example": { + "type": "number", + "example": 0 + } + } + }, "dirty": { "type": "object", "properties": { @@ -8161,7 +9393,9 @@ "world", "events", "players", - "bases" + "bases", + "pvp", + "raids" ], "items": { "type": "string" @@ -8224,6 +9458,12 @@ "bases": { "$ref": "#/components/schemas/RustAudience" }, + "pvp": { + "$ref": "#/components/schemas/RustAudience" + }, + "raids": { + "$ref": "#/components/schemas/RustAudience" + }, "mates": { "type": "object", "properties": { @@ -9109,6 +10349,12 @@ }, "bases": { "$ref": "#/components/schemas/RustMapLayer" + }, + "pvp": { + "$ref": "#/components/schemas/RustMapLayer" + }, + "raids": { + "$ref": "#/components/schemas/RustMapLayer" } } } @@ -9753,6 +10999,240 @@ } } }, + "pvp": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "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": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "zoneId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "1412203045" + } + } + }, + "event": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "Dynamic PVP’s event name: one of its own (Helicopter, Bradley, CargoShip …), a monument’s, or an operator’s." + }, + "example": { + "type": "string", + "example": "Helicopter" + } + } + }, + "radius": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "number" + }, + "example": { + "type": "number", + "example": 100 + } + } + }, + "shape": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "sphere", + "cube" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "sphere" + } + } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "number" + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "number" + } + } + } + } + } + } + } + } + }, + "raids": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Protocol 14 (§11.4): RaidableBases’ live bases, with the plugin’s own difficulty and base names (D386). `runId` is set for a base this site’s event spawned; the run’s key is never sent." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "number" + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "number" + } + } + }, + "level": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 2 + } + } + }, + "difficulty": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Hard" + } + } + }, + "base": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "raidhard2" + } + } + }, + "pvp": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "despawnAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "When the plugin despawns it, unix milliseconds." + } + } + }, + "runId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "12" + } + } + } + } + } + } + } + } + }, "mates": { "type": "object", "properties": { @@ -12842,6 +14322,636 @@ } } }, + "RustEconomicsBalances": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Coin balances per server and linked Steam account (PLAN_REDESIGNS §11.2.2). `source` is `live`, `last-known` or null." + }, + "properties": { + "type": "object", + "properties": { + "servers": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "server": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Main" + } + } + }, + "economics": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Economics is not installed on this server" + } + } + }, + "startingBalance": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "number" + }, + "example": { + "type": "number", + "example": 1000 + } + } + }, + "startingKnown": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "live": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "accounts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + } + } + }, + "example": { + "type": "array", + "items": { + "type": "object", + "properties": { + "steamId": { + "type": "string", + "example": "76561198000000001" + }, + "name": { + "type": "string", + "example": "Wanderer" + }, + "balance": { + "type": "number", + "example": 1500 + }, + "source": { + "type": "string", + "example": "live" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "RustEconomicsAdjust": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "serverId", + "steamId", + "op", + "amount" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "steamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000001" + } + } + }, + "op": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "deposit", + "withdraw", + "set" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "deposit" + } + } + }, + "amount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "number" + }, + "example": { + "type": "number", + "example": 500 + }, + "description": { + "type": "string", + "example": "0.01 to 1,000,000; 0 is allowed for set." + } + } + }, + "key": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "3f2a9c1e-7b4d-4e8a-9f10-2c6d5e8b7a41" + }, + "description": { + "type": "string", + "example": "One per submission, so a resend moves nothing." + } + } + } + } + } + } + }, + "RustEconomicsAdjusted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "before": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "number" + }, + "example": { + "type": "number", + "example": 1000 + } + } + }, + "after": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "number" + }, + "example": { + "type": "number", + "example": 1500 + } + } + }, + "repeat": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + } + } + } + } + }, + "RustBackpackStack": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "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": { + "type": "object", + "properties": { + "slot": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 2 + }, + "description": { + "type": "string", + "example": "Runs across pages: the first slot of page two is pageSize." + } + } + }, + "shortname": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "rifle.ak" + } + } + }, + "display": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Assault Rifle" + } + } + }, + "amount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1 + } + } + }, + "itemId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1545779598 + } + } + }, + "blueprint": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "rifle.ak" + } + } + }, + "skin": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "10129" + }, + "description": { + "type": "string", + "example": "A workshop skin id, as text." + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "spike-a" + }, + "description": { + "type": "string", + "example": "A renamed item’s name." + } + } + }, + "overflow": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Past the capacity: it moves to the owner’s inventory, or the ground, at their next open (D338)." + } + } + }, + "contents": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "A weapon’s attachments or a container’s items, one level deep, without `contents` or `overflow` of their own." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + } + } + } + } + } + } + } + } + }, + "RustBackpack": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One Steam account’s backpack on one server, read live and never stored. `capacity`, `pageSize` and `items` are present only when `exists`." + }, + "properties": { + "type": "object", + "properties": { + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "server": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Main" + } + } + }, + "steamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000001" + } + } + }, + "exists": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "False for an account that has never had a backpack." + } + } + }, + "capacity": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 12 + } + } + }, + "pageSize": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 48 + } + } + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/RustBackpackStack" + } + } + } + } + } + } + }, "RustVisibilityUpdate": { "type": "object", "properties": {