docs(modules): the module-rust plan — 18 decisions of record and a 21-phase schedule #249
@@ -1,8 +1,7 @@
|
|||||||
# `module-rust` — the plan
|
# `module-rust` — the plan
|
||||||
|
|
||||||
**Status:** approved in outline 2026-09-15, not started. **Fourteen decisions of record**; two
|
**Status:** approved in outline 2026-09-15, not started. **Sixteen decisions of record, no open
|
||||||
questions deferred by choice (§3). Audited against the whole contract, not just the game-facing
|
questions.** Audited against the whole contract, not just the game-facing chapters (§7).
|
||||||
chapters (§7).
|
|
||||||
|
|
||||||
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
|
||||||
@@ -219,8 +218,12 @@ including the three things the event work actually needs:
|
|||||||
It also **raises `OnKitRedeemed(BasePlayer player, string kitName)`**, which the bridge can listen on
|
It also **raises `OnKitRedeemed(BasePlayer player, string kitName)`**, which the bridge can listen on
|
||||||
to report a redemption as an ordinary event, whoever triggered it.
|
to report a redemption as an ordinary event, whoever triggered it.
|
||||||
|
|
||||||
**Two traps in `GiveKit` that phase 13 must handle, both found by reading it rather than by reasoning
|
**Two traps in `GiveKit`, both found by reading it rather than by reasoning about it.**
|
||||||
about it.**
|
|
||||||
|
> **R16 moved these off the critical path.** The reward action no longer calls `GiveKit` — it grants
|
||||||
|
> the kit's `RequiredPermission` and the player redeems it themselves. Both traps are kept here
|
||||||
|
> because the second one is *why* R16 is the better design, and because anything that ever does call
|
||||||
|
> `GiveKit` directly — an admin "give this player a kit now" button, say — walks straight into them.
|
||||||
|
|
||||||
**`GiveKit` returns `null` on a failure path, and `null` is Oxide's idiom for "no opinion".** The
|
**`GiveKit` returns `null` on a failure path, and `null` is Oxide's idiom for "no opinion".** The
|
||||||
first line is `if (!player) return null;`. Everywhere else in this ecosystem a null return means
|
first line is `if (!player) return null;`. Everywhere else in this ecosystem a null return means
|
||||||
@@ -232,11 +235,14 @@ a success.**
|
|||||||
|
|
||||||
**`GiveKit` takes a `BasePlayer`, so the player must be connected.** There is no offline grant in
|
**`GiveKit` takes a `BasePlayer`, so the player must be connected.** There is no offline grant in
|
||||||
this API. An event that rewards participants at two in the morning rewards only whoever is online at
|
this API. An event that rewards participants at two in the morning rewards only whoever is online at
|
||||||
that moment, silently. Phase 13 has to choose: accept online-only and say so in the action's
|
that moment, silently. The choice this forced was: accept online-only and say so in the action's
|
||||||
description, or keep our own persisted pending-grant queue in the bridge and redeem it on next
|
description, or keep a persisted pending-grant queue in the bridge and redeem it on next connect —
|
||||||
connect. **The queue is the honest answer and it is not free** — it is a second at-most-once store
|
a second at-most-once store with its own idempotency, which is not free.
|
||||||
with its own idempotency, which is exactly the machinery chapter 4 says to persist in the world save.
|
|
||||||
Decide it deliberately at phase 13 rather than discovering it from a complaint.
|
**R16 took a third option and it is the right one: stop granting items.** Grant the entitlement
|
||||||
|
instead. An entitlement waits without a queue, because waiting is what an entitlement does. The
|
||||||
|
problem was not hard to solve — it was the wrong problem, produced by an action declared around the
|
||||||
|
wrong noun.
|
||||||
|
|
||||||
`PopupNotifications` is small and does exactly one thing: `CreatePopupNotification(string message,
|
`PopupNotifications` is small and does exactly one thing: `CreatePopupNotification(string message,
|
||||||
BasePlayer player = null, float duration = 0f)`, where a null player makes it global. That is the
|
BasePlayer player = null, float duration = 0f)`, where a null player makes it global. That is the
|
||||||
@@ -365,21 +371,82 @@ 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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
**optional** integrations: each detects its plugin through `[PluginReference]`, degrades cleanly to
|
||||||
|
absent, and adds something the site already knows how to compute.
|
||||||
|
|
||||||
|
The first named member is **[BetterChat](https://umod.org/plugins/better-chat)** (LaserHydra, 5.2.15,
|
||||||
|
MIT, Universal/Covalence, ~200k downloads) — *"manage chat groups, customize colors, and add
|
||||||
|
titles"*. The motivating case is **titles earned from leaderboards**: top of the wipe's kill board
|
||||||
|
gets a tag in chat.
|
||||||
|
|
||||||
|
**Its integration point is a pull, not a push, and that is why it is a good first member.**
|
||||||
|
`API_RegisterThirdPartyTitle(Plugin plugin, Func<IPlayer, string> titleGetter)` registers a callback,
|
||||||
|
and BetterChat invokes it per player when it renders a chat line. So a leaderboard title is a *pure
|
||||||
|
function of state we already hold* — nothing is written into BetterChat, nothing can go stale, and
|
||||||
|
there is no drift to reconcile. Contrast R2, which is a push and needs a whole reconcile story.
|
||||||
|
|
||||||
|
**The one trap, and it is chapter 4's rule applied to somebody else's callback:** that getter runs
|
||||||
|
**synchronously on the chat path**. It must be a cheap in-memory lookup — never a socket call, never
|
||||||
|
a database query, never anything that can block. A title that costs a round trip is a chat message
|
||||||
|
that costs a round trip.
|
||||||
|
|
||||||
|
Its other two API methods, `API_AddGroup(group)` and `API_SetGroupField(group, field, value)`, pair
|
||||||
|
naturally with R2: the site already authors permission groups, so a site-authored group can carry a
|
||||||
|
chat colour and tag. That is a push and would need the same drift posture R2 has; it is a phase-17
|
||||||
|
decision, not a given.
|
||||||
|
|
||||||
|
**The tier is open-ended by design.** Other integrations get added as they prove useful, and the bar
|
||||||
|
for each is the one this plan applies everywhere: it must fulfil the contract — declare honestly,
|
||||||
|
degrade to absent, and never make the module's own surfaces depend on something that may not be
|
||||||
|
installed.
|
||||||
|
|
||||||
|
### R16 — the reward action grants the RIGHT to redeem, not the items
|
||||||
|
|
||||||
|
**Decided 2026-09-15 (org lead).** An event reward does **not** call `GiveKit`. It grants the
|
||||||
|
**permission that gates a kit**, and the player redeems it themselves in game, whenever they next
|
||||||
|
log in.
|
||||||
|
|
||||||
|
**Kits already has exactly this model built in**, which is what makes it cheap: every kit carries a
|
||||||
|
`RequiredPermission`, `GiveKit`'s own path checks it before handing anything over, and the in-game
|
||||||
|
kit menu renders a kit the player lacks the permission for as locked rather than hiding it.
|
||||||
|
`GetKitInfo` returns that permission under `["permission"]`, so the module can read which kits are
|
||||||
|
gated and which are open to everyone.
|
||||||
|
|
||||||
|
This is a better design than the one it replaces, and it is worth being explicit about how much it
|
||||||
|
removes:
|
||||||
|
|
||||||
|
- **The offline-grant problem disappears entirely.** `GiveKit` needed a connected `BasePlayer`, so an
|
||||||
|
event firing at two in the morning rewarded only whoever happened to be online. An entitlement
|
||||||
|
waits. **This closes the open question §3 carried** — no pending-grant queue, no second
|
||||||
|
at-most-once store, none of it.
|
||||||
|
- **It is the same machinery as R2, not a second mechanism.** A reward becomes a permission grant
|
||||||
|
authored by the site and mirrored into Oxide — the thing phase 7 already builds. One permission
|
||||||
|
authority, one drift story, one audit trail.
|
||||||
|
- **`reversible: 'ledger'` becomes honest**, where a direct grant could only ever be `'none'`. See
|
||||||
|
*"What phase 13 must declare honestly"* below.
|
||||||
|
- **The player gets agency.** They redeem when they want it, where they want it, with Kits' own
|
||||||
|
cooldown and use limits still applying — rather than having items appear in their inventory,
|
||||||
|
possibly while they are somewhere it is a liability.
|
||||||
|
|
||||||
|
**One design note the option source has to carry.** A kit with an **empty** `RequiredPermission` is
|
||||||
|
open to everybody, so granting a permission for it rewards nobody with anything. The authoring form's
|
||||||
|
kit dropdown must surface which kits are permission-gated and refuse — or at minimum warn loudly —
|
||||||
|
on one that is not. That is a real refusal with a real reason, and exactly what R3's envelope is for.
|
||||||
|
|
||||||
## 3. Open questions
|
## 3. Open questions
|
||||||
|
|
||||||
The fourteen decisions above close every question the contract audit raised. Two remain, both
|
**None.** Both questions this section carried were closed on 2026-09-15.
|
||||||
deliberately deferred rather than unanswered:
|
|
||||||
|
|
||||||
**Clans is in the base set *and* the Team provider reads first-party clans.** Not in conflict — the
|
*Clans in the base set while the Team provider reads first-party* was confirmed as the intended
|
||||||
plugin is installed because a community wants alliances and clan chat, while core's Teams are fed
|
reading: complementary, not in conflict — the plugin is installed for alliances and clan chat, the
|
||||||
from the first-party system that actually publishes membership transitions (R5). **Still an
|
provider is fed from the first-party system that actually publishes membership transitions (R5).
|
||||||
interpretation rather than something stated.**
|
|
||||||
|
|
||||||
**Offline reward grants.** `GiveKit` requires a connected `BasePlayer` (R6). Whether the reward phase
|
*Offline reward grants* was dissolved rather than answered. **R16 changed the noun**: the reward
|
||||||
accepts online-only or builds a persisted pending-grant queue is a real decision with real cost, and
|
action grants an entitlement instead of items, and an entitlement does not need the player to be
|
||||||
it belongs in that phase with the code in front of it.
|
online. The persisted pending-grant queue that question was weighing is not needed at all.
|
||||||
|
|
||||||
`D:\rust\oxide\plugins\` is **empty**, so the whole base set is a fresh install in phase 0.
|
|
||||||
|
|
||||||
## 4. The test rig
|
## 4. The test rig
|
||||||
|
|
||||||
@@ -433,11 +500,11 @@ Each phase ends with its findings written down, as every workstream here does.
|
|||||||
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook | Module-Rust + docs | An operator turns on a rule, edits a body, and a wipe announcement reaches the right people and nobody else |
|
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook | Module-Rust + docs | An operator turns on a rule, edits a body, and a wipe announcement reaches the right people and nobody else |
|
||||||
| 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
|
| 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
|
||||||
| 12 | **Events: one budget, one verified lease.** [kit][kit] ch. 5's own ordering — the lease before the action | Module-Rust + 2 | The leased value is observed changing in the running game and restored, per key |
|
| 12 | **Events: one budget, one verified lease.** [kit][kit] ch. 5's own ordering — the lease before the action | Module-Rust + 2 | The leased value is observed changing in the running game and restored, per key |
|
||||||
| 13 | **Events: the Kits reward action** (R3, R6). Includes the offline-grant decision (§3) | all 3 | A retried step grants loot once, and the ledger and the world agree |
|
| 13 | **Events: the Kits reward action** (R3, R16). Grants the kit's `RequiredPermission`, not the items; `reversible: 'ledger'`; the option source flags kits with no permission gate | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it |
|
||||||
| 14 | **The live map** (R9). The map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it |
|
| 14 | **The live map** (R9). The map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it |
|
||||||
| 15 | **Android leg D** (R10). Map and events | Android-app | The map renders on a phone with the same layer gates |
|
| 15 | **Android leg D** (R10). Map and events | Android-app | The map renders on a phone with the same layer gates |
|
||||||
| 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel |
|
| 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel |
|
||||||
| 17 | **Optional mod integrations.** The uMod **Clans** adapter first — alliances and clan chat, beside the provider rather than under it (R5) — then others, each detecting via `[PluginReference]` and degrading to absent | Rust-Plugins + docs | A server missing every optional mod still runs the module, Teams included |
|
| 17 | **Optional mod integrations** (R15). **BetterChat** first — leaderboard titles through `API_RegisterThirdPartyTitle`, a pull with no drift — then the uMod **Clans** adapter (alliances and clan chat, beside the provider rather than under it, R5), then others as they prove useful | Rust-Plugins + Module-Rust + docs | A server missing every optional mod still runs the module, Teams included |
|
||||||
| 18 | **The installer** (R4). `--game servuo|rust`, the bundle payload as a variant, an Oxide prerequisite check in `doctor`, the protocol pairing refusal carried over | installer + docs | An operator sets a Rust server up with the released binary and nothing hand-copied |
|
| 18 | **The installer** (R4). `--game servuo|rust`, the bundle payload as a variant, an Oxide prerequisite check in `doctor`, the protocol pairing refusal carried over | installer + docs | An operator sets a Rust server up with the released binary and nothing hand-copied |
|
||||||
| 19 | **Docs, kit feedback, cutover.** `docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change); and the Integration-kit question R2 raised | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is answered either way |
|
| 19 | **Docs, kit feedback, cutover.** `docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change); and the Integration-kit question R2 raised | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is answered either way |
|
||||||
|
|
||||||
@@ -455,19 +522,40 @@ Reversible on request.
|
|||||||
|
|
||||||
### What phase 13 must declare honestly
|
### What phase 13 must declare honestly
|
||||||
|
|
||||||
**A kit grant cannot be `reversible: 'ledger'`.** That value is a promise that core may come back and
|
> **Rewritten 2026-09-15 by R16.** This section previously argued that a kit reward could only be
|
||||||
have the thing undone, on every terminal path including an abort — and there is no honest way to
|
> `reversible: 'none'`, because there is no honest way to un-grant loot a player has already spent.
|
||||||
un-grant loot a player has already spent. The correct declaration is `reversible: 'none'`, and saying
|
> That was correct *about a direct grant* and R16 stopped doing direct grants. The reasoning is kept
|
||||||
so is the point: a capability that claims a reversal it cannot perform is the "capability that lies"
|
> below in its corrected form because the shape of the mistake is the reusable part: **the action was
|
||||||
chapter 5 names, and neither core nor review can catch it.
|
> declared around the wrong noun.** What the event makes is not loot; it is an entitlement.
|
||||||
|
|
||||||
`cost()` must count the kits actually granted, derived from the params, every time. Core prices
|
**The reward action grants an entitlement, and an entitlement is reversible.** `revert` revokes the
|
||||||
`cost` before dispatch and never reconciles it against what came back — it cannot, it does not know
|
permission, removing one that is not there is a success, and it is idempotent by construction — so
|
||||||
what a kit is — so an action that reports one while granting twelve turns an operator's cap of 30
|
**`reversible: 'ledger'` is the honest declaration**, and core's ledger sweep does real work on every
|
||||||
into a cap of 360 with nothing anywhere going red.
|
terminal path.
|
||||||
|
|
||||||
And the monthly wipe is the case `revert`'s tolerance rule was written for: every ledgered resource
|
One consequence to state rather than discover: **a player who redeemed before the revert keeps the
|
||||||
is invalidated at once, and **"gone, and that is fine" is a success**, not a failure.
|
items.** That is correct and not a hole. The ledgered resource is the *grant*, and reverting it
|
||||||
|
withdraws the entitlement rather than the consumption — the same way cancelling a coupon does not
|
||||||
|
un-eat the meal. An operator reading the run console should see that distinction in the wording.
|
||||||
|
|
||||||
|
**`cost()` counts permission grants, and unlike a kit count it is exactly knowable before dispatch.**
|
||||||
|
That removes the whole class of problem chapter 5 §4 warns about: there is no "declare the maximum
|
||||||
|
because you cannot know until the answer comes back". One recipient is one grant. Core prices `cost`
|
||||||
|
before dispatch and never reconciles it, so being able to count precisely is worth more than it
|
||||||
|
sounds.
|
||||||
|
|
||||||
|
**And the idempotency key largely stops mattering here**, which is the tidiest part of R16. Chapter 5
|
||||||
|
§2 draws the line itself: *"A key is for a write whose repetition would be a second EFFECT —
|
||||||
|
creating, granting, announcing. A write that SETS a value to X is idempotent by its own nature."* A
|
||||||
|
permission grant is a set. Pass the key through anyway — it costs nothing and it is what the
|
||||||
|
contract expects — but the failure mode it exists to prevent, a socket hiccup producing a second set
|
||||||
|
of everything, no longer has a way to happen.
|
||||||
|
|
||||||
|
The monthly wipe is still the case `revert`'s tolerance rule was written for: if a wipe or a rebuilt
|
||||||
|
host clears Oxide's permission store, every ledgered grant is invalidated at once and **"gone, and
|
||||||
|
that is fine" is a success**. Note this is also where R2 pays for itself twice — the site re-pushes
|
||||||
|
its whole permission set on the next connect, so an entitlement an event granted comes *back* rather
|
||||||
|
than being quietly lost.
|
||||||
|
|
||||||
## 6. Risks worth naming now
|
## 6. Risks worth naming now
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user