Overruled by the org lead: RCON is not used. Rust gets the same three-part shape UO has - a plugin inside the game that dials out, a sidecar that persists before it forwards, a module that talks only to the sidecar - and the plugin is a mod loaded by the server's mod framework, exposing data through hooks. The document had RCON as its premise, so the correction reaches further than the transport paragraph: - The reason Rust is a good second game changes. It was "its server speaks a protocol nobody has to write". It is now "its server is a BINARY" - the opposite of ServUO, which is source a shard owner compiles - so the way in is a published mod API and the shard-dials-out invariant has to survive that change of footing. It does, unchanged, which is a stronger result than the one the document originally claimed. - The announce leg sends a command down the socket the mod already holds, rather than calling rcon.say. - The provider refuses when no mod is connected, not when RCON is unreachable. - Two hooks answer questions UO had to work for: a wipe arrives as an event, and membership is real-time - so this module's Team provider is event-driven with a baseline on connect rather than sweep-driven. The provider contract does not change by a line, which is the part worth keeping: core never needed to know how the data arrives. The 2026-08-12 correction block stays and a second one is added beside it rather than editing the history out - this document's own convention, and the thing that makes it worth reading twice. It also records what the correction COSTS: this project no longer has a worked example of "a game that already speaks a remote-control protocol, so its sidecar is thin". Co-Authored-By: Claude <noreply@anthropic.com>
387 lines
22 KiB
Markdown
387 lines
22 KiB
Markdown
# `module-rust` — a dry run
|
|
|
|
**Phase 3's fourth acceptance criterion** ([`../website/MODULE_SYSTEM.md`](../website/MODULE_SYSTEM.md)
|
|
§2.7). A written design for a module serving a **Rust** (Facepunch) community, taken far enough to
|
|
find out whether the contract generalises past the game it was extracted from — **and deliberately
|
|
not implemented.** A contract validated only against the module it was carved out of has not been
|
|
validated.
|
|
|
|
The exercise is honest if it finds something. It found four things, one of which is a gap in the
|
|
contract that a real second module would hit on its first day. They are in *Findings* at the end; the
|
|
design comes first, because a finding is only worth anything with the design that produced it.
|
|
|
|
Rust was chosen because it is unlike Ultima Online in the ways most likely to break assumptions:
|
|
it **wipes** every month, a community runs **several servers** rather than one shard, its identity
|
|
is **Steam**, and its server is **not source you can compile** — ServUO's overlay is C# a shard owner
|
|
builds into their own server, and a Rust server is a binary nobody outside Facepunch patches. The way
|
|
in is a **mod**: a plugin loaded by the server's mod framework, hooking the game's own events. If the
|
|
contract survives that, "game-agnostic" means something.
|
|
|
|
> Nothing here re-specifies the contract. [`../website/MODULE_API.md`](../website/MODULE_API.md) is
|
|
> normative; this document only *uses* it.
|
|
>
|
|
> **Revisited 2026-08-19, for Teams** (`MODULE_API_VERSION` 1.6.0, Teams phase 11). A Rust team is a
|
|
> Team, so the design grew a provider and an inverted slot — the two places the contract changed since
|
|
> this was written. Everything else stands, including all four findings: the identity gap is still
|
|
> open and still the one a real second module hits first.
|
|
|
|
---
|
|
|
|
## 1. The manifest
|
|
|
|
```json
|
|
{
|
|
"id": "rust",
|
|
"name": "Rust",
|
|
"version": "0.1.0",
|
|
"coreApi": "^1.5.0",
|
|
"server": "server/index.js",
|
|
"client": { "entry": "client/dist/entry.js" },
|
|
"schema": "server/db/schema.sql",
|
|
"purge": "server/db/purge.sql",
|
|
"mounts": {
|
|
"public": ["/rust"],
|
|
"admin": ["/rust"],
|
|
"player": ["/rust"]
|
|
},
|
|
"extensions": ["admin.users.detail"],
|
|
"capabilities": ["servers", "wipes", "leaderboards", "map", "killfeed", "teams"]
|
|
}
|
|
```
|
|
|
|
> **Correction, 2026-08-12.** `coreApi` read `^1.3.0` here until the Integration Kit's acceptance run
|
|
> ([`kit-acceptance.md`](kit-acceptance.md)) found it. The contract was at 1.3.0 when this design was
|
|
> written and has moved twice since; the number is now `^1.5.0`. Left as a correction rather than a
|
|
> silent edit because *why* it went stale is the reusable part: this is the only complete `module.json`
|
|
> in the kit's reading path, so it is what a newcomer copies — and unlike the template, which CI holds
|
|
> against core's `MODULE_API_VERSION` on every pull request
|
|
> ([`../website/MODULE_SYSTEM.md`](../website/MODULE_SYSTEM.md) §2.11.1 d2), **a JSON block inside a
|
|
> Markdown document has nothing checking it.** A range is also the shape least likely to be noticed
|
|
> when it rots: `^1.3.0` is *satisfied* by a 1.5.0 core, so a module copied from here would have loaded
|
|
> fine and simply been wrong about what it was written against.
|
|
|
|
One prefix per tier, named for the module rather than for a feature — the opposite of module-uo's
|
|
`/shard` + `/atlas` + `/uo-link`, and the better choice for anything new. Module-uo's prefixes are
|
|
what they are because §1.2 froze the URLs core already served; a module written today has no such
|
|
debt and should claim one obvious segment. It also sidesteps the collision surface entirely:
|
|
`/shard` is a word a second game might want, `/rust` is not.
|
|
|
|
## 2. The server half
|
|
|
|
```js
|
|
module.exports = function register(ctx, api) {
|
|
core.init(ctx)
|
|
|
|
const publicRouter = require('./router/public/rust.router')
|
|
const adminRouter = require('./router/admin/rust.router')
|
|
const playerRouter = require('./router/player/rust.router')
|
|
const userExt = require('./router/admin/usersRust.router')
|
|
|
|
api.registerRoutes({
|
|
public: { '/rust': publicRouter },
|
|
admin: { '/rust': adminRouter },
|
|
player: { '/rust': playerRouter },
|
|
})
|
|
|
|
api.registerExtension('admin.users.detail', userExt)
|
|
|
|
api.registerNotificationStreams([
|
|
{ id: 'rust.wipe', label: 'Server wipes',
|
|
description: 'A server wiped — new map, new seed, everything reset.',
|
|
personal: false, requiresLinkedAccount: false },
|
|
{ id: 'rust.raid', label: 'Base raided',
|
|
description: 'Your base took damage while you were offline.',
|
|
personal: true, requiresLinkedAccount: true },
|
|
])
|
|
|
|
api.registerAnnounceLeg({
|
|
leg: 'rust.ingame',
|
|
label: 'In-game chat',
|
|
dispatch: (post) => link.command('chat.broadcast', { text: `[NEWS] ${post.title} — ${ctx.site.baseUrl}/news/${post.slug}` }),
|
|
classify: (result) => (result.ok ? { outcome: 'done' } : { outcome: 'retry', error: result.error }),
|
|
})
|
|
|
|
// Teams (1.6.0). A Rust "team" is a Team: core owns the tables, the membership
|
|
// sync, the access rules, the forum and the activity feed; this module owns the
|
|
// word and the roster behind it.
|
|
api.registerTeamProvider(teamProvider)
|
|
|
|
api.onBoot(async () => { await link.connect() })
|
|
api.onShutdown(async () => { await link.close() })
|
|
}
|
|
```
|
|
|
|
Everything above is a call the contract already has, used the way module-uo uses it. Three details
|
|
are worth pointing at:
|
|
|
|
- **`rust.wipe` and `rust.raid` are namespaced**, with no grandfathering request. Module-uo's seven
|
|
bare stream ids are allowlisted because they were in `notification_subs` before the rule existed
|
|
(§6.5); a new module gets the rule, and the rule is exactly right.
|
|
- **The announce leg goes to in-game chat**, by asking the sidecar to send a command down the socket
|
|
the mod already holds — a one-shot delivery with retry, so `registerAnnounceLeg` and not
|
|
`registerPostHook`. The distinction §2.4 draws holds up on a game that has nothing in common with
|
|
the one it was drawn for. Note the direction: the module never speaks to a game server, and the
|
|
*sidecar* never dials one either — it answers on a connection the mod opened.
|
|
- **The Team provider is the one registration core calls back into**, and Rust makes two of its rules
|
|
bite harder than UO does. `externalId` must survive a rename, and a Rust team has no name at all —
|
|
it is a numeric team id in the server's save, which is the right answer and the one a designer is
|
|
least likely to reach for. And **`complete` is per SERVER, not per community**: a community running
|
|
six servers has six team spaces, so a provider that can reach five of them must leave `complete`
|
|
off or core archives every Team on the sixth. Wipes make the same point once a month, on purpose —
|
|
a wipe empties every team, and `{ ok: true, complete: true, teams: [] }` is then *true* and core
|
|
archiving all of them is *correct*. Which is exactly why a sidecar with no mod connected must answer
|
|
`{ ok: false }` instead: the two states are one API call apart and only the module can tell them
|
|
apart.
|
|
|
|
### Tables
|
|
|
|
`rust_servers`, `rust_wipes`, `rust_players`, `rust_player_stats`, `rust_teams`, `rust_events`,
|
|
`rust_bans`, `rust_maps`. All `rust_`-prefixed, all in one idempotent `schema.sql` fragment.
|
|
|
|
**`rust_teams` stays this module's table, and core's `teams` stays core's.** They hold the same teams
|
|
and neither reads the other: the module ingests from the sidecar into `rust_teams`, core reconciles by
|
|
*asking* the provider, and §2.6's prefix rule forbids the module touching core's table even though
|
|
the module is what populates it. A module that wrote `team_members` directly would be racing core's
|
|
reconciler for rows it does not own.
|
|
|
|
**Every table that holds gameplay data carries a `wipe_id`.** That is the whole shape of the game in
|
|
one column: a leaderboard means "since the last wipe", a base means "on this map", and a player's
|
|
stats are per-wipe with an all-time rollup kept separately. It has no bearing on the contract —
|
|
core replays the fragment and never looks inside — but it is the first thing a UO-shaped mental
|
|
model gets wrong, and it is worth writing down for whoever builds this.
|
|
|
|
### Talking to the game
|
|
|
|
**A mod, a sidecar, and the same three-part shape UO has.** Rust's server is a binary, so there is no
|
|
overlay to compile into it and no source to patch — but it loads **mods**, and a mod is C# with a hook
|
|
for everything this design needs. So `rust-link` is a real sidecar rather than a wrapper around an
|
|
admin channel, and it is fed the way `uo-link` is fed:
|
|
|
|
```
|
|
Rust server + rust-bridge mod (C#, hooks)
|
|
│ loopback TCP, newline-delimited JSON, bidirectional
|
|
│ the MOD dials out to the sidecar — the game opens no listening port
|
|
▼
|
|
rust-link sidecar
|
|
│ bearer-authed HTTP + WebSocket, versioned
|
|
▼
|
|
module-rust, inside the website
|
|
```
|
|
|
|
**The mod is the interesting half, and it is where a UO-shaped model has the least to unlearn.** The
|
|
threading contract transfers whole — emit enqueues onto a bounded drop-oldest queue and returns, one
|
|
writer thread owns the socket, the world is read only on the game's own thread — because it is a
|
|
property of *game servers* and not of ServUO. What changes is that the hooks are handed to you rather
|
|
than found: the mod framework publishes them, so the plugin is small and the guesswork is in deciding
|
|
what to emit rather than in finding somewhere to hang it.
|
|
|
|
Two of them answer questions UO had to work for:
|
|
|
|
- **The wipe arrives as an event.** The mod is told a new save has begun; nothing has to detect a wipe
|
|
by noticing the world looks different.
|
|
- **Membership is real-time** — created, joined, left, disbanded, leader changed. UO has no
|
|
`guild.leave` at all and needed a 60-second sweep plus a set diff to synthesise one (protocol 4,
|
|
[`../link/v4.md`](../link/v4.md)); here every transition is delivered as it happens. So this
|
|
module's Team provider is **event-driven with a baseline on connect** rather than sweep-driven —
|
|
and the provider contract does not change by one line, because core asks the same three questions
|
|
and gets the same envelope. That is the result worth keeping: the contract never needed to know how
|
|
the data arrives.
|
|
|
|
**The sidecar still earns its place, and the store is why.** A hook fires once, and what it says while
|
|
nobody is listening is gone. So the sidecar appends every kill, wipe and chat line, keeps the latest
|
|
snapshot of each server's state, and answers the website's reads from disk — a website that is down,
|
|
restarting or mid-deploy loses nothing, and a leaderboard renders the last thing the server said
|
|
rather than an error. It also keeps the auth token, the reconnect loop and the per-server fan-out out
|
|
of an Express process, where a stalled socket is a stalled request handler.
|
|
|
|
**Several servers, one sidecar.** Each server runs the mod and each dials the same sidecar,
|
|
identifying itself on connect; the sidecar keys every board by server id. A community running six
|
|
servers deploys one thing per server and one sidecar, and the module sees a single API — which is
|
|
where the Team provider's per-server `complete` (§2) gets its meaning.
|
|
|
|
**`wipe_id` makes the durable copy load-bearing rather than a nicety.** A wipe is the moment the
|
|
game forgets; the sidecar is the only thing that remembers the shape of the map that just ended.
|
|
|
|
**Commands go back down the same socket.** The announce leg's in-game chat line is a command the
|
|
sidecar sends to the mod — the same direction UO's bridge already carries. The connection belongs to
|
|
the mod, and nothing outside the game ever dials into it.
|
|
|
|
> **Correction, 2026-08-12.** This section originally concluded **"No sidecar"** — the module dialling
|
|
> RCON directly — and offered it as evidence that core has no opinion about how a module reaches its
|
|
> game. That was overruled by the org lead when Phase 5 (§2.11.1 d3/d4) settled the kit's stance, and
|
|
> the prohibition is now contract: [`../website/MODULE_API.md`](../website/MODULE_API.md) §2.7, as of
|
|
> `MODULE_API_VERSION` 1.4.0, a module does not open a connection to a game server from the website
|
|
> process. The original reasoning was not wrong about *ServUO* — the shard-dials-out invariant in
|
|
> [`../link/PLAN.md`](../link/PLAN.md) really is a property of an engine with no remote-control
|
|
> surface — but it mistook that for the whole reason a sidecar exists. The other reason is durability:
|
|
> the website is not the right place to hold a game connection, because it is the process most likely
|
|
> to be restarted and the one facing the internet. The finding is left in view rather than edited out;
|
|
> what a dry run concluded is worth more than a tidy document.
|
|
|
|
> **Correction, 2026-08-19 (org lead).** This document originally reached the game over **RCON**, and
|
|
> this section concluded "a thin sidecar" on that basis — no wire protocol to invent and no game-side
|
|
> plugin to write. Overruled: **the transport is a mod, exactly as it is for UO, and RCON is not
|
|
> used.** Hooks inside the mod expose the data and the mod dials the sidecar.
|
|
>
|
|
> Two things that costs the document, worth stating because both were used as evidence elsewhere.
|
|
> Rust is no longer an example of *"a game that already speaks a remote-control protocol, so its
|
|
> sidecar is thin"* — that example now has none in this project. And the reason Rust is a good second
|
|
> game is no longer that its protocol comes free. It is that its server is a **binary**, the opposite
|
|
> of ServUO: the way in is a published mod API rather than source you compile, and the
|
|
> shard-dials-out invariant has to survive that change of footing. It does, unchanged, which is a
|
|
> stronger result than the one this document originally claimed.
|
|
|
|
## 3. The client half
|
|
|
|
```js
|
|
registry.registerRoutes('rust', {
|
|
public: [
|
|
{ path: 'servers', element: <Servers /> },
|
|
{ path: 'servers/:id', element: <ServerDetail /> },
|
|
{ path: 'servers/:id/map', element: <MapView /> },
|
|
{ path: 'leaderboards', element: <Leaderboards /> },
|
|
{ path: 'wipes', element: <WipeSchedule /> },
|
|
],
|
|
player: [
|
|
{ path: 'account', element: <LinkedAccount /> },
|
|
{ path: 'stats', element: <MyStats /> },
|
|
],
|
|
admin: [
|
|
{ path: 'servers', element: <AdminServers />, gate: { roles: ['admin'] } },
|
|
{ path: 'ops', element: <AdminOps />, gate: { roles: ['admin', 'moderator'] } },
|
|
],
|
|
})
|
|
|
|
registry.registerNav('rust', {
|
|
area: 'public',
|
|
items: [
|
|
{ label: 'Servers', to: '/rust/servers', group: 'Play', order: 10, icon: ServerIcon },
|
|
{ label: 'Leaderboards', to: '/rust/leaderboards', group: 'Play', order: 20, icon: TrophyIcon,
|
|
feature: 'leaderboards' },
|
|
{ label: 'Wipe schedule', to: '/rust/wipes', group: 'Play', order: 30, icon: CalendarIcon },
|
|
],
|
|
})
|
|
|
|
registry.registerFeatureProvider('rust', 'rust', useRustFeatures)
|
|
registry.registerExtension('rust', 'admin.users.detail', LinkedSteamAccounts)
|
|
|
|
// The INVERTED direction (1.6.0): this module declares places on its OWN team
|
|
// page and core fills them. Core publishes no team page — it does not own the
|
|
// word — so `/rust/servers/:id/teams/:teamId` is this module's, and core's feed
|
|
// and forum are contributed into it.
|
|
registry.declareModuleSlot('rust', 'rust.team.detail', { core: 'team.activity' })
|
|
registry.declareModuleSlot('rust', 'rust.team.forum', { core: 'team.forum' })
|
|
```
|
|
|
|
`Play` is a group core does not have; §3.3 appends an unknown group rather than dropping the items,
|
|
so this works and lands at the end of the nav — where an operator can move it, because a module row
|
|
is an ordinary row once it is interleaved.
|
|
|
|
The pages need `PublicLayout`, `PageHeader`, the three `PageState` components, `useAsync` and
|
|
`useAuth`: **six of the kit's nine members**, plus `useSite` on the wipe-schedule page for the site's
|
|
timezone and `Slot` on the team page. A second game, unrelated to the first, wanting exactly what the
|
|
kit contains is the strongest evidence available that §3.4 was curated at the right altitude.
|
|
|
|
**The team route carries a server id as well as a team id**, which is the Rust-shaped consequence of
|
|
the finding two sections down: team `4` on one server and team `4` on another are different teams,
|
|
so the module's `externalId` has to be `<serverId>:<teamId>` and its page needs both. Core stores
|
|
that string and never parses it — an external id is opaque to core by design, and this is the case
|
|
that shows why.
|
|
|
|
The map view is the one page that wants something the kit does not have — a pan/zoom canvas. It
|
|
bundles one, which is the answer §3.4 already gives ("everything else a module bundles itself"), and
|
|
it costs the chunk about 40 KB.
|
|
|
|
---
|
|
|
|
## Findings
|
|
|
|
### 1. A module cannot register an identity provider — and Rust's identity is Steam
|
|
|
|
The gap. Module-uo proves account ownership with an in-game `[link` command that issues a one-time
|
|
code; the website confirms it with the sidecar. Nothing about that needs core's auth layer, so
|
|
nothing in `api` ever needed to touch it.
|
|
|
|
**Rust's answer is Steam OpenID**, and every Rust community expects "Sign in with Steam". Core has an
|
|
SSO layer — `authProviders`, `userIdentities`, OAuth2/OIDC providers behind a registry — and
|
|
[`MODULE_API.md`](../website/MODULE_API.md) §2.4 offers a module no way in. There is no
|
|
`registerAuthProvider`, and §2.7 forbids reaching for one.
|
|
|
|
A Rust module can still ship: it can copy the UO shape, issuing a code in-game and matching it on
|
|
the website. That works, it is one screen worse, and it leaves the community's obvious expectation
|
|
unmet.
|
|
|
|
**This is not a defect in what was built** — it is the boundary of what was specified, found exactly
|
|
where a dry run is supposed to find it. It is worth stating precisely, because whoever adds it has to
|
|
answer a question the current policy already has an opinion about: **SSO is link-only by design**, an
|
|
external identity must already be linked to an existing account, and identities are never
|
|
auto-provisioned. A Steam provider a module registers must inherit that, not route around it. It is
|
|
also *not* a small addition: an identity provider participates in session creation, which is the one
|
|
part of core a module must never be able to weaken.
|
|
|
|
Recommendation: leave it out of v1 of the contract and record it here as the first candidate for
|
|
`MODULE_API_VERSION` 1.4 or 2.0, specified deliberately rather than bolted on when someone needs it.
|
|
|
|
### 2. "One module, one game" is not the same as "one module, one server"
|
|
|
|
A UO community runs one shard. A Rust community runs four or five, wipes them on different
|
|
schedules, and every page is a per-server view.
|
|
|
|
The contract is silent on this, and silence turns out to be right: multiplicity lives entirely in the
|
|
module's own tables and route parameters (`/rust/servers/:id`). Core's mount prefixes, capabilities
|
|
and state machine are per-**module**, and none of them wanted to be per-server.
|
|
|
|
Worth recording only because it looks like a problem until you try it — and because it is the shape
|
|
that would have broken a contract designed around "the shard" as a singular noun. The phase-2
|
|
inversions that removed core's opinions about game content (the push catalog, the announce legs,
|
|
`mapEvent` dropped) are why it does not.
|
|
|
|
### 3. The event catalog generalises; the *retention* assumption does not
|
|
|
|
`rust_events` is the twin of `shard_events`, and the ingest shape carries over unchanged.
|
|
|
|
What does not carry over is that a UO event log grows forever while a Rust one is **truncated every
|
|
wipe**. That is module-internal — but it lands on something core does own: the schema fragment
|
|
**must not** be where that truncation happens. §2.6's leading-verb allowlist (`CREATE`, `ALTER`,
|
|
`INSERT`, `UPDATE`) already forbids `DELETE` and `TRUNCATE` in a fragment, precisely because the
|
|
fragment is replayed on **every boot** and would empty the table each restart.
|
|
|
|
So a wipe is a runtime operation on a module route, not a schema one. The allowlist was written for a
|
|
different reason — the phase-2 note says "the file replays every boot, so TRUNCATE/DELETE would empty
|
|
a table each restart" — and it correctly forbids the first mistake this module's author would make.
|
|
A rule that catches a case it was not written for is a rule at the right altitude.
|
|
|
|
### 4. `capabilities` earns its keep the moment there are two modules
|
|
|
|
With one module, `capabilities` reads like decoration — core never interprets one, and the SPA knows
|
|
what it registered. With two, it is the only thing a *client* can ask.
|
|
|
|
The Android app is the case: it feature-detects against `GET /api/v1/public/modules` and must render
|
|
a site whose module it has never heard of. `["servers", "wipes", "leaderboards"]` tells it there is
|
|
nothing shard-shaped here without it having to know what `rust` means, and §2.9's rule — treat an
|
|
unknown capability as absent, never infer a route from one — is what keeps that from becoming a
|
|
second, worse route table.
|
|
|
|
No change needed. Recorded because the design decision looked over-engineered with one module and is
|
|
load-bearing with two.
|
|
|
|
---
|
|
|
|
## Verdict
|
|
|
|
**The contract generalises.** A second game, chosen for how little it shares with the first, is
|
|
served by the same `module.json`, the same registration calls, the same schema-fragment rules,
|
|
the same client registry and the same UI kit — with one genuine gap (identity providers), one
|
|
non-issue that looks like a gap (multiple servers), and two places where a rule written for one
|
|
reason turns out to cover another.
|
|
|
|
**And the three-part shape generalises with it**, which the 2026-08-19 correction is what actually
|
|
established: a game whose server is a binary, reached through a published mod API, still ends up with
|
|
a plugin that dials out, a sidecar that persists before it forwards, and a module that talks only to
|
|
the sidecar. Nothing about that arrangement was a property of ServUO being source you can compile.
|
|
|
|
The gap is worth having found before something was built on top of it, which is what a dry run is
|
|
for. What it does **not** establish is that someone outside this org could build this module from the
|
|
documentation alone — that is the Integration Kit's acceptance test (§2.11), and it stays untested
|
|
until a person who did not write any of this does it.
|