From 22fd8c5da75269152978891c9fc207f831df9a4b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 16 Sep 2026 21:40:28 -0500 Subject: [PATCH] feat: the first pages, and what a browser walk found behind them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4. `/rust` is the server list and the module's landing page (D12); `/rust/servers/:id` is one server with four tabs — feed, leaderboard, who is on, wipes (D13). Everything selectable lives in the URL, so any view of the page is a link. The feed and the presence list poll every twenty seconds while the tab is visible and not at all when it is not (D14); the leaderboard and the wipe list load once. `site.footer.status` is filled with a live server and player count (D15). Nothing on these pages calls a game server. Every field comes from this module's own tables, which is what the phase criterion is about: the site renders the last thing each server said while every server is off. Walking that criterion in a browser against a live rig found four defects, two of them already shipped in phase 3: * An unreachable refresh called `putState` — the whole-row write — with two fields, so a host that rebooted lost its hostname, map, size, seed and wipe id. The list then read "Offline" with nothing beside it, which is not "here is what we know" but "we have never heard of it". `markUnreachable` now moves three columns and mentions no others. * "Last reported" read `updated_at`, which a FAILED poll writes too — so an offline server claimed it had reported just now, every thirty seconds, for as long as it stayed down. `last_seen_at` is the new column, moved only by a frame that arrived. * Feed rows showed a bare time of day, so three events from six weeks ago all read as this afternoon once the feed was filtered to a past wipe. * `/rust/servers/typo` rendered core's ErrorState under its own heading and read "No such server / Something went wrong", sending a reader who mistyped a URL looking for an outage. Also: a detail route (`GET …/servers/:id`), because it is the only route under that path that can say a server does not exist — the other four answer an empty list for an id nobody configured, and each of those is a good answer to its own question. `useAsync` cannot poll: it blanks its data on every dependency change, so a twenty-second refresh built on it would clear the killfeed and re-fill it four times a minute. `hooks/usePolled.js` is the module's own, invisible when it succeeds and keeping the rows when it fails. The client test fake was *nearly* core — it prefixed routes without stripping the trailing separator, so the first module to register an index route failed the nav check for a link that works in a browser. It now copies core's line character for character. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- README.md | 18 ++- client/src/api.js | 41 ++++- client/src/components/Feed.jsx | 141 +++++++++++++++++ client/src/components/FooterStatus.jsx | 73 +++++++++ client/src/components/Leaderboard.jsx | 110 +++++++++++++ client/src/components/Online.jsx | 94 +++++++++++ client/src/components/Tabs.jsx | 63 ++++++++ client/src/components/WipeSelect.jsx | 54 +++++++ client/src/components/Wipes.jsx | 85 ++++++++++ client/src/entry.jsx | 39 ++++- client/src/hooks/usePolled.js | 116 ++++++++++++++ client/src/lib/feed.js | 178 +++++++++++++++++++++ client/src/lib/format.js | 136 ++++++++++++++++ client/src/routes/public/ServerDetail.jsx | 184 ++++++++++++++++++++++ client/src/routes/public/Servers.jsx | 107 +++++++------ client/test/feed.test.js | 141 +++++++++++++++++ client/test/format.test.js | 96 +++++++++++ client/test/registration.test.js | 35 +++- module.json | 2 +- routes.manifest.json | 5 + server/boot.js | 9 +- server/db/schema.sql | 11 ++ server/model/servers/servers.db.js | 60 ++++++- server/model/servers/servers.model.js | 34 ++++ server/router/public/rust.controller.js | 26 ++- server/router/public/rust.router.js | 12 ++ server/test/refresh.test.js | 116 ++++++++++++++ server/test/servers.test.js | 88 ++++++++++- swagger-fragment.json | 31 ++++ 29 files changed, 2040 insertions(+), 65 deletions(-) create mode 100644 client/src/components/Feed.jsx create mode 100644 client/src/components/FooterStatus.jsx create mode 100644 client/src/components/Leaderboard.jsx create mode 100644 client/src/components/Online.jsx create mode 100644 client/src/components/Tabs.jsx create mode 100644 client/src/components/WipeSelect.jsx create mode 100644 client/src/components/Wipes.jsx create mode 100644 client/src/hooks/usePolled.js create mode 100644 client/src/lib/feed.js create mode 100644 client/src/lib/format.js create mode 100644 client/src/routes/public/ServerDetail.jsx create mode 100644 client/test/feed.test.js create mode 100644 client/test/format.test.js create mode 100644 server/test/refresh.test.js diff --git a/README.md b/README.md index 345cca8..c914029 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,23 @@ rows here; the website core never learns there is more than one. | Surface | Route | |---|---| | Public | `GET /api/v1/public/rust/servers` — every server and what it last reported | -| Player | `GET /api/v1/player/rust/servers` — the same, on the authenticated tier | +| Public | `GET …/servers/:id` — one server, or a `404`; the only route under `:id` that can say a server does not exist | +| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) | +| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed | +| Public | `GET …/servers/:id/wipes` and `…/online` | +| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier | | Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` | -| Page | `/rust/servers` | +| Pages | `/rust` — the server list, and the module's landing page | +| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes | +| Slot | `site.footer.status` — a live server/player count in core's footer | -Two tables, `rust_servers` (configuration) and `rust_server_state` (what each sidecar reported). +Every page reads this module's own tables and never calls a game server, which is what lets the +whole surface render while every server in the fleet is off. Tab, feed filter, wipe and leaderboard +sort all live in the URL, so any view of it is a link. + +Seven tables: `rust_servers` (configuration), `rust_server_state` and `rust_presence` (observed +state), `rust_wipes`, `rust_players`, `rust_player_wipe_stats` and `rust_gather_totals` (the record a +wipe does not erase), plus the bounded `rust_events` window and the `rust_ingest_cursor`. The rest of the module — identity, site-owned permissions, Teams from Rust's clans, notifications, events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it diff --git a/client/src/api.js b/client/src/api.js index 0374ef7..e435da6 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -25,6 +25,45 @@ const { request: req, BASE } = rg.api // registers under the `/rust` prefix `module.json` declares. export const servers = { list: () => req('/public/rust/servers'), + + // One server, and the only route under `/servers/:id` that can answer "no such + // server": the four below answer an empty list for an id nobody ever + // configured, because an unknown server genuinely has no events. + get: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}`), + + // `kind` is a comma-separated list and `wipe` a wipe id; both are optional and + // both are built here rather than in a page, so the query string this module + // sends exists in one file. + events: (id, { kinds = null, wipe = null, limit = null } = {}) => + req(`/public/rust/servers/${encodeURIComponent(id)}/events${query({ + kind: kinds && kinds.length ? kinds.join(',') : null, + wipe, + limit, + })}`), + + leaderboard: (id, { wipe = null, sort = null, limit = null } = {}) => + req(`/public/rust/servers/${encodeURIComponent(id)}/leaderboard${query({ wipe, sort, limit })}`), + + wipes: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/wipes`), + + online: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/online`), +} + +/** + * A query string from the parameters that have a value, or `''`. + * + * **An absent parameter must be absent, not empty.** `?wipe=` is not the same + * question as no `wipe` at all — the first asks for a wipe whose id is the empty + * string — and a page that sends one because a ` onFilter(e.target.value)} + style={selectStyle} + > + {FILTERS.map((f) => ( + + ))} + + + + {/* What a refresh is FOR: saying when the page last managed one. Without + it a feed that stopped updating looks exactly like a quiet server. */} + {at && ( + updated {ago(at)} + )} + {error && ( + + the last refresh failed — showing what we had + + )} + + + {loading && } + + {/* An error with nothing to fall back on is the only case that takes over + the panel. A failed REFRESH keeps the rows and says so in the line + above, because a site whose premise is "it renders while the game is + off" must not blank itself the first time a request does. */} + {error && !data && } + + {data && events.length === 0 && ( + + )} + + {events.length > 0 && ( +
    + {events.map((event) => { + const line = describe(event) + return ( +
  1. + + + {line.actor && {line.actor}} + {line.actor && (line.join || ' ')} + {line.verb} + {line.subject && ' '} + {line.subject && {line.subject}} + {line.detail && ( + + {' · '} + {line.detail} + + )} + +
  2. + ) + })} +
