Compare commits
32 Commits
3c8b430fae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 68989c247b | |||
| 5f18097223 | |||
| 46b565ac9b | |||
| 4049abc41a | |||
| f1b8329540 | |||
| 83e57e6f47 | |||
| b7d9872fed | |||
| cb8e8853f6 | |||
| aa148adfc6 | |||
| edff0e3695 | |||
| eea4f958b2 | |||
| de25ed3276 | |||
| a4c4476c3e | |||
| 3b7b9cca4f | |||
| eb4a8bcafc | |||
| 11b8965aa7 | |||
| 15d64b28fe | |||
| 7f008fd1f3 | |||
| 82145d3b4a | |||
| 132205f0f4 | |||
| bba2ab04e0 | |||
| 4fe8864939 | |||
| e35880e713 | |||
| 54b4059091 | |||
| dfdb0a3f63 | |||
| 2f24236993 | |||
| 788100e048 | |||
| a221d4409b | |||
| f80b9f95c8 | |||
| 30e72adfcf | |||
| ddf777fd8c | |||
| 5274d5a744 |
@@ -85,8 +85,9 @@ share a shape and nothing else, so neither document is a fallback for the other.
|
|||||||
|
|
||||||
| Doc | What it covers |
|
| Doc | What it covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [PROTOCOL.md](rust-link/PROTOCOL.md) | **Canonical** — the game link and the website API, the four declaration sites of the wire version, and what protocol 1 defines |
|
| [PROTOCOL.md](rust-link/PROTOCOL.md) | **Canonical** — the game link and the website API, the four declaration sites of the wire version, and what each protocol version defines: 1 the transport, 2 the read path |
|
||||||
| [INTEGRATION.md](rust-link/INTEGRATION.md) | Standing the bridge up by hand, and which of the three components is wrong when it does not work |
|
| [INTEGRATION.md](rust-link/INTEGRATION.md) | Standing the bridge up by hand, and which of the three components is wrong when it does not work |
|
||||||
|
| [PLAYER_WALK.md](rust-link/PLAYER_WALK.md) | The half of the read path a console cannot reach: ten minutes on a rig with a player, step by step, with what each hook should produce |
|
||||||
|
|
||||||
### `android/`
|
### `android/`
|
||||||
| Doc | What it covers |
|
| Doc | What it covers |
|
||||||
|
|||||||
282
android/PLAN.md
282
android/PLAN.md
@@ -229,7 +229,8 @@ Verified green: `:app:testDebugUnitTest` (18 new JVM tests — notifications DTO
|
|||||||
parse incl. malformed, topic/URL building, stream→route map + personal gating) + `:app:lintDebug` +
|
parse incl. malformed, topic/URL building, stream→route map + personal gating) + `:app:lintDebug` +
|
||||||
`:app:assembleDebug`; backend 250 tests (+3 for `push.ntfyUrl`) and `npm run swagger` clean. The
|
`:app:assembleDebug`; backend 250 tests (+3 for `push.ntfyUrl`) and `npm run swagger` clean. The
|
||||||
foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as
|
foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as
|
||||||
planned; an on-device delivery pass against a live ntfy is the one open QA item.
|
planned; an on-device delivery pass against a live ntfy is the one open QA item. *(Closed by M16 on
|
||||||
|
2026-09-23: a raid alert delivered through `ntfy.sh` to an emulator, 19 seconds end to end.)*
|
||||||
|
|
||||||
**Part 2 (original plan) — the Android app.** UnifiedPush receiver + device registration
|
**Part 2 (original plan) — the Android app.** UnifiedPush receiver + device registration
|
||||||
against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links.
|
against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links.
|
||||||
@@ -313,7 +314,10 @@ Work items:
|
|||||||
`GET …/streams` with a per-stream toggle bound to `GET/PUT …/subscriptions`; a **personal** stream
|
`GET …/streams` with a per-stream toggle bound to `GET/PUT …/subscriptions`; a **personal** stream
|
||||||
(`requiresLinkedAccount`) is greyed with a "link a game account" hint until the user has a linked
|
(`requiresLinkedAccount`) is greyed with a "link a game account" hint until the user has a linked
|
||||||
account — reuse the linked-accounts signal already fetched for M4's player surface
|
account — reuse the linked-accounts signal already fetched for M4's player surface
|
||||||
(`PlayerShardRepository`), not a fresh source of truth. Toggling to a non-empty set triggers the
|
(`PlayerShardRepository`), not a fresh source of truth. *(M16 amends both halves: the signal is now
|
||||||
|
asked of the site's own module by capability (`LinkedAccountRepository`), because on a Rust site
|
||||||
|
`PlayerShardRepository` answered no for everyone. It holds back only switching push **on**, and no
|
||||||
|
longer greys the whole row.)* Toggling to a non-empty set triggers the
|
||||||
register flow (#5) and requests `POST_NOTIFICATIONS`; emptying the set unregisters. Each mutation
|
register flow (#5) and requests `POST_NOTIFICATIONS`; emptying the set unregisters. Each mutation
|
||||||
folds its `ApiResult` into a section-scoped, localized banner (§7 parity with M4).
|
folds its `ApiResult` into a section-scoped, localized banner (§7 parity with M4).
|
||||||
7. **Deep-links (resolves the §13 open item).** Tapping a notification opens the app to the stream's
|
7. **Deep-links (resolves the §13 open item).** Tapping a notification opens the app to the stream's
|
||||||
@@ -324,7 +328,8 @@ Work items:
|
|||||||
`label`) and deep-links — it does **not** pull `ref` content first; the content-free design means
|
`label`) and deep-links — it does **not** pull `ref` content first; the content-free design means
|
||||||
nothing needs decrypting to render the tap, and the target screen fetches fresh over the
|
nothing needs decrypting to render the tap, and the target screen fetches fresh over the
|
||||||
authenticated API on open. (Pulling `ref` for a richer inline notification is a possible later
|
authenticated API on open. (Pulling `ref` for a richer inline notification is a possible later
|
||||||
enhancement, not v1.)
|
enhancement, not v1.) *(M16 did it for `notification:<id>` refs: `PushContentResolver` pulls the
|
||||||
|
inbox row and titles the notification with it. Every other ref keeps the generic title.)*
|
||||||
8. **Menu.** Add a **Notifications** entry to the signed-in group in `ui/navigation/Menu.kt` (near My
|
8. **Menu.** Add a **Notifications** entry to the signed-in group in `ui/navigation/Menu.kt` (near My
|
||||||
Account), visible once signed in.
|
Account), visible once signed in.
|
||||||
9. **Permission UX.** Request `POST_NOTIFICATIONS` at the moment the user first enables a stream (API
|
9. **Permission UX.** Request `POST_NOTIFICATIONS` at the moment the user first enables a stream (API
|
||||||
@@ -1307,6 +1312,273 @@ push, and Play (M6–M8) follow the designed app.
|
|||||||
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
|
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
|
||||||
participation history self-scoped, proved by two accounts rather than asserted.
|
participation history self-scoped, proved by two accounts rather than asserted.
|
||||||
|
|
||||||
|
15. **M14 — the Rust module in the app** (post-v1; built 2026-09-17). The platform's **second game
|
||||||
|
module** reached its first public pages in `module-rust` phase 4
|
||||||
|
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §17), and this is phase 5 — the app's leg.
|
||||||
|
R10 has each Android leg trail the website surface it consumes by exactly one phase, so every
|
||||||
|
route here existed and answered before a line of Kotlin was written.
|
||||||
|
|
||||||
|
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §17 for the surface
|
||||||
|
this mirrors and §18 for this phase as built. The contract is normative there; this entry records
|
||||||
|
what the app does about it.
|
||||||
|
|
||||||
|
**No backend work beyond one word.** The five public routes were live. The one change is
|
||||||
|
Module-Rust#5, which adds `rust` to the module's `capabilities` — see the gate below.
|
||||||
|
|
||||||
|
#### Why this is not the shard screens with a different name
|
||||||
|
|
||||||
|
The two games have genuinely different shapes, and collapsing them would have cost the app the
|
||||||
|
thing that makes each legible. **UO is one shard: a place**, five drawer rows, a live SSE stream.
|
||||||
|
**Rust is a fleet**: a list, and one page beneath it with four tabs. The app grows a second route
|
||||||
|
tree rather than a second meaning for `shard/`, and both can be installed on one backend — in
|
||||||
|
which case both trees exist at once and neither row appears on a site without its module.
|
||||||
|
|
||||||
|
| Screen | Route | Reads |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Rust servers** (the list) | `rust` | `GET /public/rust/servers` |
|
||||||
|
| **One server** (four tabs) | `rust/servers/{serverId}` | `…/:id`, `…/:id/events`, `…/leaderboard`, `…/online`, `…/wipes` |
|
||||||
|
|
||||||
|
#### Four decisions, taken by the org lead on 2026-09-16
|
||||||
|
|
||||||
|
- **D16 — the gate is a new capability, `rust`.** `module-uo`'s five shard rows all hang on one
|
||||||
|
string, `shard`, because that is the only question a capability can answer: *is the module
|
||||||
|
there*. `module-rust` declared five and every one named a **surface** — `servers`, `killfeed`,
|
||||||
|
`leaderboard`, `presence`, `wipes`. Core flattens every started module's capabilities into a
|
||||||
|
single list, so gating on `servers` would let another module declaring that word silently reveal
|
||||||
|
these screens on a site that does not run Rust. Gating on the module **id** was considered and
|
||||||
|
rejected: `id` is a mount prefix (§2.1 requires it to equal the directory core loads from), and
|
||||||
|
`MODULE_API.md` §2.9 forbids a client inferring a route from a capability — making the two the
|
||||||
|
same thing would quietly end that separation. So the module declares its own name as a sixth
|
||||||
|
string, asserted in its suite against `module.json`'s own `id` so the two cannot drift.
|
||||||
|
- **D17 — poll every 20s while the screen is RESUMED**, the phone's version of D14's Page
|
||||||
|
Visibility gate. Immediate refresh on return to the foreground; nothing at all while away.
|
||||||
|
- **D18 — the Rust repositories move to `edge`** for the rest of the workstream, with releases at
|
||||||
|
the cutover rather than per phase. `pr-checks.yml` in all four repositories already triggers on
|
||||||
|
`[main, edge]`, so this costs no CI — the trap that made all nine M12 phase PRs land unchecked
|
||||||
|
was closed in engagement Phase 8.
|
||||||
|
- **D19 — the drawer row carries a live player count**, and NavPaths learns `/rust`.
|
||||||
|
|
||||||
|
#### A refresh is not a load, and the app had only ever done loads
|
||||||
|
|
||||||
|
The app has had exactly one shape for a read since M1: set `Loading`, ask, replace. That is right
|
||||||
|
for opening a screen and wrong for a poll — a twenty-second refresh built on it clears the
|
||||||
|
killfeed, renders a spinner in its place and re-fills it, three times a minute, for ever. **The
|
||||||
|
website hit the same wall one tier along**, which is why `module-rust` bundles its own `usePolled`
|
||||||
|
instead of using core's `useAsync` (§17.3). `ui/Polling.kt` is that hook's other half:
|
||||||
|
|
||||||
|
- `refreshInto` — **a refresh is invisible when it succeeds and keeps the rows when it fails.** A
|
||||||
|
failure with rows on screen keeps them and reports the failure beside them; a failure with
|
||||||
|
nothing on screen is an ordinary error with a retry, because there is nothing to protect.
|
||||||
|
- `PollWhileResumed` — `repeatOnLifecycle(RESUMED)`, which buys three behaviours from one line: no
|
||||||
|
requests at all while backgrounded, an immediate refresh on return, and a pause behind a dialog
|
||||||
|
or the recents switcher. `STARTED` would keep polling for a reader who is not reading.
|
||||||
|
|
||||||
|
Only the **visible** live panel is polled. The leaderboard and the wipe list never are: a
|
||||||
|
leaderboard that re-sorted itself under a finger every twenty seconds would be worse than a stale
|
||||||
|
one. Changing the filter, the sort or the wipe **is** a different question, so that panel blanks
|
||||||
|
and loads — leaving the old rows up would show last wipe's killfeed under this wipe's heading.
|
||||||
|
|
||||||
|
#### The drawer badge is D15 translated, not D15 copied
|
||||||
|
|
||||||
|
D15 put a live count in core's `site.footer.status` slot, which works because every page of the
|
||||||
|
website renders the same footer. The app has no footer and no slot. What it has is a drawer row
|
||||||
|
per surface and, since engagement Phase 8, a precedent for a number beside one — the inbox's
|
||||||
|
unread badge, in the `NavigationDrawerItem` badge slot, with a `contentDescription` so a screen
|
||||||
|
reader says "42 players online" rather than "42". The count rides there, and keeps the website
|
||||||
|
version's three rules: **zero renders nothing** (an empty fleet is not a notification), a failed
|
||||||
|
read keeps the last number, and it never polls. It is asked for only where the module is
|
||||||
|
installed, so a UO site makes no request at all.
|
||||||
|
|
||||||
|
#### Verified
|
||||||
|
|
||||||
|
The app suite (**644 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
|
||||||
|
against the phase-4 rig — a core with the module installed, one live server and one seeded fixture
|
||||||
|
that has never reported.
|
||||||
|
|
||||||
|
**Both halves of the phase criterion, directly.** With its server unreachable and reading
|
||||||
|
*Offline*, the page still rendered its map, size, seed, wipe date, killfeed, per-wipe and all-time
|
||||||
|
leaderboards, its last known presence board and its wipe history. The same app pointed at the UO
|
||||||
|
core showed Shard / Rules / Atlas / Leaderboards / Market and **no Rust row**.
|
||||||
|
|
||||||
|
Also proven rather than asserted: `refreshInto` against a genuinely dead backend (the core was
|
||||||
|
stopped with the list on screen; a poll tick later the rows were unchanged under one quiet line);
|
||||||
|
R12's arithmetic on a phone (all-time 59 = 41 + 18, and a player who appears only in the older
|
||||||
|
wipe **drops out** of it rather than reading zero); every `describe` branch from real rows,
|
||||||
|
including the fall that must not read as a kill by nobody; the calendar-day rule, filtering to the
|
||||||
|
August wipe and getting three rows six weeks old, each unmistakably dated; and the badge.
|
||||||
|
|
||||||
|
#### The walk found three defects, and 644 green tests found none of them
|
||||||
|
|
||||||
|
- **The drawer's live count resolved once per process.** It was keyed on the capability answer
|
||||||
|
alone, so it was read at connect and never again — which is not what *live* means on a row
|
||||||
|
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge.
|
||||||
|
- **Every card's text sat flush against its edge.** `ShardCard` is the themed `Card` and carries
|
||||||
|
no padding of its own; each caller pads its own content, and these four did not. On a phone the
|
||||||
|
first glyph of each line read as clipped.
|
||||||
|
- **A name touched its own kill count.** Five numeric columns beside an equal-weight name column
|
||||||
|
left *Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes,
|
||||||
|
and the **active sort is marked on the header** rather than by tinting a column of numbers — the
|
||||||
|
header is the control, and tinting the values says *these are special* instead of *this is what
|
||||||
|
the table is ordered by*.
|
||||||
|
|
||||||
|
#### The rig note worth keeping
|
||||||
|
|
||||||
|
The debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost` only**
|
||||||
|
— not `10.0.2.2`. An emulator walk against a local core therefore needs
|
||||||
|
`adb reverse tcp:<port> tcp:<port>` and the loopback address; typed as `10.0.2.2` every request
|
||||||
|
fails with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, which the
|
||||||
|
connect screen reports — correctly, and indistinguishably from a core that is not running.
|
||||||
|
|
||||||
|
- **Excluded**, in the same class as every earlier milestone's exclusions: the Rust **admin**
|
||||||
|
surface. Server configuration, the sidecar token and the connection test are admin
|
||||||
|
*configuration*, which the app consumes and does not edit. Identity and permissions are legs B
|
||||||
|
and C (phases 8 and 11), and the map is leg D.
|
||||||
|
|
||||||
|
16. **M15 — the player's own Rust account** (post-v1; built 2026-09-22). `module-rust` phase 8, and
|
||||||
|
R10's leg B: identity (phase 6) and the half of site-owned permissions (phase 7) a player is
|
||||||
|
allowed to see. Every route existed and answered before a line of Kotlin was written except one,
|
||||||
|
`GET /player/rust/permissions`, which this phase added on the website for exactly this screen.
|
||||||
|
|
||||||
|
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §22 for the phase as
|
||||||
|
built and its three decisions. The contract is normative there; this entry records what the app
|
||||||
|
does about it.
|
||||||
|
|
||||||
|
#### It is `CharactersScreen` for a different game, deliberately
|
||||||
|
|
||||||
|
The org lead's instruction was to mirror `module-uo`, and the mirror is exact: **one drawer row
|
||||||
|
under the player group**, with the code card at the top of the screen it opens — the same shape
|
||||||
|
UO has had since M4, where the `[link` card sits above the character rosters. Two alternatives
|
||||||
|
were rejected for reasons the mirror makes obvious: a tab under one Rust server (a link is
|
||||||
|
**fleet-wide** — one Steam account is one person on every server, while stats are per server and
|
||||||
|
per wipe), and a section inside core's own Account screen (the app has no slot mechanism, so the
|
||||||
|
module's data would be hard-wired into a core screen).
|
||||||
|
|
||||||
|
| Screen | Route | Reads |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **My Rust account** | `player/rust` | `GET /player/rust/links`, `POST /player/rust/link`, `DELETE …/links/{steamId}`, `GET /player/rust/permissions` |
|
||||||
|
|
||||||
|
#### The gate is `rust`, and `PLAYER` means staff too
|
||||||
|
|
||||||
|
`module-uo`'s five shard rows all hang on `shard`, and this hangs on `rust` for the same reason
|
||||||
|
D16 gave: a capability answers *is the module there*, and core flattens every started module's
|
||||||
|
capabilities into one list, so a surface word like `identity` is not something a row may hang
|
||||||
|
on. `MenuAccess.PLAYER` is `isPlayer || isStaff`, which is right here — `/player/rust/*` is
|
||||||
|
`requireAuth` with no role above it, and staff play the game as well.
|
||||||
|
|
||||||
|
#### Two reads, and neither blocks the other
|
||||||
|
|
||||||
|
The accounts and the entitlements load separately and fail separately. That is not tidiness: an
|
||||||
|
entitlement is authored against the **website** account, so it exists before a Steam id does, and
|
||||||
|
the person who has just been given something and has not linked yet is exactly the one who needs
|
||||||
|
to see both halves at once. The screen says so in as many words when nothing is linked.
|
||||||
|
|
||||||
|
**The app does no scope arithmetic.** `*` never reaches a screen: each entry arrives carrying the
|
||||||
|
servers its scope reaches, each already marked *has it* or *waiting*, because a second
|
||||||
|
implementation of that rule is a second thing to keep true.
|
||||||
|
|
||||||
|
#### The refusals stay four pieces of advice
|
||||||
|
|
||||||
|
A refusal is chosen by **status** and rendered from a string resource, the convention every
|
||||||
|
earlier milestone follows (the app is localized; the website's sentence is not). 400 is a spent
|
||||||
|
code, 409 is a Steam account another website account holds — released with `/unlink` in game,
|
||||||
|
never moved silently — 429 is the server's limiter, and 503 is a server that could not be
|
||||||
|
reached. The last of those may **not** say "get a new code": the code is still good, and a player
|
||||||
|
told otherwise goes back to the same unreachable server for another one.
|
||||||
|
|
||||||
|
The known cost, written down rather than discovered later: the website distinguishes three 503s
|
||||||
|
by sentence (one server unreachable, the whole fleet down, no servers configured at all) and the
|
||||||
|
app has one string for the status, written to be true of all three.
|
||||||
|
|
||||||
|
#### Verified
|
||||||
|
|
||||||
|
The app suite (**657 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
|
||||||
|
against a core with the module installed and a **live Oxide rig** behind it — the first Rust leg
|
||||||
|
where the backend was talking to a real game server rather than a stand-in.
|
||||||
|
|
||||||
|
Walked directly: the row absent signed-out and absent on a UO site, present for a signed-in
|
||||||
|
player; both reads; a code the live plugin genuinely refused, with its advice rendering **beside
|
||||||
|
the button** rather than at the top of a long form; a rank marked *has it* and a grant marked
|
||||||
|
*waiting* on the same screen, which is what the pushed ledger actually said; and the release.
|
||||||
|
|
||||||
|
#### The walk found one thing the suite did not
|
||||||
|
|
||||||
|
**The row said who, and not when or where.** The website's row has always read "linked just now
|
||||||
|
on rust-oxide"; the app's carried the name and the Steam id and stopped. Neither fact is part of
|
||||||
|
the identity — a link is fleet-wide — but which server minted the code is where a support
|
||||||
|
conversation starts, and the module's own schema says so in a comment.
|
||||||
|
|
||||||
|
- **Excluded**, in the same class as M14's exclusions: the Rust **admin** permission surface.
|
||||||
|
Authoring grants, groups and drift is `requireRole('admin')` on the website, the app has no
|
||||||
|
admin user-detail screen to put it in, and it writes into a running game — the phone is where
|
||||||
|
you read what you hold, not where you decide what somebody else holds.
|
||||||
|
- **No deep link yet.** `/player/rust` is not in the app's web-path table, deliberately:
|
||||||
|
`module-uo`'s player screens are not either, and that table is built from the *public* nav.
|
||||||
|
Phase 10 is when it will matter, because a notification about an entitlement will want
|
||||||
|
somewhere to land.
|
||||||
|
|
||||||
|
#### Amended 2026-09-22 — M14's Online tab and feed stop naming players by default
|
||||||
|
|
||||||
|
Not a milestone of its own: a correction to what M14 shipped, made on the website first
|
||||||
|
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §23). **Nothing names who is online by
|
||||||
|
default** — the module now withholds the Online list, and every feed item that says a named
|
||||||
|
player was on, from anyone below an operator-chosen audience (staff unless widened), while the
|
||||||
|
player **count** stays public.
|
||||||
|
|
||||||
|
The app's part is to never read that as *nobody is on*. `RustOnlineDto` carries `hidden`,
|
||||||
|
`count` and `audience`, and `RustEventListDto` carries `presenceHidden` and `presenceAudience`;
|
||||||
|
the repository and view model keep the whole answer rather than its rows. The Online tab says
|
||||||
|
"2 players online" and who can see the names; the feed says once, above the rows, that joins,
|
||||||
|
deaths and chat are not shown. An older module without the flags decodes as visible, as before.
|
||||||
|
Walked on an emulator: the withheld panel at the default, and the names arriving on the next poll
|
||||||
|
once the fleet was widened to signed-in — the app's bearer session reaching the module's viewer
|
||||||
|
check. Branch `fix/rust-presence-visibility`.
|
||||||
|
|
||||||
|
17. **M16 — a Rust notification on a phone** (post-v1; built 2026-09-23). `module-rust` phase
|
||||||
|
11, R10's leg C: the app half of phase 10's notifications. **Design of record:
|
||||||
|
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §26**, with its four decisions (D69–D72).
|
||||||
|
|
||||||
|
The inbox (engagement phase 8) and the preferences screen (engagement phase 3) are drawn from the
|
||||||
|
wire, so every Rust trigger already arrives as a row, with no app change. This milestone covers
|
||||||
|
the three places where the app still assumed it was talking to a UO shard:
|
||||||
|
|
||||||
|
- **The link check** behind the *Your game account* section asked `module-uo`'s
|
||||||
|
`/player/shard/accounts`. On a Rust site that locks the raid alert in every channel. It will
|
||||||
|
ask the site's own module, chosen by capability (`rust` → `/player/rust/links`). Switching a
|
||||||
|
channel **off** is never gated, and only switching push **on** needs a link.
|
||||||
|
- **A push is titled from the item it points at.** A `notification:<id>` ref is pulled over the
|
||||||
|
authenticated inbox API, and the relay still carries nothing. The per-stream title stays as the
|
||||||
|
fallback and as the lock-screen version. The M7 items that said nothing is
|
||||||
|
fetched to show a notification are amended in place.
|
||||||
|
- **`/player/rust` and `/rust/servers/<id>?tab=<tab>` open natively.** M15 deferred the first
|
||||||
|
of these to exactly this phase.
|
||||||
|
|
||||||
|
It also closes **M7's open QA item**. On-device delivery against a live ntfy has never been
|
||||||
|
walked. It is walked here, on `ntfy.sh` (D72).
|
||||||
|
|
||||||
|
#### As built (2026-09-23)
|
||||||
|
|
||||||
|
Branch `feat/rust-phase-11-notifications`. The app suite is **672 tests, 0 failures**, up from
|
||||||
|
657, with `lintDebug` and `assembleDebug` green. It was walked on the `s22_ultra` emulator
|
||||||
|
against real core on `main`, with the module installed, the protocol-7 sidecar inside the Oxide
|
||||||
|
rig's container, and the relay on `ntfy.sh`:
|
||||||
|
|
||||||
|
- **Linked (raidowner1):** the raid alert's push switched on, and the device registered
|
||||||
|
`https://ntfy.sh/<topic>`. A door raided with phase 10's rig helper reached the phone
|
||||||
|
**19 seconds** later as *"Your base is being raided — A door was destroyed in P16 on Oxide
|
||||||
|
rig."* The notification was `vis=PRIVATE`, with a public version carrying only the generic
|
||||||
|
title. Tapping it opened the inbox, and the row opened the Oxide rig's screen natively.
|
||||||
|
- **Switched off in the app:** push off, and the next raid enqueued in-app only. The inbox row
|
||||||
|
arrived and no system notification was posted. With nothing else on push, the device was
|
||||||
|
unregistered and the push service stopped.
|
||||||
|
- **Not linked (walkadmin):** push held back with the hint, while email and in-app stayed live.
|
||||||
|
In-app was switched off, stored as `off`, and switched back on.
|
||||||
|
- **D71:** a row linking `?tab=leaderboard` opened on the Leaderboard tab, and `/player/rust`
|
||||||
|
opened *My Rust account*. `?tab=clans` went to a Custom Tab.
|
||||||
|
|
||||||
|
**Not walked:** a UO site (no UO core was running; the path is the call it always was, and a test
|
||||||
|
pins it), and the lock-screen rendering (the emulator has no screen lock; the public version was
|
||||||
|
read back from `dumpsys notification`).
|
||||||
|
|
||||||
### Deferred (not a milestone)
|
### Deferred (not a milestone)
|
||||||
|
|
||||||
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
||||||
@@ -1529,7 +1801,9 @@ of push.
|
|||||||
push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends
|
push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends
|
||||||
anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the
|
anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the
|
||||||
route it has always had. The ref is never decoded past that prefix and never rendered - it is a hint
|
route it has always had. The ref is never decoded past that prefix and never rendered - it is a hint
|
||||||
that a row exists, and the contract stays wake-and-pull.
|
that a row exists, and the contract stays wake-and-pull. *(M16 decodes it once more, to the id,
|
||||||
|
and only to pull that row over the authenticated inbox API and title the notification with it. It is
|
||||||
|
still never rendered.)*
|
||||||
|
|
||||||
**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off
|
**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off
|
||||||
`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire:
|
`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire:
|
||||||
|
|||||||
2380
modules/rust/PLAN.md
2380
modules/rust/PLAN.md
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ differ.
|
|||||||
| [`DEFINITIONS.md`](DEFINITIONS.md) | **What things are called.** 678 items (short name, id, display name) and 2,590 workshop skin ids across 104 items. |
|
| [`DEFINITIONS.md`](DEFINITIONS.md) | **What things are called.** 678 items (short name, id, display name) and 2,590 workshop skin ids across 104 items. |
|
||||||
| [`OPERATING.md`](OPERATING.md) | **How it gets run.** The 6 operator pages — installing Oxide on a server, then installing, configuring and permissioning plugins. |
|
| [`OPERATING.md`](OPERATING.md) | **How it gets run.** The 6 operator pages — installing Oxide on a server, then installing, configuring and permissioning plugins. |
|
||||||
| [`agent/`](agent/README.md) | The same facts in **machine shape** — TSV and JSONL, ~46% of the tokens. Generated in the same pass, so it cannot drift. |
|
| [`agent/`](agent/README.md) | The same facts in **machine shape** — TSV and JSONL, ~46% of the tokens. Generated in the same pass, so it cannot drift. |
|
||||||
| [`CARBON.md`](CARBON.md) | **The other framework.** Where Carbon diverges from Oxide and nowhere else — file layout, the permission store, the `c.` commands, 30 Carbon-only hooks and 13 uMod names its catalogue omits. Sourced from Carbon's own metadata and source, **not yet proven on a live Carbon server.** |
|
| [`CARBON.md`](CARBON.md) | **The other framework.** Where Carbon diverges from Oxide and nowhere else — file layout, the permission store, the `c.` commands, 30 Carbon-only hooks and 13 uMod names its catalogue omits. Sourced from Carbon's own metadata and source, and **proven on a live Carbon 2.0.259.0 server** — R19 at phase 0, and the whole read path at phase 3. |
|
||||||
|
|
||||||
**The one file here that is ours:** [`PLAN.md`](PLAN.md) — the schedule and the decisions of record
|
**The one file here that is ours:** [`PLAN.md`](PLAN.md) — the schedule and the decisions of record
|
||||||
for actually building `module-rust`. Everything else in this directory is copied from uMod; that one
|
for actually building `module-rust`. Everything else in this directory is copied from uMod; that one
|
||||||
|
|||||||
135
rust-link/INSTALL_RIG.md
Normal file
135
rust-link/INSTALL_RIG.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# The sidecar inside the game container — the Pterodactyl rig recipe
|
||||||
|
|
||||||
|
**Added 2026-09-22, during phase 8.** It is written here rather than in a phase section because it
|
||||||
|
is not a phase: it is how the rigs are wired from now on, and it is the shape
|
||||||
|
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) R20 says the **egg** ships in phase 18.
|
||||||
|
|
||||||
|
Until now the rigs ran the sidecar on a development machine and the game on the Pterodactyl node,
|
||||||
|
which meant the plugin had to dial *out across a LAN* to reach it. That contradicts D2 — the game
|
||||||
|
link is loopback and carries no token, precisely because it is not supposed to leave the host — and
|
||||||
|
it is why the acceptance line of phases 6, 7 and 7b each ended at a firewall rule.
|
||||||
|
|
||||||
|
**The fix is not a firewall rule. It is putting the sidecar where the design always said it lives.**
|
||||||
|
A container's `127.0.0.1` is genuinely private, so a stock plugin config and a stock sidecar find
|
||||||
|
each other with nothing configured at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What goes on the volume
|
||||||
|
|
||||||
|
Two files under `/home/container/rust-link/`, plus whatever the sidecar writes beside them:
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `rust-link-sidecar` | A **statically linked** Linux binary (`x86_64-unknown-linux-musl`), `chmod 755`. Static because the game image is not ours and its glibc is not a contract |
|
||||||
|
| `with-sidecar.sh` | The launcher below, `chmod 755` |
|
||||||
|
| `sidecar.toml` | Written by the sidecar itself on first run, with a generated token. Env overrides it |
|
||||||
|
| `rust-link.db` | The store. **It must never appear in the egg's `REMOVE_FILES`** — R12 keeps all-time rollups across wipes, and a swept store is the failure that looks like success |
|
||||||
|
|
||||||
|
Building the binary needs no Rust toolchain on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "$PWD/rust-link/sidecar:/src" -v "$PWD/out:/out" rust:1-slim-bookworm bash -c '
|
||||||
|
apt-get update -qq && apt-get install -y -qq musl-tools >/dev/null
|
||||||
|
rustup target add x86_64-unknown-linux-musl
|
||||||
|
cd /src && CARGO_TARGET_DIR=/build cargo build --release --target x86_64-unknown-linux-musl
|
||||||
|
cp /build/x86_64-unknown-linux-musl/release/rust-link-sidecar /out/'
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload both files with the panel's **client** API (`POST /api/client/servers/{id}/files/write`,
|
||||||
|
raw body — it creates missing parent directories), then `files/chmod` them. **Chmod one file per
|
||||||
|
call:** a two-entry `files` array applied only the first, silently, on this panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The launcher
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
RL=/home/container/rust-link
|
||||||
|
mkdir -p "$RL"
|
||||||
|
|
||||||
|
export RUSTLINK_CONFIG="$RL/sidecar.toml"
|
||||||
|
: "${RUSTLINK_DB_PATH:=$RL/rust-link.db}"
|
||||||
|
export RUSTLINK_DB_PATH
|
||||||
|
|
||||||
|
for v in RUSTLINK_GAME_BIND RUSTLINK_WEB_BIND RUSTLINK_SERVER_ID RUSTLINK_WEB_TOKEN RUSTLINK_RETAIN_DAYS; do
|
||||||
|
eval "val=\${$v-}"
|
||||||
|
if [ -n "$val" ]; then export "$v"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
env -u LD_PRELOAD "$RL/rust-link-sidecar" >> "$RL/sidecar.log" 2>&1 &
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things in it are load-bearing, and each is a thing that went wrong first:
|
||||||
|
|
||||||
|
- **`env -u LD_PRELOAD` for the sidecar.** Carbon's entrypoint prepends
|
||||||
|
`LD_PRELOAD=$(pwd)/libdoorstop.so` to the **whole** startup string, so without this the Mono
|
||||||
|
preloader is injected into a static Rust binary that has never heard of it. RustDedicated still
|
||||||
|
inherits it from this script's environment and still boots modded — which is the composition R20
|
||||||
|
left unsettled, and this is the answer.
|
||||||
|
- **`exec "$@"` for the game.** The game *becomes* this process, so the panel console keeps its
|
||||||
|
stdin and stdout and **stop still stops the server** — which then takes the sidecar down with the
|
||||||
|
container. A `wait` here instead would leave the panel talking to a shell.
|
||||||
|
- **An unset variable is never exported.** The sidecar's precedence is env > file > default, and
|
||||||
|
exporting `RUSTLINK_WEB_TOKEN=""` would override a perfectly good token in `sidecar.toml` with
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
## The startup command
|
||||||
|
|
||||||
|
The launcher is a **prefix** on the egg's own startup, with the sidecar's settings in front of it
|
||||||
|
the way egg variables will supply them in phase 18 (R22):
|
||||||
|
|
||||||
|
```
|
||||||
|
RUSTLINK_WEB_BIND=0.0.0.0:<sidecar allocation> RUSTLINK_SERVER_ID=<server id> \
|
||||||
|
RUSTLINK_WEB_TOKEN=<token> ./rust-link/with-sidecar.sh <the egg's unchanged startup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Set it with the **application** API (`PATCH /api/application/servers/{id}/startup`, sending the
|
||||||
|
server's existing `environment`, `egg` and `image` back unchanged with `skip_scripts: true`).
|
||||||
|
|
||||||
|
**Shell operators cannot be used here.** The image's entrypoint runs the startup string through
|
||||||
|
`eval echo` before handing it to `node /wrapper.js`, so an `&` in it would background the *eval*
|
||||||
|
and a quoted sub-shell would lose its quotes. A wrapper program that `exec`s the rest is the shape
|
||||||
|
that survives that, which is why the launcher takes the game command as arguments rather than
|
||||||
|
containing it.
|
||||||
|
|
||||||
|
## Wiring the website to it
|
||||||
|
|
||||||
|
The sidecar's web API is on the **second allocation**, so from the site it is
|
||||||
|
`http://<node ip>:<sidecar allocation>` with the token above — the ordinary Admin → Rust server row,
|
||||||
|
no tunnel and no rule. `POST /admin/rust/servers/{id}/test` should answer with
|
||||||
|
`plugin_connected: true` and the protocol version.
|
||||||
|
|
||||||
|
The plugin needs **no configuration**: `oxide/config/RunicGateway.json`'s defaults
|
||||||
|
(`127.0.0.1:7799`) are already right, which is the clearest statement of why the sidecar belongs in
|
||||||
|
the container.
|
||||||
|
|
||||||
|
## What it proved, first time
|
||||||
|
|
||||||
|
On `rust-oxide` (egg 18, `ghcr.io/pterodactyl/games:rust`), from a cold start:
|
||||||
|
|
||||||
|
```
|
||||||
|
web server listening addr=0.0.0.0:21004
|
||||||
|
plugin connected peer=127.0.0.1:51148
|
||||||
|
{"kind":"server.hello","protocol":5,"serverId":"rust-oxide",...}
|
||||||
|
```
|
||||||
|
|
||||||
|
— the sidecar bound its allocation **29 seconds** before the world had finished generating, and the
|
||||||
|
plugin found it on loopback as soon as Oxide loaded. `/health` from another machine on the LAN
|
||||||
|
answered `plugin_connected: true`.
|
||||||
|
|
||||||
|
## Still open for phase 18
|
||||||
|
|
||||||
|
- **The egg's own variables.** `RUSTLINK_*` are not egg variables yet, so they live in the startup
|
||||||
|
string on the rigs. Pterodactyl rejects environment keys an egg does not declare, which is exactly
|
||||||
|
what R22's variable block is for.
|
||||||
|
- **Carbon.** The launcher is written for it and the reasoning above is specific about why, but at
|
||||||
|
the time of writing it has run on the Oxide rig only. The two rigs cannot be up at once on this
|
||||||
|
node, so this is a walk to run, not a claim to repeat.
|
||||||
|
- **The framework is reinstalled on every boot** (Carbon from `production_build`, Oxide from
|
||||||
|
`releases/latest`), so a restart is a framework upgrade and neither is pinnable. Unchanged by any
|
||||||
|
of this, and still the reason a rig can differ from itself between two runs.
|
||||||
@@ -164,6 +164,17 @@ Two failures that look alike and are not:
|
|||||||
says what was true when it was written; nothing has written it since. Either the poll is failing
|
says what was true when it was written; nothing has written it since. Either the poll is failing
|
||||||
(the website's log) or the sidecar stopped (its own).
|
(the website's log) or the sidecar stopped (its own).
|
||||||
|
|
||||||
|
### 3.0 `untyped_frames` on `/health` is not zero
|
||||||
|
|
||||||
|
**The plugin and the sidecar are on different protocol versions.** The game link has no handshake
|
||||||
|
to catch that at connect time (`PROTOCOL.md` §2), so it shows up here instead: the sidecar files a
|
||||||
|
frame by its `type`, a frame from the wrong version does not carry one it recognises, and it is
|
||||||
|
dropped and counted rather than guessed at.
|
||||||
|
|
||||||
|
The symptom without this counter is the confusing one — a game server plainly up, a sidecar plainly
|
||||||
|
healthy, and a website showing nothing. Check the plugin's `rg.link` (it prints its protocol) against
|
||||||
|
the sidecar's `/health` (which prints its own) and upgrade whichever is behind.
|
||||||
|
|
||||||
### 3.1 The failures that are supposed to happen
|
### 3.1 The failures that are supposed to happen
|
||||||
|
|
||||||
Three things look like breakage and are the design:
|
Three things look like breakage and are the design:
|
||||||
@@ -193,6 +204,92 @@ you get a warning naming both ids.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 4.1 What the bridge sends, and how much of it is kept
|
||||||
|
|
||||||
|
From protocol 2 the plugin sends the read path: connects and disconnects, deaths, chat, gathering,
|
||||||
|
bans and reports, and the wipe. Two things about the volume are worth knowing before you size
|
||||||
|
anything.
|
||||||
|
|
||||||
|
**Gathering and NPC kills are counted, not forwarded.** `OnDispenserGather` fires on every swing at
|
||||||
|
a tree; sending one frame per swing would make the bridge the most expensive thing on the server. The
|
||||||
|
plugin keeps a per-player tally and flushes it once a minute as a single `player.tally` frame. So the
|
||||||
|
leaderboard is exact and the wire is quiet.
|
||||||
|
|
||||||
|
**The sidecar's history is bounded; the website's is not.** `[store].retain_days` (default 14) is
|
||||||
|
how long the sidecar keeps raw events. The permanent record — per-wipe totals that survive a wipe —
|
||||||
|
lives in the website's own tables, so shortening this loses recent detail and never loses a player's
|
||||||
|
history. Set it to `0` to keep everything, if the host's disk is yours to spend.
|
||||||
|
|
||||||
|
**From protocol 3 your players can link their Steam account.** In game they type `/link` and the
|
||||||
|
server answers them privately with a six-character code; on the website they type that code in
|
||||||
|
within five minutes and the two are joined. Nothing about the link is stored on the game host — the
|
||||||
|
website owns the record, and `/unlink` in game asks it to let go.
|
||||||
|
|
||||||
|
Two things an operator should know about it:
|
||||||
|
|
||||||
|
- **The code is never in a frame.** It reaches the player and nobody else, which is what makes typing
|
||||||
|
it into a signed-in browser proof that they are the one who asked. What crosses the bridge is
|
||||||
|
`account.link.requested`, a staff-visible note that somebody asked.
|
||||||
|
- **A Steam account can belong to one website account at a time, across your whole fleet.** A code
|
||||||
|
from any of your servers links for all of them. If somebody links the wrong account the site
|
||||||
|
refuses to move it — the player runs `/unlink` in game, or staff release it from the user's page in
|
||||||
|
the admin panel.
|
||||||
|
|
||||||
|
**From protocol 4 the website owns your permissions.** Groups and grants are written in
|
||||||
|
Admin → Rust permissions and pushed into this server's own Oxide/Carbon permission store, so every
|
||||||
|
plugin you already run honours them — Kits, ZoneManager, anything that calls `UserHasPermission`.
|
||||||
|
Nothing is required of those plugins and nothing is configured twice.
|
||||||
|
|
||||||
|
Four things an operator should know about it, because each looks like something else from the game
|
||||||
|
side:
|
||||||
|
|
||||||
|
- **A wipe does not lose them.** The site re-pushes the whole set when the server comes back. If your
|
||||||
|
wipe script clears `oxide/data/`, the permissions the site authored are back within a minute of the
|
||||||
|
server being up; ones granted at the console are not, because nothing remembers those.
|
||||||
|
- **Granting at the console still works, and the website notices.** A hand edit is reported as
|
||||||
|
drift on that screen and is **never** undone on its own — an operator is offered two answers to
|
||||||
|
it: adopt it, so the site maintains it from then on, or revoke it. That is deliberate: a console
|
||||||
|
grant during an incident must survive the next sync.
|
||||||
|
- **A permission no loaded plugin has registered cannot be granted.** Oxide's own API silently does
|
||||||
|
nothing for an unknown name, so the site checks first and reports the name as unresolved instead
|
||||||
|
of claiming a privilege nobody has. Load the plugin and the grant lands by itself.
|
||||||
|
- **A player who has never connected to that server can hold a grant but cannot be in a group.**
|
||||||
|
The store has no record of them to put in a group yet; the site says which memberships are waiting
|
||||||
|
and they land on that player's first connection.
|
||||||
|
|
||||||
|
`rg.perms` at the server console prints what the last sync did, which is the fastest way to tell
|
||||||
|
"that permission does not exist here" from "that player has never been seen here".
|
||||||
|
|
||||||
|
**From protocol 5 you can edit your plugins' settings from the website**, in Admin → Rust mod
|
||||||
|
config. It reads the configuration directory your framework actually uses — `oxide/config` or
|
||||||
|
`carbon/configs`, or wherever you moved it — and generates a form from the values it finds, so it
|
||||||
|
works for whatever you have installed. Four things worth knowing before you use it:
|
||||||
|
|
||||||
|
- **A save reloads the plugin and watches the reload.** If the plugin does not come back within four
|
||||||
|
seconds, the old file is **restored automatically** and the site shows you the log line that says
|
||||||
|
why. A typo costs you a few seconds, not a plugin.
|
||||||
|
- **Your data directory is not listed, deliberately.** `oxide/data` (or `carbon/data`) holds live
|
||||||
|
state — kit cooldowns, zone definitions, the permission store itself — not settings. Editing it
|
||||||
|
from a web form edits your players' cooldowns, and a running plugin overwrites the change on its
|
||||||
|
next save anyway.
|
||||||
|
- **Which plugin gets reloaded is your choice, with a guess filled in.** A folder name is
|
||||||
|
convention, not contract, so the site suggests one and lets you change it. The suggestion is right
|
||||||
|
nearly always and wrong silently when it is wrong, which is why it is a field rather than an
|
||||||
|
assumption.
|
||||||
|
- **This bridge's own `Host`, `Port` and `ServerId` are read-only there.** Changing them from the
|
||||||
|
website would cut the link carrying the change, or strand every row the site holds for this
|
||||||
|
server. Edit them on the host; everything else in that file is editable from the site.
|
||||||
|
|
||||||
|
`rg.config` at the server console prints which directory the site is reading and what the last write
|
||||||
|
from it did.
|
||||||
|
|
||||||
|
**Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and
|
||||||
|
stamps it on every frame, so a wipe splits the history rather than ending it. That is also why
|
||||||
|
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's
|
||||||
|
`REMOVE_FILES`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. Upgrading
|
## 5. Upgrading
|
||||||
|
|
||||||
The four declaration sites in [`PROTOCOL.md` §2](PROTOCOL.md#2-versioning) must agree. In practice
|
The four declaration sites in [`PROTOCOL.md` §2](PROTOCOL.md#2-versioning) must agree. In practice
|
||||||
|
|||||||
242
rust-link/PLAYER_WALK.md
Normal file
242
rust-link/PLAYER_WALK.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# The player walk — proving the half of the read path a console cannot reach
|
||||||
|
|
||||||
|
Protocol 2's catalogue divides cleanly in two, and the line is not about importance: it is about
|
||||||
|
whether a hook can fire without somebody holding a mouse.
|
||||||
|
|
||||||
|
Everything in the first half was proven from a console and a REST client while phase 3 was built —
|
||||||
|
the boards, the wipe id, the envelope, bans, the server lifecycle. Everything below needs a **real
|
||||||
|
player on a real server**, because the hooks carry a `BasePlayer`, a `HitInfo` or a chat line, and
|
||||||
|
none of those three can be manufactured from a console without becoming a different test.
|
||||||
|
|
||||||
|
This document is the walk that closes it. It takes about ten minutes, it is the same on Oxide and on
|
||||||
|
Carbon, and it is written so that the answer is readable afterwards rather than watched live.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
1. A rig running, with `RunicGateway.cs` loaded — `oxide.plugins` (or `c.plugins`) lists *Runic
|
||||||
|
Gateway*, and `rg.link` answers `connected=True`.
|
||||||
|
2. A sidecar the rig can reach, with its store **empty** — that is what makes the event list at the
|
||||||
|
end readable as a transcript of the walk and nothing else.
|
||||||
|
3. The sidecar's token to hand, for the reads at the bottom.
|
||||||
|
|
||||||
|
Run this once, before you join:
|
||||||
|
|
||||||
|
```
|
||||||
|
rg.hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
Every player hook should read **silent**. That is the baseline: the point of the walk is to move
|
||||||
|
them, and starting from a run where some already fired proves less.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The walk
|
||||||
|
|
||||||
|
Do these in order. The order matters only in two places, noted where it does.
|
||||||
|
|
||||||
|
| # | Do this | Fires | The frame should carry |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Join the server** | `CanUserLogin`, `OnUserApproved`, `OnPlayerConnected` | Three frames, in that order. The first two carry your **IP address** — check it is a real address and not the string `0`. `player.connected` carries your steam id and name |
|
||||||
|
| 2 | **Wake up / spawn in** (click Respawn if you are dead) | `OnPlayerRespawned` | `player.respawned`, steam id only. It does **not** fire if you simply wake from sleeping — that is the hook's own documented behaviour, so no frame here is a pass, not a failure |
|
||||||
|
| 3 | **Say something in chat**, then **say something in team chat** if you have a team | `OnPlayerChat` | Two `player.chat` frames, with `channel` reading `Global` and `Team`. The message must arrive whole — if it is truncated or the frame is missing, the flattener ate it |
|
||||||
|
| 4 | **Chop a tree for about twenty seconds**, then **mine a node** | `OnDispenserGather` | **Nothing immediately.** This is the aggregate: one `player.tally` frame within 60 seconds, carrying `gathered` with `wood` and `stones`, summed. Seeing a frame per swing would be the bug |
|
||||||
|
| 5 | **Kill an animal or a scientist** | `OnEntityDeath` | Again nothing immediately — `npcKills` on the next `player.tally`. No `player.death`: a chicken is not a killfeed entry |
|
||||||
|
| 6 | **Die to the environment** — fall damage is easiest | `OnPlayerDeath` | `player.death` with `attackerType: "environment"`, a `grid` like `H7`, and **no** `attackerId`. Check the grid against the map: a wrong sign in the row arithmetic mirrors the whole map, and only a human with the map open can see that |
|
||||||
|
| 7 | **Kill yourself** — `kill` in the F1 console | `OnPlayerDeath` | `attackerType: "self"`, no `attackerId` |
|
||||||
|
| 8 | **If a second player is available**: kill each other once | `OnPlayerDeath` | `attackerType: "player"`, with `attackerId`, `attackerName`, a `weapon` shortname and a `distance` in metres. This is the killfeed's whole shape, and it is the one row phase 4's page is built from |
|
||||||
|
| 9 | **Build a foundation, then destroy it yourself** | `OnEntityDeath` | `entity.destroyed` with `ownerId` (yours), `prefab`, `grid` and `attackerId`. Decay must **not** produce one of these — only a player breaking it |
|
||||||
|
| 10 | **Disconnect** | `OnPlayerDisconnected` | `player.disconnected` with a `reason` and a **`sessionSec`** roughly equal to how long you were on. It also flushes your tally first, so any gathering since the last minute arrives immediately before it |
|
||||||
|
|
||||||
|
Two ordering notes: step 4 must come before step 10 by at least a minute if you want to see the
|
||||||
|
cadence flush rather than the disconnect flush, and step 1's three frames are the only place the
|
||||||
|
order between hooks is itself part of the answer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reading the result
|
||||||
|
|
||||||
|
From the machine running the sidecar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=… # [web].auth_token from sidecar.toml, or `--print-config`
|
||||||
|
BASE=http://127.0.0.1:8090
|
||||||
|
|
||||||
|
# The whole walk, oldest first, as a transcript.
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/feed?since=0&limit=500" \
|
||||||
|
| python -m json.tool
|
||||||
|
|
||||||
|
# Or one kind at a time.
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/events?kind=player.death&limit=20"
|
||||||
|
```
|
||||||
|
|
||||||
|
And from the game console:
|
||||||
|
|
||||||
|
```
|
||||||
|
rg.hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
Every hook in the walk should now read **fired**, with a count. A hook still `silent` after the step
|
||||||
|
that should have fired it is the finding — and on Carbon it is the specific question
|
||||||
|
[`CARBON.md`](../modules/rust/CARBON.md) §6 asks, since Carbon's catalogue omits thirteen uMod names
|
||||||
|
and nobody has yet checked whether they are renames or holes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What counts as a pass
|
||||||
|
|
||||||
|
Not "frames arrived". Three things, and the third is the one worth slowing down for:
|
||||||
|
|
||||||
|
1. **Every hook in the table fired**, on both frameworks, from the same plugin file.
|
||||||
|
2. **Every frame carries the envelope** — `type`, `serverId` and `wipeId` on all of them
|
||||||
|
([`PROTOCOL.md`](PROTOCOL.md) §8.1). A player frame without a `wipeId` cannot be attributed to a
|
||||||
|
wipe and its rollup is lost.
|
||||||
|
3. **The aggregates are aggregates.** `player.tally` is a delta since the last flush, so two minutes
|
||||||
|
of chopping is two frames that sum to the total, not two frames each carrying the total. Getting
|
||||||
|
this backwards makes every leaderboard roughly double, and it looks correct until somebody counts.
|
||||||
|
|
||||||
|
Anything that disagrees with the table is a finding about the game or the framework rather than a
|
||||||
|
mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The identity walk (protocol 3, phase 6)
|
||||||
|
|
||||||
|
Added 2026-09-21, and here for the same reason as everything above: **a link code reaches a player
|
||||||
|
and nobody else**, so no console can read one. The site's own half was walked in a browser — the
|
||||||
|
refusals, the admin panel, staff unlink, the rate limit — and what needs a person in game is the
|
||||||
|
three steps below.
|
||||||
|
|
||||||
|
It takes two minutes, and it wants **two website accounts** — one you will link, one you will try to
|
||||||
|
link the same Steam account to.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **In game, type `/link`** | A private reply with a six-character code and a five-minute deadline. Check it is private: a second player on the server must not see it. The code has **no O, 0, I or 1** in it — those glyphs are not in the alphabet, so one in your code is a finding |
|
||||||
|
| 2 | **Type `/link` again straight away** | *"Please wait a moment…"* — the thirty-second cooldown. The first code is now dead either way: a new request drops the old one, so only the newest ever works |
|
||||||
|
| 3 | **On the website, sign in and open `/player/rust`. Type the code** | The account appears, named as the game knows you, with the server it came from. Try the same code again: *"That code is unknown or has expired"* — it works once |
|
||||||
|
| 4 | **Sign in as the SECOND account and type a fresh code for the same Steam account** | Refused, naming the account that holds it: *"That Steam account is already linked to <name>. Run /unlink in game to release it."* The link must **not** move — it is what phase 7 grants permissions against |
|
||||||
|
| 5 | **In game, type `/unlink`** | The site's row disappears within one ingest tick (five seconds by default). Reload `/player/rust` to confirm — this is the frame arriving over the feed, not the page asking |
|
||||||
|
| 6 | **Type a code from a server whose sidecar you have just stopped** | *"One of the servers could not be reached… your code is still good — try again in a minute."* Distinct from step 3's refusal, and the distinction is the point: the code is fine and fetching another one would not help |
|
||||||
|
|
||||||
|
Step 6 needs a fleet of two, one of them down; on a single-server rig it reads *"The game servers are
|
||||||
|
unreachable right now"* instead, which is the same rule with nothing left to be unsure about.
|
||||||
|
|
||||||
|
**What counts as a pass here:** the code never appears anywhere but in front of the player who asked
|
||||||
|
for it (check the chat log and the sidecar's `/events?kind=account.link.requested` — the frame
|
||||||
|
carries the steam id, the name and a TTL, and **no code**), a Steam account belongs to one website
|
||||||
|
account at a time, and every refusal is a sentence that tells the player what to do next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The permission walk (protocol 4, phase 7)
|
||||||
|
|
||||||
|
Added 2026-09-21. The website half was walked end to end against a stand-in plugin — the authoring
|
||||||
|
screen, the report, drift and its two answers, and a restart that emptied the store and was fully
|
||||||
|
re-pushed. **What is left is the sentence the phase exists for: a grant made on the website gates a
|
||||||
|
third-party plugin in the game.**
|
||||||
|
|
||||||
|
It cannot be walked from a console, and it cannot be walked on the owner's account:
|
||||||
|
|
||||||
|
- **A console session bypasses every gate.** The standard idiom is
|
||||||
|
`return !player || permission.UserHasPermission(...)`, and an RCON command has no `BasePlayer` —
|
||||||
|
so the console is unconditionally allowed ([PLAN.md §12.5](../modules/rust/PLAN.md)).
|
||||||
|
- **An admin account bypasses most plugins' gates too**, and not uniformly: Popup Notifications
|
||||||
|
(`player.IsAdmin ||`) and Zone Manager (`authLevel > 0 ||`) are hard bypasses. Kits is the
|
||||||
|
exception — its `IsAdmin` is the `kits.admin` **permission** and `AdminIgnoreRestrictions`
|
||||||
|
defaults to `false` — so a kit's `RequiredPermission` does apply to a server owner.
|
||||||
|
|
||||||
|
So this walk wants a **second, non-admin Steam account** connected to the rig. Kits alone can be
|
||||||
|
walked on the owner's account; steps 4 and 5 cannot.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Link the second account** (the identity walk above), then on the website open Admin → Rust permissions and grant it a kit's `RequiredPermission` — pick the kit from `GetKitNames`, or read one out of `oxide/config/Kits.json` | The grant appears with the account beside it. Within a minute the server row reads **in sync** — or press *Sync now* and watch it happen |
|
||||||
|
| 2 | **In game on that account, open the kit menu** | The kit is no longer locked. Before the grant it shows as locked; that difference is the whole phase |
|
||||||
|
| 3 | **At the server console, `oxide.show user <steamid>`** | The permission is there, granted by this plugin rather than by hand |
|
||||||
|
| 4 | **At the console, `oxide.grant user <steamid> zonemanager.admin`** (a permission the site manages but did not grant) | Within seconds the website's screen shows it under *Changed in game*. **Revoke** it there, and it is gone from `oxide.show user` on the next sync. **Adopt** a different one instead and it stays, now listed as the site's own |
|
||||||
|
| 5 | **Put the second account in a group on the website, then wipe or restart the server** (a wipe script that clears `oxide/data/` is the interesting case) | After the server is back: the group exists again, the membership is back, and the grant is back — without anybody touching the website. This is R2's central promise and the one thing a stand-in cannot prove |
|
||||||
|
| 6 | **Grant a permission whose plugin you have just unloaded** | The site reports it **unresolved** against that server and keeps the grant. Load the plugin again: it lands on the next sync, with nothing typed |
|
||||||
|
| 7 | **Add a website account that has never connected to this server to a group** | The site reports the membership as *waiting on their first connection*. Have them connect: it lands. A **direct grant** to the same account, by contrast, is in `oxide.show user` immediately |
|
||||||
|
|
||||||
|
**Run it on both frameworks.** From phase 3, done means done on Oxide and on Carbon (R19/R21), and
|
||||||
|
this phase has two specific things to confirm there rather than assume:
|
||||||
|
|
||||||
|
- **`GetPermissionUsers` / `GetUsersInGroup` entry format.** Both answer `id(name)`, and the spacing
|
||||||
|
differs between the calls and between the frameworks. The plugin takes everything before the first
|
||||||
|
bracket. If that parse is wrong, **every holder is reported as foreign** — which is visible
|
||||||
|
immediately: the drift list fills with grants the site itself made.
|
||||||
|
- **`GetGroupPermissions(name, false)`** is called with both arguments. If Carbon's signature has no
|
||||||
|
second parameter, the plugin does not compile there at all — the one place in protocol 4 where
|
||||||
|
R19's byte-identical-plugin claim is at risk.
|
||||||
|
|
||||||
|
**What counts as a pass:** a non-admin player's access in game changes because of something typed on
|
||||||
|
the website and nothing else; a hand edit is reported rather than undone; and a wipe costs the
|
||||||
|
operator nothing.
|
||||||
|
|
||||||
|
## The configuration walk (protocol 5, phase 7b)
|
||||||
|
|
||||||
|
Added 2026-09-22. The website half was walked end to end against a real sidecar and a stand-in
|
||||||
|
plugin over a real directory of real config files — the recursive walk, a form save, a rollback, a
|
||||||
|
refusal, a version conflict and the locked keys — and the plugin half **compiles and loads on the
|
||||||
|
live Oxide rig**, where `rg.config` answers
|
||||||
|
`protocol=5 framework=oxide root=/home/container/oxide/config`.
|
||||||
|
|
||||||
|
**What is left is the sentence the phase exists for: a setting changed on the website takes effect
|
||||||
|
in the running game.** It needs the sidecar and the game server on **one host**, because the game
|
||||||
|
link is loopback by design (D2).
|
||||||
|
|
||||||
|
**Since 2026-09-22 the rigs have that**, and it is no longer a firewall rule on anybody's
|
||||||
|
development machine: the sidecar runs **inside the game container** on the Pterodactyl rigs, which
|
||||||
|
is the shape phase 18's egg ships. The recipe is in [`INSTALL_RIG.md`](INSTALL_RIG.md); a stock
|
||||||
|
plugin config (`127.0.0.1:7799`) and a stock sidecar need no configuration at all to find each
|
||||||
|
other, which is the whole point of putting them in one container.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Open Admin → Rust mod config** and pick the server | The tree the framework actually uses — `oxide/config` on Oxide, `carbon/configs` on Carbon — grouped by plugin, with every loaded plugin's version beside it |
|
||||||
|
| 2 | **Open `ZoneManager.json`, change a setting, leave the reload target on its guess, and save** | "Saved, and the plugin reloaded." At the console, `oxide.show`/`c.show` is irrelevant — the proof is the plugin behaving differently, so pick a setting you can see: `Auto Show Zones`, or an entry message |
|
||||||
|
| 3 | **Check a float nobody touched**, e.g. a rate ending `.0`, in the file on the host | It is still `1.0`, not `1`. This is the trap the whole editor exists for, and a server whose configs are full of whole-numbered floats is where it bites |
|
||||||
|
| 4 | **Break a config on purpose** — in Raw JSON, give a numeric field a string, or anything the plugin's own class cannot deserialize — and save with that plugin as the reload target | Within about four seconds: *"The plugin did not come back, so the old file was put back automatically"*, the compiler's own line underneath it, and the file on the host back as it was. `oxide.plugins` shows the plugin **loaded** — because the restore was reloaded too |
|
||||||
|
| 5 | **Save a nested file** (`Kits/kits.json`, or any `config/<Mod>/x.json`) **and confirm the reload target** | The right plugin reloads. Reloading the wrong one is the failure this field exists to prevent, and it reports success — so check `oxide.plugins`' timestamps, not the website's word |
|
||||||
|
| 6 | **Open the bridge's own config** | `Host`, `Port` and `ServerId` are read-only with the reason; `QueueCap` saves; the reload dropdown does not offer this plugin. The save says it was written and **not** reloaded, which is the honest answer — our settings apply on the next deliberate reload |
|
||||||
|
| 7 | **Edit a file on the host over SSH while the website has it open, then save from the website** | A conflict, with the current file offered — never an overwrite |
|
||||||
|
| 8 | **Ask for a file outside the tree** (`../data/oxide.users.data`, an absolute path) with `curl` against the sidecar, with a valid token | Refused by the **plugin**, with a reason. The sidecar forwards paths and judges none of them; the guard is where the directory is |
|
||||||
|
|
||||||
|
**Run it on both frameworks.** From phase 3, done means done on Oxide and on Carbon (R19/R21), and
|
||||||
|
this phase has two specific things to confirm rather than assume:
|
||||||
|
|
||||||
|
- **The reload path.** The plugin asks `Interface.Oxide` for `ReloadPlugin` by reflection and falls
|
||||||
|
back to a console command — `c.reload` on Carbon, `oxide.reload` on Oxide, chosen by looking for a
|
||||||
|
Carbon assembly at runtime. A wrong prefix on Carbon prints **nothing at all**, which looks
|
||||||
|
exactly like a command that worked (`CARBON.md` §5), so the proof is `OnPluginLoaded` arriving,
|
||||||
|
not the command being accepted.
|
||||||
|
- **`OnPluginLoaded` / `OnPluginUnloaded` firing at all.** They are the rollback's only evidence. If
|
||||||
|
either does not fire on a framework, every save there rolls itself back four seconds later and
|
||||||
|
reports a plugin that is in fact perfectly fine. `rg.hooks` at the console is the standing answer:
|
||||||
|
both names are in `ExpectedHooks`, so a framework that never raises one shows a zero.
|
||||||
|
|
||||||
|
**What counts as a pass:** a setting typed on the website changes what the running game does; a
|
||||||
|
deliberately broken config leaves the plugin loaded and the operator holding the reason; and no file
|
||||||
|
the save did not touch differs by a single byte.
|
||||||
|
|
||||||
|
## The account walk on a phone (phase 8, Android leg B)
|
||||||
|
|
||||||
|
Added 2026-09-22. The app's half was walked on an emulator against a core with the module installed
|
||||||
|
and a **live** rig behind it — the drawer row appearing only for a signed-in player on a site that
|
||||||
|
runs the module, both reads, a refused code rendering beside the button, the entitlement list with
|
||||||
|
its per-server marks, and a release. What no emulator can produce is the code itself, so this is the
|
||||||
|
same three minutes as the identity walk above, done on the phone instead of in a browser.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **In game, type `/link`.** On the phone, open the drawer → *My Rust account*, type the code and press *Link account* | The account appears with the name the game knows you by, when it was linked and which server minted the code |
|
||||||
|
| 2 | **Press it again with the same code** | *"That code is unknown or has expired."* Beside the button, not at the top of the screen |
|
||||||
|
| 3 | **Turn the site off and try a fresh code** | *"A server could not be reached… your code is still good — try again in a minute."* It must NOT tell you to get a new code: you would get it from the same unreachable server |
|
||||||
|
| 4 | **Have an operator grant you something on the website, then pull down / reopen the screen** | It appears under *What you can do in game*, marked **waiting** until a sync lands it and **has it** afterwards. The two states are a word as well as a colour |
|
||||||
|
| 5 | **Press *Unlink*** | The row goes, and every entitlement returns to *waiting* on the next read — the site still holds them, and they now reach nobody |
|
||||||
|
| 6 | **Sign out** | The row is gone from the drawer. On a site with no Rust module it is never there at all, whoever is signed in |
|
||||||
|
|
||||||
|
**What counts as a pass:** a player links an account from the phone without touching a browser, and
|
||||||
|
the screen never claims an entitlement is in the game when the site has not confirmed it there.
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user