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:
@@ -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 —
|
||||||
|
|||||||
Reference in New Issue
Block a user