diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 8a7ddef..a63ee0b 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1,8 +1,7 @@ # `module-rust` — the plan -**Status:** approved in outline 2026-09-15, not started. **Fourteen decisions of record**; two -questions deferred by choice (§3). Audited against the whole contract, not just the game-facing -chapters (§7). +**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). 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 @@ -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 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 -about it.** +**Two traps in `GiveKit`, both found by reading it rather than by reasoning 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 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 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 -description, or keep our own persisted pending-grant queue in the bridge and redeem it on next -connect. **The queue is the honest answer and it is not free** — it is a second at-most-once store -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. +that moment, silently. The choice this forced was: accept online-only and say so in the action's +description, or keep a persisted pending-grant queue in the bridge and redeem it on next connect — +a second at-most-once store with its own idempotency, which is not free. + +**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, 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 `/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 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 -The fourteen decisions above close every question the contract audit raised. Two remain, both -deliberately deferred rather than unanswered: +**None.** Both questions this section carried were closed on 2026-09-15. -**Clans is in the base set *and* the Team provider reads first-party clans.** Not in conflict — the -plugin is installed because a community wants alliances and clan chat, while core's Teams are fed -from the first-party system that actually publishes membership transitions (R5). **Still an -interpretation rather than something stated.** +*Clans in the base set while the Team provider reads first-party* was confirmed as the intended +reading: complementary, not in conflict — the plugin is installed for alliances and clan chat, the +provider is fed from the first-party system that actually publishes membership transitions (R5). -**Offline reward grants.** `GiveKit` requires a connected `BasePlayer` (R6). Whether the reward phase -accepts online-only or builds a persisted pending-grant queue is a real decision with real cost, and -it belongs in that phase with the code in front of it. - -`D:\rust\oxide\plugins\` is **empty**, so the whole base set is a fresh install in phase 0. +*Offline reward grants* was dissolved rather than answered. **R16 changed the noun**: the reward +action grants an entitlement instead of items, and an entitlement does not need the player to be +online. The persisted pending-grant queue that question was weighing is not needed at all. ## 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 | | 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 | -| 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 | | 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 | -| 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 | | 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 -**A kit grant cannot be `reversible: 'ledger'`.** That value is a promise that core may come back and -have the thing undone, on every terminal path including an abort — and there is no honest way to -un-grant loot a player has already spent. The correct declaration is `reversible: 'none'`, and saying -so is the point: a capability that claims a reversal it cannot perform is the "capability that lies" -chapter 5 names, and neither core nor review can catch it. +> **Rewritten 2026-09-15 by R16.** This section previously argued that a kit reward could only be +> `reversible: 'none'`, because there is no honest way to un-grant loot a player has already spent. +> That was correct *about a direct grant* and R16 stopped doing direct grants. The reasoning is kept +> below in its corrected form because the shape of the mistake is the reusable part: **the action was +> 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 -`cost` before dispatch and never reconciles it against what came back — it cannot, it does not know -what a kit is — so an action that reports one while granting twelve turns an operator's cap of 30 -into a cap of 360 with nothing anywhere going red. +**The reward action grants an entitlement, and an entitlement is reversible.** `revert` revokes the +permission, removing one that is not there is a success, and it is idempotent by construction — so +**`reversible: 'ledger'` is the honest declaration**, and core's ledger sweep does real work on every +terminal path. -And the monthly wipe is the case `revert`'s tolerance rule was written for: every ledgered resource -is invalidated at once, and **"gone, and that is fine" is a success**, not a failure. +One consequence to state rather than discover: **a player who redeemed before the revert keeps the +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