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": [