docs(rust): RaidableBases built (PROTOCOL §20.12, PLAN_REDESIGNS §11.4.6, D391, MODULE_API 1.16.0)

- rust-link/PROTOCOL.md §20.12: RaidableBases — hello's raidableBases, the
  five raid.* events, the tally's raidsCompleted, map.live's raids layer (and
  its §17.3 row), raid.spawn and raid.check with their refusals, the raids
  helper, the profiles in the configuration editor, the sidecar's two
  forwards and the website's step, feed, map layer and Warlord.
- modules/rust/PLAN_REDESIGNS.md: D391 (the editor's spot check is advice
  through core), §11.4.6 (what was built, the rig tests on both rigs, the
  Oxide rig's RaidableBases fault that a restart cleared, twelve points
  settled in code, the end walk's rows), and the order of work.
- website/MODULE_API.md, EVENTS.md: 1.16.0, a dry run's detail.warnings.
- website/STEAM_SIGNIN.md: its contract moves to 1.17.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-16 19:29:02 -05:00
parent a9bc2c5feb
commit 9eb6a2edc4
5 changed files with 340 additions and 7 deletions

View File

@@ -1810,6 +1810,7 @@ drawing a second time. A new map key makes the file stale, and it is ignored.
| `players` | `steamId`, `name`, `x`, `z`, `sleeping`, `online`. Connected players, then offline sleepers |
| `bases` | `kind` (`tc`, `vending`), `x`, `z`. **Positions only**: no owner, no authorised list, no shop name |
| `pvp` | Protocol 14 (§20.7): Dynamic PVP's own zones, `zoneId`, `event`, `x`, `z` (where the zone is now), `radius`, `shape` (`sphere`, `cube`). Empty without Dynamic PVP |
| `raids` | Protocol 14 (§20.12): RaidableBases' live bases, `x`, `z`, `level`, `difficulty`, `pvp`, `base`, `despawnAt`, and a run's `runId` and `key`. Empty without RaidableBases |
It also carries `mapKey` and `tookMs`, the plugin's own time for the ask. `playersTruncated` and
`basesTruncated` are set when a bound cut a layer short.
@@ -3500,3 +3501,208 @@ is not "it worked". Nothing that follows reaches the feed, the map, a title or a
placeholder itself. Priced commands × people, the 100-recipient bound for a mode that is not a count.
- `unknown-command`, `malformed`, `interrupted`, `failed` and `events-disabled` are permanent. The error says
how many commands ran before it. A game that is down retries on core's ordinary delay.
### 20.12 RaidableBases — `raid.*` events, `raid.spawn`, `raid.check`, the raids layer, profiles in the editor (PLAN_REDESIGNS §11.4, D202–D208, D386–D391)
nivex's **RaidableBases** (3.2.2, free on uMod or paid on Codefling), optional like Economics (D199) and not in
`overlay.toml`, with its dependency **CopyPaste** (4.3.0), which pastes every base. A base is a stocked,
guarded CopyPaste building that players raid; it despawns on its profile's own timer. The bridge reports its
bases, spawns one for a run's step, and opens RaidableBases' profiles to the site's configuration editor. The
design is [`PLAN_REDESIGNS.md`](../modules/rust/PLAN_REDESIGNS.md) §11.4, and what the build found is §11.4.6
there.
**Names are the plugin's (D386).** Every difficulty, profile and base name below is read from the installed
copy and carried as its own string, with the plugin's level int beside a difficulty. The paid edition's
difficulties are the operator's data (each profile's *Difficulty* and *Difficulty Level*); the free edition
has one, *Normal* at 512. Nothing on the wire, in the bridge or on the site lists them.
**The raids helper, `RunicGatewayRaids.cs`** (D168, D204–D207, D387), ships beside the bridge and is installed
by default. It reaches RaidableBases' internals by reflection and three Harmony patches: a prefix on its
spawn queue's `Add` tags the request the helper's own call made; a postfix on `OpenEvent` sees the base that
request became; a prefix on each base's `BypassUseOwners()` answers the run's own lock. Without it the bridge
still reports bases, but a step's base is not the run's (below). A RaidableBases reload drops the patches with
the old assembly, and the helper patches again. `rgr.status` prints its state.
**Hello and status.** `integrations` gains `raidableBases`:
```json
"raidableBases": { "loaded": true, "version": "3.2.2", "premium": true, "spawnCall": true,
"copyPaste": { "loaded": true, "version": "4.3.0" },
"difficulties": [{ "name": "Easy", "level": 0 }, { "name": "Hard", "level": 2 }],
"helper": { "state": "patched", "version": "0.1.0" },
"gridReady": true,
"profiles": [{ "name": "Hard Bases", "difficulty": "Hard", "level": 2, "enabled": true,
"pvp": true, "bases": ["raidhard1", "raidhard2"], "seabed": false, "float": false }] }
```
- `premium` is the plugin's own `IsPremium()`. **`spawnCall`** is whether the paid edition's private
`SpawnRandomBase(string, Vector3, int, int, bool)` is there, looked for by name (D348): "paid" is never
taken to mean a call exists.
- `difficulties` comes from its public `GetAllDifficulties()`, each name once with its level, by level.
- `helper.state` is `missing`, `patched`, `unsupported` (with `reason`) or `no-raidablebases`. `helper` is sent
even while RaidableBases is absent.
- **`gridReady` and `profiles` only with a patched helper**, which is what can read them. `bases` are a
profile's own name and its additional bases, **only those with a CopyPaste file**. `seabed` is a seabed
chance above 0 and `float` the profile's *Allow Bases To Float Above Water* (D389).
- Hello is sent again when RaidableBases, the helper or CopyPaste loads or unloads. `rg.world` prints
`raidableBases=`, `raidsHelper=`, `raidGrid=`, `raids=` (live bases the bridge knows) and `raidBases=` (runs'
bases on record).
**Five events**, from RaidableBases' own lifecycle hooks, which both editions raise alike (the 17-argument
`Started`, `Completed` and `Ended`; the paid edition's `Purchased` and `Locked`):
```json
{"kind":"raid.started","type":"event","x":650.0,"z":2037.0,"level":2,"difficulty":"Hard","pvp":true,"base":"raidhard2",
"radius":50.0,"despawnAt":1792180000000,"id":"raid-1792178807764-4","runId":"12","key":"12:4"}
{"kind":"raid.completed","type":"event","x":650.0,"z":2037.0,"level":2,"difficulty":"Hard","pvp":true,"base":"raidhard2",
"raiders":[{"steamId":"76561198000000001","name":"Alice"}],"raiderCount":1,"ownerId":"76561198000000001","ownerName":"Alice"}
{"kind":"raid.ended","type":"event","x":650.0,"z":2037.0,"level":2,"difficulty":"Hard","pvp":true,"base":"raidhard2"}
{"kind":"raid.purchased","type":"event","steamId":"76561198000000001","name":"Alice","x":650.0,"z":2037.0,"level":2,"difficulty":"Hard","pvp":false,"base":"raidhard2"}
{"kind":"raid.locked","type":"event","steamId":"76561198000000001","name":"Alice","x":650.0,"z":2037.0,"level":2,"difficulty":"Hard","pvp":false,"base":"raidhard2"}
```
- **`id`, `runId` and `key` are set when the base is a run's** (the helper's tag, joined by the base's
position), on every kind.
- **`raid.completed`'s `raiders` are the plugin's own participants** (its `GetRaiders()`), at most 32 named,
all counted in `raiderCount`; `ownerId` and `ownerName` are the player the base was locked to, when it was.
- **The unload trap (§11.4.2):** a RaidableBases unload raises `Completed` for every live base with no owner
and no raiders. **Such a completion is neither sent nor counted.**
- `raid.purchased` and `raid.locked` come from the paid edition's `OnRaidableBasePurchased` and
`OnRaidableBaseLocked`: a player bought a base, or an admin ran `rbevent setowner` (D390). **Lockouts have no
hook in either edition and are not sent.**
- `despawnAt` is the plugin's despawn time, in unix milliseconds.
- **None is sent while the world is not live** (§20.5's rule). A completion is still counted.
**The tally** gains **`raidsCompleted`**: a completed base counts once for each of its raiders, or for its
owner alone when none is listed (*Warlord*, D202).
**`map.live`** (§17.3) gains **`raids`**, a layer of its own: every live base, `x`, `z`, `level`,
`difficulty`, `pvp`, `base`, `despawnAt`, and `runId` and `key` for a run's. The bridge keeps the set by the
hooks, and finds it again from the plugin's public `GetAllEvents()` at its own load and RaidableBases' (the
tuple's position, mode, level, PvP, base name and despawn time; a shape it cannot read leaves the set to the
hooks). It is cleared when RaidableBases unloads. A run's base is not also in `events`.
**`raid.spawn` — one base for a run (D203–D207, D387).**
```json
{"cmd":"raid.spawn","reqId":"r-70","key":"12:4","runId":"12","difficulty":"Hard","profile":"Hard Bases","base":"raidhard2",
"x":650.0,"z":2037.0,"heightAdjust":5,"pvp":false,"npcs":true,"npcCount":6,"despawnMinutes":90,"lock":true}
```
- **Every field but `key` and `runId` is optional.** `difficulty`, `profile` and `base` are the plugin's
names; a base names its profile, a profile picks one of its bases at random, a difficulty alone lets the
plugin pick among its own. **A spot** is a world call's location (§20.5: `monument` with `offsetX`/`offsetZ`,
or `x` and `z`; `y` is ignored), with `heightAdjust` (metres added to the profile's paste height) **or**
`height` (the profile's forced height). The settings are `pvp`, `npcs` (on or off), `npcCount` (0 to 20, in
the profile's own mix of scientists and murderers, never a random amount), `despawnMinutes` (1 to 1440,
inactive and total) and `lock` (to the first attacker).
- **With a patched helper the base is the run's.** It is a world registry entry under the bridge's own id
(`raid-<ms>-<n>`, never a zone's `rg-`), so **`world.revert` despawns that base and no other** through its
own `Despawn()` (one still on its way is taken off the plugin's queue, or despawned the moment it opens);
**`world.owned`** asks the helper whether it is open or on its way; and when RaidableBases ends it first (its
own timer, raided, or its unload), **`world.expired`** says so, `what: "raidbase"`. A base not open fifteen
minutes after it was asked for never will be (its paste failed, or it found no room): its request is
dropped and it expires with `reason: "never-started"`. A base does not survive a restart, so one from an
earlier boot is gone.
- **Settings go on a copy of the base's profile**, member by member, with its `Setup`, `NPC` and
`DespawnOptions` copied again, so the profile every other base shares is never touched (§11.4.4). **PvP is
also set on the base after `OpenEvent`**, because the plugin's config switches are applied inside it.
- **At a spot** the helper checks, before anything is pasted (D208, D388, D389): its own look for a player,
a player's building or deployable, and a raid base within the profile's protection radius; then
RaidableBases' own area check, whose reason it reads from the plugin's message queue (**a rock or a cliff
is a warning, and the base spawns**); then the ocean level against the ground (**in water is refused unless
the profile has a seabed chance or allows floating**). A spot is pasted at once, as the plugin's own
`rbe` pastes.
- **Without the helper** a spot, a profile, a base or settings are refused (`helper-needed`). The base can
still be asked for, but it is **not the run's**: no registry entry, and it despawns on its own timer. The
paid edition's own `SpawnRandomBase` at the difficulty's level (a random one when none is named), or else
the free edition's console command `rbevent`, which needs hello's `console.plugins` and answers nothing.
- **A key already answered is answered again with its first spawn, `repeat: true`, and spawns nothing.** Keys
are kept seven days in `data/RunicGateway/raid-starts.json`.
The answer:
```json
{"kind":"raid.queued","type":"reply","reqId":"r-70","key":"12:4","route":"spot","repeat":false,"despawns":"teardown",
"id":"raid-1792178807764-4","difficulty":"Hard","level":2,"profile":"Hard Bases","base":"raidhard2","x":650.0,"z":2037.0,
"warnings":["Rock is too large: 640.10 3.20 2041.80"]}
```
`route` is `pick` or `spot` (the helper's), `plugin` (the paid call) or `console`. `despawns` is `teardown`
for the run's base and `own-timer` otherwise. **"Queued" is not "open"**: the plugin pastes in its own time,
and `raid.started` says where and when. Refusals are `{"kind":"raid.error","reason":…,"message":…,"warnings":[…]}`:
| `reason` | When |
|---|---|
| `malformed` | No key or run, a key over 128 characters, a height adjustment and an exact height together, or a location that is both or neither (§20.5) |
| `rb-missing` | RaidableBases is not loaded |
| `copypaste-missing` | CopyPaste is not loaded |
| `helper-needed` | A spot, a profile, a base or a setting without `RunicGatewayRaids.cs` |
| `helper-unsupported` | The helper is loaded but could not patch this RaidableBases |
| `rb-not-ready` | Its spawn grid is still building (up to ten minutes after it loads, §11.4.2): it refuses every spawn |
| `rb-busy` | It is pasting another base; it pastes one at a time |
| `bad-difficulty`, `bad-profile`, `bad-base` | A name it does not have, or names that do not fit together |
| `spot-blocked` | A player, a player's building or deployable, a raid base, or the plugin's own refusal other than rocks and cliffs |
| `in-water` | The spot is under the ocean and the profile is not built for water |
| `no-monument`, `out-of-range`, `off-map` | The location, as §20.5 |
| `console-unavailable` | No helper, no paid call, and hello's `console.plugins` is false (D363, Rust-Plugins#34) |
| `refused` | RaidableBases did not queue a base, in its own words |
`events-disabled` and the world's `not-ready` come first, as `world.error`, as for every world verb (§15).
**`raid.check` — a spot's checks, spawning nothing (D208, D391).** `{ "cmd":"raid.check", "x", "z" }` (or a
monument), with the `difficulty`, `profile` or `base` whose radius and water settings apply. The answer is
`{"kind":"raid.checked","ok":…,"reason":…,"message":…,"warnings":[…],"base":…,"x":…,"z":…}`, or `raid.error`
`helper-needed` without the helper. The site's dry run asks it and shows the answer as advice.
**RaidableBases' profiles in the configuration editor (D205).** The one named exception to the editor never
walking a data directory: `config.list` adds each `data/RaidableBases/Profiles/*.json` (not the plugin's
`_emptyfile`) under the **virtual path** `@data/RaidableBases/Profiles/<file>.json`, `plugin: "RaidableBases"`,
`profile: true`. No path from the config tree can name one, and `config.read` and `config.write` resolve that
prefix to the one folder only, one file deep, never through a link. A write is **all profiles or none**
(`mixed` otherwise) and is refused before anything lands (`profiles-unavailable`) without RaidableBases, a
patched helper, or a built grid. It is version-checked and backed up like any config write, then **the helper
reloads the profiles as `rb.reloadprofiles` does**, and the reply is `pending` as a protocol 13 save's is
(§19.1). The `config.outcome` (`reload: "RaidableBases"`) settles when the helper's reload finishes: every
written profile in the plugin's table is `reloaded`; one that is not is **rolled back** and the old file
reloaded. Thirty seconds without a finished reload rolls back too. RaidableBases rewrites every profile file as
it reloads, so the outcome carries each file's version as it is on disk afterwards.
**Hooks.** `OnRaidableBaseStarted`, `OnRaidableBaseEnded`, `OnRaidableBaseCompleted`, `OnRaidableBasePurchased`
and `OnRaidableBaseLocked` join `rg.hooks`' list.
**The sidecar** adds `POST /raid/spawn` and `POST /raid/check`, thin forwards that keep nothing, and stores and
forwards the five events like any other.
**The website (Module-Rust).**
- **`raid.started`, `raid.completed`, `raid.purchased` and `raid.locked` are public kinds; `raid.ended` is
staff's.** Each row is read per viewer by `raidableBases.shapeFor`: everyone gets `grid`, `level`,
`difficulty`, `base`, `pvp`, `despawnAt` and a completion's `raiderCount`; **the raiders' `names`, the lock
holder's `ownerName` and a buyer's `name` only for the presence audience** (D376, D390); **`x`, `z`, the
run's `id`, `runId` and `key`, and every Steam id only for staff**. The lines read *"A Hard raid base
appeared · G7 · PvP"*, *"Alice and Bob raided a Hard raid base"* (*"A Hard raid base was raided · G7 · by 2
players"* below the presence audience), *"Alice bought a Hard raid base"*, *"A Hard raid base was locked to
Alice"*, and for staff *"A Hard raid base despawned"*; all but the last are in the feed's *World events*
filter.
- **The step `rust.raidbase.spawn`**, *Spawn a raid base* (`risk: 'change'`, `reversible: 'ledger'`, budget
`rust.raids`, one per step): the server; a difficulty, profile and base from option sources that list each
server's own names (`rust.options.raid_difficulties`, `raid_profiles`, `raid_bases`, the last searchable);
a spot by monument or x and z, with a height adjustment or an exact height; and the settings, PvP, NPCs and
the lock as on/off/blank (`rust.options.raid_switch`, blank keeps the profile's or the config's). The run's
base is a `world` resource (`<server>:raid-…`), so `eventWorld`'s revert and reconcile serve it and
`world.expired` marks it `expired`. A base that is not the run's records no resource, and the run log says it
despawns on its own timer. It is refused at verify where the server said RaidableBases or CopyPaste is
missing, that a spot, profile, base or setting has no patched helper, or that it has no such name; a server
that has not said is left to the plugin. **A dry run with a spot asks `raid.check` and shows its answer as
advice** (MODULE_API 1.16.0, D391): *"the spot is refused now … It is checked again when the event runs"*,
*"at the spot now: Rock is too large"*. `rb-not-ready` retries in ten minutes (`retryAfterMs`, D365's
pattern), `rb-busy` and a game that is down on core's ordinary delay; every other refusal is permanent.
- **Warlord (D202):** `raidsCompleted` → `rust_player_wipe_stats.raids_completed`, the title category
`raidscompleted` (default *Warlord*), and the leaderboard's **Raid bases** column (`sort=raidsCompleted`),
shown where the server reports RaidableBases or a row counts one (`raidableBases: true` on the answer).
- **The map's `raids` layer** (D114), public by default with its own audience in Admin → Rust visibility: an
orange dot per base, yellow for one this site's event spawned, with its difficulty, base, PvP and despawn
time. A run's `key` is dropped before any viewer is sent the layer.
- Server settings' plugin line names the edition, the difficulties, CopyPaste and the helper, and what a step
may do with or without it. RaidableBases' profiles appear in Mod configuration under RaidableBases.