docs(modules): R15 the optional tier with BetterChat, R16 rewards grant the right to redeem
R16 changes a design rather than adding to it. The reward action no longer calls GiveKit; it grants the permission that GATES a kit, and the player redeems it themselves in game. Kits already has exactly this model built in - every kit carries a RequiredPermission, GiveKit's own path checks it, the in-game menu renders an ungated kit as locked rather than hiding it, and GetKitInfo returns the permission so the module can read which kits are gated. What that removes is most of the hard part. The offline-grant problem disappears. GiveKit needed a connected BasePlayer, so an event firing at 2am rewarded only whoever was online; an entitlement waits. That CLOSES the open question section 3 carried - no pending-grant queue, no second at-most-once store. It is the same machinery as R2 rather than a second mechanism: a reward becomes a permission grant authored by the site and mirrored into Oxide, which 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'. revert revokes the permission, removing one that is not there is a success, and it is idempotent by construction. A player who redeemed before the revert keeps the items, and that is correct: the ledgered resource is the GRANT, so reverting withdraws the entitlement rather than the consumption. cost() counts grants and is exactly knowable before dispatch, which removes the whole declare-the-maximum-because-you-cannot-know class of problem from chapter 5 section 4. And the idempotency key largely stops mattering: chapter 5 draws the line itself - a key is for a write whose repetition would be a second EFFECT, and a permission grant is a SET. The earlier section arguing a kit grant can only be reversible: none is rewritten rather than deleted, with the correction stated: it was right about a direct grant, and the reusable part is that the action had been declared around the wrong noun. What the event makes is not loot, it is an entitlement. One design note carried into the option source: a kit with an EMPTY RequiredPermission is open to everybody, so granting a permission for it rewards nobody. The dropdown must surface which kits are gated and refuse or warn on one that is not. R15 opens an optional-integration tier, with BetterChat (LaserHydra, 5.2.15, MIT, Universal) as the first member, for leaderboard-earned chat titles. Its integration point is a PULL - API_RegisterThirdPartyTitle registers a callback BetterChat invokes per player - so a title is a pure function of state we already hold, with nothing written into it and no drift to reconcile. The trap is chapter 4's rule applied to somebody else's callback: that getter runs synchronously on the chat path and must be a cheap in-memory lookup, never a socket call. Its API_AddGroup and API_SetGroupField pair naturally with R2's site-authored groups, but that direction is a push and would need R2's drift posture, so it is a phase 17 decision rather than a given. Section 3 is now empty. Clans-in-the-base-set was confirmed complementary, and the offline-grant question was dissolved rather than answered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -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