docs(modules): R17 makes ZoneManager a base plugin, and it settles two loose ends

ZoneManager (k1lly0u, 3.1.14, MIT, ~218k downloads) is a fourth REQUIRED plugin
rather than an optional one. It is what makes an event able to answer where a
player is, and reading its source changed two things this plan had been vague
about while promoting one action out of the optional tier.

Its API is private methods reached through Oxide's reflection Call() - no
HookMethod, no API_ prefix. That makes THREE conventions among the four base
plugins: Kits declares HookMethod, BetterChat uses API_ prefixed methods, and
ZoneManager uses plain private methods resolved by name. All are reached the
same way from our side, but only the first is greppable as a declared API, which
is worth knowing before someone goes hunting for one that is not there.

Participation stops being the hard part. EVENTS.md section H rates participation
the hard part for UO and substantially easier for Rust because hooks carry
attacker and victim. ZoneManager makes it exact rather than merely easier:
OnEnterZone and OnExitZone are presence transitions delivered as events, so the
participation ledger is fed from what happened rather than reconstructed from a
sweep. That is where an action's participants envelope member gets its content.

Advance conditions become expressible. A phase that waits until ten players are
at the monument is a real gate that reads zone membership, rather than distance
arithmetic against a point recomputed on a timer.

And rust.zone.open moves from the optional tier into the base catalogue with an
honest reversible: ledger. CreateOrUpdateTemporaryZone takes a Plugin owner and
EraseTemporaryZone is SCOPED to that owner, so ZoneManager already has a
first-class notion of a zone belonging to the plugin that made it. That is most
of the persisted ownership registry chapter 4 demands - we still keep our own
map from core's resource reference to the zone id, but we are borrowing a
concept rather than inventing one. Erasing a zone that is gone is a success,
which is what revert needs.

One trap recorded, chapter 4's rule meeting a chatty hook: OnEnterZone and
OnExitZone fire on the game thread and a large zone on a busy server produces a
great many. The emit path already enqueues and returns so the game cannot stall,
but the bridge should subscribe SELECTIVELY rather than forwarding every
transition in every zone - a zone no event cares about should cost nothing on
the wire. Decide the filter at phase 12 with the hooks in front of you, and
measure it: a sweep over GetPlayerZoneIDsNoAlloc is cheap and a flood of wire
traffic is not.

Also adds rust.options.zones, makes the rust.zone.minutes budget dimension real,
and takes phase 0's install step to four curls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-15 12:23:13 -05:00
parent 601ee4e05f
commit 1138fd36dd

View File