+ )} + + ) +} + +const selectStyle = { + background: 'var(--panel-flat, transparent)', + color: 'var(--text)', + border: '1px solid var(--line)', + borderRadius: 'var(--radius-input, 6px)', + padding: '3px 8px', + fontSize: '0.78rem', +} diff --git a/client/src/components/FooterStatus.jsx b/client/src/components/FooterStatus.jsx new file mode 100644 index 0000000..27ce760 --- /dev/null +++ b/client/src/components/FooterStatus.jsx @@ -0,0 +1,73 @@ +// ── This module's fill for core's `site.footer.status` slot ─────────────── +// +// R13, and the contract is MODULE_API.md §3.7. Core owns the position in the +// footer's info row and the separator around it, and passes `linkStyle` so the +// row stays visually one row. **The label, the destination, the data and whether +// anything renders at all are this component's** — that is the whole division, +// and it is why the slot is named for a place rather than for a meaning. +// +// ── The live count, and what it costs ───────────────────────────────────── +// +// The org lead chose a live count ("3 servers · 42 online") over a static link, +// so this fetches. Be clear-eyed about where it fetches from: core renders +// `SiteFooter` inside `PublicLayout`, and every public page renders +// `PublicLayout` ITSELF (§3.3) — so this component mounts once per public page +// view, not once per session. Every public page on the site therefore carries one +// `/public/rust/servers` request, including pages that have nothing to do with +// Rust. +// +// Two things keep that honest rather than merely cheap: +// +// • **It renders NOTHING until it has an answer, and nothing again if the +// request fails.** An unfilled slot renders nothing and core's `wrap` takes +// the separator with it, so a failed fetch degrades to exactly the footer an +// instance with no module installed has. A spinner in a footer would be worse +// than silence on every page of the site. +// • **It never polls.** One request per page view is a cost; a timer in the +// footer of every page would be a different kind of thing entirely. +// +// If that per-page request ever shows up in an operator's logs as a problem, the +// fix is a short-lived module-scope cache here — the decision to keep the number +// live stays intact, and nothing else on the site has to change. + +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import api from '../api.js' + +export default function FooterStatus({ linkStyle }) { + const [summary, setSummary] = useState(null) + + useEffect(() => { + let live = true + + api.servers + .list() + .then(({ servers }) => { + if (!live) return + // `online` already accounts for staleness — the model refuses to let a + // row that has not been written in five minutes claim a server is up — + // so this is a sum, not a judgement. + setSummary({ + servers: servers.length, + players: servers.reduce((total, server) => total + (server.online ? server.players : 0), 0), + }) + }) + // Silence, deliberately. This is the footer of every page on the site; a + // module that cannot reach its own API has nothing to say there. + .catch(() => {}) + + return () => { + live = false + } + }, []) + + if (!summary || summary.servers === 0) return null + + return ( + + {summary.servers === 1 ? '1 server' : `${summary.servers} servers`} + {' · '} + {summary.players === 1 ? '1 online' : `${summary.players} online`} + + ) +} diff --git a/client/src/components/Leaderboard.jsx b/client/src/components/Leaderboard.jsx new file mode 100644 index 0000000..cf386dc --- /dev/null +++ b/client/src/components/Leaderboard.jsx @@ -0,0 +1,110 @@ +// ── The leaderboard ─────────────────────────────────────────────────────── +// +// Per wipe when a wipe is selected, all-time when it is not (R12). The two are +// the same rows summed differently rather than two sets of counters, so they can +// never disagree — which is worth knowing here because it means "All time" is +// not a slower or less accurate answer, it is the same table without a WHERE. +// +// It does NOT poll. A leaderboard moves on the scale of a session; a table that +// re-sorted itself under the reader's cursor every twenty seconds would be worse +// than one that is four minutes old, and the page has a `Refresh` on the tab +// strip for anybody who disagrees. + +import { EmptyState, ErrorState, Loading, useAsync } from '../core.js' +import { ago, count, duration, shortId } from '../lib/format.js' +import api from '../api.js' + +// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`), +// and the column it maps to is this file's. Keeping them in one list is what +// stops a header that sorts by something other than what it says. +const COLUMNS = [ + { key: 'kills', label: 'Kills', sort: 'kills', value: (r) => count(r.kills) }, + { key: 'deaths', label: 'Deaths', sort: 'deaths', value: (r) => count(r.deaths) }, + { key: 'npcKills', label: 'NPC kills', sort: 'npcKills', value: (r) => count(r.npcKills) }, + { key: 'structures', label: 'Structures', sort: null, value: (r) => count(r.structures) }, + { key: 'playtimeSec', label: 'Played', sort: 'playtime', value: (r) => duration(r.playtimeSec) }, +] + +export default function Leaderboard({ serverId, wipeId, sort, onSort }) { + const { data, loading, error } = useAsync( + () => api.servers.leaderboard(serverId, { wipe: wipeId, sort, limit: 50 }), + [serverId, wipeId, sort], + ) + + const rows = data ? data.leaderboard : [] + + if (loading) return + if (error) return + + if (rows.length === 0) { + return ( + + ) + } + + return ( +
+ + + + + {COLUMNS.map((column) => ( + + ))} + + + + + {rows.map((row, index) => ( + + + {COLUMNS.map((column) => ( + + ))} + + + ))} + +
Player + {column.sort ? ( + + ) : ( + column.label + )} + Last seen
+ {index + 1} + {/* A player this module has never seen NAMED is shown by the tail + of their id rather than as a blank: the row is real, and a + nameless one reads as a rendering fault. */} + {row.name || shortId(row.steamId)} + + {column.value(row)} + {ago(row.lastSeen)}
+
+ ) +} + +const cell = { padding: '8px 10px', whiteSpace: 'nowrap' } diff --git a/client/src/components/Online.jsx b/client/src/components/Online.jsx new file mode 100644 index 0000000..f725fb0 --- /dev/null +++ b/client/src/components/Online.jsx @@ -0,0 +1,94 @@ +// ── Who is on the server right now ──────────────────────────────────────── +// +// Read from the presence BOARD, not counted from connect and disconnect events: +// the bridge re-sends the whole board on every connect and every sixty seconds, +// so this is right even after the website has missed something (PROTOCOL.md +// §8.3). Counting transitions instead would drift, and drift in the direction +// people notice — players who never left. +// +// It polls with the feed, because "who is on" is the one thing on this page that +// is a live question. + +import { EmptyState, ErrorState, Loading } from '../core.js' +import { duration, shortId } from '../lib/format.js' +import usePolled from '../hooks/usePolled.js' +import api from '../api.js' + +export default function Online({ serverId, online }) { + const { data, error, loading } = usePolled(() => api.servers.online(serverId), { + key: serverId, + intervalMs: 20_000, + }) + + const players = data ? data.players : [] + + if (loading) return + if (error && !data) return + + if (players.length === 0) { + return ( + + ) + } + + return ( + <> + {/* A board is the last one that ARRIVED, and an unreachable sidecar does not + clear it — deliberately, because the rows are still the best answer + anybody has. But presented bare they read as "these people are on right + now", which is the one thing an offline server cannot be saying. The + page walk found this with a fixture server whose header said Offline + above three apparently-connected players. */} + {!online && ( +

+ This server is offline. Below is the last board it sent, not who is on it now. +

+ )} +
    + {players.map((player) => ( +
  • + + {player.name || shortId(player.steamId)} + {/* Sleeping is not idle and not offline — a sleeping player's body is + in the world and can be killed, which is why the board carries the + flag at all. */} + {player.sleeping && ( + · sleeping + )} + + {/* `connectedAt` is absent for a player who was already on when the + plugin loaded — an unknown session length, which is not a session of + no length. Saying nothing is the honest render of that. */} + + {player.connectedAt ? `on for ${sessionSoFar(player.connectedAt)}` : ''} + +
  • + ))} +
+ + ) +} + +/** How long a player has been on, from the DATETIME the board reported. */ +function sessionSoFar(connectedAt) { + const since = Date.parse(connectedAt) + if (Number.isNaN(since)) return '' + return duration((Date.now() - since) / 1000) +} diff --git a/client/src/components/Tabs.jsx b/client/src/components/Tabs.jsx new file mode 100644 index 0000000..3b6c7c3 --- /dev/null +++ b/client/src/components/Tabs.jsx @@ -0,0 +1,63 @@ +// ── Tabs, bundled rather than borrowed ──────────────────────────────────── +// +// The shared kit is nine members and it is CLOSED (MODULE_API.md §3.4): layout, +// headings, the three data-page states, the fetch hook, the session, the site +// and `Slot`. A tab strip is not in it, so it is here — which is the kit working +// as designed rather than a gap in it. What the kit guarantees is that a module +// page looks like the site while it loads and while it fails; everything a page +// builds on top of that is the module's own. +// +// It is styled with core's CSS VARIABLES and its `.pill` class rather than with +// colours of its own, so it re-themes with the instance (THEMING_AND_NAV.md). +// The one class this module must never write by hand is the shell wrapper — +// `PublicLayout`'s `shell` prop exists precisely so that one stays core's. +// +// **The selected tab lives in the URL, not in this component.** A tab strip that +// owned its own state would make every panel on this page unlinkable: "look at +// the leaderboard for this server" would be a sentence rather than a link, back +// would leave the page entirely, and a refresh would land on the first tab. So +// this is a controlled component and `ServerDetail` keeps the state in a search +// parameter. + +export default function Tabs({ tabs, active, onSelect, label = 'Sections' }) { + return ( +
+ {tabs.map((tab) => { + const selected = tab.id === active + return ( + + ) + })} +
+ ) +} diff --git a/client/src/components/WipeSelect.jsx b/client/src/components/WipeSelect.jsx new file mode 100644 index 0000000..91fe6af --- /dev/null +++ b/client/src/components/WipeSelect.jsx @@ -0,0 +1,54 @@ +// ── "This wipe" or "All time" ───────────────────────────────────────────── +// +// One control, used by two panels, because the wipe is a property of the PAGE +// rather than of the feed or the leaderboard — a reader who has chosen last +// month's map means it for both, and two selects that could disagree is a page +// that shows one wipe's kills next to another's leaderboard. +// +// It loads the wipe list itself. That is a second request for the same list the +// Wipes tab fetches, and it is the right trade: the alternative is the page +// fetching it on mount for a control most visitors never touch, on every visit, +// for every server. + +import { useAsync } from '../core.js' +import { day } from '../lib/format.js' +import api from '../api.js' + +/** The value that means "no wipe filter at all". Never the empty string — see `api.js`'s `query`. */ +export const ALL_TIME = 'all' + +export default function WipeSelect({ serverId, value, onChange, currentWipeId }) { + const { data } = useAsync(() => api.servers.wipes(serverId), [serverId]) + const wipes = data ? data.wipes : [] + + // A server with one wipe has nothing to choose between, so the control is not + // offered. "All time" and "this wipe" are the same answer there, and a select + // with one real option is furniture that invites a question with no answer. + if (wipes.length < 2) return null + + return ( + + ) +} diff --git a/client/src/components/Wipes.jsx b/client/src/components/Wipes.jsx new file mode 100644 index 0000000..f7b5080 --- /dev/null +++ b/client/src/components/Wipes.jsx @@ -0,0 +1,85 @@ +// ── Every wipe this server has had ──────────────────────────────────────── +// +// The list is what makes the rest of the page navigable — picking a wipe here +// filters the feed and the leaderboard — and it is also the proof R12 asks for: +// a wipe that ended is still here, with its record still attached. A Rust server +// wipes monthly, and a community site that forgot the previous map every time +// would throw away most of what it knows about its own players. +// +// `wipeId` is derived by the bridge PLUGIN from the save's creation time and +// stamped on every frame (PROTOCOL.md §8.2), so the id in this list is the same +// id the events and the leaderboard filter by. There is no second derivation +// anywhere that could disagree. + +import { EmptyState, ErrorState, Loading, useAsync } from '../core.js' +import { ago, day } from '../lib/format.js' +import api from '../api.js' + +export default function Wipes({ serverId, currentWipeId, selected, onSelect }) { + const { data, loading, error } = useAsync(() => api.servers.wipes(serverId), [serverId]) + const wipes = data ? data.wipes : [] + + if (loading) return + if (error) return + + if (wipes.length === 0) { + return ( + + ) + } + + return ( +
    + {wipes.map((wipe) => { + const current = wipe.wipeId === currentWipeId + const active = wipe.wipeId === selected + return ( +
  • + +
  • + ) + })} +
