|
|
|
|
@@ -981,9 +981,9 @@ Each phase ends with its findings written down, as every workstream here does.
|
|
|
|
|
| 1 | **Protocol 1, three skeletons, and every bundle seam at once.** ✅ **Done 2026-09-15 — as built and findings in §13.** Plugin, sidecar and module all exist and all three were exercised against the live rig; three org-lead decisions (§13.0), five defects only a running server found (§13.3), and a correction to §11.3 (§13.2). **Both criteria met** | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton |
|
|
|
|
|
| 2 | **Packaging and release.** ✅ **Done 2026-09-16 — as built and findings in §15.** `release.yml` *and* the gate that was missing entirely (`pr-checks.yml`, including the frozen-manifest job); the include list with two readers; `v0.1.0` published and installed into a running core from its manifest URL. Three org-lead decisions (§15.0), and the first proof by a core that `/rust` collides with nothing (§15.2). **Criterion met** | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
|
|
|
|
|
| 3 | **The read path, on both frameworks.** ✅ **Built and largely proven 2026-09-16 — as built and findings in §16.** Protocol 2: fifteen hooks, an envelope every frame carries, boards, a cursor feed and bounded history; four org-lead decisions (§16.0), two defects only a booted server could find (§16.2), and CI for the two bridge repositories that had none. **The player half of the catalogue is written down as a walk to run rather than measured** — see §16.7. First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** |
|
|
|
|
|
| 4 | **The first pages.** Server list as the landing page, `/rust/servers/:id` beneath it, killfeed, leaderboard; nav rows; the UI kit (`PublicLayout` `shell`, `PageHeader` props); `capabilities`; the `site.footer.status` slot (R13) | Module-Rust | The site renders the last thing each server said while every server is off |
|
|
|
|
|
| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged |
|
|
|
|
|
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
|
|
|
|
|
| 4 | **The first pages.** ✅ **Done 2026-09-16 — as built and findings in §17.** `/rust` is the list (D12), `/rust/servers/:id` is one server with four tabs (D13), everything selectable in the URL; visibility-gated polling (D14); the `site.footer.status` slot filled with a live count (D15). Four decisions (§17.0) and **four defects a browser walk found, two of them already shipped in phase 3** (§17.2) — an unreachable refresh that erased the server's description, and a "last reported" line reading the wrong timestamp. **Criterion met**, walked against a live rig | Module-Rust | The site renders the last thing each server said while every server is off |
|
|
|
|
|
| 5 | **Android leg A** (R10). ✅ **Done 2026-09-17 — as built and findings in §18.** The server list and one server with four tabs, gated on a NEW capability the module had to declare (D16 — its five named surfaces, and a client needs one that names the module); a poll that keeps its rows when it fails, which the app had no shape for (D17); the drawer badge as D15 translated (D19). Four decisions (§18.0) and **three defects an emulator walk found that 644 green tests did not** (§18.4). **Both halves of the criterion walked on one device against two cores** | Android-app + Module-Rust + docs | The app renders a Rust site it has never seen, and a UO site unchanged |
|
|
|
|
|
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
|
|
|
|
|
| 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported. The `PermissionExists` pre-check stays the mechanism on **both** frameworks (R19); Carbon's 14 permission hooks are tested here as a possible live drift signal, and suppressed against our own pushes if they fire | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
|
|
|
|
|
| 7b | **Mod configuration from the site** (R18). **Recursive** walk of `Interface.Oxide.ConfigDirectory` — never `DataDirectory`, and never either as a literal path (R19) — generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
|
|
|
|
|
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
|
|
|
|
|
@@ -2417,6 +2417,472 @@ on a shared rig something usually does.
|
|
|
|
|
because the sidecar under test was an older `cargo build`; `clippy` and `test` compile without
|
|
|
|
|
writing one. Rebuild before believing a rig.
|
|
|
|
|
|
|
|
|
|
## 17. Phase 4 as built — the first pages, 2026-09-16
|
|
|
|
|
|
|
|
|
|
One repository, and the first phase whose whole deliverable is something a visitor looks at. It
|
|
|
|
|
consumes exactly the routes phase 3 built and adds one of its own; nothing here talks to a game
|
|
|
|
|
server, which is the point of the criterion it was written against: **the site renders the last
|
|
|
|
|
thing each server said while every server is off.**
|
|
|
|
|
|
|
|
|
|
**Status: criterion met, and met the hard way.** The pages were walked in a browser against a live
|
|
|
|
|
rig — a real Rust server behind the real sidecar for `main`, and a second, deliberately unreachable
|
|
|
|
|
server for everything the record holds. That walk found four defects, two of them in code phase 3
|
|
|
|
|
had already shipped, and all four are fixed here (§17.2).
|
|
|
|
|
|
|
|
|
|
### 17.0 The four decisions this phase needed
|
|
|
|
|
|
|
|
|
|
- **D12 — `/rust` is the server list.** Phase 1 registered it at `/rust/servers` and left the
|
|
|
|
|
module's own namespace root answering core's CMS catch-all. R8 calls the list "the landing page",
|
|
|
|
|
so it is registered with an **empty path** — core renders that as `/rust` — and the detail page
|
|
|
|
|
hangs beneath it at `/rust/servers/:id`. One canonical address, and it is the module's name.
|
|
|
|
|
- **D13 — one page with in-page tabs**, not four routes. `/rust/servers/:id` carries a header and
|
|
|
|
|
four panels (feed, leaderboard, online, wipes). A tab strip is not in the shared UI kit, so the
|
|
|
|
|
module bundles its own — which is the kit working as designed rather than a gap in it (§3.4 is a
|
|
|
|
|
closed list of nine members, and everything above them is the module's).
|
|
|
|
|
- **D14 — poll while the tab is visible.** The feed and the presence list re-fetch every twenty
|
|
|
|
|
seconds, paused by the Page Visibility API and refreshed the instant a viewer comes back. The
|
|
|
|
|
leaderboard and the wipe list load once: a table that re-sorts itself under the reader's cursor is
|
|
|
|
|
worse than one four minutes old. Server-Sent Events were considered and are not this phase — core
|
|
|
|
|
has a fan-out, but the module registers no stream, and a cursor-driven one is phase scope.
|
|
|
|
|
- **D15 — the footer slot carries a live count**, not a static link: `2 servers · 42 online`, linking
|
|
|
|
|
to `/rust`. The cost is stated rather than assumed — see §17.4.
|
|
|
|
|
|
|
|
|
|
### 17.1 What is on the pages
|
|
|
|
|
|
|
|
|
|
`/rust` is the list: name, map, size, when it was wiped, when it last reported, and the player count
|
|
|
|
|
or `Offline`. The whole row is the link.
|
|
|
|
|
|
|
|
|
|
`/rust/servers/:id` is the server. The header is what a Rust player asks first — map, world size,
|
|
|
|
|
seed, wipe date — with live status beside it and a wipe selector that applies to the whole page.
|
|
|
|
|
Then four tabs:
|
|
|
|
|
|
|
|
|
|
| Tab | Reads | Refresh |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| Feed | `…/events`, with a filter that maps to the `kind` parameter | 20s, visibility-gated |
|
|
|
|
|
| Leaderboard | `…/leaderboard`, sortable, per wipe or all-time | on mount |
|
|
|
|
|
| Online | `…/online` — the presence **board**, not counted transitions | 20s, visibility-gated |
|
|
|
|
|
| Wipes | `…/wipes`; picking one filters the feed | on mount |
|
|
|
|
|
|
|
|
|
|
**Everything selectable is in the URL** — tab, filter, wipe, sort. That costs a little ceremony in
|
|
|
|
|
the page and buys the thing a community site is for: *"last wipe's leaderboard on Main"* is a link,
|
|
|
|
|
the back button undoes a click rather than leaving the page, and a refresh lands where the reader
|
|
|
|
|
was. `wipe=current` is a word rather than an id on purpose, so a shared link stays about now.
|
|
|
|
|
|
|
|
|
|
**The feed renders parts, not sentences.** `lib/feed.js` turns a stored frame into
|
|
|
|
|
`{tone, actor, join, verb, subject, detail}`, which keeps the names emphasised without any HTML in a
|
|
|
|
|
string, and makes the whole thing testable in a runner with no DOM. A death is four sentences, not
|
|
|
|
|
one — `player`, `self`, `npc`, `environment` — because the plugin distinguishes them so a reader does
|
|
|
|
|
not have to guess, and a fall reported as a kill by nobody is the failure that avoids. **A kind this
|
|
|
|
|
build has never heard of renders as itself** rather than vanishing: the server's allowlist has
|
|
|
|
|
already decided the row may be seen, so what is left here is presentation, and the honest
|
|
|
|
|
presentation of a kind we have no words for is its own name.
|
|
|
|
|
|
|
|
|
|
**`player.tally` is public and deliberately not in the feed.** It is an aggregate the plugin flushes
|
|
|
|
|
once a minute per active player (PROTOCOL.md §8.6), so a feed carrying it would be mostly wood
|
|
|
|
|
counts. It is the leaderboard's input, and that is where it shows up.
|
|
|
|
|
|
|
|
|
|
### 17.2 Four defects the page walk found, and two of them were already shipped
|
|
|
|
|
|
|
|
|
|
The walk was the whole point of doing one. None of these is visible in a test that stubs a sidecar
|
|
|
|
|
which answers.
|
|
|
|
|
|
|
|
|
|
1. **An unreachable refresh erased what the server last said.** Phase 3's refresh loop called
|
|
|
|
|
`putState` — the whole-row write — with two fields when a sidecar did not answer, so `hostname`,
|
|
|
|
|
`level`, `seed`, `world_size` and `wipe_id` all went to NULL the first time a game host rebooted.
|
|
|
|
|
The list then read `Offline` with nothing beside it, which is not *"here is what we know about a
|
|
|
|
|
server that is down"*, it is *"we have never heard of it"* — and it defeats this phase's
|
|
|
|
|
criterion exactly. Fixed with `markUnreachable`, which moves three columns and mentions no
|
|
|
|
|
others; `server/test/refresh.test.js` asserts against the SQL, because the defect is about which
|
|
|
|
|
columns a statement names.
|
|
|
|
|
2. **"Last reported" was reading the wrong timestamp.** `updated_at` is when this module last WROTE
|
|
|
|
|
the row, which a failed poll does too — so an offline server claimed it had reported just now,
|
|
|
|
|
every thirty seconds, for as long as it stayed down. They are two facts and both are wanted:
|
|
|
|
|
`updated_at` decides staleness, and a new `last_seen_at` records when a `server.hello` last
|
|
|
|
|
arrived. Only a successful refresh moves it.
|
|
|
|
|
3. **Every feed row showed a bare time of day.** Correct for today's killfeed and wrong the moment
|
|
|
|
|
the feed is filtered to a past wipe: three events from six weeks ago all rendered as `02:03 PM`.
|
|
|
|
|
Rows from another calendar day now carry the date. The boundary is the calendar rather than a
|
|
|
|
|
duration, because that is what a reader means by "what time was that".
|
|
|
|
|
4. **A mistyped address was dressed as a fault.** The detail page rendered core's `ErrorState` under
|
|
|
|
|
its own heading, so `/rust/servers/typo` read "No such server / Something went wrong" and sent a
|
|
|
|
|
reader looking for an outage. A 404 is now its own answer and `ErrorState` is kept for a request
|
|
|
|
|
that failed for a reason nobody can see.
|
|
|
|
|
|
|
|
|
|
A fifth, smaller: the Online tab listed three players under a header reading `Offline`. An
|
|
|
|
|
unreachable sidecar does not clear the presence board — deliberately, the rows are still the best
|
|
|
|
|
answer anybody has — but presented bare they read as *who is on right now*, which is the one thing
|
|
|
|
|
an offline server cannot be saying. The panel now says which it is.
|
|
|
|
|
|
|
|
|
|
### 17.3 `useAsync` cannot poll, and that is not a defect in it
|
|
|
|
|
|
|
|
|
|
Core's fetch hook (§3.4) blanks `data` and sets `loading` on every dependency change. That is right
|
|
|
|
|
for a page load and wrong for a poll: bumping a dependency every twenty seconds would clear the
|
|
|
|
|
killfeed, render a spinner in its place and re-fill it, four times a minute, for ever.
|
|
|
|
|
|
|
|
|
|
So the module bundles `hooks/usePolled.js`: a refresh that is **invisible when it succeeds** and
|
|
|
|
|
keeps the rows *and* reports the error when it fails — because a site whose premise is "it renders
|
|
|
|
|
while the game is off" must not blank itself the first time a request does. `key` (the question)
|
|
|
|
|
resets the data; the interval does not. `useAsync` is still the right hook for everything that loads
|
|
|
|
|
once, and both are used here.
|
|
|
|
|
|
|
|
|
|
The live proof: with the tab hidden the log shows no requests at all, and the instant it became
|
|
|
|
|
visible there was one refresh followed by one every 20.0 seconds.
|
|
|
|
|
|
|
|
|
|
### 17.4 The footer slot's real cost, stated
|
|
|
|
|
|
|
|
|
|
Core renders `SiteFooter` inside `PublicLayout`, and **every public page renders `PublicLayout`
|
|
|
|
|
itself** (§3.3) — so a component in that slot mounts once per public page view, not once per
|
|
|
|
|
session. D15's live count therefore puts one `/public/rust/servers` request on every public page of
|
|
|
|
|
the site, including pages with nothing to do with Rust.
|
|
|
|
|
|
|
|
|
|
Two things keep that honest rather than merely cheap. It **renders nothing until it has an answer,
|
|
|
|
|
and nothing at all if the request fails** — core's `<Slot wrap>` takes its separator with it, so a
|
|
|
|
|
failure degrades to exactly the footer an instance with no module installed has. And it **never
|
|
|
|
|
polls**: one request per page view is a cost; a timer in the footer of every page is a different
|
|
|
|
|
kind of thing. If it ever shows up in an operator's logs, the fix is a short-lived cache in that one
|
|
|
|
|
file and nothing else on the site changes.
|
|
|
|
|
|
|
|
|
|
### 17.5 Smaller things worth keeping
|
|
|
|
|
|
|
|
|
|
- **The registration fake was *nearly* core, which is worse than obviously not.** `client/test`'s
|
|
|
|
|
fake registry prefixed routes as `` `${id}/${path}` ``; core strips the trailing separator too,
|
|
|
|
|
which is exactly what lets a module register `path: ''` and own its namespace root. The day a
|
|
|
|
|
module did, the fake produced `rust/` where a real core produces `rust`, and the suite failed the
|
|
|
|
|
nav check for a link that works perfectly in a browser. The fake now copies core's line character
|
|
|
|
|
for character.
|
|
|
|
|
- **A SQL comment inside a JS template literal may not contain a backtick.** Obvious written down,
|
|
|
|
|
invisible while writing prose about `markUnreachable` inside a query string; the file stops
|
|
|
|
|
parsing several lines later and the error names an argument list.
|
|
|
|
|
- **The detail route exists so that a page can 404.** Every other route under `/servers/:id` answers
|
|
|
|
|
an empty list for an id nobody configured — an unknown server genuinely has no events, no
|
|
|
|
|
leaderboard and nobody online, and each of those is a good answer to its own question. Only
|
|
|
|
|
`GET …/servers/:id` can say the server is not there. A disabled server answers the same 404 as a
|
|
|
|
|
missing one: an operator who switched a server off did not switch it into a 403.
|
|
|
|
|
- **`capabilities` grew to what the pages serve** — `servers`, `killfeed`, `leaderboard`,
|
|
|
|
|
`presence`, `wipes` — which is the list phase 5's Android leg feature-detects against.
|
|
|
|
|
- **The rig had two cores again**, and this time the mechanism was visible rather than inferred: a
|
|
|
|
|
leftover core holding an older module release spoke protocol 1, was refused `409`, and rewrote the
|
|
|
|
|
state row as unreachable every thirty seconds — the phase-3 trap, and also how defect 1 above was
|
|
|
|
|
found. One of the two was stopped with the org lead's say-so before the walk continued.
|
|
|
|
|
|
|
|
|
|
### 17.6 What was proven, and how
|
|
|
|
|
|
|
|
|
|
- **The criterion, directly.** A second server was configured pointing at a dead address and seeded
|
|
|
|
|
with a fixture shaped exactly as the plugin emits (two wipes, 26 events, four players, a presence
|
|
|
|
|
board). With that server unreachable and reporting `Offline`, its page still rendered its map,
|
|
|
|
|
size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, last known board and wipe
|
|
|
|
|
history. That is the phase criterion in one screenshot.
|
|
|
|
|
- **The allowlist, on real rows.** The fixture includes a kind this build has never heard of. The
|
|
|
|
|
public route answered `{"events":[]}` for it when asked **by name** — a refusal, not a filter.
|
|
|
|
|
- **R12's arithmetic, on the page.** All-time equals the two wipes summed (41 + 18 = 59), and a
|
|
|
|
|
player who only appears in the older wipe drops out of the current one rather than reading zero.
|
|
|
|
|
- **The footer slot**, live in core's own footer on every public page, linking to `/rust`.
|
|
|
|
|
- **The chunk's identity check**, which is the one failure only a browser can show: the console
|
|
|
|
|
carried the module's own registration line and nothing else — no second React, no bare import.
|
|
|
|
|
|
|
|
|
|
What is **not** proven here and is deliberately left: the pages have not been read on a phone-width
|
|
|
|
|
viewport, and the Android leg (phase 5) is where the same surface gets a second client anyway.
|
|
|
|
|
|
|
|
|
|
## 18. Phase 5 as built — Android leg A, 2026-09-17
|
|
|
|
|
|
|
|
|
|
The first leg of R10, and the first time this module's surface has had a second client. The app's
|
|
|
|
|
own record of it is [`../../android/PLAN.md`](../../android/PLAN.md) **M14**; what follows is what
|
|
|
|
|
the phase decided and what the walk found.
|
|
|
|
|
|
|
|
|
|
**Criterion met, and walked in an emulator rather than asserted:** *the app renders a Rust site it
|
|
|
|
|
has never seen, and a UO site unchanged.* Both halves were shown on one device against two running
|
|
|
|
|
cores.
|
|
|
|
|
|
|
|
|
|
### 18.0 Four org-lead decisions
|
|
|
|
|
|
|
|
|
|
- **D16 — the app gates on a new capability, `rust`.** This module declared five strings and every
|
|
|
|
|
one names a **surface**: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`. Core flattens
|
|
|
|
|
every started module's capabilities into one list, so a client gating a whole navigation group on
|
|
|
|
|
`servers` would have those screens revealed by any future module that declared the same generic
|
|
|
|
|
word. `module-uo` has exactly one string for this job — `shard` — and this module had none.
|
|
|
|
|
|
|
|
|
|
**Gating on the module `id` was considered and rejected.** It is the strongest fact available and
|
|
|
|
|
it is already on the wire, but `id` is a **mount prefix** (§2.1 requires it to equal the directory
|
|
|
|
|
core loads the module from) and `MODULE_API.md` §2.9 forbids a client inferring a route from a
|
|
|
|
|
capability. Letting a client gate on `id` makes the two the same value in practice, and the day one
|
|
|
|
|
builds `/<id>/servers` from it the separation that lets this module move its own pages is gone.
|
|
|
|
|
|
|
|
|
|
So `module.json` declares its own name as a sixth capability, and `server/test/entry.test.js`
|
|
|
|
|
asserts it **against `manifest.id`** rather than against the literal `"rust"` — the day the id
|
|
|
|
|
changes, the string a client gates on has to change with it. Module-Rust#5.
|
|
|
|
|
|
|
|
|
|
- **D17 — the app polls every 20 seconds while its screen is RESUMED.** D14's Page Visibility gate,
|
|
|
|
|
translated. `repeatOnLifecycle(RESUMED)` gives the same three behaviours from one line: nothing at
|
|
|
|
|
all while the app is away, an immediate refresh on return, and a pause behind a dialog. `STARTED`
|
|
|
|
|
was rejected — it keeps polling behind a partially obscured screen, which is precisely the reader
|
|
|
|
|
who is not reading.
|
|
|
|
|
|
|
|
|
|
- **D18 — the three Rust repositories move to `edge`** for the rest of the workstream, with releases
|
|
|
|
|
cut at the cutover rather than per phase. `edge` branches were created from `main` in Module-Rust,
|
|
|
|
|
Rust-Link and Rust-Plugins; `pr-checks.yml` in all three (and in Android-app) already triggers on
|
|
|
|
|
`[main, edge]`, so this costs no CI. `release.yml` still fires only on a push to `main`, which is
|
|
|
|
|
what makes the cutover the release.
|
|
|
|
|
|
|
|
|
|
- **D19 — the drawer row carries a live player count, and `NavPaths` learns `/rust`.** The second is
|
|
|
|
|
small and load-bearing: without it an admin's nav override on the module's own `Servers` row, or an
|
|
|
|
|
added link to `/rust`, hands off to a browser rather than opening the native screen.
|
|
|
|
|
|
|
|
|
|
### 18.1 D15 has no analogue on a phone, so it was translated
|
|
|
|
|
|
|
|
|
|
The footer slot works on the web because every public page renders the same footer (§17.4). The app
|
|
|
|
|
has no footer and no slot. What it has is one drawer row per surface and, since engagement Phase 8, a
|
|
|
|
|
precedent for a number beside one — the inbox's unread badge, in `NavigationDrawerItem`'s badge slot,
|
|
|
|
|
with a `contentDescription` so a screen reader says *"42 players online"* and not *"42"*.
|
|
|
|
|
|
|
|
|
|
The count rides there and keeps all three of the website version's rules: **zero renders nothing** (an
|
|
|
|
|
empty fleet is not a notification, and a badge reading `0` on a quiet evening is worse than none), a
|
|
|
|
|
failed read keeps the last number rather than dropping to zero, and it **never polls**. It is asked
|
|
|
|
|
for only where the module is installed, so a UO site makes no request at all.
|
|
|
|
|
|
|
|
|
|
**The number is players, not servers.** A badge is one integer, and of D15's two halves the live one
|
|
|
|
|
is how many people are on — a server count changes when an operator edits configuration, which is not
|
|
|
|
|
news, and is on the page the row opens anyway.
|
|
|
|
|
|
|
|
|
|
### 18.2 What the app had to grow: a refresh that is not a load
|
|
|
|
|
|
|
|
|
|
The app has had exactly one shape for a read since its first milestone — set `Loading`, ask, replace.
|
|
|
|
|
That is right for opening a screen and wrong for a poll, and it is the *same* wall this module hit one
|
|
|
|
|
tier along with core's `useAsync` (§17.3). A twenty-second refresh built on it would clear the
|
|
|
|
|
killfeed, render a spinner in its place and re-fill it, three times a minute, for ever.
|
|
|
|
|
|
|
|
|
|
`ui/Polling.kt` is `usePolled`'s other half, and it keeps the same rule: **a refresh is invisible when
|
|
|
|
|
it succeeds and keeps the rows when it fails.** Three cases, and the middle one is the whole point:
|
|
|
|
|
|
|
|
|
|
| Result | What the reader sees |
|
|
|
|
|
| --- | --- |
|
|
|
|
|
| It answered | New rows. Nothing else. |
|
|
|
|
|
| It failed, and there are rows | The same rows, and one quiet line saying the refresh failed. |
|
|
|
|
|
| It failed, and there is nothing yet | An ordinary error with a retry — a first load that failed. |
|
|
|
|
|
|
|
|
|
|
Only the **visible** live panel is polled. The website can afford to mount the one tab it is
|
|
|
|
|
rendering; the app's four tabs are one screen, so the refresh asks what the reader is actually looking
|
|
|
|
|
at. The leaderboard and the wipe list are never polled at all.
|
|
|
|
|
|
|
|
|
|
**Changing the question is not a poll.** Filter, sort and wipe blank their panel and load, because
|
|
|
|
|
what is on screen is an answer to something the reader has stopped asking — leaving it up would show
|
|
|
|
|
last wipe's killfeed under this wipe's heading.
|
|
|
|
|
|
|
|
|
|
### 18.3 What the walk proved, and how
|
|
|
|
|
|
|
|
|
|
The rig is phase 4's, unchanged: a core on `:3200` with this module installed, one live server
|
|
|
|
|
(`main`, a real sidecar and a real game host) and the seeded `demo` fixture that has never reported.
|
|
|
|
|
A second core on `:3100` serves `module-uo` and no Rust.
|
|
|
|
|
|
|
|
|
|
- **The criterion, first half.** With `demo` unreachable and reading *Offline*, the phone rendered its
|
|
|
|
|
map, size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, its last known presence
|
|
|
|
|
board and its wipe history. Nothing on the screen is a live call to a game host.
|
|
|
|
|
- **The criterion, second half.** The same app, switched to the UO core, showed Shard / Rules / Atlas
|
|
|
|
|
/ Leaderboards / Market and **no Rust row**.
|
|
|
|
|
- **`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 line reading *"Could not refresh just now. This
|
|
|
|
|
is the last thing the site heard."* — no spinner, no error page, nothing blanked.
|
|
|
|
|
- **R12's arithmetic, on a phone.** All-time 59 = 41 + 18 across two wipes, and Drift — who appears
|
|
|
|
|
only in the current wipe — **drops out** of the August board rather than reading zero.
|
|
|
|
|
- **Every `describe` branch, from real rows**: a player kill with weapon, distance and grid; an NPC
|
|
|
|
|
kill with the prefab read as words; a suicide; an environment death (the fall that must not read as
|
|
|
|
|
a kill by nobody); chat with its colon in the join and a non-Global channel beside it; a disconnect
|
|
|
|
|
with reason and session length; and *while sleeping*.
|
|
|
|
|
- **The calendar-day rule.** Filtering to the August wipe produced three rows six weeks old, each
|
|
|
|
|
carrying its date — the §17.2 defect, not re-introduced in Kotlin.
|
|
|
|
|
- **The presence panel saying which it is.** The offline server's board rendered under *"The last
|
|
|
|
|
board this server sent. It is offline, so this is who was on then — not who is on now."*
|
|
|
|
|
- **The badge**, showing a live count on the drawer row.
|
|
|
|
|
|
|
|
|
|
### 18.4 The walk found three defects, and a green suite found none of them
|
|
|
|
|
|
|
|
|
|
1. **The drawer's live count resolved once per process.** It was keyed on the capability answer alone,
|
|
|
|
|
so it was read when the app connected 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 and for
|
|
|
|
|
the same reason: coming back to the app is exactly when a stale number would be noticed. *Visible
|
|
|
|
|
only by backgrounding the app and returning to it.*
|
|
|
|
|
2. **Every card's text sat flush against its edge.** The app's themed `ShardCard` is a `Card` and
|
|
|
|
|
nothing more — it carries no padding, and each caller pads its own content. Four new call sites did
|
|
|
|
|
not, and on a phone the first glyph of each line read as clipped.
|
|
|
|
|
3. **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 moved to the header**, because the header is the control: tinting a column of numbers
|
|
|
|
|
says *these are special* where tinting the header says *this is what the table is ordered by*.
|
|
|
|
|
|
|
|
|
|
### 18.5 The rig note worth keeping
|
|
|
|
|
|
|
|
|
|
The app's 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`, and the connect
|
|
|
|
|
screen reports *"Couldn't reach that site"* — correct, and indistinguishable from a core that is not
|
|
|
|
|
running.
|
|
|
|
|
|
|
|
|
|
Two smaller things: the first AVD tried had 95% of `/data` used and refused a 44 MB install with
|
|
|
|
|
*"Requested internal only, but not enough space"* — `pm trim-caches` freed nothing, and the second AVD
|
|
|
|
|
was the answer. And the app's own `pm clear` is the way to reach the first-run connect screen, because
|
|
|
|
|
an `install -r` over an earlier install keeps the stored base URL.
|
|
|
|
|
|
|
|
|
|
### 18.6 What is not proven here
|
|
|
|
|
|
|
|
|
|
- **A phone-width read of the website's own pages**, which §17.6 deliberately left open. This phase
|
|
|
|
|
gave the surface a second client rather than re-reading the first, and the pages have still not been
|
|
|
|
|
looked at in a narrow browser.
|
|
|
|
|
- **The badge's non-zero case on real traffic.** Nobody was playing on the rig, so the count was shown
|
|
|
|
|
by seeding a player count on the unreachable server and reading the badge before the 30-second
|
|
|
|
|
refresh zeroed it. The arithmetic and the rendering are proven; a fleet with people on it is not.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 19. Phase 6 as built — identity, 2026-09-21
|
|
|
|
|
|
|
|
|
|
R1, and R13's first slot. A player proves a Steam account is theirs by typing `/link` in game and
|
|
|
|
|
entering the code on the website; an operator sees the result inside core's own user page.
|
|
|
|
|
|
|
|
|
|
**Criterion met for everything the site owns, and walked in a browser** — the Steam id, the link
|
|
|
|
|
date and per-server all-time totals render under core's security panel on `/admin/users/:id`, with a
|
|
|
|
|
staff unlink that writes an audit row. **The in-game half is written down as a walk to run** rather
|
|
|
|
|
than claimed: a code reaches a player and nobody else, so no console can read one. It is
|
|
|
|
|
[`../../rust-link/PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md)'s new *identity walk*, beside the
|
|
|
|
|
read-path walk phase 3 left there for the same reason.
|
|
|
|
|
|
|
|
|
|
### 19.0 The org-lead decisions this phase needed
|
|
|
|
|
|
|
|
|
|
Four were taken while it was being built (D20-D23) and three during the walk (D24-D26):
|
|
|
|
|
|
|
|
|
|
- **D20 — `/link` is a CHAT command, not a console one.** The Rust idiom, and what players expect
|
|
|
|
|
from every Discord-linking server they have used. Both frameworks consume a `/` command rather
|
|
|
|
|
than broadcasting it and `SendReply` addresses one player, so neither the request nor the code
|
|
|
|
|
reaches public chat.
|
|
|
|
|
- **D21 — pending codes live in plugin memory, matching `module-uo`.** Asked as "where does an
|
|
|
|
|
unconfirmed code live"; answered *"match how it works for the uo module"*, and the UO bridge's
|
|
|
|
|
shape was then read out of `BridgeAccountLink.cs` rather than guessed: six characters, five-minute
|
|
|
|
|
TTL, thirty-second cooldown, one outstanding code per player. A plugin reload drops them, which
|
|
|
|
|
matters because phase 7b's config editor will reload plugins routinely — and the cost is a player
|
|
|
|
|
typing `/link` again, which is cheaper than an unconfirmed credential in a second process.
|
|
|
|
|
- **D22 — the code alphabet has no O, 0, I or 1.** A player reads it off their screen and types it
|
|
|
|
|
into a browser, often on a phone, sometimes reading it to somebody else.
|
|
|
|
|
- **D23 — a Steam id another website account holds is REFUSED, never moved**, and the refusal names
|
|
|
|
|
the holder. Phase 7 grants permissions against a link and phase 13 hangs entitlements off it, so a
|
|
|
|
|
silent move is an account takeover performed by typing six characters. The way out is `/unlink` in
|
|
|
|
|
game, which the player can reach from the machine they are sitting at.
|
|
|
|
|
- **D24 — the website asks EVERY configured server, first `link.ok` wins.** A code is minted by one
|
|
|
|
|
server and nothing in it says which. Asking the player to pick was rejected: a wrong pick comes
|
|
|
|
|
back indistinguishable from a wrong code, and that is the one refusal which must not be ambiguous.
|
|
|
|
|
- **D25 — staff can sever a link from the `admin.users.detail` panel**, with an activity row. It is
|
|
|
|
|
the counterweight to D23: a player who has lost access to that Steam account in game has no other
|
|
|
|
|
route back.
|
|
|
|
|
- **D26 — the activity-row overflow (§19.4, defect 3) is CORE's to fix, in its own PR.** A module may
|
|
|
|
|
legitimately name an action; shortening this module's names only moves the ceiling to the next one.
|
|
|
|
|
|
|
|
|
|
### 19.1 What the three repos gained
|
|
|
|
|
|
|
|
|
|
| Repo | What |
|
|
|
|
|
|---|---|
|
|
|
|
|
| [Rust-Plugins][rp] | `/link` and `/unlink` chat commands, the in-memory code table with its purge timer, and `link.confirm` — the first command the **website** originates. `checkPlugin.js` grew a check for chat commands, which bind by reflection with the same silent-failure mode as hooks |
|
|
|
|
|
| [Rust-Link][rl] | `POST /link/confirm` — the first route on this sidecar that is not a GET. It forwards and nothing else: it does not mint codes, does not store them, and cannot tell a good one from a bad one |
|
|
|
|
|
| [Module-Rust][mr] | `rust_account_links`, the fleet loop, the player page at `/player/rust`, and both halves of `admin.users.detail` |
|
|
|
|
|
|
|
|
|
|
**A refused code is a `200` all the way up.** `link.ok` and `link.error` are both answers; the
|
|
|
|
|
sidecar keeps its own status codes for the transport (503 game down, 504 game silent), because the
|
|
|
|
|
website has to tell *"that code is wrong"* from *"the game never replied"*.
|
|
|
|
|
|
|
|
|
|
### 19.2 `admin.users.detail` is declared in three places, and they are three different registries
|
|
|
|
|
|
|
|
|
|
The slot cost more wiring than its size suggests, and each of the three is held by a different thing:
|
|
|
|
|
|
|
|
|
|
1. **`module.json`'s `extensions` array** — validated by the loader against the **server** registry.
|
|
|
|
|
Naming a client slot there fails the load outright, which phase 1 found the hard way with
|
|
|
|
|
`site.footer.status`.
|
|
|
|
|
2. **`api.registerExtension('admin.users.detail', router)`** in `server/index.js` — the routes,
|
|
|
|
|
mounted inside core's `/api/v1/admin/users/:id` with `mergeParams`. Without that flag
|
|
|
|
|
`req.params.id` is `undefined` and every statement in the panel silently scopes to nobody.
|
|
|
|
|
3. **`registry.registerExtension(ID, 'admin.users.detail', Component)`** in the chunk — the panel.
|
|
|
|
|
Core passes it `userId` and nothing else, so it builds its own client for the routes the server
|
|
|
|
|
half registered.
|
|
|
|
|
|
|
|
|
|
**The gate is core's and it is stricter than the admin tier's.** Core's users router is
|
|
|
|
|
`requireRole('admin')` and the slot is mounted inside it, so editors and moderators never reach these
|
|
|
|
|
routes — which is right for a surface that can sever what phases 7 and 13 grant against.
|
|
|
|
|
|
|
|
|
|
### 19.3 The hole the slot found in this repo's own OpenAPI generator
|
|
|
|
|
|
|
|
|
|
`swaggerFragment.js` ran `register()` against a recording api and walked `record.routes` — the three
|
|
|
|
|
tiers. A slot router is not registered under a tier, so **the two routes under `/admin/users/:id`
|
|
|
|
|
were generated by nothing**: a fragment that was internally consistent, passed every check in the
|
|
|
|
|
repo, and described two routes fewer than the module serves.
|
|
|
|
|
|
|
|
|
|
A slot's mount is **core's**, so it cannot be derived from anything here — it is a fourth constant
|
|
|
|
|
beside `TIER_BASE`, and like `TIER_BASE` it is held to account by a real core in the frozen-manifest
|
|
|
|
|
job. That check was verified to catch exactly this, by deleting the two paths from the fragment and
|
|
|
|
|
watching it fail.
|
|
|
|
|
|
|
|
|
|
`test/frozenManifest.test.js` grew the other half. Its *mounts agree* case was written in phase 1
|
|
|
|
|
with this phase named in a comment — *"when the slot arrives this test must grow the exception
|
|
|
|
|
deliberately, rather than a route outside every declared mount arriving unnoticed"* — and it failed
|
|
|
|
|
on the first run after the slot was filled. It now also fails when a **declared slot contributes no
|
|
|
|
|
route**, because core never checks that a declared slot was filled.
|
|
|
|
|
|
|
|
|
|
### 19.4 Three defects the browser walk found, and 122 green tests did not
|
|
|
|
|
|
|
|
|
|
1. **Every refusal sentence was invisible.** Core's request primitive is the only thing that reads a
|
|
|
|
|
module's failures, and it reads one field:
|
|
|
|
|
|
|
|
|
|
```js
|
|
|
|
|
const message = (data && data.message) || res.statusText || 'Request failed'
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
This module has answered `{ error: … }` since phase 1 and got away with it, because until now
|
|
|
|
|
every failure landed in `ErrorState` on a page whose whole content was missing — where a generic
|
|
|
|
|
sentence is honest. **A form is different: the sentence IS the outcome.** The link page showed
|
|
|
|
|
*Service Unavailable* for all four of the refusals this phase exists to write. All 23 error bodies
|
|
|
|
|
in the module now answer in `message`, and `test/errorShape.test.js` drives each outcome rather
|
|
|
|
|
than grepping for the field.
|
|
|
|
|
|
|
|
|
|
It is also a correction to phases 1-4, which shipped the wrong shape while referencing core's
|
|
|
|
|
`Error` schema — `{ message }` — in their own `#swagger.responses` annotations.
|
|
|
|
|
|
|
|
|
|
2. **The player saw a stale name.** `/player/rust` showed `Wanderer-old` — the name recorded at link
|
|
|
|
|
time — while the admin panel showed `Wanderer`, the name the game last saw. Only the admin read
|
|
|
|
|
joined `rust_players`. The same person, labelled two ways on one site, because a Rust name changes
|
|
|
|
|
on a whim.
|
|
|
|
|
|
|
|
|
|
3. **Core's activity row collides with a long action name.** `Dashboard.jsx` renders the action in a
|
|
|
|
|
`width: 110`, `flex: 'none'` span with no overflow handling, so `rust.account.unlink.staff`
|
|
|
|
|
overlaps the detail text beside it. D26 sends it to core as its own change.
|
|
|
|
|
|
|
|
|
|
### 19.5 What the walk proved, and how
|
|
|
|
|
|
|
|
|
|
Against a core at the pinned ref with the module installed, two configured servers (one sidecar up
|
|
|
|
|
with no game behind it, one address with nothing listening) and three logins:
|
|
|
|
|
|
|
|
|
|
- **The criterion**: `/admin/users/2` rendered the Steam id, *linked last month on rust-oxide*, *last
|
|
|
|
|
played 12 hours ago*, and per-server all-time totals — 59 kills across two wipes on one server, 3
|
|
|
|
|
on another — under core's own security panel.
|
|
|
|
|
- **D25**: Unlink removed the row, the panel then rendered *nothing at all* (most users have no Rust
|
|
|
|
|
account, and a "no linked accounts" notice on every user page is noise), and
|
|
|
|
|
`rust.account.unlink.staff` landed in the activity log naming the operator.
|
|
|
|
|
- **The player page**: the link row with its own Unlink, the empty state, and the three-step
|
|
|
|
|
instruction that is the only place on the site a player learns the code comes from the game.
|
|
|
|
|
- **A refusal that is a sentence**: with both sidecars unreachable, *"The game servers are unreachable
|
|
|
|
|
right now — try again in a minute."* — which is what defect 1 above was hiding.
|
|
|
|
|
- **Ownership**: a second player deleting the first player's link gets the same `404` as one that does
|
|
|
|
|
not exist, so a signed-in stranger cannot discover linked Steam ids by deleting them one at a time.
|
|
|
|
|
A player reaching the admin slot route gets `403` from core's own gate.
|
|
|
|
|
- **R1's rate limit, live**: ten attempts pass, the eleventh answers `429` with *"Too many link
|
|
|
|
|
attempts."* The limiter is per-IP, like core's own login limiter — which means two players behind
|
|
|
|
|
one address share the allowance, and that is core's policy rather than a choice made here.
|
|
|
|
|
|
|
|
|
|
### 19.6 What is not proven here
|
|
|
|
|
|
|
|
|
|
- **The code from the game.** The rig booted with the phase-6 plugin loaded and announcing protocol 3,
|
|
|
|
|
and could not reach the sidecar on the development machine: no inbound firewall rule for TCP 7800
|
|
|
|
|
on this Windows host, which is not a change to make from a session. The plugin half's own checks
|
|
|
|
|
are green and its shape is the UO bridge's, proven; what is untested is the whole path with a
|
|
|
|
|
person in it. **D27 (org lead): it goes in the manual walk document**, as its own *identity walk*
|
|
|
|
|
beside phase 3's player walk.
|
|
|
|
|
- **`unsure` against a real refusal.** Proving it needs one server that genuinely refuses a code —
|
|
|
|
|
which needs a plugin connected — alongside one that is down. The branch is unit-tested and its
|
|
|
|
|
sentence was read in a browser; the live combination is step 6 of the identity walk.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
|
|
|
|
|