diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 7e6596b..4a833d2 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1,7 +1,8 @@ # `module-rust` — the plan -**Status:** approved in outline 2026-09-15, not started. **Sixteen decisions of record, no open -questions.** Audited against the whole contract, not just the game-facing chapters (§7). +**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); 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. 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 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 | |---|---|---|---| | [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` | | [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 `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 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. @@ -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 `/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)`** | 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 **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 | |---|---|---|---| -| 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 | | 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 | @@ -682,7 +748,7 @@ its reasoning as the starting point rather than inventing a parallel set. | Dimension | Counts | |---|---| | `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.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.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.zones` | ZoneManager `GetZoneIDs` / `GetZoneName` (R17) | 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 @@ -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.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.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 it carried four Ultima Online nouns inside a core signature. A reward here is an ordinary action —