+ ) +} diff --git a/client/src/entry.jsx b/client/src/entry.jsx index 249098f..9177e22 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -19,6 +19,8 @@ import { registry, coreApiVersion } from './core.js' import Servers from './routes/public/Servers.jsx' +import ServerDetail from './routes/public/ServerDetail.jsx' +import FooterStatus from './components/FooterStatus.jsx' // The module id, exactly as `module.json` spells it. Core keys the registry by it // and prefixes every route path with it. @@ -33,7 +35,7 @@ const ID = 'rust' // installed side by side cannot collide, and an operator can see from a URL which // module served it. // -// So this page is at `/rust/servers`. +// So the list below is at `/rust` and the detail page at `/rust/servers/:id`. // // **Note what is NOT here: an auth wrapper.** `gate: { roles: [...] }` is // available and core applies it as its own `RoleGate`; supplying your own is not @@ -41,11 +43,22 @@ const ID = 'rust' // see what, and they only do if one thing decides. // // R8's landing page is the server list, and `/rust/servers/:id` hangs beneath it. -// The detail route is a later phase's, and it is deliberately not stubbed here: a -// registered route that renders nothing is a 200 with a blank page, which is -// worse than the 404 an unregistered one gives. +// +// **The list is registered with an EMPTY path**, which core renders as the +// module's namespace root: `/rust`. The prefixing code strips the separator it +// would otherwise leave behind (`registry.js`: `${id}/${path}` with trailing +// slashes trimmed), so a module can own its own root without being able to spell +// its way out of it. Phase 1 served this page at `/rust/servers` and left `/rust` +// to core's CMS catch-all; the org lead settled it at `/rust` in phase 4, so the +// address an operator links to is the module's name. +// +// React Router ranks a static segment above a dynamic one, so `/rust` wins +// against core's `/:slug` CMS route without depending on registration order. registry.registerRoutes(ID, { - public: [{ path: 'servers', element: }], + public: [ + { path: '', element: }, + { path: 'servers/:id', element: }, + ], }) // ── Nav ─────────────────────────────────────────────────────────────────── @@ -67,9 +80,23 @@ registry.registerRoutes(ID, { // one is the only row in its sidebar with no glyph, which reads as breakage. registry.registerNav(ID, { area: 'public', - items: [{ label: 'Servers', to: '/rust/servers' }], + items: [{ label: 'Servers', to: '/rust' }], }) +// ── Extension slots ─────────────────────────────────────────────────────── +// +// Core declares a slot, only core may declare one, and at most one module may +// fill it (§3.7). `site.footer.status` is the status-ish spot in core's footer +// info row: core owns the position and passes `linkStyle`; the label, the +// destination, the data and whether anything renders at all are the module's. +// +// It is a CLIENT slot and cannot be named in `module.json`'s `extensions` — +// that array is validated against the SERVER registry, and naming a client slot +// there fails the load outright with `unknown extension slot`. Phase 1 found +// that the hard way; the two halves of R13 are declared in different places on +// purpose. +registry.registerExtension(ID, 'site.footer.status', FooterStatus) + // `module.json`'s `coreApi` range was checked by the loader before this file was // ever served, so there is nothing to re-check here. Log it anyway: a mismatch // between the core that validated the manifest and the core that published this diff --git a/client/src/hooks/usePolled.js b/client/src/hooks/usePolled.js new file mode 100644 index 0000000..faaae63 --- /dev/null +++ b/client/src/hooks/usePolled.js @@ -0,0 +1,116 @@ +// ── A poll that keeps what it already had ───────────────────────────────── +// +// **Why this is not `useAsync`.** Core's hook (MODULE_API.md §3.4, and +// `client/src/lib/useAsync.js` in core) is `useState({loading:true,error:null,data:null})` +// re-run on a dependency change — and the first thing it does on every run is +// blank `data` and set `loading`. That is right for a page load and wrong for a +// poll: bumping a dependency every twenty seconds would clear the killfeed, +// render `` in its place and re-fill it, four times a minute, for ever. +// +// So a poll needs a hook whose refresh is INVISIBLE when it succeeds. It keeps +// the previous rows on screen, replaces them when the new ones arrive, and keeps +// them *and* reports the error when the fetch fails — because a site whose whole +// premise is "it renders while the game is off" must not blank the page the +// first time a request does. +// +// `useAsync` is still the right hook for everything that loads once, and the +// pages here use it for exactly that. Bundling this beside it is the kit working +// as intended: the nine shared members are the chrome every module must share, +// not a ceiling on what a module may write. +// +// ── Two behaviours worth knowing ────────────────────────────────────────── +// +// 1. **A backgrounded tab does not poll.** Page Visibility, plus an immediate +// refresh when the viewer comes back — which is also the moment stale rows +// are most visible. A tab left open overnight is otherwise a request every +// twenty seconds until the laptop dies. +// 2. **`key` resets, dependencies do not.** Switching server or wipe SHOULD +// blank the rows: what is on screen belongs to a different question. That is +// what `key` is for, and it is separate from the interval. + +import { useCallback, useEffect, useRef, useState } from 'react' + +/** + * @param {() => Promise} fetcher called with no arguments; must not throw synchronously + * @param {object} options + * @param {string} options.key changes when the QUESTION changes, blanking the answer + * @param {number} options.intervalMs 0 disables polling — the hook then loads once + * @param {boolean} options.enabled false while the page has nothing to ask about yet + */ +export function usePolled(fetcher, { key = '', intervalMs = 20000, enabled = true } = {}) { + const [state, setState] = useState({ data: null, error: null, loading: enabled, at: null }) + + // The fetcher is rebuilt on every render — it closes over props — and a hook + // that listed it as a dependency would restart its interval every render. The + // ref is how the timer keeps calling the CURRENT one without depending on it. + const latest = useRef(fetcher) + latest.current = fetcher + + // Guards a reply from a question nobody is asking any more: a slow request + // whose page has moved on, or one still in flight at unmount. + const generation = useRef(0) + + const run = useCallback( + async (mine) => { + try { + const data = await latest.current() + if (mine !== generation.current) return + setState({ data, error: null, loading: false, at: Date.now() }) + } catch (error) { + if (mine !== generation.current) return + // `data` is carried forward deliberately. A failed refresh is a page that + // says "this is what we last knew, and it did not refresh", which is the + // same promise the server list makes about a game server being down. + setState((prev) => ({ data: prev.data, error, loading: false, at: prev.at })) + } + }, + [], + ) + + const refresh = useCallback(() => run(generation.current), [run]) + + useEffect(() => { + generation.current += 1 + const mine = generation.current + + if (!enabled) { + setState({ data: null, error: null, loading: false, at: null }) + return undefined + } + + setState({ data: null, error: null, loading: true, at: null }) + run(mine) + + if (!intervalMs) return () => { generation.current += 1 } + + let timer = null + + const visible = () => typeof document === 'undefined' || document.visibilityState === 'visible' + + const start = () => { + if (timer === null) timer = setInterval(() => run(mine), intervalMs) + } + const stop = () => { + if (timer !== null) { clearInterval(timer); timer = null } + } + + const onVisibility = () => { + if (visible()) { run(mine); start() } else stop() + } + + if (visible()) start() + if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility) + + return () => { + // Bumping the generation on teardown is what makes an in-flight reply from + // the old question land nowhere. Clearing the timer alone would not. + generation.current += 1 + stop() + if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility) + } + }, [key, intervalMs, enabled, run]) + + return { ...state, refresh } +} + +export default usePolled diff --git a/client/src/lib/feed.js b/client/src/lib/feed.js new file mode 100644 index 0000000..5c8bb1f --- /dev/null +++ b/client/src/lib/feed.js @@ -0,0 +1,178 @@ +// ── One stored frame as one line of a feed ──────────────────────────────── +// +// `GET /public/rust/servers/:id/events` answers rows shaped +// `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame +// the plugin emitted — this module stores what it is given and indexes only the +// columns it serves (PROTOCOL.md §8.4, and the `raw` column in schema.sql). So +// everything a killfeed line needs is in `frame`, under the names the plugin +// wrote, and this file is the one place that knows them. +// +// **It returns PARTS, not a sentence.** A component wants the names emphasised +// and the detail muted, and a function returning `"Alice killed Bob"` forces +// either a `dangerouslySetInnerHTML` or a re-parse. Parts also make this +// testable without a DOM, which is the whole reason it is not a component. +// +// ── The rule for an unknown kind ────────────────────────────────────────── +// +// It renders as itself. A later protocol adds kinds, an operator's module may be +// older than their game host, and a feed that DROPPED what it did not recognise +// would be a page that quietly says less than the truth. The server's allowlist +// has already decided this row may be seen (`server/catalogue.js`); what is left +// here is presentation, and the honest presentation of a kind we have no words +// for is its own name. + +import { duration, prefab } from './format.js' + +/** + * Kinds this feed asks for. + * + * `player.tally` is public and deliberately NOT here: it is an aggregate the + * plugin flushes every sixty seconds per active player (§8.6), so a feed + * including it would be mostly wood counts. It is the leaderboard's input, and + * the leaderboard is where it shows up. + */ +export const FEED_KINDS = Object.freeze([ + 'player.death', + 'player.connected', + 'player.disconnected', + 'player.respawned', + 'player.chat', + 'server.wipe', + 'server.initialized', + 'server.shutdown', +]) + +/** The filters the feed offers, and the kinds each one asks the API for. */ +export const FILTERS = Object.freeze([ + { id: 'all', label: 'Everything', kinds: FEED_KINDS }, + { id: 'kills', label: 'Kills', kinds: ['player.death'] }, + { id: 'chat', label: 'Chat', kinds: ['player.chat'] }, + { + id: 'sessions', + label: 'Comings and goings', + kinds: ['player.connected', 'player.disconnected', 'player.respawned'], + }, + { id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] }, +]) + +export function kindsFor(filterId) { + const filter = FILTERS.find((f) => f.id === filterId) + return (filter || FILTERS[0]).kinds +} + +/** + * One row as `{ tone, actor, join, verb, subject, detail }`. + * + * `actor` and `subject` are names and are emphasised; `verb` and `detail` are + * prose. Any of them may be empty. `tone` is the row's category, for the small + * colour the component gives it — never for deciding what a row means. + * + * `join` is what goes between the actor and the verb, and it exists for exactly + * one case: chat. "Brannock see you in september" is not a sentence anybody + * writes, and putting the colon in the message would put presentation inside the + * text a player typed. + */ +export function describe(row) { + const frame = (row && row.frame) || {} + const name = frame.name || null + + switch (row && row.kind) { + case 'player.death': + return death(frame, name) + + case 'player.connected': + return { tone: 'join', actor: name, verb: 'connected', subject: null, detail: '' } + + case 'player.disconnected': + return { + tone: 'leave', + actor: name, + verb: 'disconnected', + subject: null, + // Two optional halves, and the session is the interesting one: the plugin + // omits `sessionSec` for a player who was already on when it loaded, so an + // absent value means "unknown", never zero (§8.4's note, and OnPlayerDisconnected). + detail: [frame.reason || null, frame.sessionSec ? `after ${duration(frame.sessionSec)}` : null] + .filter(Boolean) + .join(' · '), + } + + case 'player.respawned': + return { tone: 'join', actor: name, verb: 'respawned', subject: null, detail: '' } + + case 'player.chat': + return { + tone: 'chat', + actor: name, + join: ': ', + // The message is the row, so it goes in `verb` where a component renders + // it unemphasised — and it is the one field on this wire a player chooses + // the bytes of. React escapes it; nothing here may ever stop doing that. + verb: frame.message || '', + subject: null, + detail: frame.channel && frame.channel !== 'Global' ? frame.channel : '', + } + + case 'server.wipe': + return { + tone: 'server', + actor: null, + verb: 'The map was wiped', + subject: null, + detail: frame.wipeId ? `new wipe ${frame.wipeId}` : '', + } + + case 'server.initialized': + return { tone: 'server', actor: null, verb: 'The server came up', subject: null, detail: '' } + + case 'server.shutdown': + return { tone: 'server', actor: null, verb: 'The server went down', subject: null, detail: '' } + + default: + return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' } + } +} + +/** + * A death, which is four different sentences. + * + * The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so + * that a reader does not have to guess from an absent field, and collapsing any + * two of them loses something (see `DescribeAttacker` in the bridge plugin). A + * killfeed that reported a fall as a kill by nobody is the failure this avoids. + */ +function death(frame, name) { + const where = [ + frame.weapon ? `with ${prefab(frame.weapon)}` : null, + frame.distance ? `${Math.round(frame.distance)}m` : null, + frame.grid || null, + frame.sleeping ? 'while sleeping' : null, + ] + .filter(Boolean) + .join(' · ') + + switch (frame.attackerType) { + case 'player': + return { tone: 'kill', actor: frame.attackerName || null, verb: 'killed', subject: name, detail: where } + + case 'self': + return { tone: 'death', actor: name, verb: 'died by their own hand', subject: null, detail: where } + + case 'npc': + return { + tone: 'death', + actor: prefab(frame.attackerName) || 'Something', + verb: 'killed', + subject: name, + detail: where, + } + + // `environment` and anything else: falling, drowning, the world. `HitInfo` + // is legitimately null on this path, so an absent attacker type is this case + // rather than a missing field to complain about. + default: + return { tone: 'death', actor: name, verb: 'died', subject: null, detail: where } + } +} + +export default { describe, FEED_KINDS, FILTERS, kindsFor } diff --git a/client/src/lib/format.js b/client/src/lib/format.js new file mode 100644 index 0000000..920da42 --- /dev/null +++ b/client/src/lib/format.js @@ -0,0 +1,136 @@ +// ── Formatting, with no dependencies and no React ───────────────────────── +// +// Every function here is pure and takes what the API answered, so the suite next +// door can ask all of it without a DOM. That is deliberate: the client half's +// real failures are timing and resolution (see `test/build.test.js`), which a +// DOM-less runner cannot see — so the way to have any test coverage at all on +// this side is to keep the parts that CAN be tested free of React. +// +// `Intl` does the work. It is in every browser core supports, it knows the +// viewer's locale and their clock, and it is one fewer thing in a chunk an +// operator ships. + +const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + +const UNITS = [ + ['year', 31536000], + ['month', 2592000], + ['week', 604800], + ['day', 86400], + ['hour', 3600], + ['minute', 60], + ['second', 1], +] + +/** + * "3 minutes ago", from an ISO string or an epoch-millisecond number. + * + * Both shapes arrive from this module's own API: `updatedAt` is an ISO string + * the model produced, and an event's `t` is the millisecond stamp the plugin put + * on the frame. Accepting both here is what stops every caller remembering which + * is which. + */ +export function ago(value, now = Date.now()) { + const at = toMillis(value) + if (at === null) return 'never' + + const seconds = Math.round((at - now) / 1000) + const magnitude = Math.abs(seconds) + + // Under a minute, "in 0 seconds" is what `numeric: 'auto'` produces and it is + // not what anybody means. Say the thing. + if (magnitude < 45) return 'just now' + + const [unit, size] = UNITS.find(([, s]) => magnitude >= s) || ['second', 1] + return RELATIVE.format(Math.round(seconds / size), unit) +} + +/** + * The stamp on a feed row. + * + * **Today's rows get a time; everything older gets a date as well.** The feed can + * be filtered to a past wipe, and a row from six weeks ago rendered as `02:03 PM` + * reads as this afternoon — which the page walk found the moment it looked at the + * previous wipe: three events from August, all apparently a few minutes old. + * + * `now` is a parameter so the boundary is testable rather than a property of the + * machine the test runs on. + */ +export function clock(value, now = Date.now()) { + const at = toMillis(value) + if (at === null) return '' + + const when = new Date(at) + const time = when.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) + + const today = new Date(now) + const sameDay = + when.getFullYear() === today.getFullYear() && + when.getMonth() === today.getMonth() && + when.getDate() === today.getDate() + + if (sameDay) return time + return `${when.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}` +} + +/** A date, for a wipe: the thing people actually compare wipes by. */ +export function day(value) { + const at = toMillis(value) + if (at === null) return 'unknown' + return new Date(at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +} + +/** + * A session or a playtime, as `4h 12m`. + * + * Seconds are dropped above a minute and kept below it, because a two-hour + * session reported to the second is noise and a forty-second one reported as + * "0m" is wrong. + */ +export function duration(seconds) { + const total = Number(seconds) + if (!Number.isFinite(total) || total <= 0) return '—' + if (total < 60) return `${Math.round(total)}s` + + const hours = Math.floor(total / 3600) + const minutes = Math.round((total % 3600) / 60) + + if (hours === 0) return `${minutes}m` + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m` +} + +/** Thousands separators, in the viewer's locale. */ +export function count(value) { + const n = Number(value) + return Number.isFinite(n) ? n.toLocaleString() : '0' +} + +/** + * A prefab short name as something readable — `patrolhelicopter` stays itself, + * `rifle.ak` becomes `rifle ak`. + * + * Deliberately a light touch rather than a lookup table. A table mapping every + * Rust prefab to a pretty name is a second copy of the game's item list that + * goes stale every wipe, and the short name is what a Rust player reads on their + * own server console anyway. + */ +export function prefab(name) { + if (!name) return '' + return String(name).replace(/[_.]+/g, ' ').trim() +} + +/** A steam id, shortened for a table cell, without pretending it is a name. */ +export function shortId(steamId) { + const id = String(steamId || '') + return id.length > 10 ? `…${id.slice(-6)}` : id +} + +function toMillis(value) { + if (value === null || value === undefined || value === '') return null + if (typeof value === 'number') return Number.isFinite(value) ? value : null + + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? null : parsed +} + +export default { ago, clock, day, duration, count, prefab, shortId } diff --git a/client/src/routes/public/ServerDetail.jsx b/client/src/routes/public/ServerDetail.jsx new file mode 100644 index 0000000..2750826 --- /dev/null +++ b/client/src/routes/public/ServerDetail.jsx @@ -0,0 +1,184 @@ +// ── One server ──────────────────────────────────────────────────────────── +// +// R8's page beneath the landing page, and the phase-4 criterion lives here: it +// renders the last thing this server said while every server is off. Nothing on +// it is a live call to a game host — every panel reads this module's own tables, +// filled by the ingest cursor — so a shard that has been down for a week renders +// a week-old killfeed and a leaderboard that is still correct, rather than an +// error page. +// +// ── Everything selectable is in the URL ─────────────────────────────────── +// +// Tab, feed filter, wipe and leaderboard sort all live in search parameters. +// That costs a little ceremony here and buys the thing a community site is for: +// "look at last wipe's leaderboard on Main" is a LINK. State held in `useState` +// would make every one of those sentences unlinkable, lose the reader's place on +// a refresh, and make the browser's back button leave the page instead of +// undoing what they just clicked. +// +// `useSearchParams` comes from CORE's router (the shim in `src/shim/`), so it is +// the same live navigation context core's own pages use. A module with its own +// copy of react-router would get a `useParams` that returns nothing on a page +// that otherwise renders perfectly — see `core.js`'s identity check. + +import { useSearchParams, useParams, Link } from 'react-router-dom' +import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js' +import Feed from '../../components/Feed.jsx' +import Leaderboard from '../../components/Leaderboard.jsx' +import Online from '../../components/Online.jsx' +import Tabs from '../../components/Tabs.jsx' +import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.jsx' +import Wipes from '../../components/Wipes.jsx' +import { ago, count, day } from '../../lib/format.js' +import api from '../../api.js' + +const TABS = [ + { id: 'feed', label: 'Feed' }, + { id: 'leaderboard', label: 'Leaderboard' }, + { id: 'online', label: 'Online' }, + { id: 'wipes', label: 'Wipes' }, +] + +export default function ServerDetail() { + const { id } = useParams() + const [params, setParams] = useSearchParams() + + const { data, loading, error } = useAsync(() => api.servers.get(id), [id]) + const server = data ? data.server : null + + const tab = TABS.some((t) => t.id === params.get('tab')) ? params.get('tab') : 'feed' + const filter = params.get('show') || 'all' + const sort = params.get('sort') || 'kills' + + // `wipe` absent means all time; `wipe=current` means whatever wipe the server + // is on now, which is a moving target and therefore a word rather than an id — + // a link somebody shares stays about "now" rather than about the map that was + // current when they sent it. + const wipeParam = params.get('wipe') + const wipeId = !wipeParam || wipeParam === ALL_TIME ? null : wipeParam === 'current' ? (server && server.wipeId) || null : wipeParam + + const set = (key, value) => { + const next = new URLSearchParams(params) + if (!value || value === 'all' || (key === 'tab' && value === 'feed')) next.delete(key) + else next.set(key, value) + // `replace` so that flipping between tabs does not fill the reader's history + // with one entry per click — back should leave the page they arrived on. + setParams(next, { replace: true }) + } + + if (loading) { + return ( + + + + ) + } + + // A 404 from the detail route is the one answer the other four cannot give: + // an unknown id has no events, no leaderboard and nobody online, and each of + // those empty lists is a perfectly good answer to its own question. So this is + // where "there is no such server" is said. + // + // **A mistyped address is not a fault, and must not be dressed as one.** The + // first version of this page rendered core's `ErrorState` under the heading and + // the result read "No such server / Something went wrong" — which sends a + // reader who fat-fingered a URL looking for an outage. `ErrorState` is kept for + // the case it is for: a request that failed for a reason nobody can see. + if (error || !server) { + const missing = !error || error.status === 404 + + return ( + + + {!missing && } +

