Stage 7 of the §14.3 integration plan (D374): Server settings' plugin line says whether ZLevels is loaded and that an event may lend its XP boosts as permissions. The integrations schema gains zLevels; the swagger fragment is regenerated. No leaderboard change, nothing stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
292 lines
20 KiB
Markdown
292 lines
20 KiB
Markdown
# Module-Rust
|
||
|
||
The **[Rust](https://rust.facepunch.com/) module** for the Runic Gateway platform: everything that
|
||
makes a Runic Gateway site a site *for* Rust. It installs into a website core as
|
||
`modules/rust/` and is the platform's second game module, after
|
||
[`Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo).
|
||
|
||
It is also the first module built from the
|
||
[Integration Kit](https://gitea.whitlocktech.com/RunicGateway/Integration-kit) rather than extracted
|
||
from the website — which makes it the kit's acceptance test from the inside.
|
||
|
||
**The repository name is not the module id.** This ships a module whose `id` is `rust`, because the
|
||
contract requires `id` to equal the directory core loads it from (`modules/rust/`), and that id is
|
||
the prefix of every table and every mount.
|
||
|
||
## What it is, in one diagram
|
||
|
||
```
|
||
Rust server + Oxide (RunicGateway/Rust-Plugins)
|
||
│ loopback TCP, the plugin dials out
|
||
▼
|
||
rust-link sidecar (RunicGateway/Rust-Link) one per game server
|
||
│ HTTPS + WebSocket, bearer token
|
||
▼
|
||
this module, inside a website core one client per server
|
||
│ same-origin JSON
|
||
▼
|
||
browser · Android app
|
||
```
|
||
|
||
**One server, one sidecar.** A community running six Rust servers runs six pairs and configures six
|
||
rows here; the website core never learns there is more than one.
|
||
|
||
## What ships today
|
||
|
||
| Surface | Route |
|
||
|---|---|
|
||
| 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, 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 |
|
||
| Pages | `/rust` — the server list, and the module's landing page |
|
||
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes, clans |
|
||
| Pages | `/rust/clans/:externalId` — one clan, with core's Team notify, activity and forum in three module slots |
|
||
| Pages | `/admin/rust/servers` — add, edit, test and remove servers, set each one's wipe schedule and chat titles, and choose the announcement voice |
|
||
| Discord | `/status`, `/wipe`, `/top`, `/online`, `/clan` — read-only, answered from this module's tables |
|
||
| Teams | The deployment's Team provider: a first-party Rust clan is a Team |
|
||
| Slot | `site.footer.status` — a live server/player count in core's footer |
|
||
|
||
**Nothing names who is online by default.** The Online list, every feed item that says a named
|
||
player was on the server (connects, respawns, deaths, chat, gather tallies) and the leaderboard's
|
||
"last seen" reach **staff** unless an operator widens them in Admin → Rust visibility — fleet-wide,
|
||
with an optional override per server. How many players are online is public at every setting. The
|
||
viewer's standing is re-read from the database on each request, so a demotion or a ban applies at
|
||
once rather than when a token expires.
|
||
|
||
Every page reads this module's own tables and never calls a game server, which is what lets the
|
||
whole surface render while every server in the fleet is off. Tab, feed filter, wipe and leaderboard
|
||
sort all live in the URL, so any view of it is a link.
|
||
|
||
Seven tables: `rust_servers` (configuration), `rust_server_state` and `rust_presence` (observed
|
||
state), `rust_wipes`, `rust_players`, `rust_player_wipe_stats` and `rust_gather_totals` (the record a
|
||
wipe does not erase), plus the bounded `rust_events` window and the `rust_ingest_cursor`.
|
||
|
||
**Teams come from Rust's own clans**, not from the uMod Clans plugin, which is optional and whose
|
||
clans never become Teams. A clan's roster reaches its own members and staff unless an operator
|
||
widens it in Admin → Rust visibility; its name, colour, score and count are public. The game lists
|
||
at most 100 clans per server, and a server at that ceiling answers core partially, so core never
|
||
removes a Team on its word. Core holds one Team provider per site, which is one reason **a site runs
|
||
one module**: core's installer refuses a second.
|
||
|
||
**The next wipe is the operator's to state** (Admin → Rust servers): a rule — the monthly forced
|
||
wipe only, weekly or every other week, each in the server's own time zone and always including the
|
||
forced wipe (first Thursday, 19:00 UK time) — plus an optional one-off date that replaces the next
|
||
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.
|
||
|
||
**Rust's own world events need no plugin** (`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.1). The patrol
|
||
helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the
|
||
satellite and the Deep Sea reach the feed (a **World events** filter) and each is an engagement trigger
|
||
(`rust.world.<event>`), so a notice or an event's phase can follow one. The event and its grid are public;
|
||
a player's name in it only for the presence audience, a Steam id only for staff. **Who downs a helicopter
|
||
or a Bradley** is the operator's rule per server — killing blow (the default), last hit, most damage, or
|
||
everyone who hurt it — and the same rule writes the line and counts toward **Rotorbane** and
|
||
**Tankbreaker**. **Safecracker** (locked crates hacked) and **Scavenger** (airdrops looted first) are
|
||
title categories with leaderboard columns, and the **Call a world event** step (`rust.world.call`) calls
|
||
one now, kept when the run ends: an airdrop or a patrol helicopter can be sent to a point.
|
||
|
||
**Four uMod plugins are optional, and the module works without any of them** (`docs/modules/rust/PLAN.md`
|
||
§33). The bridge plugin's hard requirements are **Kits** and **ZoneManager**.
|
||
|
||
- **BetterChat** (LaserHydra, 5.2.15). With it: **chat titles** an operator sets per server — "top 3
|
||
playtime", "#1 kills" on the current wipe — shown in game chat and beside the name on the web and
|
||
app leaderboards; and a **chat style** on any permission group this site authors, all twelve of
|
||
BetterChat's group fields, mirrored like the group's permissions (a field changed in game is
|
||
reported, never overwritten). Without it the titles still show on the web and the app, and the
|
||
styles wait until it is installed.
|
||
- **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).
|
||
- **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).
|
||
|
||
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
|
||
player as its sender, so it looks the same with or without BetterChat.
|
||
|
||
**The Discord commands answer from the tables, never from a game server**, inside core's three-second
|
||
budget. Every refusal is private. **An answer narrower than public goes to the caller alone:** a
|
||
moderator's `/online` in a public channel shows the names to the moderator, never to the channel, and
|
||
the same for a clan roster. Everything else is posted where it was asked.
|
||
|
||
The rest of the module arrives phase by phase. **Nothing is registered before it
|
||
has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are
|
||
both surfaces an operator can configure and then wait on, which is worse than an absent one.
|
||
|
||
### What a client feature-detects on
|
||
|
||
`module.json` declares six capability strings, and `GET /api/v1/public/modules` hands them to any
|
||
client that asks — the website's own nav, and the Android app (`docs/modules/rust/PLAN.md` R10).
|
||
Five of them name a surface: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`.
|
||
|
||
The sixth is `rust`, and it names **the module itself**. It looks redundant beside `id`, and it is
|
||
not, for two reasons worth writing down before somebody tidies it away:
|
||
|
||
- **A client that asks "is this module installed" has nowhere else to ask.** Core flattens every
|
||
started module's capabilities into one list, so `servers` alone is a word another module could
|
||
declare tomorrow and silently reveal this one's screens. `rust` is the string that can only mean
|
||
this module, and it is the single gate a whole navigation group hangs on — exactly the job `shard`
|
||
does for `module-uo`.
|
||
- **`id` answers a different question.** It is a *mount prefix* (§2.1 requires it to equal the
|
||
directory core loads the module from), and `MODULE_API.md` §2.9 is explicit that a client must
|
||
never infer a route from a capability. Gating on `id` would quietly make the two the same thing,
|
||
and the day a client builds `/<id>/servers` from it, the contract that lets this module move its
|
||
own pages is gone.
|
||
|
||
An unknown capability is absent, and no route is ever derived from one.
|
||
|
||
## Build and check
|
||
|
||
```bash
|
||
npm ci --prefix server && npm test --prefix server
|
||
npm run check:imports --prefix server
|
||
npm run check:bundle --prefix server
|
||
npm run check:swagger --prefix server
|
||
npm ci --prefix client && npm run build --prefix client
|
||
npm run check:externals --prefix client && npm test --prefix client
|
||
```
|
||
|
||
**Build the client BEFORE running its tests** — two of them read the built chunk and skip when there
|
||
is none, so a run in the other order passes while asking nothing about the artifact that ships.
|
||
|
||
Regenerate the OpenAPI fragment whenever a route or an annotation changes:
|
||
|
||
```bash
|
||
npm run swagger --prefix server # writes swagger-fragment.json; commit it
|
||
```
|
||
|
||
`.gitea/workflows/pr-checks.yml` runs all of the above on every pull request, plus one job this
|
||
machine cannot run on its own: **frozen-manifest** clones core at the sha pinned in
|
||
[`ci/core-ref.json`](ci/core-ref.json), generates its route table without this module and then with
|
||
it, and takes the difference. That difference is the URL surface this module serves — checked
|
||
against the committed [`routes.manifest.json`](routes.manifest.json), against the OpenAPI fragment
|
||
in both directions, and against the rule that **a module may only add**. It is the only thing that
|
||
can see whether `/rust` collides with one of the routes core mounts at a tier root (`/status`,
|
||
`/version`), which the loader's own collision probe cannot find.
|
||
|
||
## How it reaches an operator
|
||
|
||
**An operator never builds anything.** A release is not source: it is the directory core's loader
|
||
expects at `modules/rust/`, already assembled — the prebuilt client chunk, the schema fragment and
|
||
the OpenAPI fragment, packed as they will be unpacked.
|
||
|
||
**Every merge to `main` carrying a releasable commit publishes a bundle.** The next version is
|
||
computed from conventional-commit subjects since the newest `v*` tag, as in `link`, `installer` and
|
||
`Module-uo`: `feat!:` or `BREAKING CHANGE` is a major, `feat:` a minor, `fix:` or `perf:` a patch,
|
||
and a `main` that gained none of those cuts no release. The number that ships is the **tag**, and CI
|
||
writes it into the `module.json` inside the bundle. `module.json`'s version survives as a **floor**:
|
||
name a version there above the newest tag and that version releases, which is how you overrule the
|
||
subjects. For a change with nothing releasable behind it — a widened `coreApi`, a new mount, a
|
||
capability — run the **Release** workflow by hand (Actions → Release → Run workflow).
|
||
|
||
Each release carries:
|
||
|
||
| Asset | What it is |
|
||
|---|---|
|
||
| `module-rust-<version>.tar.gz` | the directory core expects at `modules/rust/`, already assembled |
|
||
| `module-rust-<version>.json` | the install manifest: id, version, `coreApi`, the artifact's URL, size and **`sha256`** |
|
||
| `SHA256SUMS` | the same hash, in the shape every other repo here publishes |
|
||
|
||
Releases are **unsigned**; the `sha256` is the trust anchor, and the website verifies it before
|
||
unpacking. That is the model `installer`'s bundles already use, and a second trust model would be a
|
||
second thing to get right.
|
||
|
||
The tarball is assembled from an **include** list ([`ci/bundle.json`](ci/bundle.json)), never an
|
||
exclude list — an exclude list ships whatever it forgot. Tests, scripts, `client/src`, `ci/` and the
|
||
dev dependencies are not in it. It carries **no `node_modules`**, because the shipped half declares
|
||
no runtime dependencies: everything it needs arrives on `ctx`. `npm run check:bundle` holds both
|
||
halves of that — that the list still covers every file `server/index.js` can reach, and that no
|
||
runtime dependency has appeared without the release learning to pack it.
|
||
|
||
## Install it into a core
|
||
|
||
**From a release**, which is the supported path: in Admin → Modules, paste the URL of that release's
|
||
`module-rust-<version>.json`, and restart when the panel offers. Core fetches the manifest, checks
|
||
every URL and redirect hop against its own host allowlist, streams the artifact under a byte cap
|
||
while hashing it, verifies the `sha256`, inspects the archive in full before unpacking it to a
|
||
temporary directory, and only then moves it into `modules/rust/`. Nothing is written into the
|
||
modules directory until every check has passed. The allowlist must contain
|
||
`gitea.whitlocktech.com` — it is seeded from `MODULE_SOURCE_HOSTS` on a fresh install and is
|
||
DB-owned from then on, edited on that same screen. **An empty allowlist forbids every install rather
|
||
than permitting all of them.**
|
||
|
||
**From a working tree**, for development: copy the whole tree to `<website>/modules/rust/` and
|
||
restart. **Copy, do not symlink** — the loader lists directory entries and asks each whether it is a
|
||
directory; a symlink answers no and the module is skipped in complete silence.
|
||
|
||
Either way, the module appears when the process restarts: the volume is read at require time.
|
||
|
||
Then, in Admin → Rust servers, add a server: its name, the sidecar's base URL, and the token the
|
||
sidecar printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is
|
||
stored encrypted through core's own secret box and never returned to any client; the panel reports
|
||
only whether one is set.
|
||
|
||
`POST /api/v1/admin/rust/servers/:id/test` probes a sidecar and reports what came back in one word.
|
||
That is the route that tells a wrong URL from a wrong token from a mismatched protocol version —
|
||
all three present as "the site says my server is offline" and each has a different fix.
|
||
|
||
## The protocol is a contract
|
||
|
||
`PROTOCOL_VERSION` in `server/sidecarClient.js` is sent on every request as `X-RustLink-Version`,
|
||
and a sidecar speaking a different one answers `409` rather than serving something this module will
|
||
mis-parse. It must agree with the sidecar's own constant and with `overlay.toml` in the plugin repo.
|
||
|
||
Canonical spec:
|
||
[`docs/rust-link/PROTOCOL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/PROTOCOL.md).
|
||
The module's own design of record is
|
||
[`docs/modules/rust/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust/PLAN.md).
|
||
|
||
## Licence
|
||
|
||
GPL-3.0-or-later. See [LICENSE.md](LICENSE.md).
|