docs(modules): phase 4 as built — the first pages, and the four defects a browser walk found #256
@@ -981,7 +981,7 @@ 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 |
|
||||
| 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). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged |
|
||||
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
|
||||
| 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported. 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 |
|
||||
@@ -2417,6 +2417,172 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
||||
|
||||
Reference in New Issue
Block a user