Every question the contract audit raised was answered the same day. Eight new decisions of record, and the schedule goes from twelve phases to twenty. R7 the notifications and engagement set ships in v1 - streams, triggers, audiences, seeds, announce leg, post hook - as ONE phase because they are a matched set in the template rather than three independent gaps. The three rules it lives on: ceiling is required and is not a ladder (a staff ceiling does not permit owner, because one person for a cheat-detection event is the player it was detected on); subjectKey must name a declared variable or every subject shares undefined; and a rule group is offered once, so a rule appended to an existing group reaches fresh installs only. R8 multi-server from the start - the dry run's finding 2 taken at face value. R12 per-wipe detail plus all-time rollups, with the truncation as a runtime route and never a schema statement, since the fragment replays every boot. R14 /rust on all three tiers, chosen deliberately because prefixes share one namespace with core's and the collision probe cannot see core's root-mounted endpoints. R13 two extension slots. R11 a small read-only slash command set, with ephemerality fixed at the deferral so a refusal must defer ephemeral. R9 is the one that split in two. The map IMAGE is static content on the game host regenerated only on a wipe - the ch.3 2b case, so request/reply, one in flight, two stages, its own derivation version, and no import on boot - while everything moving on it is live state down the ordinary read path. Every layer is an operator switch, and that is a security boundary rather than a preference: public player positions in Rust locate players and let anyone infer base positions. Default is monuments and world events public, players and bases admin-only. This is the ONLY asset-bridge work in scope; item icons and the 2,590 skin ids stay out of v1. R10 the Android app is in this workstream, deciding its screens from /api/v1/public/modules capabilities, with each leg trailing the website surface it consumes by one phase. Records the two endpoints that are not this and are easy to confuse with it: /api/v1/public/status is site mode plus a version block, and /api/health is an internal liveness probe. Section 7 now carries the phase each previously-unplanned element lands in, and the honest cost: twelve phases, from reading the chapters that describe the game bridge and treating the module as the thin part when the kit says in its first paragraph that the module is most of the work.
576 lines
38 KiB
Markdown
576 lines
38 KiB
Markdown
# `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).
|
||
|
||
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
|
||
run's four *findings* still stand, and one of them (identity) has moved from a footnote to the
|
||
critical path. §2 says why.
|
||
|
||
Nothing here is normative. [`MODULE_API.md`](../../website/MODULE_API.md) is the contract,
|
||
[`MODULE_SYSTEM.md`](../../website/MODULE_SYSTEM.md) the system, [`EVENTS.md`](../../website/EVENTS.md)
|
||
the event design of record, and the [Integration Kit][kit] is the teaching text this plan follows
|
||
chapter by chapter. This document is a *schedule and a set of decisions*, not a specification.
|
||
|
||
---
|
||
|
||
## 1. The shape, and what is already settled
|
||
|
||
Three new repositories, mirroring the three the platform already has for Ultima Online, plus the
|
||
optional fourth part that lives inside the module:
|
||
|
||
| Part | Repo | Mirrors | What it is |
|
||
|---|---|---|---|
|
||
| Website module | [`RunicGateway/Module-Rust`](https://gitea.whitlocktech.com/RunicGateway/Module-Rust) | `Module-uo` | Routes, schema fragment, prebuilt client chunk, nav |
|
||
| Sidecar | [`RunicGateway/Rust-Link`](https://gitea.whitlocktech.com/RunicGateway/Rust-Link) | `link` | Owns the game connection and the durable copy |
|
||
| Oxide bridge plugin | [`RunicGateway/Rust-Plugins`](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins) | `servuo-plugins` | C# inside the game, dials out, never blocks |
|
||
| *(event capability)* | — | — | Declarations inside `Module-Rust` ([kit][kit] ch. 5) |
|
||
|
||
All three were created empty on **2026-09-15**. The module is built from
|
||
[`integration-kit/template/`][kit], which CI holds against a pinned core — currently
|
||
`MODULE_API_VERSION` **1.10.0**.
|
||
|
||
**The repository name is not the module id.** `Module-uo` ships a module whose `id` is `uo`; this one
|
||
ships `rust`. §2.1 of the contract requires `id` to equal the directory core loads it from, which is
|
||
`modules/rust/`, and it is the prefix of every table and every mount — so the capitalisation in the
|
||
repository name reaches nothing inside the bundle.
|
||
|
||
Settled before this document and unchanged by it (dry run, org lead, 2026-08-19):
|
||
|
||
- **One server, one sidecar**, on that server's own host. A community with six servers runs six
|
||
pairs; the module holds six clients and core never learns there is more than one.
|
||
- **The plugin dials out.** Rust's server is a binary, so the way in is Oxide's published hook API
|
||
rather than source — and the shard-dials-out invariant survives that change of footing unchanged.
|
||
- **No RCON.** It was the original design and it was overruled.
|
||
- **`wipe_id` on every table that holds gameplay data.** It is the whole shape of the game in one
|
||
column, and it is the first thing a UO-shaped mental model gets wrong.
|
||
|
||
## 2. Decisions of record
|
||
|
||
### R1 — identity is an in-game link code for v1
|
||
|
||
**Decided 2026-09-15 (org lead).** A player proves account ownership by typing a command in-game; the
|
||
plugin issues a one-time code; the website confirms it through the sidecar. Exactly the shape
|
||
`module-uo` uses.
|
||
|
||
This is the dry run's finding 1 answered for now rather than closed. There is still **no
|
||
`registerAuthProvider`** in the contract at 1.10.0 — verified against `MODULE_API.md` §2.4 on
|
||
2026-09-15 — so "Sign in with Steam", which every Rust community expects, is not reachable from a
|
||
module today. The link code is one screen worse and needs no core change, so it is what v1 ships.
|
||
|
||
**What changed since the dry run is the stakes, not the options.** The dry run rated this survivable
|
||
because the module only *read*: a site that renders a leaderboard does not need to know which account
|
||
owns a Steam ID. R2 makes the site the author of who may do what in the game, and R3 makes it the
|
||
thing that hands out loot. Both are grants against a Steam ID. **A weak identity link is now a
|
||
privilege-escalation path, not a missing convenience** — so the code must be single-use,
|
||
short-lived, rate-limited, and issued in-game to the player who will own it.
|
||
|
||
Adding `registerAuthProvider` properly stays the first candidate for a future `MODULE_API` bump. It
|
||
participates in session creation, which is the one part of core a module must never be able to
|
||
weaken, and it must inherit core's existing policy: **SSO is link-only, identities are never
|
||
auto-provisioned.** Specified deliberately, not bolted on. It is out of scope here.
|
||
|
||
### R2 — site-authored permissions are mirrored into Oxide's own permission store
|
||
|
||
**Decided 2026-09-15 (org lead).** The website is the author of record for groups and grants. The
|
||
bridge plugin applies them through Oxide's own API (`permission.GrantUserPermission` /
|
||
`RevokeUserPermission`), so **Oxide is an enforcement cache and the site is the thing that
|
||
remembers.**
|
||
|
||
The alternative — the plugin keeping a private table only our own features consult — was rejected
|
||
because it cannot reach any third-party plugin, and reaching them is the point: a grant authored on
|
||
the site has to gate Kits.
|
||
|
||
Three properties fall out, and they are the reason this shape is worth its cost:
|
||
|
||
- **Every third-party plugin honours site-authored grants with no adapter**, because they all already
|
||
call `permission.UserHasPermission`.
|
||
- **A wipe stops being a data-loss event for permissions.** The game forgets; the site does not, and
|
||
re-pushes the whole set on the next connect.
|
||
- **Hand edits are reported, not overwritten.** Somebody typing `oxide.grant` at the console is
|
||
drift, and drift is surfaced to an operator — the same posture a lease's `restore()` takes when it
|
||
finds a value a human has moved ([kit][kit] ch. 5).
|
||
|
||
**This is a direction the Integration Kit has no chapter for, and that is a finding.** Chapters 3 and
|
||
4 are the read path — data leaving the game. Chapter 5 is one-shot commands with a ledger and a
|
||
teardown. This is neither: it is *continuously reconciled state where the website is authoritative*,
|
||
and its nearest relative in the contract is the Team provider **inverted** — instead of core asking
|
||
the module what the game knows, the module tells the game what the site knows. The mechanism it
|
||
borrows is chapter 4's: **every board's current state has exactly one producer, and it runs on
|
||
connect**, pointed the other way. Whether this deserves a sixth chapter is a question for phase 19,
|
||
after it has been built once.
|
||
|
||
### R3 — the Kits reward action registers always and refuses with a reason
|
||
|
||
**Decided 2026-09-15 (org lead).** Kits is required for event rewards, but "required" means the
|
||
action always exists and fails honestly where it cannot work — `{ ok: false, retry: false, error:
|
||
'Kits plugin not installed' }` — rather than vanishing from the authoring form or refusing to boot
|
||
the module.
|
||
|
||
Two details from [kit][kit] ch. 5 that this depends on and are easy to get wrong:
|
||
|
||
- **The reason must be in `error`.** Core reads exactly `ok`, `retry` and `error` off a failure
|
||
envelope; a message under any other name is dropped and the operator sees a bare
|
||
`"<action id> refused"`.
|
||
- **`retry: false` has to be reachable.** Core's dispatcher enforces `budgetMs` and classifies a
|
||
budget timeout as retry *unconditionally* — so if the sidecar client's timeout is longer than
|
||
`budgetMs`, our own `retry: false` is unreachable code. Derive one constant from the other and
|
||
assert the inequality in a test. The first module this project shipped had exactly that pairing.
|
||
|
||
### R4 — Rust reaches an operator through the existing installer, behind `--game`
|
||
|
||
**Decided 2026-09-15 (org lead).** Not a second binary and not a shared-core refactor: the shipped
|
||
[`installer`](../../installer/PLAN.md) grows a game dimension, `--game servuo|rust`, and keeps one
|
||
release stream, one `doctor`, one `update`, one `uninstall`.
|
||
|
||
The shape of the work is set by where the coupling already is. **The reusable half is already
|
||
game-agnostic** — `src/service.rs` and `src/main.rs` mention ServUO zero times, and `net.rs`,
|
||
`diff.rs`, `paths.rs` and `ui.rs` barely more. **The UO-specific half is concentrated in four files**
|
||
— `install.rs`, `doctor.rs`, `overlay.rs`, `tier.rs` — plus the bundle manifest, where
|
||
`OverlayComponent` and `ServUoCompat` name the game in the schema itself.
|
||
|
||
So the change is: make the bundle's game payload a **variant** rather than an overlay, and
|
||
`ServUoCompat` a per-game compat block. That is a schema change on the published **`bundles`**
|
||
branch, and it is the part to design before touching code.
|
||
|
||
**The Rust payload is much simpler than the UO one**, which is what makes this affordable: no source
|
||
tree to overlay, no `patches/`, no opt-in patch tier — a Rust install is a `.cs` file dropped into
|
||
`oxide/plugins/`, plus the sidecar and its service, which the installer already knows how to do. What
|
||
it gains instead is a **prerequisite check**: is Oxide installed, and is its build current enough.
|
||
That is `doctor`'s shape, not a new concept.
|
||
|
||
The protocol pairing check generalises unchanged. `servuo-plugins/overlay.toml` declares the protocol
|
||
the overlay speaks and the installer refuses to pair a disagreeing sidecar; `rust-plugins` needs the
|
||
same declaration under whatever name the variant gives it, and the refusal is the same refusal.
|
||
|
||
### R5 — Teams come from Rust's first-party clans; the uMod Clans plugin is the richer tier
|
||
|
||
**Decided 2026-09-15 (org lead).** Basic functionality is built on **Rust's own clan system**; the
|
||
third-party **uMod Clans plugin** is an optional layer for a much richer experience, and lands in
|
||
phase 17 rather than phase 9.
|
||
|
||
The distinction matters more than the names suggest, so it is worth stating precisely:
|
||
|
||
- **Rust's first-party clans** are seven hooks in [`HOOKS.md`](HOOKS.md) — `OnClanCreated`,
|
||
`OnClanDisbanded`, `OnClanMemberAdded`, `OnClanMemberKicked`, `OnClanMemberLeft`, plus colour and
|
||
logo — each handing you a `LocalClan`. **All seven are "no return behavior"**, which is exactly
|
||
what a read-only bridge wants: there is nothing to abstain from, so [kit][kit] ch. 4's
|
||
return-`null`-from-every-veto rule has no work to do here. They need no plugin, and the rig is
|
||
already running them (`D:\rust\server\server1\clans.287.db`).
|
||
- **Rust's first-party *Teams* are a different system** — twelve hooks, mostly vetoable — and are the
|
||
transient in-game squad, not the persistent organisation. They are **not** what core's Team
|
||
provider should be fed. Clans is the right choice and this is why.
|
||
- **The uMod Clans plugin's API is not in our mirror.** [`HOOKS.md`](HOOKS.md) is the *game's* 477
|
||
hooks; a plugin's own API is its own documentation. It is reached through `[PluginReference]` and
|
||
is null when absent, making it an R3-shaped dependency that must refuse with a reason.
|
||
|
||
**Reading that plugin's source settled R5 far more firmly than the reasoning above did, and in a
|
||
direction worth stating plainly: for Teams, the plugin is *worse* than first-party, not richer.**
|
||
`Clans` v0.2.10 (k1lly0u, MIT, 2,692 lines) publishes **fifteen `[HookMethod]`s and every one of
|
||
them is a mutation** — `CreateClan`, `JoinClan`, `LeaveClan`, `KickPlayer`, `PromotePlayer`,
|
||
`DemotePlayer`, `DisbandClan`, and the eight alliance verbs. **There is no read API whatsoever**: no
|
||
`GetClan`, no `GetClanOf`, no `GetClanMembers`, no `GetAllClans`. And it raises exactly **three**
|
||
hooks — `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — **none of which is a membership
|
||
transition**.
|
||
|
||
So it cannot answer any of core's three provider questions from its published surface, while
|
||
first-party clans answer all three. Feeding the Team provider from first-party clans is therefore
|
||
**permanent, not a first step**.
|
||
|
||
What the plugin genuinely adds is **alliances and clan/alliance chat** — richer in *features*, not in
|
||
roster data. That is what phase 17's adapter surfaces, and it sits beside the Team provider rather
|
||
than under it.
|
||
|
||
One route is deliberately not taken: the plugin persists to its own files under `oxide/data/`, and a
|
||
determined integration could read those directly. **That is reaching into another plugin's private
|
||
storage, not using an API** — it breaks without warning on any upstream refactor and is not a
|
||
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
|
||
|
||
**Named 2026-09-15 (org lead).** All three are MIT, all by the same author, 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` |
|
||
|
||
Every one has a direct `.cs` download, so phase 0's install step is three `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.
|
||
|
||
`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.
|
||
|
||
**Kits is the opposite of Clans and is a genuinely good dependency.** Twenty-three `[HookMethod]`s,
|
||
including the three things the event work actually needs:
|
||
|
||
- `GiveKit(BasePlayer player, string name)` — the reward action's call;
|
||
- `GetKitNames(List<string>)` / `GetAllKits()` — **the option source** for the authoring form, so an
|
||
operator picks a kit from the live server instead of typing an identifier from memory;
|
||
- `GetKitInfo` / `KitDescription` / `KitImage` / `KitMax` / `KitCooldown` /
|
||
`GetPlayerKitUses` / `GetPlayerKitCooldown` — form metadata and per-player eligibility.
|
||
|
||
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.**
|
||
|
||
**`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
|
||
*abstain*, so the reflex — treat null as fine — reports a reward as delivered when there was no
|
||
player to deliver it to. The success value is the literal `true`; a refusal is a **message string**,
|
||
which drops straight into chapter 5's `error` field. So the mapping is
|
||
`result is bool ok && ok` for success, a string for a refusal reason, and **`null` is a failure, not
|
||
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.
|
||
|
||
`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
|
||
server-side notification surface, and it needs no more than that.
|
||
|
||
**The one gap to design around:** the seven first-party hooks carry created, disbanded, added,
|
||
kicked and left — but **no promote or leader-changed event**. Core's provider requires
|
||
`getTeamLeaders`, so leadership is read off `LocalClan` at snapshot time rather than tracked from
|
||
transitions. That makes phase 9 *partly* snapshot-driven where the dry run predicted it would be
|
||
fully event-driven — a small correction to that document, recorded here rather than silently.
|
||
|
||
### R7 — the notifications and engagement set ships in v1
|
||
|
||
**Decided 2026-09-15 (org lead).** `registerNotificationStreams`, `registerEventTriggers`,
|
||
`registerAudiences` and `registerEngagementSeeds` are all in the first release, plus
|
||
`registerAnnounceLeg` and `registerPostHook`.
|
||
|
||
They are a **matched set**, which is why they are one decision and one phase: a trigger declares the
|
||
payload contract and the widest audience a rule on it may ever be given, an audience resolves *people*
|
||
over module data, and seeds ship the bodies and the rules that use them. Three rules from the kit
|
||
that this phase lives or dies on:
|
||
|
||
- **`ceiling` is required, has no default, and is not a ladder.** A `staff` ceiling does **not** permit
|
||
`owner`, because "one person" for a cheat-detection event is *the player it was detected on*. Fewer
|
||
people is not less exposure.
|
||
- **`subjectKey` must name one of your declared variables** — it is what the cooldown keys on. Core
|
||
refuses the module at boot if it names nothing, which is the good failure; the bad one it prevents
|
||
is every subject sharing `undefined`.
|
||
- **A rule group is offered once, per group key.** A rule appended to an existing group reaches
|
||
**fresh installs only** — so a rule that must reach existing deployments takes a new group key.
|
||
|
||
And the emit discipline: **emit on the transition, not on the poll.** Core's cooldown would hide a
|
||
module that emitted "the server is still up" as news.
|
||
|
||
### R8 — multi-server from the start
|
||
|
||
**Decided 2026-09-15 (org lead).** The server list is the landing page and everything else hangs
|
||
under `/rust/servers/:id`. Every gameplay row carries a server id as well as a `wipe_id`, and the
|
||
module holds one sidecar client per configured server.
|
||
|
||
This is the dry run's finding 2 taken at face value: *"one module, one game" is not the same as "one
|
||
module, one server"*. Retrofitting an `:id` segment through every route, table and page is the
|
||
expensive version, and a Rust community runs several servers by default.
|
||
|
||
### R9 — the live map, with every layer toggleable
|
||
|
||
**Decided 2026-09-15 (org lead).** The site offers a live map, and **each layer is an operator switch
|
||
set to public / players-only / admin-only** through the existing visibility framework
|
||
([`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md)).
|
||
|
||
**The map is two problems and they take different paths, which is the thing to get right before
|
||
building either:**
|
||
|
||
- **The map image is static content on the game host** — `proceduralmap.<size>.<seed>.<save>.map`
|
||
beside the world save, regenerated only on a wipe. That is exactly [kit][kit] ch. 3 §2b's case, and
|
||
it takes that shape: **request/reply, never events** (a sidecar that broadcast it would write
|
||
megabytes into its own store and fan them at every client), **one in flight with an explicit busy**,
|
||
**two stages — what exists, then what changed**, and a **derivation version** separate from the
|
||
protocol so improving how we read the file invalidates a cached image whose source hash did not
|
||
move. And **no import on boot**: a wipe is an event the operator knows about and the website does
|
||
not.
|
||
- **Everything moving on it is live state** down the ordinary read path — monuments, cargo ship,
|
||
patrol helicopter, airdrops, locked crates, and player positions.
|
||
|
||
**The layer switches are a security boundary, not a preference.** Public player positions in Rust are
|
||
a competitive-advantage leak — anyone, including people who do not play on the server, could locate
|
||
players and infer base positions. The default posture is monuments and world events public, player
|
||
and base layers admin-only, and an operator opening one up is a deliberate act with the consequence
|
||
stated on the switch.
|
||
|
||
**This is the only asset-bridge work in scope.** Item icons and the 2,590 workshop skin ids stay out
|
||
of v1; kill feeds and kit lists render as text.
|
||
|
||
### R10 — the Android app is in this workstream, capability-driven, trailing by one phase
|
||
|
||
**Decided 2026-09-15 (org lead).** Full Rust support in the app, not a degradation check. It decides
|
||
which screens to show from **`GET /api/v1/public/modules`** — each started module's `capabilities`
|
||
array — and each app leg lands **one phase after** the website surface it consumes is merged, so it
|
||
is always built against a real endpoint rather than a planned one.
|
||
|
||
Two endpoints that are *not* this and are easy to confuse with it, both checked on 2026-09-15:
|
||
`/api/v1/public/status` returns site mode plus a version block — the first-run probe and
|
||
version-mismatch guard, not a feature manifest — and `/api/health` on the internal app is a liveness
|
||
probe returning `{status:'ok'}`. Neither can say which pages exist.
|
||
|
||
§2.9's rule governs: **treat an unknown capability as absent, and never infer a URL from one.**
|
||
|
||
### R11 — a small read-only set of Discord slash commands
|
||
|
||
**Decided 2026-09-15 (org lead).** Questions answered from data the module already holds — server
|
||
status, wipe schedule, leaderboards, who is online. No write verbs, and the account link stays on the
|
||
two surfaces R1 names rather than acquiring a third.
|
||
|
||
The trap to carry in from the Teams work: **ephemerality is fixed at the deferral**, so a command
|
||
that might refuse must defer ephemeral or its refusal goes public in the channel. And the registries
|
||
have no removal path, which is an argument for adding a command late rather than early.
|
||
|
||
### R12 — per-wipe detail plus all-time rollups
|
||
|
||
**Decided 2026-09-15 (org lead).** Every gameplay row carries `wipe_id`; leaderboards default to the
|
||
current wipe; a separate rollup accumulates per player across wipes so a returning player's history
|
||
survives the monthly reset.
|
||
|
||
The truncation is a **runtime operation on a module route, never a schema one** — §2.6's leading-verb
|
||
allowlist forbids `DELETE` and `TRUNCATE` in a fragment precisely because the fragment replays at
|
||
every boot and would empty the table on each restart.
|
||
|
||
### R13 — two extension slots: `admin.users.detail` and `site.footer.status`
|
||
|
||
**Decided 2026-09-15 (org lead).** An operator looking at a user sees their linked Steam identity,
|
||
per-server stats and site-authored permission grants in core's own admin user page; the footer
|
||
carries a shard-status indicator on every page.
|
||
|
||
One module per slot, so claiming them also reserves them. And the naming rule matters for anyone
|
||
reading this later: **a slot is named for a PLACE, never for a meaning** — `site.footer.status` is
|
||
"the status-ish spot in the footer", not core knowing what a game server is.
|
||
|
||
### R14 — `/rust` on all three tiers
|
||
|
||
**Decided 2026-09-15 (org lead).** `public`, `admin` and `player` all mount `/rust`, exactly as
|
||
`module-uo` mounts `/uo`. Sub-surfaces are path segments: `/rust/servers/:id`, `/rust/map`,
|
||
`/rust/clans`.
|
||
|
||
Chosen deliberately because **prefixes share one namespace with core's own and the loader's collision
|
||
probe cannot see all of core's** — several core endpoints are mounted at the tier root rather than
|
||
under a prefix. A noun from our own domain that equals the module id cannot collide, where
|
||
`/servers` or `/map` very well might.
|
||
|
||
## 3. Open questions
|
||
|
||
The fourteen decisions above close every question the contract audit raised. Two remain, both
|
||
deliberately deferred rather than unanswered:
|
||
|
||
**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.**
|
||
|
||
**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.
|
||
|
||
## 4. The test rig
|
||
|
||
`D:\rust` on the org lead's workstation. It has been booted, it has a generated world
|
||
(procedural, seed 1234, size 4000, save v287) and Oxide **2.0.7585** matched to its build, and its
|
||
Oxide permission store already holds a `default` and an `admin` group with one admin user — which
|
||
means R2's mechanism can be exercised on day one.
|
||
|
||
Two traps recorded here because both cost time before they were understood:
|
||
|
||
- **`D:\rust\start.bat` updated the wrong directory — fixed 2026-09-15.** It ran
|
||
`steamcmd +force_install_dir c:\rustserver\ +app_update 258550` and then launched
|
||
`D:\rust\RustDedicated.exe`. The server that boots had never been updated by its own script, which
|
||
is why a second, never-booted install exists at `C:\rustserver` and why `D:\rust` is a wipe behind.
|
||
Now reads `+force_install_dir d:\rust\`; the original is kept at `D:\rust\start.bat.bak`.
|
||
`D:\rust\steamapps\appmanifest_258550.acf` was already present at the same buildid, so the first
|
||
corrected run is a delta to the current wipe rather than a 5.9 GB re-download.
|
||
- **`C:\oxide_files` is a 2025-04-23 Oxide and must not be copied anywhere.** Oxide ships a patched
|
||
`Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,780,224, so copying it
|
||
over a real install is a hard downgrade. `D:\rust` is already correct and needs nothing from it.
|
||
|
||
**Rust force-wipes on the first Thursday of the month and Oxide is rebuilt to match**, so "is the
|
||
rig current" is a recurring question, not a one-time setup step. Every phase that touches the plugin
|
||
re-checks it.
|
||
|
||
## 5. The phases
|
||
|
||
**Twenty phases, roughly doubled from the first draft** — the contract audit in §7 is why, and the
|
||
honest reading is that the first list was a game-bridge plan with a website module bolted on, where
|
||
the kit treats the module as the bulk of the work.
|
||
|
||
0–4 produce a working read-only multi-server Rust site that an operator can actually install. 6–7 are
|
||
the permissions product. 9 is Teams. 10 is the notifications set. 12–13 are events. 14 is the map. 18
|
||
is how any of it reaches somebody who is not us. The Android legs (5, 8, 11, 15) each trail the
|
||
website surface they consume by one phase, per R10.
|
||
|
||
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 |
|
||
| 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 |
|
||
| 4 | **The first pages.** Server list as the landing page, `/rust/servers/:id` beneath it, killfeed, leaderboard; nav rows; the UI kit (`PublicLayout` `shell`, `PageHeader` props); `capabilities`; the `site.footer.status` slot (R13) | Module-Rust | The site renders the last thing each server said while every server is off |
|
||
| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged |
|
||
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
|
||
| 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe |
|
||
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
|
||
| 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
|
||
| 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 |
|
||
| 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 |
|
||
| 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 |
|
||
|
||
### Why the lease comes before the reward action
|
||
|
||
The stated priority is Kits rewards, and this plan still schedules a lease first (12 before 13). [kit][kit] ch. 5 is
|
||
explicit about it and the reasoning survives restating: a lease is *a value that already existed,
|
||
changed for a while, and put back*, so reading it first gives you the baseline for nothing. An action
|
||
makes something that did not exist. The lease proves the whole command path — correlation, the
|
||
idempotency key, `budgetMs` against the client timeout, the deadline the game enforces on its own —
|
||
before anything hands out loot. It is a cheaper place to find all four of chapter 5's invisible
|
||
failures.
|
||
|
||
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.
|
||
|
||
`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.
|
||
|
||
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.
|
||
|
||
## 6. Risks worth naming now
|
||
|
||
- **Hooks bind by name and arity, by reflection, with no compile-time check.** A misspelled hook is
|
||
never called, silently, with no warning at load — the single most common way a Rust plugin does
|
||
nothing. The plugin must log which of its expected hooks have fired at least once, so a hook
|
||
Facepunch renamed on a wipe is visible rather than mysterious. See [`README.md`](README.md) §2.
|
||
- **A convar that applies cleanly and does nothing.** Most game config is read once at boot and
|
||
cached; applying it later succeeds, reads back correctly, and changes nothing. Every lease key gets
|
||
verified live — apply, observe in the running game, restore — before it is advertised. The UO
|
||
module surveyed 156 config reads and found roughly eight that were live.
|
||
- **The wipe cadence is the schedule.** A monthly force wipe moves the hook list, rebuilds Oxide, and
|
||
invalidates every ledgered resource. Phases that end near one should expect to re-verify rather
|
||
than assume.
|
||
- **`start.bat`'s RCON password is `letmein` in plaintext with `rcon.web 1`.** Acceptable on a
|
||
loopback dev rig, and it must never be the shape anything published copies.
|
||
|
||
## 7. Contract coverage audit
|
||
|
||
Added 2026-09-15 after re-reading the whole kit rather than only chapters 3-5. **The first draft of
|
||
§5 was built from the game-facing chapters and under-planned the website module by a wide margin** —
|
||
the template makes eight of the ten non-event registrations and the plan covered three. Every element
|
||
is listed with the phase it now lands in; the column worth reading is *"was it in the first draft"*,
|
||
because that is the shape of the mistake.
|
||
|
||
### The server handshake — ten registrations plus two hooks (ch. 2)
|
||
|
||
| Call | In the first draft | Phase |
|
||
|---|---|---|
|
||
| `registerRoutes` | yes | 4 |
|
||
| `registerTeamProvider` | yes | 9 |
|
||
| `registerEventBudgets` / `OptionSources` / `Leases` / `Actions` | yes | 12-13 |
|
||
| `onBoot` / `onShutdown` | implicit only | 1 |
|
||
| `registerExtension` | **no** | 4 (`site.footer.status`), 6 (`admin.users.detail`) |
|
||
| `registerNotificationStreams` | **no** | 10 |
|
||
| `registerEventTriggers` | **no** | 10 |
|
||
| `registerAudiences` | **no** | 10 |
|
||
| `registerEngagementSeeds` | **no** | 10 |
|
||
| `registerAnnounceLeg` | **no** | 10 |
|
||
| `registerPostHook` | **no** | 10 |
|
||
| `registerSlashCommands` | **no** | 16 |
|
||
|
||
Triggers, audiences and seeds are one phase because they are a **matched set** in
|
||
`template/server/index.js`, not three independent gaps — see R7.
|
||
|
||
### The bundle's own parts
|
||
|
||
| Part | In the first draft | Phase |
|
||
|---|---|---|
|
||
| `module.json` `id` / `coreApi` / `capabilities` | yes | 1, 4 |
|
||
| `mounts` and prefix choice | **no** | 1 (R14) |
|
||
| `schema.sql` | yes | 1, 3 |
|
||
| `purge.sql` | **no** | 1 |
|
||
| `swagger-fragment.json` + generator + `check:swagger` | **no** | 1 |
|
||
| `vite.config.js` aliases / shims / `checkExternals` | **no** | 1 |
|
||
| `checkImports.js` | **no** | 1 |
|
||
| `release.yml`, install manifest, `sha256`, host allowlist | **no** | **2** — there was no packaging phase at all |
|
||
|
||
Phase 1 carries most of these on purpose. [kit][kit] ch. 1's whole argument is to get every seam
|
||
working at once with almost nothing in them, so that afterwards you break exactly one at a time.
|
||
|
||
### The client half
|
||
|
||
| Part | In the first draft | Phase |
|
||
|---|---|---|
|
||
| `registry.registerRoutes` / `registerNav` | yes | 4 |
|
||
| `declareModuleSlot` | **no** | 9 — the kit: *"you will need it the moment your game has anything like a guild"* |
|
||
| `registerFeatureProvider` | **no** | 4 |
|
||
| UI kit discipline (`PublicLayout` `shell`, `PageHeader` props) | **no** | 4 |
|
||
|
||
### Beyond the module
|
||
|
||
| Item | In the first draft | Phase |
|
||
|---|---|---|
|
||
| Sidecar rpc correlation | implicit | 1, stated |
|
||
| Asset bridge (ch. 3 §2b) | **no** | 14 — **map image only** (R9) |
|
||
| `.profile` landing page | **no** | 19 |
|
||
| `runicgateway.com` | **no** | 19 |
|
||
| Android app | **no** | 5, 8, 11, 15 (R10) |
|
||
| `docs/` | yes | every phase, plus 19 |
|
||
|
||
**What the audit cost the schedule: twelve phases.** That is the honest number, and it is worth
|
||
recording because the under-planning had one cause — reading the chapters that describe the game
|
||
bridge and treating the module as the thin part, when the kit says in its first paragraph that the
|
||
website module is most of the work.
|
||
|
||
## 8. Questions the audit raised — all answered
|
||
|
||
Every question §7 produced was put to the org lead on 2026-09-15 and answered the same day. They are
|
||
recorded as R7–R14 in §2 rather than repeated here:
|
||
|
||
| Question | Answer | Decision |
|
||
|---|---|---|
|
||
| Notifications and engagement in v1? | the full matched set | R7 |
|
||
| How many servers does the UI support? | multi-server from the start | R8 |
|
||
| The asset bridge? | **only** the live map — not item icons, not skins | R9 |
|
||
| Android in this workstream? | full app, capability-driven, trailing by one phase | R10 |
|
||
| Discord slash commands? | a small read-only set | R11 |
|
||
| What survives a wipe? | per-wipe detail plus all-time rollups | R12 |
|
||
| Extension slots? | `admin.users.detail` **and** `site.footer.status` | R13 |
|
||
| Which mount prefixes? | `/rust` on all three tiers | R14 |
|
||
|
||
The two in §3 are deferred by choice rather than unanswered.
|
||
|
||
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit
|