+ Back to the server list +

+
+ ) + } + + return ( + + + +
+ + {server.online + ? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online` + : 'Offline'} + + {/* `lastSeenAt` is when a frame arrived; `updatedAt` is when this site + last wrote the row, which a FAILED poll does too. Reading the second + as the first is what made an offline server claim it had reported just + now, every thirty seconds, for as long as it stayed down. */} + + {server.lastSeenAt ? `last reported ${ago(server.lastSeenAt)}` : 'has never reported'} + {server.stale && server.lastSeenAt ? ' — out of date, so it is shown as offline' : ''} + + + set('wipe', value === ALL_TIME ? null : value)} + /> + +
+ + set('tab', next)} label={`${server.name} sections`} /> + + {tab === 'feed' && ( + set('show', value)} /> + )} + + {tab === 'leaderboard' && ( + set('sort', value)} /> + )} + + {tab === 'online' && } + + {tab === 'wipes' && ( + { + const next = new URLSearchParams(params) + next.set('wipe', value) + next.delete('tab') + setParams(next, { replace: true }) + }} + /> + )} +
+ ) +} + +/** The world line under the heading — the things a Rust player asks first. */ +function describeWorld(server) { + const parts = [ + server.level || null, + server.worldSize ? `size ${count(server.worldSize)}` : null, + server.seed ? `seed ${server.seed}` : null, + server.wipedAt ? `wiped ${day(server.wipedAt)}` : null, + ].filter(Boolean) + + return parts.length > 0 ? parts.join(' · ') : 'This server has not described itself yet.' +} diff --git a/client/src/routes/public/Servers.jsx b/client/src/routes/public/Servers.jsx index e6e9087..46318fa 100644 --- a/client/src/routes/public/Servers.jsx +++ b/client/src/routes/public/Servers.jsx @@ -1,43 +1,45 @@ -// ── The server list ─────────────────────────────────────────────────────── +// ── The server list, and the module's landing page ──────────────────────── +// +// R8: the list is what `/rust` renders, and `/rust/servers/:id` hangs beneath +// it. The route is registered with an empty path in `entry.jsx` — core turns +// that into the module's own namespace root — so this page's address is the one +// an operator links to when they mean "our Rust servers". // // An ordinary React component. Nothing about being inside a module changes how -// you write one — the only differences are where React comes from (core, via the +// you write one; the only differences are where React comes from (core, via the // aliases in `vite.config.js`, so the import below looks completely normal and is // not) and where the chrome comes from (`../../core.js`, the shared UI kit). // -// **Render `PublicLayout` yourself.** Core wraps public routes in its maintenance -// gate and nothing else, so a page that omits the layout renders bare — no -// header, no footer, no site chrome — which looks like a bug and is the contract -// (§3.3). Admin and player routes are the other way round: core wraps those. +// **Render `PublicLayout` yourself, and pass a `shell`.** Core wraps public +// routes in its maintenance gate and nothing else, so a page that omits the +// layout renders bare; without a `shell` it renders full-bleed with the footer +// riding up underneath it. Name a width, never a class — the classes are core's +// (MODULE_API.md §3.3). // -// **And pass a `shell`.** The layout is the chrome; `shell` is the body — the -// centred column, the vertical padding, and the thing that holds the footer at -// the bottom of the viewport. Widths are 'narrow', 'mid' and 'wide'; name a -// width, never a class, because the classes belong to core's stylesheet. -// -// This is the phase-1 version of the landing page R8 calls for. It lists servers -// and links nowhere yet — `/rust/servers/:id` is the next phase's work — so it is -// deliberately a table and not a design. +// **This page never calls a game server.** Every field it renders comes from +// this module's own tables, written by the ingest cursor, which is what lets it +// render "offline, last seen an hour ago" instead of an error page when a shard +// is down. The site's availability does not depend on the game's. +import { Link } from 'react-router-dom' import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js' +import { ago, count, day } from '../../lib/format.js' import api from '../../api.js' -// A relative time that does not need a date library. `Intl.RelativeTimeFormat` -// is in every browser core supports, and one fewer dependency in the chunk is -// one fewer thing an operator ships. -const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - -function ago(iso) { - if (!iso) return 'never' - const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000) - const [unit, size] = Math.abs(seconds) < 3600 ? ['minute', 60] : ['hour', 3600] - return RELATIVE.format(Math.round(seconds / size), unit) +/** The "last reported" line, which has three cases and not one. */ +function reported(server) { + if (!server.lastSeenAt) return 'This server has never reported.' + if (server.stale) return `Last reported ${ago(server.lastSeenAt)} — out of date, so it is shown as offline.` + return `Last reported ${ago(server.lastSeenAt)}.` } export default function Servers() { // `useAsync` is core's fetch/loading/error hook, and the components below are // its states. Using them rather than rolling your own is what makes a module // page indistinguishable from a core one while it loads and while it fails. + // + // It loads once, deliberately. The DETAIL page polls, because that is where + // somebody watching a server sits; a list is a place people pass through. const { data, loading, error } = useAsync(() => api.servers.list(), []) const servers = data ? data.servers : [] @@ -66,37 +68,54 @@ export default function Servers() { )} {servers.length > 0 && ( -
+
{servers.map((server) => ( -
-
- {server.name} - {server.level ? · {server.level} : null} -
- {/* `stale` is a first-class part of the answer rather than - something the page infers from a timestamp. The server - decides what counts as stale, because the server is what - knows how often a sidecar is supposed to check in. */} - Last reported {ago(server.updatedAt)} - {server.stale ? ' — out of date, so it is shown as offline.' : '.'} -
-
-
+ + {server.name} + + {[ + server.level || null, + server.worldSize ? `size ${count(server.worldSize)}` : null, + server.wipedAt ? `wiped ${day(server.wipedAt)}` : null, + ] + .filter(Boolean) + .join(' · ')} + + + {/* `lastSeenAt`, never `updatedAt`. The second is when THIS + site last wrote the row — which a failed poll does too — so + a page reading it told a reader that a server down for three + days had reported just now. And `stale` is a first-class + part of the answer rather than something inferred from a + timestamp: the server decides what counts as stale, because + the server knows how often a sidecar is supposed to check in. */} + {reported(server)} + + + {server.online - ? `${server.players}${server.maxPlayers ? ` / ${server.maxPlayers}` : ''} online` + ? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online` : 'Offline'} -
-
+ + ))}
)} diff --git a/client/test/feed.test.js b/client/test/feed.test.js new file mode 100644 index 0000000..4dcbb79 --- /dev/null +++ b/client/test/feed.test.js @@ -0,0 +1,141 @@ +// ── The feed's sentences ────────────────────────────────────────────────── +// +// `lib/feed.js` is the one part of the client half with real branching in it, and +// it is pure on purpose so that a DOM-less runner can ask all of it. Everything +// here is a claim about what a reader sees for a given frame — which is exactly +// the kind of thing that rots silently, because a wrong killfeed line is still a +// killfeed line. +// +// The fixtures are the frames the bridge plugin actually emits (its +// `DescribeAttacker`, and PROTOCOL.md §8.4), not invented shapes. + +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' + +import { describe, FEED_KINDS, FILTERS, kindsFor } from '../src/lib/feed.js' + +const row = (kind, frame = {}) => ({ id: 1, kind, t: Date.now(), wipeId: 'w1', steamId: '7656', frame }) + +test('a player kill names the killer and the victim, in that order', () => { + const line = describe(row('player.death', { + name: 'Bob', + attackerType: 'player', + attackerName: 'Alice', + weapon: 'rifle.ak', + distance: 42.4, + grid: 'H7', + })) + + assert.equal(line.tone, 'kill') + assert.equal(line.actor, 'Alice') + assert.equal(line.verb, 'killed') + assert.equal(line.subject, 'Bob') + assert.match(line.detail, /rifle ak/) + assert.match(line.detail, /42m/) + assert.match(line.detail, /H7/) +}) + +test('the four attacker types are four different sentences', () => { + // The plugin distinguishes them precisely so a reader does not have to guess + // from an absent field, and collapsing any two loses something: a fall reported + // as a kill by nobody is the failure this prevents. + const victim = { name: 'Bob' } + + const npc = describe(row('player.death', { ...victim, attackerType: 'npc', attackerName: 'scientistnpc_full_any' })) + assert.equal(npc.actor, 'scientistnpc full any') + assert.equal(npc.subject, 'Bob') + + const self = describe(row('player.death', { ...victim, attackerType: 'self' })) + assert.equal(self.actor, 'Bob') + assert.equal(self.subject, null) + assert.match(self.verb, /own hand/) + + const environment = describe(row('player.death', { ...victim, attackerType: 'environment' })) + assert.equal(environment.actor, 'Bob') + assert.equal(environment.verb, 'died') + assert.equal(environment.subject, null) + + // `HitInfo` is legitimately null on the environment path, so a death frame with + // NO attacker type at all is that case — not a missing field to render around. + const bare = describe(row('player.death', victim)) + assert.equal(bare.verb, 'died') + assert.equal(bare.subject, null) +}) + +test('a sleeping victim is said to have been sleeping', () => { + const line = describe(row('player.death', { name: 'Bob', attackerType: 'player', attackerName: 'Alice', sleeping: true })) + assert.match(line.detail, /while sleeping/) +}) + +test('a disconnect with no session length says nothing about one', () => { + // The plugin OMITS `sessionSec` for a player who was already on when it loaded: + // an unknown session is not a session of no length. A line reading "after 0s" + // would be a lie this module invented. + const unknown = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit' })) + assert.equal(unknown.detail, 'Quit') + + const known = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit', sessionSec: 3720 })) + assert.equal(known.detail, 'Quit · after 1h 2m') +}) + +test('a chat line carries the message as text, never as markup', () => { + // The message is the one field on this wire whose bytes a player chooses. It + // comes back as a STRING and is rendered as a React child, which escapes it; + // this test is here so that a later "render the message with formatting" idea + // has to delete an explicit assertion rather than quietly change behaviour. + const line = describe(row('player.chat', { name: 'Bob', message: '', channel: 'Global' })) + assert.equal(line.verb, '') + assert.equal(typeof line.verb, 'string') + // Global is the default channel and saying so on every line is noise; Team is + // information. + assert.equal(line.detail, '') + assert.equal(describe(row('player.chat', { name: 'B', message: 'hi', channel: 'Team' })).detail, 'Team') + + // A chat row is the one line where the actor is a speaker rather than a + // subject, and "Brannock see you in september" is not a sentence anybody + // writes. The colon is presentation, so it lives here and not inside the text + // the player typed. + assert.equal(line.join, ': ') + assert.equal(describe(row('player.connected', { name: 'B' })).join, undefined) +}) + +test('an unknown kind renders as itself rather than vanishing', () => { + // A later protocol adds kinds, and a module may be older than the game host it + // is reading. The server's allowlist has already decided the row may be seen; + // dropping it here would make the page quietly say less than the truth. + const line = describe(row('player.teleported', { name: 'Bob' })) + assert.equal(line.verb, 'player.teleported') + assert.equal(line.tone, 'other') +}) + +test('the feed never asks for the aggregate kind', () => { + // `player.tally` is public and is flushed once a minute per active player + // (§8.6). A feed that included it would be mostly wood counts; it is the + // leaderboard's input, and that is where it shows up. + assert.ok(!FEED_KINDS.includes('player.tally')) + for (const filter of FILTERS) { + for (const kind of filter.kinds) { + assert.ok(FEED_KINDS.includes(kind), `filter "${filter.id}" asks for ${kind}, which the feed does not carry`) + } + } +}) + +test('every kind the feed asks for is one the public route will serve', () => { + // Held against the module's own allowlist rather than against a copy of it: a + // kind this file asked for and `server/catalogue.js` refuses is a filter that + // silently returns nothing, which reads as a quiet server. + // + // A CommonJS file from the server half, read by an ESM test through + // `createRequire`. Crossing the two halves is fine HERE and nowhere else: + // `test/` is not shipped, and `scripts/checkImports.js` governs what is. + const catalogue = createRequire(import.meta.url)('../../server/catalogue.js') + for (const kind of FEED_KINDS) { + assert.ok(catalogue.PUBLIC_KINDS.includes(kind), `the feed asks for ${kind}, which is not public`) + } +}) + +test('an unknown filter falls back to everything rather than to nothing', () => { + assert.deepEqual(kindsFor('nonsense'), FEED_KINDS) + assert.deepEqual(kindsFor(undefined), FEED_KINDS) +}) diff --git a/client/test/format.test.js b/client/test/format.test.js new file mode 100644 index 0000000..052c6c4 --- /dev/null +++ b/client/test/format.test.js @@ -0,0 +1,96 @@ +// ── Formatting ──────────────────────────────────────────────────────────── +// +// Small functions, and the tests are small too — but three of them guard claims +// that would otherwise be made by a page that looks fine: an unknown duration +// rendered as zero, a timestamp in the wrong unit, and "in 0 seconds". +// +// Locale-dependent output is asserted loosely on purpose. `Intl` formats to the +// RUNNER's locale, and a test pinned to "3 minutes ago" would be a test that +// fails on a machine set to French while the page it describes is correct. + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { ago, clock, count, day, duration, prefab, shortId } from '../src/lib/format.js' + +const NOW = Date.parse('2026-09-16T12:00:00Z') + +test('a relative time picks the unit that fits', () => { + assert.match(ago(NOW - 3 * 60_000, NOW), /3/) + assert.match(ago(NOW - 5 * 3600_000, NOW), /5/) + assert.match(ago(NOW - 3 * 86400_000, NOW), /3/) +}) + +test('"just now" rather than "in 0 seconds"', () => { + // What `numeric: 'auto'` produces under a minute is not what anybody means, + // and a feed row a few seconds old is the commonest row on the page. + assert.equal(ago(NOW, NOW), 'just now') + assert.equal(ago(NOW - 10_000, NOW), 'just now') +}) + +test('both time shapes this module serves are accepted', () => { + // `updatedAt` is an ISO string the model produced; an event's `t` is the + // millisecond stamp the plugin put on the frame. A helper that took only one + // would be a helper every caller has to remember the type for. + assert.equal(ago('2026-09-16T11:57:00.000Z', NOW), ago(NOW - 3 * 60_000, NOW)) +}) + +test('a missing time is "never", not the epoch', () => { + assert.equal(ago(null), 'never') + assert.equal(ago(undefined), 'never') + assert.equal(ago(''), 'never') + assert.equal(day(null), 'unknown') +}) + +test('an unknown duration is a dash, and a short one keeps its seconds', () => { + // The distinction the plugin makes and this must not lose: `sessionSec` is + // ABSENT for a player who was already on when it loaded, so zero and unknown + // arrive at the same function and must not render the same way. + assert.equal(duration(null), '—') + assert.equal(duration(0), '—') + assert.equal(duration(40), '40s') + assert.equal(duration(90), '2m') + assert.equal(duration(3720), '1h 2m') + assert.equal(duration(7200), '2h') +}) + +test('a prefab reads as words, without a lookup table', () => { + assert.equal(prefab('rifle.ak'), 'rifle ak') + assert.equal(prefab('scientistnpc_full_any'), 'scientistnpc full any') + assert.equal(prefab(null), '') +}) + +test('a steam id is shortened without pretending to be a name', () => { + assert.equal(shortId('76561198000000001'), '…000001') + assert.equal(shortId(''), '') +}) + +test('a count that is not a number is zero, never NaN on the page', () => { + assert.equal(count(undefined), '0') + assert.equal(count(null), '0') +}) + +test("a feed row from another day carries its date, not just a time", () => { + // Found by the page walk: with the feed filtered to the previous wipe, three + // events from six weeks ago rendered as `02:03 PM` and read as this afternoon. + // Today's rows stay bare, because a killfeed of today's fights does not want + // the date on every line. + // Asserted against `Intl` rather than against a literal: a 12-hour locale puts + // letters in a bare time ("05:30 AM"), so "has letters in it" is not the test — + // "is exactly the time, and nothing else" is. + const time = (at) => new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) + + const todayAt = NOW - 90 * 60_000 + assert.equal(clock(todayAt, NOW), time(todayAt)) + + const olderAt = NOW - 46 * 86400_000 + assert.ok(clock(olderAt, NOW).endsWith(time(olderAt))) + assert.ok(clock(olderAt, NOW).length > time(olderAt).length, 'an older row carries no date') + + // Yesterday counts as another day even when it is only a few hours back — the + // boundary is the calendar, not a duration, because that is what a reader + // means by "what time was that". + const lateLastNight = Date.parse('2026-09-15T23:50:00') + const earlyToday = Date.parse('2026-09-16T00:20:00') + assert.ok(clock(lateLastNight, earlyToday).length > time(lateLastNight).length) +}) diff --git a/client/test/registration.test.js b/client/test/registration.test.js index b66d40b..be9ac9b 100644 --- a/client/test/registration.test.js +++ b/client/test/registration.test.js @@ -66,9 +66,22 @@ function fakeRg() { ), api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' }, registry: { + // Core's own prefixing, character for character (client/src/modules/registry.js): + // the leading separators of the module's path are stripped and so are the + // TRAILING ones, which is what lets a module register `path: ''` and own its + // namespace root — `/rust` rather than `/rust/`. + // + // This fake did the obvious `${id}/${path}` until phase 4, and the day a + // module registered an index route it produced `rust/` while a real core + // produced `rust`. The suite then failed the nav check for a link that works + // perfectly in a browser. A fake that is nearly core is worse than one that + // is obviously not: it fails on the truth. registerRoutes(id, byArea) { for (const [area, list] of Object.entries(byArea || {})) { - for (const r of list || []) routes[area].push({ ...r, path: `${id}/${r.path}`, moduleId: id }) + for (const r of list || []) { + const path = `${id}/${String(r.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '') + routes[area].push({ ...r, path, moduleId: id }) + } } }, registerNav(id, { area, items }) { @@ -120,7 +133,12 @@ it('registers at least one route, namespaced under the module id', () => { assert.ok(all.length > 0, 'the chunk registered no routes at all') for (const [area, list] of Object.entries(registered.routes)) { for (const r of list) { - assert.ok(r.path.startsWith(`${manifest.id}/`), `${area} route "${r.path}" is not under the namespace`) + // Either the namespace root itself (a module's index route, `rust`) or + // something under it (`rust/servers/:id`). `startsWith('rust/')` alone + // would reject the root — and `startsWith('rust')` alone would accept a + // hypothetical `rustling`, which is why this is spelled out. + const under = r.path === manifest.id || r.path.startsWith(`${manifest.id}/`) + assert.ok(under, `${area} route "${r.path}" is not under the namespace`) assert.ok(r.element, `${area} route "${r.path}" has no element`) } } @@ -176,6 +194,19 @@ it('a nav row that gates on a feature has a provider to resolve it', () => { assert.ok(registered.providers.size > 0, 'rows carry feature gates but no provider was registered') }) +it('the footer slot core declares is filled, and by a component', () => { + // R13's first slot, and the half that lives in the CHUNK: `site.footer.status` + // is a CLIENT slot, so it cannot be named in `module.json`'s `extensions` — + // that array is validated against the SERVER registry and naming a client slot + // there fails the load outright. Nothing else holds this registration, and an + // extension that stopped being registered is invisible: an unfilled slot + // renders nothing, exactly as an uninstalled module does. + const footer = registered.extensions.get('site.footer.status') + assert.ok(footer, 'nothing fills site.footer.status') + assert.equal(footer.id, manifest.id) + assert.equal(typeof footer.Component, 'function') +}) + it('every slot module.json declares is one the chunk fills', () => { // `module.json` declares SERVER slots, and the loader validates those before // the chunk is ever served. Client slots cannot be declared there — the server diff --git a/module.json b/module.json index 1724639..b481b67 100644 --- a/module.json +++ b/module.json @@ -12,5 +12,5 @@ "admin": ["/rust"], "player": ["/rust"] }, - "capabilities": ["servers"] + "capabilities": ["servers", "killfeed", "leaderboard", "presence", "wipes"] } diff --git a/routes.manifest.json b/routes.manifest.json index 14049de..97906d9 100644 --- a/routes.manifest.json +++ b/routes.manifest.json @@ -21,6 +21,11 @@ "path": "/api/v1/public/rust/servers", "tier": "public" }, + { + "method": "GET", + "path": "/api/v1/public/rust/servers/:id", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/public/rust/servers/:id/events", diff --git a/server/boot.js b/server/boot.js index 1f5dc8e..945cf5f 100644 --- a/server/boot.js +++ b/server/boot.js @@ -106,7 +106,12 @@ async function refreshOne(server) { // whose plugin is not loaded yet, and reporting it as unreachable sends the // operator to look at the network instead of at the game server. if (!board.ok) { - await db.putState({ serverId: server.id, reachable: false, online: false }) + // `markUnreachable`, not `putState`: nothing answered, so the only new fact + // is that nothing answered. Writing the whole row from that one fact would + // blank the hostname, the map, the seed and the wipe — the last thing this + // server said, which is exactly what the pages exist to render while it is + // off. + await db.markUnreachable(server.id, false) return } @@ -117,7 +122,7 @@ async function refreshOne(server) { // The sidecar is up and has never heard from the game. Presence is emptied // rather than left alone: a stale list of players on a server nobody can // reach is worse than an empty one, because it looks current. - await db.putState({ serverId: server.id, reachable: true, online: false }) + await db.markUnreachable(server.id, true) await ingest.applyBoards(server.id, {}) return } diff --git a/server/db/schema.sql b/server/db/schema.sql index d82cf3d..d32fbdb 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -298,3 +298,14 @@ CREATE TABLE IF NOT EXISTS rust_ingest_cursor ( -- would reach fresh installs only — which is the worst possible distribution for -- a schema change, because it works everywhere it is tested. ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS wipe_id VARCHAR(48) NULL; + +-- Phase 4. `updated_at` is when THIS module last wrote the row, which is not the +-- same fact as when the server last said something — and the pages were reading +-- the first as if it were the second, so a server that had been down for three +-- days rendered "last reported just now" on every failed poll. +-- +-- They are genuinely two facts and both are wanted: `updated_at` decides whether +-- the row is stale (a module that stopped polling must not leave a page claiming +-- a server is up), and `last_seen_at` is when a `server.hello` last arrived. Only +-- a successful refresh moves it. +ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS last_seen_at DATETIME NULL; diff --git a/server/model/servers/servers.db.js b/server/model/servers/servers.db.js index ec02452..76fb8d6 100644 --- a/server/model/servers/servers.db.js +++ b/server/model/servers/servers.db.js @@ -80,11 +80,56 @@ async function listState() { `SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers, hostname, level, seed, world_size AS worldSize, boot_id AS bootId, save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol, - updated_at AS updatedAt + last_seen_at AS lastSeenAt, updated_at AS updatedAt FROM ${STATE}`, ) } +/** One server's observed state, or `null`. The single-row twin of `listState`. */ +async function getState(serverId) { + const rows = await core.query( + `SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers, + hostname, level, seed, world_size AS worldSize, boot_id AS bootId, + save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol, + last_seen_at AS lastSeenAt, updated_at AS updatedAt + FROM ${STATE} + WHERE server_id = ?`, + [serverId], + ) + return rows[0] || null +} + +/** + * Mark a server unreachable **without forgetting what it last said**. + * + * `putState` replaces the row whole, which is right when a sidecar answered: the + * frame it answered with is the complete truth about that server. It is wrong + * when nothing answered. A refresh that cannot reach a sidecar knows exactly one + * new fact — that it could not reach it — and writing the whole row from that + * one fact sets `hostname`, `level`, `seed`, `world_size` and `wipe_id` to NULL. + * + * The site's whole premise is that it renders the last thing each server said + * while every server is off. A row blanked the first time a game host reboots + * cannot do that: the page loses the map, the size, the seed and the wipe, and + * what it shows is not "offline, here is what we know" but "offline, and we have + * never heard of it". It is invisible in every test that stubs a reachable + * sidecar, and it shows up as a page that was complete an hour ago. + * + * So: three columns move, and the description stays where it is. + */ +async function markUnreachable(serverId, reachable = false) { + await core.query( + `INSERT INTO ${STATE} (server_id, reachable, online, players, updated_at) + VALUES (?, ?, 0, 0, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE + reachable = VALUES(reachable), + online = 0, + players = 0, + updated_at = CURRENT_TIMESTAMP`, + [serverId, reachable ? 1 : 0], + ) +} + /** * Replace one server's observed state. * @@ -100,15 +145,20 @@ async function putState(state) { await core.query( `INSERT INTO ${STATE} (server_id, reachable, online, players, max_players, hostname, level, seed, - world_size, boot_id, save_created_at, wipe_id, protocol, raw, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + world_size, boot_id, save_created_at, wipe_id, protocol, raw, last_seen_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE reachable = VALUES(reachable), online = VALUES(online), players = VALUES(players), max_players = VALUES(max_players), hostname = VALUES(hostname), level = VALUES(level), seed = VALUES(seed), world_size = VALUES(world_size), boot_id = VALUES(boot_id), save_created_at = VALUES(save_created_at), wipe_id = VALUES(wipe_id), protocol = VALUES(protocol), - raw = VALUES(raw), updated_at = CURRENT_TIMESTAMP`, + raw = VALUES(raw), + -- Only a frame moves this; an unreachable write leaves it alone, which is + -- what lets a page say how long a server has been down rather than how + -- recently we failed to reach it. + last_seen_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP`, [ state.serverId, state.reachable ? 1 : 0, @@ -136,5 +186,7 @@ module.exports = { upsertServer, deleteServer, listState, + getState, + markUnreachable, putState, } diff --git a/server/model/servers/servers.model.js b/server/model/servers/servers.model.js index 2b34cf8..40736b8 100644 --- a/server/model/servers/servers.model.js +++ b/server/model/servers/servers.model.js @@ -73,6 +73,7 @@ async function listPublic(now = Date.now()) { function shapePublic(row, state, now) { const updatedAt = state && state.updatedAt ? new Date(state.updatedAt) : null + const lastSeenAt = state && state.lastSeenAt ? new Date(state.lastSeenAt) : null const stale = !updatedAt || now - updatedAt.getTime() > STALE_AFTER_MS return { @@ -87,11 +88,43 @@ function shapePublic(row, state, now) { level: (state && state.level) || null, worldSize: state && state.worldSize != null ? Number(state.worldSize) : null, seed: state && state.seed != null ? Number(state.seed) : null, + // The CURRENT wipe, from the state row rather than from the newest row in + // `rust_wipes`. The two usually agree and the state row is the one that is + // right when they do not: a wipe list is derived from events that have been + // ingested, so a server that has just wiped and said nothing since has a new + // wipe id here and no row there at all. + wipeId: (state && state.wipeId) || null, + wipedAt: (state && state.saveCreatedAt) || null, + // Two timestamps, because they are two facts. `lastSeenAt` is when a frame + // last arrived and is what a page means by "last reported"; `updatedAt` is + // when this module last wrote the row, and is what `stale` is computed from. + // Reading the second as the first is what made an offline server claim it had + // reported just now, on every failed poll, for as long as it stayed down. + lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null, updatedAt: updatedAt ? updatedAt.toISOString() : null, stale, } } +/** + * One enabled server, or `null`. + * + * It exists because `/rust/servers/:id` is a page and a page needs to be able to + * 404. A detail view built by fetching the list and finding the row in it cannot + * tell "no such server" from "a server that has said nothing" — both are an + * absence — and renders an empty page under a heading for a server that does not + * exist. Filtering happens here, where `enabled = 0` and "never configured" are + * the same answer on purpose: a disabled server is not a 403, it is not there. + */ +async function getPublic(id, now = Date.now()) { + if (!id) return null + + const row = await db.getServer(id) + if (!row || !row.enabled) return null + + return shapePublic(row, await db.getState(row.id), now) +} + /** * The admin view: configuration plus reachability, and **no token**. * @@ -133,6 +166,7 @@ module.exports = { withToken, listForPolling, listPublic, + getPublic, listForAdmin, shapePublic, encryptToken, diff --git a/server/router/public/rust.controller.js b/server/router/public/rust.controller.js index 65fb8c1..8b799b6 100644 --- a/server/router/public/rust.controller.js +++ b/server/router/public/rust.controller.js @@ -25,6 +25,30 @@ async function listServers(req, res) { } } +/** + * One server, or a 404. + * + * **The 404 is the feature.** Everything else under `/servers/:id` answers an + * empty list for a server that does not exist — an unknown id has no events, no + * leaderboard and nobody online, and each of those is a perfectly good answer to + * the question it was asked. Only this route can tell the page that the server + * itself is not there, which is what stops `/rust/servers/typo` rendering as a + * quiet server with nothing to say. + */ +async function getServer(req, res) { + try { + const server = await servers.getPublic(req.params.id) + if (!server) { + res.status(404).json({ error: 'No such server' }) + return + } + res.json({ server }) + } catch (err) { + log.error('failed to read a server', { server: req.params.id, error: err.message }) + res.status(500).json({ error: 'Failed to read the server' }) + } +} + /** * The killfeed, and everything else public that happened on one server. * @@ -83,4 +107,4 @@ async function listOnline(req, res) { } } -module.exports = { listServers, listEvents, listLeaderboard, listWipes, listOnline } +module.exports = { listServers, getServer, listEvents, listLeaderboard, listWipes, listOnline } diff --git a/server/router/public/rust.router.js b/server/router/public/rust.router.js index 959c593..5a98c3c 100644 --- a/server/router/public/rust.router.js +++ b/server/router/public/rust.router.js @@ -52,6 +52,18 @@ rustRouter.get( // Protocol 2 carries IP addresses and player reports; they are stored, and they // do not come out here. +rustRouter.get( + '/servers/:id', + // #swagger.tags = ['Public · Rust'] + // #swagger.summary = 'One Rust server' + // #swagger.description = 'The same shape the list answers with, for one server, and a `404` when there is no such server or an operator has disabled it. The detail page needs the difference: every other route under this path answers an empty list for an id that does not exist, because an unknown server genuinely has no events and nobody online.' + // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } } + /* #swagger.responses[200] = { description: 'The server' } */ + /* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */ + siteMode, + servers.getServer, +) + rustRouter.get( '/servers/:id/events', // #swagger.tags = ['Public · Rust'] diff --git a/server/test/refresh.test.js b/server/test/refresh.test.js new file mode 100644 index 0000000..42309c0 --- /dev/null +++ b/server/test/refresh.test.js @@ -0,0 +1,116 @@ +// ── What a refresh writes when nobody answers ───────────────────────────── +// +// The refresh loop has three outcomes (see `boot.js`), and the two unhappy ones +// are the interesting half of this module's promise: the site renders the last +// thing each server said **while every server is off**. A page can only do that +// if the row still holds what the server said. +// +// The defect this suite exists for shipped in phase 3 and was found by walking +// phase 4's own pages: an unreachable refresh called `putState` with two fields, +// and `putState` replaces the row — so the first time a game host rebooted, the +// hostname, the map, the size, the seed and the wipe id were all set to NULL. +// 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". +// +// It is invisible to any test that stubs a sidecar which answers, which is why +// there was not one. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +function withCore(ctx = fakeCtx()) { + require('../core')._reset() + require('../core').init(ctx) + return ctx +} + +/** The columns a description lives in — the ones an unreachable write must not touch. */ +const DESCRIPTION = ['hostname', 'level', 'seed', 'world_size', 'boot_id', 'save_created_at', 'wipe_id'] + +test('an unreachable refresh does not write the description columns at all', async () => { + const queries = [] + withCore(fakeCtx({ + db: { + query: (sql, params) => { + queries.push({ sql, params }) + return Promise.resolve([]) + }, + pool: {}, + }, + })) + + const db = require('../model/servers/servers.db') + await db.markUnreachable('main', false) + + assert.equal(queries.length, 1) + const { sql, params } = queries[0] + + // Asserted against the SQL rather than against a round trip, because the whole + // failure is about which columns a statement mentions. A column named here is + // a column that can be nulled. + for (const column of DESCRIPTION) { + assert.ok(!sql.includes(column), `markUnreachable writes ${column}, which is the server's description`) + } + + assert.ok(sql.includes('reachable')) + assert.ok(sql.includes('online')) + assert.ok(sql.includes('updated_at')) + assert.deepStrictEqual(params, ['main', 0]) +}) + +test('a sidecar that is up with no game behind it is reachable and offline', async () => { + // The middle outcome, and the one that is easy to collapse into the other two: + // a fresh install whose plugin is not loaded yet. Reporting it as unreachable + // sends an operator to look at the network instead of at the game server. + const queries = [] + withCore(fakeCtx({ + db: { + query: (sql, params) => { + queries.push({ sql, params }) + return Promise.resolve([]) + }, + pool: {}, + }, + })) + + await require('../model/servers/servers.db').markUnreachable('main', true) + assert.deepStrictEqual(queries[0].params, ['main', 1]) +}) + +test('neither unhappy path calls putState', async () => { + // The regression in one assertion: `putState` is the whole-row write, and + // calling it with two fields is what blanked the description. + withCore(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } })) + + const db = require('../model/servers/servers.db') + const sidecar = require('../sidecarClient') + const boot = require('../boot') + + const originalPut = db.putState + const originalMark = db.markUnreachable + const originalBoards = sidecar.boards + const marked = [] + let putCalls = 0 + + db.putState = async () => { putCalls += 1 } + db.markUnreachable = async (id, reachable) => { marked.push([id, reachable]) } + + try { + // Nothing answered. + sidecar.boards = async () => ({ ok: false, status: 0, data: null }) + await boot.refreshOne({ id: 'main', baseUrl: 'http://127.0.0.1:1', token: 't', protocol: 2 }) + + // The sidecar answered, and has never heard from a game. + sidecar.boards = async () => ({ ok: true, status: 200, data: { boards: {} } }) + await boot.refreshOne({ id: 'main', baseUrl: 'http://127.0.0.1:1', token: 't', protocol: 2 }) + + assert.equal(putCalls, 0, 'an unhappy refresh replaced the whole state row') + assert.deepStrictEqual(marked, [['main', false], ['main', true]]) + } finally { + db.putState = originalPut + db.markUnreachable = originalMark + sidecar.boards = originalBoards + } +}) diff --git a/server/test/servers.test.js b/server/test/servers.test.js index 76e150c..e4abfdd 100644 --- a/server/test/servers.test.js +++ b/server/test/servers.test.js @@ -97,10 +97,96 @@ test('the public shape carries nothing about the sidecar', () => { // someone who did not read this file, and an allowlist is the only assertion // that catches one. assert.deepStrictEqual(Object.keys(shaped).sort(), [ - 'hostname', 'id', 'level', 'maxPlayers', 'name', 'online', 'players', 'seed', 'stale', 'updatedAt', 'worldSize', + 'hostname', 'id', 'lastSeenAt', 'level', 'maxPlayers', 'name', 'online', 'players', 'seed', 'stale', + 'updatedAt', 'wipeId', 'wipedAt', 'worldSize', ]) }) +test('"last reported" is when a frame arrived, not when we last polled', () => { + withCore() + const servers = require('../model/servers/servers.model') + + // The defect the phase-4 page walk found, in one assertion. A refresh that + // cannot reach a sidecar still writes `updated_at` — it has to, because that is + // what staleness is computed from — and a page reading it as "last reported" + // told a reader that a server which had been down for days had reported just + // now, every thirty seconds, for as long as it stayed down. + const state = stateRow({ + online: 0, + reachable: 0, + updatedAt: new Date(NOW - 5_000).toISOString(), + lastSeenAt: new Date(NOW - 3 * 86400_000).toISOString(), + }) + + const shaped = servers.shapePublic(serverRow(), state, NOW) + assert.strictEqual(shaped.lastSeenAt, new Date(NOW - 3 * 86400_000).toISOString()) + assert.strictEqual(shaped.stale, false, 'the row itself is fresh — it was written five seconds ago') + assert.strictEqual(shaped.online, false) + + // A server nothing has ever heard from has no such moment, and `null` is what + // a page renders as "never" rather than as the epoch. + assert.strictEqual(servers.shapePublic(serverRow(), undefined, NOW).lastSeenAt, null) +}) + +test('the public shape carries the current wipe, from the state row', () => { + withCore() + const servers = require('../model/servers/servers.model') + + // The wipe id on the STATE row, not the newest row in `rust_wipes`. The two + // usually agree, and the state row is the one that is right when they do not: + // the wipe list is derived from events that have been ingested, so a server + // that has just wiped and said nothing since has a new id here and no row there. + const shaped = servers.shapePublic(serverRow(), stateRow({ wipeId: 'w-2026-09', saveCreatedAt: '2026-09-04T18:00:00Z' }), NOW) + assert.strictEqual(shaped.wipeId, 'w-2026-09') + assert.strictEqual(shaped.wipedAt, '2026-09-04T18:00:00Z') + + // A server nothing has polled yet has no wipe, and `null` is the honest answer + // — an empty string would be sent back as `?wipe=`, which asks a different + // question and answers nothing. + const never = servers.shapePublic(serverRow(), undefined, NOW) + assert.strictEqual(never.wipeId, null) + assert.strictEqual(never.wipedAt, null) +}) + +test('a disabled server is not there, rather than forbidden', async () => { + withCore() + + const db = require('../model/servers/servers.db') + const model = require('../model/servers/servers.model') + const originalServer = db.getServer + const originalState = db.getState + + db.getState = async () => stateRow() + + try { + // The detail route is the only one under `/servers/:id` that can say "no such + // server" — the other four answer an empty list, because an unknown id + // genuinely has no events. So what `null` means here decides what a page + // renders, and a disabled server and a missing one must mean the same thing: + // an operator who switched a server off did not switch it into a 403. + db.getServer = async () => ({ ...serverRow(), enabled: 0 }) + assert.strictEqual(await model.getPublic('main', NOW), null) + + db.getServer = async () => null + assert.strictEqual(await model.getPublic('nope', NOW), null) + + // And an id nobody asked about never reaches the database. + let asked = false + db.getServer = async () => { asked = true; return null } + assert.strictEqual(await model.getPublic('', NOW), null) + assert.strictEqual(asked, false) + + db.getServer = async () => serverRow() + const server = await model.getPublic('main', NOW) + assert.strictEqual(server.id, 'main') + assert.strictEqual(server.online, true) + assert.ok(!Object.prototype.hasOwnProperty.call(server, 'sidecarBaseUrl')) + } finally { + db.getServer = originalServer + db.getState = originalState + } +}) + test('the admin shape reports whether a token is stored, never the token', () => { withCore() const servers = require('../model/servers/servers.model') diff --git a/swagger-fragment.json b/swagger-fragment.json index 6e30171..096ae61 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -196,6 +196,37 @@ } } }, + "/api/v1/public/rust/servers/{id}": { + "get": { + "tags": [ + "Public · Rust" + ], + "summary": "One Rust server", + "description": "The same shape the list answers with, for one server, and a `404` when there is no such server or an operator has disabled it. The detail page needs the difference: every other route under this path answers an empty list for an id that does not exist, because an unknown server genuinely has no events and nobody online.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The server’s slug" + } + ], + "responses": { + "200": { + "description": "The server" + }, + "404": { + "description": "No such server, or it is disabled" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/api/v1/public/rust/servers/{id}/events": { "get": { "tags": [ -- 2.49.1