PLAN.md §30 as approved, plus D119/D120 from the build. Server: - rust_map_images (one row per server: picture as MEDIUMBLOB, geometry, monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair. - mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and asks map.info; a new key or hash from the free Rust+ cache (or a render kept on disk) is fetched in slices, checked against its SHA-256 and stored in one statement. One fetch per server, a backoff on failure, `stale` abandons a fetch that straddles a map change. Render now (D109) is admin-only and watched to completion. - mapLive.js: D111. One map.live per server per 5 s whoever asks; positions are held in memory only. - model/map: four layers (world, events public; players, bases staff), a fleet default plus per-server override (D114), the players layer capped by presence (D113), own dot and online first-party clan mates for a linked viewer (D115, D117, D118). A layer the viewer may not see is absent from the answer, never sent and hidden. - Routes: public /servers/:id/map, /map/image (immutable under its hash), /map/live; admin /servers/:id/map/fetch and /render; the Map card on the visibility PUT. Swagger fragment and frozen manifest regenerated. Client: - A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid (labels only when a cell is wide enough to hold one), a legend that lists hidden layers with who can see them, polled every 10 s while visible. - D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml copies every dist/*.js; checkExternals and build.test.js hold both ends. - The Map card on Admin -> Rust visibility, with Fetch again and Render now. Capability `map` declared for the Android app (phase 15). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
195 lines
8.3 KiB
JavaScript
195 lines
8.3 KiB
JavaScript
// ── 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 Clans from '../../components/Clans.jsx'
|
|
import Feed from '../../components/Feed.jsx'
|
|
import Leaderboard from '../../components/Leaderboard.jsx'
|
|
import MapView from '../../components/MapView.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' },
|
|
// Phase 14. The picture is public; what moves on it has an audience per layer.
|
|
{ id: 'map', label: 'Map' },
|
|
{ id: 'wipes', label: 'Wipes' },
|
|
// Phase 9. The list is public (D58); each clan's roster is on its own page.
|
|
{ id: 'clans', label: 'Clans' },
|
|
]
|
|
|
|
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 (
|
|
<PublicLayout shell="mid">
|
|
<Loading />
|
|
</PublicLayout>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<PublicLayout shell="mid">
|
|
<PageHeader
|
|
title={missing ? 'No such server' : 'That server could not be loaded'}
|
|
lead={
|
|
missing
|
|
? 'This address does not name a server this site follows.'
|
|
: 'The site could not read this server just now. It is worth trying again.'
|
|
}
|
|
/>
|
|
{!missing && <ErrorState error={error} />}
|
|
<p className="sans" style={{ marginTop: 20 }}>
|
|
<Link to="/rust">Back to the server list</Link>
|
|
</p>
|
|
</PublicLayout>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<PublicLayout shell="mid">
|
|
<PageHeader
|
|
eyebrow="Rust"
|
|
title={server.name}
|
|
lead={describeWorld(server)}
|
|
/>
|
|
|
|
<div
|
|
className="sans"
|
|
style={{ display: 'flex', flexWrap: 'wrap', gap: 16, alignItems: 'baseline', marginBottom: 24 }}
|
|
>
|
|
<span style={{ color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
|
|
{server.online
|
|
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
|
: 'Offline'}
|
|
</span>
|
|
{/* `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. */}
|
|
<span style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>
|
|
{server.lastSeenAt ? `last reported ${ago(server.lastSeenAt)}` : 'has never reported'}
|
|
{server.stale && server.lastSeenAt ? ' — out of date, so it is shown as offline' : ''}
|
|
</span>
|
|
<span style={{ marginLeft: 'auto' }}>
|
|
<WipeSelect
|
|
serverId={server.id}
|
|
value={wipeParam}
|
|
currentWipeId={server.wipeId}
|
|
onChange={(value) => set('wipe', value === ALL_TIME ? null : value)}
|
|
/>
|
|
</span>
|
|
</div>
|
|
|
|
<Tabs tabs={TABS} active={tab} onSelect={(next) => set('tab', next)} label={`${server.name} sections`} />
|
|
|
|
{tab === 'feed' && (
|
|
<Feed serverId={server.id} wipeId={wipeId} filter={filter} onFilter={(value) => set('show', value)} />
|
|
)}
|
|
|
|
{tab === 'leaderboard' && (
|
|
<Leaderboard serverId={server.id} wipeId={wipeId} sort={sort} onSort={(value) => set('sort', value)} />
|
|
)}
|
|
|
|
{tab === 'online' && <Online serverId={server.id} online={server.online} />}
|
|
|
|
{tab === 'map' && <MapView serverId={server.id} online={server.online} />}
|
|
|
|
{tab === 'clans' && <Clans serverId={server.id} />}
|
|
|
|
{tab === 'wipes' && (
|
|
<Wipes
|
|
serverId={server.id}
|
|
currentWipeId={server.wipeId}
|
|
selected={wipeId}
|
|
// Picking a wipe here is a navigation as much as a filter: it is the
|
|
// question "what happened during that map", and the answer is the feed.
|
|
onSelect={(value) => {
|
|
const next = new URLSearchParams(params)
|
|
next.set('wipe', value)
|
|
next.delete('tab')
|
|
setParams(next, { replace: true })
|
|
}}
|
|
/>
|
|
)}
|
|
</PublicLayout>
|
|
)
|
|
}
|
|
|
|
/** 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.'
|
|
}
|