diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 59b19eb..52e9258 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -130,6 +130,9 @@ jobs: - name: Check the OpenAPI fragment is current (MODULE_API.md §2.8) run: npm run check:swagger --prefix server + - name: Check the engagement freeze is current (PLAN.md §25) + run: npm run check:engagement --prefix server + client-build: runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index ae78e71..30884d9 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -336,10 +336,13 @@ jobs: cp -r "server/$d" "$OUT/server/" done - # The client half is the BUILT chunk only. `client/src` is source an - # operator has no use for and core will never read. + # The client half is the BUILT chunks only. `client/src` is source an + # operator has no use for and core will never read. Every `.js`, not + # `entry.js` by name: since D120 the Map tab imports Leaflet's split + # chunk from beside it, and a release that shipped the entry alone + # would load everywhere and spin for ever on that tab. mkdir -p "$OUT/client/dist" - cp client/dist/entry.js "$OUT/client/dist/" + cp client/dist/*.js "$OUT/client/dist/" # Prove the bundle is loadable before it is published: these are the # paths core's loader resolves out of module.json, and a release whose diff --git a/README.md b/README.md index c914029..98f1c02 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,29 @@ rows here; the website core never learns there is more than one. | Public | `GET /api/v1/public/rust/servers` — every server and what it last reported | | 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/leaderboard` — per wipe, or all-time as those rows summed; each row carries the player's chat titles | | Public | `GET …/servers/:id/wipes` and `…/online` | +| Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) | +| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience | | 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` | +| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` — the `PUT` carries the wipe schedule | +| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server | +| Admin | `PUT …/servers/:id/titles`, `GET …/servers/:id/integrations`, `GET/PUT /api/v1/admin/rust/voice` — chat titles, the optional mods a server has, and the announcement voice | | Pages | `/rust` — the server list, and the module's landing page | -| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes | +| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes, clans | +| Pages | `/rust/clans/:externalId` — one clan, with core's Team notify, activity and forum in three module slots | +| Pages | `/admin/rust/servers` — add, edit, test and remove servers, set each one's wipe schedule and chat titles, and choose the announcement voice | +| Discord | `/status`, `/wipe`, `/top`, `/online`, `/clan` — read-only, answered from this module's tables | +| Teams | The deployment's Team provider: a first-party Rust clan is a Team | | Slot | `site.footer.status` — a live server/player count in core's footer | +**Nothing names who is online by default.** The Online list, every feed item that says a named +player was on the server (connects, respawns, deaths, chat, gather tallies) and the leaderboard's +"last seen" reach **staff** unless an operator widens them in Admin → Rust visibility — fleet-wide, +with an optional override per server. How many players are online is public at every setting. The +viewer's standing is re-read from the database on each request, so a demotion or a ban applies at +once rather than when a token expires. + 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. @@ -54,11 +69,67 @@ Seven tables: `rust_servers` (configuration), `rust_server_state` and `rust_pres 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 +**Teams come from Rust's own clans**, not from the uMod Clans plugin, which is optional and whose +clans never become Teams. A clan's roster reaches its own members and staff unless an operator +widens it in Admin → Rust visibility; its name, colour, score and count are public. The game lists +at most 100 clans per server, and a server at that ceiling answers core partially, so core never +removes a Team on its word. Core holds one Team provider per site, which is one reason **a site runs +one module**: core's installer refuses a second. + +**The next wipe is the operator's to state** (Admin → Rust servers): a rule — the monthly forced +wipe only, weekly or every other week, each in the server's own time zone and always including the +forced wipe (first Thursday, 19:00 UK time) — plus an optional one-off date that replaces the next +computed wipe. It is computed on every read and never stored, so it cannot go stale after a wipe. +The server list, the server page, the Android app and `/wipe` all show it. + +**Two uMod plugins are optional, and the module works without either** (`docs/modules/rust/PLAN.md` +§33). The bridge plugin's hard requirements are **Kits** and **ZoneManager**. + +- **BetterChat** (LaserHydra, 5.2.15). With it: **chat titles** an operator sets per server — "top 3 + playtime", "#1 kills" on the current wipe — shown in game chat and beside the name on the web and + app leaderboards; and a **chat style** on any permission group this site authors, all twelve of + BetterChat's group fields, mirrored like the group's permissions (a field changed in game is + reported, never overwritten). Without it the titles still show on the web and the app, and the + styles wait until it is installed. +- **PopupNotifications** (k1lly0u, 0.2.1). With it: `rust.announce` and a server's news posts can be + a popup instead of a chat line. Without it a popup is refused with a sentence saying so, and chat + works as before. + +News and event lines said in chat can wear one styled group's title and colours — the +**announcement voice**, chosen in Admin → Rust servers. The line is said by the bridge plugin with no +player as its sender, so it looks the same with or without BetterChat. + +**The Discord commands answer from the tables, never from a game server**, inside core's three-second +budget. Every refusal is private. **An answer narrower than public goes to the caller alone:** a +moderator's `/online` in a public channel shows the names to the moderator, never to the channel, and +the same for a clan roster. Everything else is posted where it was asked. + +The rest of the module arrives phase by phase. **Nothing is registered before it has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are both surfaces an operator can configure and then wait on, which is worse than an absent one. +### What a client feature-detects on + +`module.json` declares six capability strings, and `GET /api/v1/public/modules` hands them to any +client that asks — the website's own nav, and the Android app (`docs/modules/rust/PLAN.md` R10). +Five of them name a surface: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`. + +The sixth is `rust`, and it names **the module itself**. It looks redundant beside `id`, and it is +not, for two reasons worth writing down before somebody tidies it away: + +- **A client that asks "is this module installed" has nowhere else to ask.** Core flattens every + started module's capabilities into one list, so `servers` alone is a word another module could + declare tomorrow and silently reveal this one's screens. `rust` is the string that can only mean + this module, and it is the single gate a whole navigation group hangs on — exactly the job `shard` + does for `module-uo`. +- **`id` answers a different question.** It is a *mount prefix* (§2.1 requires it to equal the + directory core loads the module from), and `MODULE_API.md` §2.9 is explicit that a client must + never infer a route from a capability. Gating on `id` would quietly make the two the same thing, + and the day a client builds `//servers` from it, the contract that lets this module move its + own pages is gone. + +An unknown capability is absent, and no route is ever derived from one. + ## Build and check ```bash @@ -140,8 +211,8 @@ directory; a symlink answers no and the module is skipped in complete silence. Either way, the module appears when the process restarts: the volume is read at require time. -Then, in Admin → Rust, add a server: its name, the sidecar's base URL, and the token the sidecar -printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is +Then, in Admin → Rust servers, add a server: its name, the sidecar's base URL, and the token the +sidecar printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is stored encrypted through core's own secret box and never returned to any client; the panel reports only whether one is set. diff --git a/ci/bundle.json b/ci/bundle.json index a38c7af..80783ea 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -29,16 +29,29 @@ "server": [ "boot.js", "catalogue.js", + "commands", + "configEdit.js", "core.js", "db", + "engagement", + "eventLeases.js", + "eventProgress.js", + "eventRewards.js", + "eventWorld.js", "index.js", "ingest.js", + "mapImages.js", + "mapLive.js", "model", + "npcSync.js", "package.json", + "permSync.js", "router", - "sidecarClient.js" + "sidecarClient.js", + "titleSync.js" ], "root": [ + "engagement-triggers.json", "swagger-fragment.json", "LICENSE.md", "README.md" diff --git a/ci/core-ref.json b/ci/core-ref.json index d9c692d..bd3dea6 100644 --- a/ci/core-ref.json +++ b/ci/core-ref.json @@ -1,6 +1,6 @@ { - "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet.", + "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as.", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", - "ref": "efa9db73304552dd8bb7a84030b258c6320f79f7", - "refName": "main @ MODULE_API 1.10.0, the Asset Bridge cutover 2 of 5 (website#202)" + "ref": "f0e7d2aa2a4dbdd848f92648085181611b7418f0", + "refName": "main @ MODULE_API 1.11.0 (website#209 merged)" } diff --git a/client/package-lock.json b/client/package-lock.json index fa61633..0700ab6 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,6 +10,7 @@ "license": "GPL-3.0-or-later", "devDependencies": { "@vitejs/plugin-react": "^4.3.2", + "leaflet": "1.9.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", @@ -1448,6 +1449,13 @@ "node": ">=6" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", diff --git a/client/package.json b/client/package.json index f57233f..03212bf 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ "//dependencies": "Deliberately none that ship. react, react-dom/client, react/jsx-runtime and react-router-dom are aliased to the shims in src/shim/ and arrive at runtime on window.__rg - there is exactly one React in the page and core owns it (MODULE_API.md 3.2, 3.6). They are devDependencies so that Vite and the JSX transform can resolve them during the build, and for no other reason.", "devDependencies": { "@vitejs/plugin-react": "^4.3.2", + "leaflet": "1.9.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", diff --git a/client/scripts/checkExternals.js b/client/scripts/checkExternals.js index 031b226..b54b5ef 100644 --- a/client/scripts/checkExternals.js +++ b/client/scripts/checkExternals.js @@ -82,9 +82,10 @@ export function stringMask(src) { return inString } -// Static and dynamic imports that survived into the output. A relative or -// absolute specifier is a chunk that was split, which this build does not do — -// `lib` mode with one entry emits one file — so anything here is a bare name. +// Static and dynamic imports that survived into the output. A relative +// specifier is a chunk that was split — since D120 there is one, Leaflet's, +// which the Map tab imports with `import()` — and is `relativeImports`'s +// concern below; a bare name is this check's. // // **This pattern used to require whitespace after `import`, and so could not see // the one shape the build actually emits.** Minified Rollup output is @@ -116,6 +117,28 @@ export function bareImports(chunk) { return [...bare] } +/** + * Every relative specifier the chunk imports — its split chunks (D120). + * + * Each one is a file the browser will ask for beside `entry.js`, and core + * serves that directory, so it works in development. Whether it SHIPS is + * `release.yml`'s business, which is why the script below also asserts every + * one of them exists in `dist/`, and `test/build.test.js` asserts the release + * copies every `.js` in `dist/` rather than naming `entry.js`. A split chunk the + * release forgot is a Map tab that spins for ever on an operator's site while + * every check here passes. + */ +export function relativeImports(chunk) { + const masked = stringMask(chunk) + const found = new Set() + for (const match of chunk.matchAll(IMPORTS)) { + const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1) + if (masked[keywordAt]) continue + if (match[1].startsWith('./')) found.add(match[1].slice(2)) + } + return [...found] +} + // Fingerprints from the shared libraries' own source. Each is a string those // packages ship and this module has no other reason to contain. // @@ -161,12 +184,30 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`) process.exit(1) } - const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8')) + const dist = path.dirname(CHUNK) + const entry = fs.readFileSync(CHUNK, 'utf8') + const problems = [] + + // Every chunk, not only the entry: a split chunk that bundled a second React + // would load on the tab that imports it and fail there, and nowhere else. + for (const file of fs.readdirSync(dist).filter((f) => f.endsWith('.js'))) { + for (const p of problemsWith(fs.readFileSync(path.join(dist, file), 'utf8'))) problems.push(`${file}: ${p}`) + } + for (const name of relativeImports(entry)) { + if (!fs.existsSync(path.join(dist, name))) { + problems.push(`entry.js imports ./${name}, which is not in dist/ — the chunk would load and that import would fail`) + } + } + if (problems.length) { console.error('\nThe built chunk breaks the shared-dependency rule:\n') for (const p of problems) console.error(` - ${p}\n`) process.exit(1) } const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1) - console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`) + const split = relativeImports(entry) + console.log( + `OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency` + + (split.length ? `; its ${split.length} split chunk(s) (${split.join(', ')}) are present and clean.` : '.'), + ) } diff --git a/client/src/api.js b/client/src/api.js index e435da6..ee68435 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -47,6 +47,35 @@ export const servers = { wipes: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/wipes`), online: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/online`), + + // Phase 9. The clan list is public (D58): name, colour, score and member count + // name nobody. `board` says whether the list can be trusted right now. + clans: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/clans`), + + // Phase 14. The map's picture address, geometry and which layers this viewer + // gets; then what moves on it, already cut down to this viewer on the server. + map: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map`), + mapLive: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map/live`), + + // RunicNPC (runicnpc stage 4). The profiles a leaderboard can rank by, one + // profile's ranking counted as the profile says (D247, D250), and one + // player's kills by profile, for an opened leaderboard row (D252). + npcProfiles: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/npc-profiles`), + npcLeaderboard: (id, { profile, wipe = null, limit = null } = {}) => + req(`/public/rust/servers/${encodeURIComponent(id)}/npc-leaderboard${query({ profile, wipe, limit })}`), + npcKills: (id, steamId, { wipe = null } = {}) => + req(`/public/rust/servers/${encodeURIComponent(id)}/players/${encodeURIComponent(steamId)}/npc-kills${query({ wipe })}`), +} + +// One clan. Its roster comes back only for a viewer inside the operator's roster +// audience (D48) — clan members and staff by default — and `roster.visible` +// says which answer this was, so a page can explain an empty roster rather than +// imply an empty clan. +// +// The id carries colons (`::`). They are legal in a path +// segment, and encoded anyway so that a server slug is never read as structure. +export const clans = { + get: (externalId) => req(`/public/rust/clans/${encodeURIComponent(externalId)}`), } /** @@ -74,6 +103,32 @@ export const playerServers = { list: () => req('/player/rust/servers'), } +// Your own kills of each RunicNPC profile, current wipes, across your linked accounts (D252). +export const playerNpcKills = { + list: () => req('/player/rust/npc-kills'), +} + +// R1's identity link, from the signed-in player's side. +// +// **The code is the whole of what goes up.** The site has no idea which server +// minted it — nothing in six characters says — so the server half asks each +// configured server in turn (D24). A page that asked the player to pick would be +// asking them a question the site can answer itself, and a wrong pick would come +// back indistinguishable from a wrong code. +export const playerLinks = { + list: () => req('/player/rust/links'), + confirm: (code) => req('/player/rust/link', { method: 'POST', body: { code } }), + remove: (steamId) => + req(`/player/rust/links/${encodeURIComponent(steamId)}`, { method: 'DELETE' }), +} + +// What the site has given the caller in game (phase 8). Read-only, and beside +// `playerLinks` rather than under it: an entitlement exists whether or not an +// account is linked yet, which is exactly the state worth showing. +export const playerPermissions = { + list: () => req('/player/rust/permissions'), +} + // ── admin ───────────────────────────────────────────────────────────────── // **`sidecarToken` goes up and never comes back.** The list answers `hasToken`, // and a save that omits the field leaves the stored credential alone — so an @@ -87,10 +142,215 @@ export const admin = { req(`/admin/rust/servers/${encodeURIComponent(id)}`, { method: 'DELETE' }), testServer: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }), + // Phase 14: fetch a server's map picture again, or ask a server with no + // picture to draw one — which stalls that game for seconds (D109). + fetchMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/fetch`, { method: 'POST' }), + renderMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/render`, { method: 'POST' }), + // Phase 17: a server's chat titles, what optional mods it has, and the voice. + saveTitles: (id, body) => req(`/admin/rust/servers/${encodeURIComponent(id)}/titles`, { method: 'PUT', body }), + // PLAN_REDESIGNS §5.5 (D175): a category's title for the whole site; '' resets it. + saveTitleCategory: (stat, text) => + req(`/admin/rust/title-categories/${encodeURIComponent(stat)}`, { method: 'PUT', body: { text } }), + integrations: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/integrations`), + voice: () => req('/admin/rust/voice'), + saveVoice: (group) => req('/admin/rust/voice', { method: 'PUT', body: { group } }), +} + +// ── admin · permissions (R2) ────────────────────────────────────────────── +// +// The authoring surface. Every call here writes to the SITE, and none of them +// reaches a game server — the mirror's own loop does that on its own cadence. +// `sync` is the exception and says so in its name: it runs the pass now and +// answers with what each server reported, which is the only call on this screen +// that can be slow or fail because a game host is down. +// +// A write is followed by a re-read rather than a local edit of the model: what +// the screen is showing is partly the game's answer, and the honest way to learn +// the new one is to ask. +const P = '/admin/rust/permissions' +const enc = encodeURIComponent + +export const adminPermissions = { + overview: () => req(P), + catalogue: () => req(`${P}/catalogue`), + + // One server, as PermissionsManager shows one (D162). + server: (serverId) => req(`${P}/servers/${enc(serverId)}`), + players: (serverId, q) => req(`${P}/servers/${enc(serverId)}/players?q=${enc(q || '')}`), + setPolicy: (serverId, policy) => req(`${P}/servers/${enc(serverId)}/policy`, { method: 'PUT', body: { policy } }), + + // A subject is `{ steamId }` or `{ userId }`; `everywhere` reaches every server. + grant: (serverId, subject, permissions, everywhere = false) => + req(`${P}/servers/${enc(serverId)}/grant`, { method: 'POST', body: { ...subject, permissions, everywhere } }), + revoke: (serverId, subject, permissions, everywhere = false) => + req(`${P}/servers/${enc(serverId)}/revoke`, { method: 'POST', body: { ...subject, permissions, everywhere } }), + removeException: (id) => req(`${P}/exceptions/${enc(id)}`, { method: 'DELETE' }), + + // Groups by id (D189). `here` is `{ onlyHere: true, serverId }` to split a + // shared group's copy off first (D190), or null to change it everywhere. + createGroup: (serverId, body) => req(`${P}/servers/${enc(serverId)}/groups`, { method: 'POST', body }), + updateGroup: (id, body, here = null) => req(`${P}/groups/${enc(id)}`, { method: 'PATCH', body: { ...body, ...(here || {}) } }), + deleteGroup: (id) => req(`${P}/groups/${enc(id)}`, { method: 'DELETE' }), + setGroupPermissions: (id, permissions, here = null) => + req(`${P}/groups/${enc(id)}/permissions`, { method: 'PUT', body: { permissions, ...(here || {}) } }), + setGroupServers: (id, body) => req(`${P}/groups/${enc(id)}/servers`, { method: 'PUT', body }), + splitGroup: (id, serverId) => req(`${P}/groups/${enc(id)}/split`, { method: 'POST', body: { serverId } }), + addMember: (id, subject, here = null) => + req(`${P}/groups/${enc(id)}/members`, { method: 'POST', body: { ...subject, ...(here || {}) } }), + removeMember: (id, subject, here = null) => + req(`${P}/groups/${enc(id)}/members/remove`, { method: 'POST', body: { ...subject, ...(here || {}) } }), + clearMembers: (id, here = null) => req(`${P}/groups/${enc(id)}/members/clear`, { method: 'POST', body: { ...(here || {}) } }), + + // What waits for a person (D161). + adoptDrift: (id) => req(`${P}/drift/${enc(id)}/adopt`, { method: 'POST' }), + revokeDrift: (id) => req(`${P}/drift/${enc(id)}/revoke`, { method: 'POST' }), + acceptDrift: (id) => req(`${P}/drift/${enc(id)}/accept`, { method: 'POST' }), + restoreDrift: (id) => req(`${P}/drift/${enc(id)}/restore`, { method: 'POST' }), + dismissDrift: (id) => req(`${P}/drift/${enc(id)}/dismiss`, { method: 'POST' }), + + sync: (serverId = null) => req(`${P}/sync`, { method: 'POST', body: serverId ? { serverId } : {} }), +} + +// ── admin · visibility ──────────────────────────────────────────────────── +// +// Who may see who is online. The org lead's rule is that nothing names who is +// online by default; this is where an operator deliberately widens it. A save +// answers the whole new state, so the screen re-renders from the server's word +// rather than from what it sent. +export const adminVisibility = { + read: () => req('/admin/rust/visibility'), + save: (body) => req('/admin/rust/visibility', { method: 'PUT', body }), +} + +// ── admin · zone presets (PLAN_REDESIGNS §3.1, D210) ─────────────────────── +// +// An admin's named sets of ZoneManager flags and settings. `read` also carries +// what each server's last hello said about ZoneManager and ZoneDomes, so the +// page works while a server is off. +export const adminZones = { + read: () => req('/admin/rust/zones'), + create: (body) => req('/admin/rust/zones/presets', { method: 'POST', body }), + update: (id, body) => req(`/admin/rust/zones/presets/${encodeURIComponent(id)}`, { method: 'PUT', body }), + remove: (id) => req(`/admin/rust/zones/presets/${encodeURIComponent(id)}`, { method: 'DELETE' }), +} + +// ── admin · NPCs (runicnpc stage 4) ───────────────────────────────────────── +// +// The site's RunicNPC profiles (per server, shared or fleet), pushed to each +// server; and each server's placements, which live on the server (D222), so +// every placement call is a live round trip that fails while the game is off. +const npcServer = (serverId) => `/admin/rust/npcs/servers/${encodeURIComponent(serverId)}` +export const adminNpcs = { + read: () => req('/admin/rust/npcs'), + setFactions: (factions) => req('/admin/rust/npcs/factions', { method: 'PUT', body: { factions } }), + create: (body) => req('/admin/rust/npcs/profiles', { method: 'POST', body }), + update: (id, body) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}`, { method: 'PUT', body }), + remove: (id) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}`, { method: 'DELETE' }), + restore: (id) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}/restore`, { method: 'POST' }), + push: (serverId) => req(`${npcServer(serverId)}/push`, { method: 'POST' }), + placements: (serverId) => req(`${npcServer(serverId)}/placements`), + place: (serverId, body) => req(`${npcServer(serverId)}/placements`, { method: 'POST', body }), + setPlacement: (serverId, id, body) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}`, { method: 'PUT', body }), + removePlacement: (serverId, id) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}`, { method: 'DELETE' }), + renamePlacement: (serverId, id, to) => + req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}/rename`, { method: 'POST', body: { to } }), + respawnPlacement: (serverId, id) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}/respawn`, { method: 'POST' }), +} + +// ── admin · mod configuration (R18) ─────────────────────────────────────── +// +// Every call here is a LIVE round trip to a game host, which makes this the only +// section of this file where a call can be slow, or fail because a server is +// off. Nothing is cached anywhere between the browser and the host's disk: a +// cached config is an edit an operator made over SSH that this website then +// silently overwrote. +// +// `save` carries a `version` the host issued with the file. Send a stale one and +// the answer is a 409 with the current file attached, rather than an overwrite +// of whatever somebody else changed in the meantime. +export const adminConfig = { + files: (serverId) => req(`/admin/rust/config/${encodeURIComponent(serverId)}/files`), + + file: (serverId, path) => + req(`/admin/rust/config/${encodeURIComponent(serverId)}/file${query({ path })}`), + + // Two tiers, one route. `edits` is the generated form — pointers and literals, + // type-preserving — and `text` is the raw document. A number travels as TEXT + // in both: `1.0` parsed into a JavaScript number and sent back as `1` is the + // whole failure this feature was designed around. + save: (serverId, body) => + req(`/admin/rust/config/${encodeURIComponent(serverId)}/file`, { method: 'POST', body }), + + writes: (serverId, limit = null) => + req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes${query({ limit })}`), + + // One write, polled while its reload settles (protocol 13). A save that + // reloads a plugin answers before the reload has finished — behind a cold + // compile that can be many seconds — and this is where the outcome lands. + write: (serverId, id) => + req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes/${encodeURIComponent(id)}`), +} + +// ── the admin.users.detail extension slot ───────────────────────────────── +// +// The client half of R13's first slot. Core hands the component a `userId` and +// NOTHING else — not a client — so an extension builds its own bindings for the +// routes it registered at the other end (§3.5). These two are the only calls in +// this file whose path is core's rather than this module's: the resource is +// core's user, and the module's own segment is the part after it. +export const adminUserLinks = { + list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/links`), + remove: (userId, steamId) => + req(`/admin/users/${encodeURIComponent(userId)}/rust/links/${encodeURIComponent(steamId)}`, { + method: 'DELETE', + }), +} + +// The same panel's phase 7 half: what this person may do in game. The id in the +// path is the one the slot handed the component, so these send `userId` rather +// than a name — the screen already knows who it is looking at. +export const adminUserPermissions = { + list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions`), + grant: (userId, body) => + req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants`, { + method: 'POST', + body, + }), + revoke: (userId, grantId) => + req( + `/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants/${encodeURIComponent(grantId)}`, + { method: 'DELETE' }, + ), +} + +// ── core's public event calendar ─────────────────────────────────────────── +// The one CORE route this module reads, and core published `runId` on it for +// exactly this: a map marker holds a run id and nothing else, and the calendar is +// how a client finds the event it belongs to (website EVENTS.md §I, Phase 14a). +// It lists only announced events, so an unannounced one stays unnamed here too. +export const coreEvents = { + calendar: () => req('/public/events'), } // Exported for the rare caller that needs the base itself — an ``, a // download link, an EventSource. Reach for `request` first. export { BASE, query } -export default { servers, playerServers, admin, BASE } +export default { + servers, + clans, + playerServers, + playerLinks, + playerPermissions, + admin, + adminPermissions, + adminConfig, + adminVisibility, + adminZones, + adminNpcs, + playerNpcKills, + adminUserLinks, + adminUserPermissions, + coreEvents, + BASE, +} diff --git a/client/src/components/Clans.jsx b/client/src/components/Clans.jsx new file mode 100644 index 0000000..7786a2b --- /dev/null +++ b/client/src/components/Clans.jsx @@ -0,0 +1,118 @@ +// ── The clans on one server ─────────────────────────────────────────────── +// +// Rust's OWN clans (R5), best score first. Public at every setting (D58): a +// clan's name, colour, score and member count name nobody. Who is IN a clan is +// the roster, and that lives on the clan's own page behind the operator's +// roster audience (D48). +// +// **The list is only as good as the board it came from**, and the answer says +// how good that is. Three cases would all look like an empty list if rendered +// bare, and they are three different sentences: +// +// • the bridge cannot read this server's clans at all (an older plugin, or a +// Nexus server whose clans live elsewhere) — "unavailable"; +// • the game's clan system is switched off — "this server has no clans"; +// • it can, and there are none — "nobody has founded one yet". +// +// And a board at the game's 100-clan ceiling (D55) says there may be more. + +import { Link } from 'react-router-dom' +import { ErrorState, Loading, useAsync } from '../core.js' +import Empty from './Empty.jsx' +import { count } from '../lib/format.js' +import api from '../api.js' + +export default function Clans({ serverId }) { + const { data, loading, error } = useAsync(() => api.servers.clans(serverId), [serverId]) + + if (loading) return + if (error) return + + const clans = (data && data.clans) || [] + const board = (data && data.board) || {} + + if (clans.length === 0) { + if (!board.supported) { + return ( + + ) + } + if (board.enabled === false) { + return + } + return + } + + return ( + <> + {board.truncated && ( +

+ The game lists at most 100 clans, by score, so there may be more on this server than are shown here. +

+ )} +
    + {clans.map((clan, index) => ( +
  • + + {index + 1} + + + + {clan.name} + + + {count(clan.memberCount)} {clan.memberCount === 1 ? 'member' : 'members'} + + + {count(clan.score)} pts + +
  • + ))} +
+ + ) +} + +/** Where a clan's page is: the same template the Team provider hands core. */ +export function clanPath(externalId) { + return `/rust/clans/${encodeURIComponent(externalId)}` +} + +/** + * A clan's colour, as a small square. The server has already checked it is a + * `#rrggbb` — it ends up in a style — and a clan with no colour gets an outline + * rather than a guess. + */ +export function Swatch({ color, size = 12 }) { + return ( +