@@ -1,7 +1,8 @@
# `module-rust` — the plan # `module-rust` — the plan
**Status:** approved in outline 2026-09-15, not started. **Sixteen decisions of record, no open **Status:** approved in outline 2026-09-15, not started. **Seventeen decisions of record, no open
questions.** Audited against the whole contract, not just the game-facing chapters (§7). questions.** Audited against the whole contract, not just the game-facing chapters (§7); the event and
engagement catalogues are §9 and §10.
The [dry run](../rust-dryrun.md) designed this module on paper and deliberately did not build it. The [dry run](../rust-dryrun.md) designed this module on paper and deliberately did not build it.
This is the document that builds it. Where the two disagree, this one is later and wins — but the dry This is the document that builds it. Where the two disagree, this one is later and wins — but the dry
@@ -188,20 +189,28 @@ storage, not using an API** — it breaks without warning on any upstream refact
contract anybody owes us. If phase 17 wants roster data from the plugin, the honest path is an contract anybody owes us. If phase 17 wants roster data from the plugin, the honest path is an
upstream pull request adding a read method, not a file reader. upstream pull request adding a read method, not a file reader.
### R6 — the required base set is Kits, Clans and PopupNotifications, all k1lly0u ### R6 — the required base set: Kits, Clans, PopupNotifications and ZoneManager
**Named 2026-09-15 (org lead).** All three are MIT, all by the same author, all fetched at plan time: **Named 2026-09-15 (org lead), extended the same day by R17.** All four are MIT; three are k1lly0u's
and BetterChat's author differs only in the optional tier. All fetched at plan time:
| Plugin | Version | Released | Source | | Plugin | Version | Released | Source |
|---|---|---|---| |---|---|---|---|
| [Kits](https://umod.org/plugins/rust-kits) | 4.4.9 | 2026-06-04 | `https://umod.org/plugins/Kits.cs` | | [Kits](https://umod.org/plugins/rust-kits) | 4.4.9 | 2026-06-04 | `https://umod.org/plugins/Kits.cs` |
| [Clans](https://umod.org/plugins/clans) | 0.2.10 | 2026-05-06 | `https://umod.org/plugins/Clans.cs` | | [Clans](https://umod.org/plugins/clans) | 0.2.10 | 2026-05-06 | `https://umod.org/plugins/Clans.cs` |
| [PopupNotifications](https://umod.org/plugins/popup-notifications) | 0.2.1 | 2026-08-09 | `https://umod.org/plugins/PopupNotifications.cs` | | [PopupNotifications](https://umod.org/plugins/popup-notifications) | 0.2.1 | 2026-08-09 | `https://umod.org/plugins/PopupNotifications.cs` |
| [ZoneManager](https://umod.org/plugins/zone-manager) (R17) | 3.1.14 | 2026-09-02 | `https://umod.org/plugins/ZoneManager.cs` |
Every one has a direct `.cs` download, so phase 0's install step is three `curl`s into Every one has a direct `.cs` download, so phase 0's install step is four `curl`s into
`oxide/plugins/` and no manual retrieval. **Pull them fresh rather than using the copies staged in `oxide/plugins/` and no manual retrieval. **Pull them fresh rather than using the copies staged in
`Downloads`**, which are an older vintage. `Downloads`**, which are an older vintage.
**The four base plugins use three different conventions for their callable API**, which is worth
knowing before someone goes hunting for the wrong one: Kits uses `[HookMethod]`, BetterChat uses
`API_`-prefixed methods, and ZoneManager uses plain private methods resolved by name. All three are
reached the same way from our side — `[PluginReference]` plus `Call()` — but only the first is
greppable as a declared API.
`Clans` is listed by uMod as a **Universal** plugin rather than a Rust one — it is written against `Clans` is listed by uMod as a **Universal** plugin rather than a Rust one — it is written against
Covalence, and its API takes `IPlayer` rather than `BasePlayer`. That is the portable half of the Covalence, and its API takes `IPlayer` rather than `BasePlayer`. That is the portable half of the
uMod surface, and it is why the same plugin serves several games. uMod surface, and it is why the same plugin serves several games.
@@ -371,6 +380,63 @@ probe cannot see all of core's** — several core endpoints are mounted at the t
under a prefix. A noun from our own domain that equals the module id cannot collide, where under a prefix. A noun from our own domain that equals the module id cannot collide, where
`/servers` or `/map` very well might. `/servers` or `/map` very well might.
### R17 — ZoneManager joins the required base set, because events need to know where people are
**Decided 2026-09-15 (org lead).** **[ZoneManager](https://umod.org/plugins/zone-manager)** (k1lly0u,
3.1.14 released 2026-09-02, MIT, Rust, ~218k downloads) is a fourth **required** plugin, not an
optional one. It is what makes an event able to answer *where a player is*.
Reading its source changed two things this plan had been vague about, and promoted one action out of
the optional tier.
**Its API is private methods reached through Oxide's reflection `Call()`** — no `[HookMethod]`, no
`API_` prefix, which is a third convention among the four base plugins and worth knowing before
someone goes looking for one that is not there.
| Surface | What it gives an event |
|---|---|
| `IsPlayerInZone(zoneId, player)``bool` | the direct question |
| `GetPlayerZoneIDs(player)``string[]` | every zone a player is in |
| **`GetPlayerZoneIDsNoAlloc(player, List<string>)`** | the allocation-free variant — **the one to use on any sweep** |
| `CreateOrUpdateZone(zoneId, args, position)` | make a zone |
| **`CreateOrUpdateTemporaryZone(..., Plugin owner)`** | make a zone *owned by our plugin* |
| **`EraseTemporaryZone(Plugin owner, zoneId)`** | remove one, **scoped to the owner** |
| `GetZoneIDs` / `GetZoneName` / `GetZoneLocation` / `CheckZoneID` | the catalogue, for an option source |
And nine hooks raised: `OnEnterZone` / `OnExitZone`, `OnEntityEnterZone` / `OnEntityExitZone`,
`OnZoneInitialize` / `OnZoneUpdated` / `OnZoneDestroyed` / `OnZoneErased`, plus `CanSpawnInZone`,
which is a veto our bridge abstains from like every other.
**Three things this settles.**
**Participation stops being the hard part.** [`EVENTS.md`](../../website/EVENTS.md) §H rates
participation *"the hard part"* for UO and *"substantially easier"* for Rust because hooks carry
attacker and victim. ZoneManager makes it exact rather than merely easier: `OnEnterZone` and
`OnExitZone` are **presence transitions delivered as events**, so the participation ledger is fed
from what happened rather than reconstructed from a sweep. An action's reply carries its
`participants` envelope member, and this is where that member gets its content.
**Advance conditions become expressible.** A phase that waits until *"ten players are at the
monument"* is a real gate rather than a wish — it reads zone membership. Without ZoneManager the
same condition is distance arithmetic against a point, recomputed on a timer, which is both more
expensive and less accurate.
**`rust.zone.open` moves from the optional tier into the base catalogue, and it can be
`reversible: 'ledger'` honestly.** This is the nicer half: `CreateOrUpdateTemporaryZone` takes a
**`Plugin owner`** and `EraseTemporaryZone` is **scoped to that owner**, so ZoneManager already has a
first-class notion of a zone belonging to the plugin that made it. That is most of the persisted
ownership registry [kit][kit] ch. 4 demands — we still keep our own map from core's resource
reference to the zone id, but we are not inventing ownership, we are borrowing a concept the plugin
already has. And erasing a zone that is gone is a success, which is what `revert` needs.
**One trap to design against, and it is chapter 4's rule meeting a chatty hook.** `OnEnterZone` and
`OnExitZone` fire on the game thread and a large zone with a busy server produces a great many of
them. The emit path already enqueues and returns, so the game cannot stall — but **the bridge should
subscribe selectively rather than forwarding every transition in every zone**. A zone no event cares
about should cost nothing on the wire. Decide the filter with the hooks in front of you at phase 12,
and measure it, because a sweep over `GetPlayerZoneIDsNoAlloc` is cheap and a flood of wire traffic
is not.
### R15 — an optional-integration tier, with BetterChat as the first member ### R15 — an optional-integration tier, with BetterChat as the first member
**Decided 2026-09-15 (org lead).** Beyond the required base set (R6) the module carries a tier of **Decided 2026-09-15 (org lead).** Beyond the required base set (R6) the module carries a tier of
@@ -488,7 +554,7 @@ Each phase ends with its findings written down, as every workstream here does.
| # | Phase | Repos | Done when | | # | Phase | Repos | Done when |
|---|---|---|---| |---|---|---|---|
| 0 | **The rig.** Update to the current wipe (the script is fixed), confirm the Oxide build still matches, install the base set (R6), prove a console grant reaches a plugin | docs | A current server boots with Kits, Clans and PopupNotifications loaded and `oxide.grant` demonstrably gates something | | 0 | **The rig.** Update to the current wipe (the script is fixed), confirm the Oxide build still matches, install the base set — Kits, Clans, PopupNotifications, ZoneManager (R6, R17) — prove a console grant reaches a plugin | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it |
| 1 | **Protocol 1, three skeletons, and every bundle seam at once.** Plugin: bounded drop-oldest queue, one writer thread, tagged reconnect epoch, dial-out. Sidecar: listener, SQLite, always-on token auth, version header, rpc correlation. Module: `id: rust`, `/rust` on all three tiers (R14), `schema.sql` **and `purge.sql`**, the vite aliases and shims, `checkExternals`, `checkImports`, the swagger fragment and its staleness check, explicit `onBoot`/`onShutdown`, `capabilities` | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton | | 1 | **Protocol 1, three skeletons, and every bundle seam at once.** Plugin: bounded drop-oldest queue, one writer thread, tagged reconnect epoch, dial-out. Sidecar: listener, SQLite, always-on token auth, version header, rpc correlation. Module: `id: rust`, `/rust` on all three tiers (R14), `schema.sql` **and `purge.sql`**, the vite aliases and shims, `checkExternals`, `checkImports`, the swagger fragment and its staleness check, explicit `onBoot`/`onShutdown`, `capabilities` | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton |
| 2 | **Packaging and release.** `release.yml`, the install manifest, the `sha256`, the host allowlist — and a real install into a running core from a manifest URL | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` | | 2 | **Packaging and release.** `release.yml`, the install manifest, the `sha256`, the host allowlist — and a real install into a running core from a manifest URL | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
| 3 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, and a wipe does not erase a player's history | | 3 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, and a wipe does not erase a player's history |
@@ -682,7 +748,7 @@ its reasoning as the starting point rather than inventing a parallel set.
| Dimension | Counts | | Dimension | Counts |
|---|---| |---|---|
| `rust.prefabs` | objects placed into the world by a run | | `rust.prefabs` | objects placed into the world by a run |
| `rust.zone.minutes` | zone/dome time held | | `rust.zone.minutes` | zone time held — real since R17 |
| `rust.grants` | entitlements granted (R16) | | `rust.grants` | entitlements granted (R16) |
| `rust.announcements` | in-game broadcasts | | `rust.announcements` | in-game broadcasts |
@@ -699,6 +765,7 @@ operator setting a cap of 30 prefabs is setting it per server. Say so on the fie
| `rust.options.permissions` | registered permissions | | `rust.options.permissions` | registered permissions |
| `rust.options.prefabs` | a plugin-declared constructible allowlist — the analogue of UO's spawn atlas | | `rust.options.prefabs` | a plugin-declared constructible allowlist — the analogue of UO's spawn atlas |
| `rust.options.monuments` | monument names, shared with the map work (R9) | | `rust.options.monuments` | monument names, shared with the map work (R9) |
| `rust.options.zones` | ZoneManager `GetZoneIDs` / `GetZoneName` (R17) |
Every one resolves from live data and returns `[]` on failure rather than defending with a hardcoded Every one resolves from live data and returns `[]` on failure rather than defending with a hardcoded
list that will be wrong. A source that refuses degrades its field to free text with a warning and list that will be wrong. A source that refuses degrades its field to free text with a warning and
@@ -738,7 +805,7 @@ cleanly, reads back cleanly, and does nothing at all, and neither core nor revie
| `rust.kit.entitle` | `change` | `ledger` | R16 — grants the kit's `RequiredPermission`; revert revokes | | `rust.kit.entitle` | `change` | `ledger` | R16 — grants the kit's `RequiredPermission`; revert revokes |
| `rust.prefab.place` | `change` | `ledger` | §H's verb; revert kills the entity, and needs the persisted ownership registry ch. 4 describes | | `rust.prefab.place` | `change` | `ledger` | §H's verb; revert kills the entity, and needs the persisted ownership registry ch. 4 describes |
| `rust.announce` | `notify` | `none` | via PopupNotifications (R6) — global or targeted | | `rust.announce` | `notify` | `none` | via PopupNotifications (R6) — global or targeted |
| `rust.zone.open` | `change` | `ledger` | §H's other verb; needs a zone plugin, so it belongs in the optional tier (R15) rather than the base | | `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17**`CreateOrUpdateTemporaryZone` takes a `Plugin owner` and `EraseTemporaryZone` is scoped to it, so the undo is real |
**Rewards are not a contract member.** `EVENTS.md` deleted a `registerEventRewards` registry because **Rewards are not a contract member.** `EVENTS.md` deleted a `registerEventRewards` registry because
it carried four Ultima Online nouns inside a core signature. A reward here is an ordinary action — it carried four Ultima Online nouns inside a core signature. A reward here is an ordinary action —