docs(rust): protocol 13 step 2 — expiry, plugin loads, the zone helper, the tally (§19.4-19.8, MODULE_API 1.11.0)

The spec for PLAN_FIXES §6 step 2, as built (D181-D185):

- PROTOCOL.md §19.4 world.expired's `what` and the website recording an
  expiry (amends §15's "maps it to nothing"); §19.5 plugin.loaded /
  plugin.unloaded with the permission diff; §19.6 the ZoneManager helper and
  `zoneHelper` at hello; §19.7 what the tally counts (F1, F3, F4); §19.8 the
  website (F7 hold, F5/F6 link fleet, F2 names).
- MODULE_API.md 1.11.0 and ctx.events.expired; EVENTS.md §L the `expired`
  status, terminal and green, and `resource.expired`.
- rust-link/INSTALL.md: the helper in the tarball, by hand, and in doctor.
- PLAYER_WALK.md: events step 6 can pass now; a step-2 section whose rows 1-2
  were walked on both rigs without a player and 3-9 need one.
- PLAN_FIXES §6: step 2 as built, with its PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-26 21:47:42 -05:00
parent e7551cbf8f
commit 1a7f52743f
6 changed files with 185 additions and 11 deletions

View File

@@ -1511,8 +1511,11 @@ erases a zone whose `holdMs` has run out, whether or not the website is heard fr
{"kind":"world.expired","type":"event","id":"rg-13-35875416-1","runId":"13"}
```
The website maps it to nothing. Core learns about it through `reconcile` and `revert`, as with
`lease.expired`.
Until protocol 13 the website mapped it to nothing (D96), and core learned about it only through
`reconcile` and `revert`, as with `lease.expired`. **Protocol 13 changes both halves** (§19.4): the
frame carries the entity's kind as `what` — protocols 9 to 12 wrote it over the frame's own `kind`, so no
`world.expired` ever arrived as one — and the website records the run's resource as `expired`
(PLAN_FIXES D170, D183).
### 15.4 `EventsEnabled`, and the two bounds
@@ -1950,8 +1953,9 @@ untouched.
Planned in [`PLAN_FIXES.md`](../modules/rust/PLAN_FIXES.md): one bump for every wire change the org
lead's first walk with a player in the game found (§5 there). It is built on `edge` in Rust-Plugins,
Rust-Link and Module-Rust and released together (D177). **This section grows as the pieces land**;
the first is the configuration save.
Rust-Link and Module-Rust and released together (D177). **This section grows as the pieces land**:
the configuration save first (§19.1–19.3, step 1), then step 2's fixes (§19.4–19.8, decisions
D181–D185).
One fix rides in the same release without a wire change: **the plugin decodes what the sidecar sends
as UTF-8** (F15). Protocols 1 to 12 decoded each byte as a character — Latin-1 — so every non-ASCII
@@ -2073,3 +2077,108 @@ from `config.outcome` by `(server_id, write_id)`, and only while it is still `re
frame moves nothing and a late one still lands. A row past `settle_by` reads as **`lost`**. The page polls
`GET /api/v1/admin/rust/config/{serverId}/writes/{writeId}` every two seconds until the write settles
(D179).
### 19.4 `world.expired` is recognisable, and an expiry is recorded (F13, F14)
The frame gains **`what`**, the entity's kind — the name the website's resource rows already use:
```json
{"kind":"world.expired","type":"event","id":"rg-13-35875416-1","what":"zone","runId":"13"}
```
Protocols 9 to 12 wrote the entity's kind into `kind`, over the frame's own, so every expiry reached the
sidecar as `kind: "zone"` and nothing listening for `world.expired` ever heard one (F13). A website that
reads protocol 12 frames will find them filed under `zone`; nothing is re-filed.
**The website records it** (D170, D183). Module-Rust hands core the resource the zone step ledgered —
kind `world`, ref `<serverId>:<id>` (§15, the ref names the server) — through
`ctx.events.expired({ kind, ref })` (MODULE_API 1.11.0). Core marks that row **`expired`**: terminal,
never taken back at teardown, and distinct from `orphaned`, which `reconcile` uses for a thing that
vanished with nobody asking. An expiry for a zone no run ledgered is not an error.
`world.expired` and `lease.expired` are **staff** kinds (§8.5). Neither was classified before protocol
13 — default deny kept both off public pages — and both are an event's machinery; the public hears what
an event did from core's announcements.
### 19.5 `plugin.loaded` and `plugin.unloaded` (F8)
A grant the site made for a plugin that was not loaded stays unresolved until the plugin is back. The
first walk watched one land thirteen minutes after the plugin loaded, on the fifteen-minute audit. So
the bridge now announces a load or an unload of **another** plugin, with the permissions it registered
or dropped (D184):
```json
{"kind":"plugin.loaded","type":"event","name":"Kits","version":"4.4.9","permissions":["kits.admin","kits.rgreward"]}
```
- **`permissions`** is a diff of the framework's registered-permission list, taken **one tick after the
hook**, because the order of "drop an unloaded plugin's permissions" and "call `OnPluginUnloaded`"
is the framework's own. It is sorted, and it may be empty.
- **Only once the world is ready** (§15.5). A boot loads every plugin, and a boot is the restart sync's
job; the bridge takes its baseline list at `OnServerInitialized`.
- **A reload in one tick shows an empty list on both frames.** Carbon's `c.reload` unloads and loads in
the same tick, so by the next the list is what it was. That is the right answer: what a grant
resolves against did not change. Two plugins loaded in one tick share one diff, under the first name.
- Both are **staff** kinds. The website marks the server's permission sync dirty when `permissions` is
not empty, and the next tick (30 s) pushes. §4.1's permission manager will take plugin ownership from
its own inventory read, not from this.
Walked on both rigs, 2026-09-27: `oxide.unload Kits` / `oxide.load Kits` and Carbon's `c.unload` and
the queued load each carried `["kits.admin","kits.rgreward"]`; the ZoneManager helper's load carried `[]`.
### 19.6 The ZoneManager helper, and `zoneHelper` at hello (F12)
ZoneManager counts a player as inside a zone only when the zone's trigger fires on **entry**, so a zone
created, updated or re-created around somebody standing there holds nobody — its flags, messages and
`OnEnterZone` never happen for them, and the bridge's tally, which asks ZoneManager's membership, scored
nobody already in an arena after a restart. ZoneManager has no public way to look again.
**`RunicGatewayZones.cs`** (D181, D168) ships beside the bridge and is installed by default (D182). It
Harmony-postfixes ZoneManager's `Zone.InitializeZone` — the one method a zone passes through when it is
created, updated (`Zone.Reset`) or loaded from ZoneManager's data file — and one tick later enters every
connected player ZoneManager's own `IsPositionInZone` places inside, through ZoneManager's own
`OnPlayerEnterZone`. A player ZoneManager already counts is skipped, and ZoneManager refuses a second
entry anyway. It fixes **every** zone on the server, not only the bridge's. It sweeps all zones once when
it patches, and re-patches when ZoneManager reloads. `rgz.status` prints its state.
The bridge never depends on it. `server.hello` carries, whenever ZoneManager is loaded:
```json
"zoneHelper": { "state": "patched", "version": "0.1.0" }
```
`state` is `patched`, `unsupported` (loaded, but this ZoneManager lacks what it patches — `reason` says
what), `no-zonemanager`, or `missing` (the file is not there). **Anything but `patched` and the bridge
scores the zones it made by ZoneManager's public `IsPositionInZone`**, for the participation tally and
the kill credit alike — so a missing helper costs ZoneManager's flags for somebody already inside, never
their score. The website shows the cost on the servers page.
Walked 2026-09-27: it patched ZoneManager 3.1.14 on Oxide and on Carbon, and the site read
`{"state":"patched","version":"0.1.0"}` from both hellos. **Not yet walked with a player standing in a
zone** — that needs somebody in the game.
### 19.7 What the tally counts (F1, F3, F4)
- **`gathered` is everything a player harvests** (D159). `OnDispenserGather` is the swings;
`OnDispenserBonus` (the final hit's bonus), `OnCollectiblePickup` (hemp, mushrooms, stones off the
ground) and `OnGrowableGathered` (a farmed plant) now add to the same per-item counter. All three are
`void` (§8.7) and in `rg.hooks`. A pick-up another plugin vetoes is still counted — the hook fires
before the items move — which is rarer than a second hook is worth.
- **`structures` no longer counts your own base.** A building block the attacker placed, or one on a
cupboard they are authorised on, is not counted. The `entity.destroyed` frame still goes; the site
already skips the raid alert for an authorised attacker (D59).
- **Every Steam id comes from `userID`.** The thirteen frames that read `UserIDString` sent `null` for a
player another plugin spawned; they now use the same `SteamIdOf` as the map.
### 19.8 The website
- **The loading hold** (F7). Permission and title pushes wait while the last hello says
`worldReady: false` — the first walk's restart sync went 35 s before the save loaded, timed out, and
the retry reported "0 applied". A human's "sync now" is not held. A failed or refused sync logs a
warning.
- **Link codes** (F5, F6). A code is asked of the servers that minted one in the last six minutes (every
`/link` stores an `account.link.requested`) first, then of the rest, each group **in parallel**. The
answer is "a server could not be reached — your code is still good" only when one of the minting
servers is unreachable; a dead server that minted nothing no longer makes a wrong code look good.
- **NPC attackers** (F2, D185) are named by the killfeed: a family (`scientistnpc_*` → Scientist,
`bradleyapc` → Bradley APC) or the prefab without its variant digits (`wolf2` → Wolf). No wire change.