The first draft of the phase list was built from chapters 3-5 and under-planned the website module by a wide margin. The template registers eight of the ten non-event registrations; the plan covered three. Unplanned and now recorded: registerExtension, registerNotificationStreams, registerEventTriggers, registerAudiences, registerEngagementSeeds, registerAnnounceLeg, registerPostHook, registerSlashCommands - with triggers, audiences and seeds being a matched set rather than three independent gaps. Also unplanned: purge.sql, mount prefix choice, the swagger fragment and its staleness check, the vite alias/shim mechanism the kit calls the highest-risk mechanical detail in the system, checkImports, and the entire packaging and release path - there was no packaging phase at all. On the client: declareModuleSlot, which the kit says is needed the moment a game has anything like a guild, so it belongs in the Teams phase; registerFeatureProvider; and the UI kit discipline that has caught this project twice already (PublicLayout needs shell, PageHeader silently drops unknown props). Beyond the module: the asset bridge, which Rust's item icons and 2,590 skin ids fit exactly; the .profile landing page, owed whenever repos are added and three just were; runicgateway.com, for which a second game is a headline change; and the Android app, which feature-detects capabilities and must render a site whose module it has never heard of. Section 8 carries the eight questions the plan cannot answer for itself.
448 lines
30 KiB
Markdown
448 lines
30 KiB
Markdown
# `module-rust` — the plan
|
||
|
||
**Status:** approved in outline 2026-09-15, not started. Six decisions of record settled; two
|
||
questions open (§3).
|
||
|
||
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 10,
|
||
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 9 rather than phase 6.
|
||
|
||
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 9'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 9 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 8 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 8 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 8 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 6 *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.
|
||
|
||
## 3. Open questions
|
||
|
||
The base set question is closed by R6. Two smaller ones are open, neither blocking:
|
||
|
||
**Clans is in the base set *and* the Team provider reads first-party clans.** Those are not in
|
||
conflict and this plan reads them as complementary: 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). Phase 9's adapter surfaces the plugin's extra features beside
|
||
the provider rather than under it. **Worth confirming** — it is an interpretation, not something that
|
||
was stated.
|
||
|
||
**Offline reward grants.** `GiveKit` requires a connected `BasePlayer` (R6). Whether phase 8 accepts
|
||
online-only or builds a persisted pending-grant queue is a real decision with real cost, and it is
|
||
deliberately deferred to that phase rather than guessed now.
|
||
|
||
`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
|
||
|
||
Phases 0–3 produce a working read-only Rust site. 4–5 are the permissions product. 6 is Teams. 7–8
|
||
are events. 9 is the optional tier. 10 is how any of it reaches somebody who is not us. 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, prove a grant made at the console is visible to a plugin | docs | A current server boots with the base mods loaded and `oxide.grant` demonstrably gates something |
|
||
| 1 | **Protocol 1 and three skeletons.** Bounded drop-oldest queue, one writer thread, reconnect with a tagged epoch, dial-out; sidecar listener + SQLite + always-on token auth + version header; module from `template/` | all 3 + docs | One hello line travels game → sidecar → module, and killing the sidecar does not stall the game |
|
||
| 2 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots kept distinct at the wire; `wipe_id` everywhere; every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, with no negotiation |
|
||
| 3 | **The first pages.** Server list, per-server status, killfeed, leaderboard; `capabilities`; nav | Module-rust | The site renders the last thing the game said while the game is off |
|
||
| 4 | **Identity** (R1) | 3 + docs | A player links an account in-game and the site names their Steam ID |
|
||
| 5 | **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 |
|
||
| 6 | **Teams provider** (R5). Rust's first-party clans; membership event-driven, leadership read off `LocalClan` at snapshot | Module-rust + 2 | Core's reconciler is answered from live transitions, `complete` claimed only per reachable server |
|
||
| 7 | **Events: one budget, one 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 |
|
||
| 8 | **Events: the Kits reward action** (R3) | all 3 | A retried step grants loot once, and the ledger and the world agree |
|
||
| 9 | **Optional mod integrations**, the uMod **Clans** plugin first (R5). One adapter per plugin, each detecting via `[PluginReference]` and degrading to absent | rust-plugins + docs | A server missing every optional mod still runs the module, Teams included |
|
||
| 10 | **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 |
|
||
| 11 | **Docs, kit feedback, cutover** | docs + Integration-kit | `docs/` describes what shipped; R2's missing chapter 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. [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 8 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.**
|
||
Every element of the contract is listed below with where it lands; the unplanned ones are the point
|
||
of the table.
|
||
|
||
### The server handshake — ten registrations plus two hooks (ch. 2)
|
||
|
||
| Call | Planned? | Where |
|
||
|---|---|---|
|
||
| `registerRoutes` | yes | 3 |
|
||
| `registerTeamProvider` | yes | 6 |
|
||
| `registerEventBudgets` / `OptionSources` / `Leases` / `Actions` | yes | 7–8 |
|
||
| `onBoot` / `onShutdown` | **implicit only** | make explicit in 1 |
|
||
| `registerExtension` | **no** | the dry run's own manifest declared `admin.users.detail` |
|
||
| `registerNotificationStreams` | **no** | — |
|
||
| `registerEventTriggers` | **no** | — |
|
||
| `registerAudiences` | **no** | — |
|
||
| `registerEngagementSeeds` | **no** | — |
|
||
| `registerAnnounceLeg` | **no** | — |
|
||
| `registerPostHook` | **no** | — |
|
||
| `registerSlashCommands` | **no** | — |
|
||
|
||
**The template registers eight of these and the plan covered three.** Triggers, audiences and
|
||
engagement seeds are in `template/server/index.js` as a matched set — a trigger declares the payload
|
||
and its audience `ceiling`, an audience resolves people over module data, and seeds ship the bodies
|
||
and rules. None of the three was in §5.
|
||
|
||
### The bundle's own parts
|
||
|
||
| Part | Planned? | Note |
|
||
|---|---|---|
|
||
| `module.json` `id` / `coreApi` / `capabilities` | yes | 1, 3 |
|
||
| **`mounts` and prefix choice** | **no** | shares one namespace with core's, and the loader's probe cannot see core's root-mounted endpoints |
|
||
| `schema.sql` | yes | 2 |
|
||
| **`purge.sql`** | **no** | required whenever a schema ships |
|
||
| **`swagger-fragment.json` + generator + `check:swagger`** | **no** | fixed filename, merged verbatim, must be committed and checked current |
|
||
| **`vite.config.js` aliases / shims / `checkExternals`** | **no** | the kit calls this the highest-risk mechanical detail in the system |
|
||
| **`checkImports.js`** | **no** | the guard that makes a reach into core's tree fail the build |
|
||
| **`release.yml`, install manifest, `sha256`, host allowlist** | **no** | there was no packaging phase at all |
|
||
|
||
### The client half
|
||
|
||
| Part | Planned? | Note |
|
||
|---|---|---|
|
||
| `registry.registerRoutes` / `registerNav` | yes | 3 |
|
||
| **`declareModuleSlot`** | **no** | the kit: *"you will need it the moment your game has anything like a guild"* — so phase 6 |
|
||
| `registerFeatureProvider` | **no** | how a nav row becomes conditional |
|
||
| **UI kit discipline** | **no** | `PublicLayout` needs `shell`; `PageHeader` takes `eyebrow`/`title`/`lead`/`center` and drops unknown props silently |
|
||
|
||
### Beyond the module
|
||
|
||
| Item | Planned? | Note |
|
||
|---|---|---|
|
||
| Sidecar rpc correlation | implicit | needed by 7–8; state it in 1 |
|
||
| **Asset bridge (ch. 3 §2b)** | **no** | Rust's item icons and 2,590 skin ids are exactly the "content already on the game host" case |
|
||
| **`.profile` landing page** | **no** | three repos added is precisely when it is owed |
|
||
| **`runicgateway.com`** | **no** | a second game is a headline platform change |
|
||
| **Android app** | **no** | it feature-detects `capabilities` and must render a site whose module it has never heard of |
|
||
| `docs/` | yes | 11 |
|
||
|
||
### What this does to the phase list
|
||
|
||
The gaps are not evenly distributed: most land in the website module, which the original §5 treated
|
||
as three phases and which the kit treats as the bulk of the work. The phase list needs a packaging
|
||
phase, a notifications-and-engagement phase, and explicit bundle-mechanics work inside phase 1 —
|
||
plus decisions on the optional surfaces (slash commands, extension slots, the asset bridge) before
|
||
they can be scheduled. Those decisions are §8.
|
||
|
||
## 8. Questions this plan cannot answer for itself
|
||
|
||
Open, in rough order of how much they change the schedule. None is blocking phase 0.
|
||
|
||
1. **Notifications and engagement** — does v1 ship triggers, audiences and engagement seeds, or is
|
||
that a later workstream? The template treats them as core furniture; skipping them means a Rust
|
||
site that cannot mail anybody about anything.
|
||
2. **Discord slash commands** — the platform has a bot and `registerSlashCommands` exists. Does the
|
||
Rust module claim any?
|
||
3. **Extension slots** — the dry run's manifest declared `admin.users.detail` (a user's Rust
|
||
identities inside core's admin user page). Keep it?
|
||
4. **The asset bridge** — item icons and skins over the bridge, or not in v1? It is a whole
|
||
protocol's worth of work and the site renders without it.
|
||
5. **Android** — does the app get Rust support in this workstream, or does it stay UO-only until the
|
||
module ships?
|
||
6. **Which mount prefixes**, given they share a namespace with core's own.
|
||
7. **Server scale** — how many Rust servers should the first release support in the UI? The dry run
|
||
says the module holds one client per server; the pages differ a lot between one and six.
|
||
8. **Wipe retention** — when a wipe truncates gameplay data, what survives? All-time rollups, or a
|
||
clean slate each month?
|
||
|
||
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit
|