Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac0bcd850a | |||
| 42029734ad | |||
| cc185db26b | |||
| 9753dda4af | |||
| ba636c8939 | |||
| fab31f23e8 | |||
| a3bcec9cde | |||
| 36a5cb975a | |||
| d973db7a43 | |||
| 2f561b1103 | |||
| 648d3fd2e1 | |||
| 285db0baa7 | |||
| dc3c9689b4 | |||
| c94271104f | |||
| da1a393702 | |||
| be44839896 | |||
| 480a99f661 | |||
| c4dda5f85c | |||
| 383e89442e | |||
| 47756d392a | |||
| e54ae3afb9 | |||
| b1abd87c3d | |||
| f35e70e7d3 | |||
| 43147b796a | |||
| a1b6d155a1 | |||
| 0876a1d568 | |||
| f3e274b33d | |||
| 0a1e558942 | |||
| baffaa46c9 | |||
| 28e46771b1 | |||
| 8df850f73e | |||
| 7e1f037aad | |||
| 22fd8c5da7 | |||
| 5ce711048c | |||
| f211969ee1 |
@@ -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
|
||||
|
||||
61
README.md
61
README.md
@@ -36,17 +36,70 @@ 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` |
|
||||
| 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` |
|
||||
| Page | `/rust/servers` |
|
||||
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
|
||||
| Pages | `/rust` — the server list, and the module's landing page |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
Two tables, `rust_servers` (configuration) and `rust_server_state` (what each sidecar reported).
|
||||
**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.
|
||||
|
||||
The rest of the module — identity, site-owned permissions, Teams from Rust's clans, notifications,
|
||||
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`.
|
||||
|
||||
**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 rest of the module — notifications,
|
||||
events, the live map, Discord commands — 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 `/<id>/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
|
||||
|
||||
@@ -28,15 +28,24 @@
|
||||
],
|
||||
"server": [
|
||||
"boot.js",
|
||||
"catalogue.js",
|
||||
"configEdit.js",
|
||||
"core.js",
|
||||
"db",
|
||||
"engagement",
|
||||
"eventLeases.js",
|
||||
"eventRewards.js",
|
||||
"eventWorld.js",
|
||||
"index.js",
|
||||
"ingest.js",
|
||||
"model",
|
||||
"package.json",
|
||||
"permSync.js",
|
||||
"router",
|
||||
"sidecarClient.js"
|
||||
],
|
||||
"root": [
|
||||
"engagement-triggers.json",
|
||||
"swagger-fragment.json",
|
||||
"LICENSE.md",
|
||||
"README.md"
|
||||
|
||||
@@ -25,6 +25,60 @@ 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`),
|
||||
|
||||
// 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`),
|
||||
}
|
||||
|
||||
// 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 (`<server>:<clan>:<created>`). 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)}`),
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<select>` is on "All time" gets
|
||||
* an empty leaderboard and no error.
|
||||
*/
|
||||
function query(params) {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== null && value !== undefined && value !== '') search.set(key, String(value))
|
||||
}
|
||||
const string = search.toString()
|
||||
return string ? `?${string}` : ''
|
||||
}
|
||||
|
||||
// ── player ────────────────────────────────────────────────────────────────
|
||||
@@ -35,6 +89,27 @@ export const playerServers = {
|
||||
list: () => req('/player/rust/servers'),
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -50,8 +125,136 @@ export const admin = {
|
||||
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
export const adminPermissions = {
|
||||
overview: () => req('/admin/rust/permissions'),
|
||||
catalogue: () => req('/admin/rust/permissions/catalogue'),
|
||||
|
||||
saveGroup: (name, body) =>
|
||||
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'PUT', body }),
|
||||
deleteGroup: (name) =>
|
||||
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
|
||||
addMember: (name, username) =>
|
||||
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members`, {
|
||||
method: 'POST',
|
||||
body: { username },
|
||||
}),
|
||||
removeMember: (name, userId) =>
|
||||
req(
|
||||
`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(userId)}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
|
||||
grant: (body) => req('/admin/rust/permissions/grants', { method: 'POST', body }),
|
||||
revoke: (id) =>
|
||||
req(`/admin/rust/permissions/grants/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
adoptDrift: (id) =>
|
||||
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/adopt`, { method: 'POST' }),
|
||||
revokeDrift: (id) =>
|
||||
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/revoke`, { method: 'POST' }),
|
||||
|
||||
sync: (serverId = null) =>
|
||||
req('/admin/rust/permissions/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 · 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 })}`),
|
||||
}
|
||||
|
||||
// ── 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' },
|
||||
),
|
||||
}
|
||||
|
||||
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
||||
// download link, an EventSource. Reach for `request` first.
|
||||
export { BASE }
|
||||
export { BASE, query }
|
||||
|
||||
export default { servers, playerServers, admin, BASE }
|
||||
export default {
|
||||
servers,
|
||||
clans,
|
||||
playerServers,
|
||||
playerLinks,
|
||||
playerPermissions,
|
||||
admin,
|
||||
adminPermissions,
|
||||
adminConfig,
|
||||
adminVisibility,
|
||||
adminUserLinks,
|
||||
adminUserPermissions,
|
||||
BASE,
|
||||
}
|
||||
|
||||
118
client/src/components/Clans.jsx
Normal file
118
client/src/components/Clans.jsx
Normal file
@@ -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 <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
const clans = (data && data.clans) || []
|
||||
const board = (data && data.board) || {}
|
||||
|
||||
if (clans.length === 0) {
|
||||
if (!board.supported) {
|
||||
return (
|
||||
<Empty
|
||||
title="Clans are unavailable for this server"
|
||||
message={board.reason ? `${capitalise(board.reason)}.` : 'The bridge has not reported this server’s clans yet.'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (board.enabled === false) {
|
||||
return <Empty title="This server has no clans" message="Its operator has switched the game’s clan system off." />
|
||||
}
|
||||
return <Empty title="No clans yet" message="Nobody on this server has founded a clan." />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{board.truncated && (
|
||||
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||
The game lists at most 100 clans, by score, so there may be more on this server than are shown here.
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{clans.map((clan, index) => (
|
||||
<li
|
||||
key={clan.externalId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 12,
|
||||
padding: '10px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
}}
|
||||
>
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', minWidth: 22, textAlign: 'right' }}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<Swatch color={clan.color} />
|
||||
<Link to={clanPath(clan.externalId)} style={{ fontWeight: 600, flex: 1, minWidth: 0 }}>
|
||||
{clan.name}
|
||||
</Link>
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', whiteSpace: 'nowrap' }}>
|
||||
{count(clan.memberCount)} {clan.memberCount === 1 ? 'member' : 'members'}
|
||||
</span>
|
||||
<span className="sans" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap', minWidth: 70, textAlign: 'right' }}>
|
||||
{count(clan.score)} pts
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
flex: 'none',
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 3,
|
||||
background: color || 'transparent',
|
||||
border: color ? 'none' : '1px solid var(--line)',
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function capitalise(text) {
|
||||
return text ? text.charAt(0).toUpperCase() + text.slice(1) : text
|
||||
}
|
||||
26
client/src/components/Empty.jsx
Normal file
26
client/src/components/Empty.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// ── An empty state with a heading and a sentence ──────────────────────────
|
||||
//
|
||||
// Core's `EmptyState` renders its CHILDREN and nothing else. This module passed
|
||||
// it `title` and `message` from phase 4 onwards — the shape the Integration Kit's
|
||||
// template teaches — and React drops an unknown prop without a word, so every
|
||||
// empty panel in the module rendered as a blank box: "Nobody is on", "No scores
|
||||
// yet", "No servers yet", all of them. Found by the presence fix's browser walk,
|
||||
// when the "12 players online" it depended on came out as nothing.
|
||||
//
|
||||
// Fixed here rather than in core: core's component is shared by every module,
|
||||
// and a module-side wrapper changes nothing anybody else renders. The client
|
||||
// suite (`test/uiKitProps.test.js`) refuses a titled EmptyState so the mistake
|
||||
// cannot come back.
|
||||
|
||||
import { EmptyState } from '../core.js'
|
||||
|
||||
export default function Empty({ title, message }) {
|
||||
return (
|
||||
<EmptyState>
|
||||
{title && (
|
||||
<strong style={{ display: 'block', color: 'var(--head)', marginBottom: message ? 6 : 0 }}>{title}</strong>
|
||||
)}
|
||||
{message && <span>{message}</span>}
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
157
client/src/components/Feed.jsx
Normal file
157
client/src/components/Feed.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
// ── The feed: what happened on one server ─────────────────────────────────
|
||||
//
|
||||
// Rows come from `/public/rust/servers/:id/events`, which serves a default-deny
|
||||
// ALLOWLIST (`server/catalogue.js`). Everything carrying an IP address, a
|
||||
// player's report about another player, or the grid square somebody's base is in
|
||||
// is stored and never answered here — so this component cannot leak one by
|
||||
// forgetting to filter, which is the point of the boundary living on the server.
|
||||
//
|
||||
// It polls (org lead, phase 4): every twenty seconds while the tab is visible,
|
||||
// paused when it is not. `usePolled` keeps the rows on screen across a refresh —
|
||||
// see the comment at the top of that file for why core's `useAsync` cannot do
|
||||
// this job.
|
||||
|
||||
import { ErrorState, Loading } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
|
||||
import { ago, clock } from '../lib/format.js'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import api from '../api.js'
|
||||
import { hiddenMessage } from './Online.jsx'
|
||||
|
||||
const TONE = {
|
||||
kill: 'var(--accent-bright)',
|
||||
death: 'var(--muted)',
|
||||
join: 'var(--mode-live, #5fb98a)',
|
||||
leave: 'var(--dim)',
|
||||
chat: 'var(--text)',
|
||||
server: 'var(--mode-maint, #e6c26a)',
|
||||
other: 'var(--muted)',
|
||||
}
|
||||
|
||||
export default function Feed({ serverId, wipeId, filter, onFilter }) {
|
||||
const kinds = kindsFor(filter)
|
||||
|
||||
const { data, error, loading, at } = usePolled(
|
||||
() => api.servers.events(serverId, { kinds, wipe: wipeId, limit: 100 }),
|
||||
// The key is the QUESTION. Changing server, wipe or filter blanks the rows,
|
||||
// because what is on screen is an answer to a different one; a poll tick
|
||||
// does not, because it is the same question asked again.
|
||||
{ key: `${serverId}|${wipeId || ''}|${filter}`, intervalMs: 20_000 },
|
||||
)
|
||||
|
||||
const events = data ? data.events : []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 16 }}
|
||||
>
|
||||
<label style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
|
||||
Showing{' '}
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => onFilter(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
{FILTERS.map((f) => (
|
||||
<option key={f.id} value={f.id}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* 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 && (
|
||||
<span style={{ color: 'var(--dim)', fontSize: '0.74rem' }}>updated {ago(at)}</span>
|
||||
)}
|
||||
{error && (
|
||||
<span style={{ color: 'var(--mode-maint, #e6c26a)', fontSize: '0.74rem' }}>
|
||||
the last refresh failed — showing what we had
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <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 && <ErrorState error={error} />}
|
||||
|
||||
{/* Below the operator's presence audience the server withholds every item
|
||||
that names a player who was on — the killfeed, chat, joins — and keeps
|
||||
only the server's own story. Said once, above the rows, so a thin feed
|
||||
reads as withheld rather than as a quiet server. */}
|
||||
{data && data.presenceHidden && (
|
||||
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||
Joins, deaths and chat are not shown. {hiddenMessage(data.presenceAudience, 'what players did')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && events.length === 0 && (
|
||||
<Empty
|
||||
title="Nothing here yet"
|
||||
message={
|
||||
data.presenceHidden
|
||||
? 'Nothing this server has reported about itself matches.'
|
||||
: 'Nothing this server has reported matches. A server that has just been added has no history until it says something.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{events.map((event) => {
|
||||
const line = describe(event)
|
||||
return (
|
||||
<li
|
||||
key={event.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'baseline',
|
||||
padding: '7px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
}}
|
||||
>
|
||||
<time
|
||||
className="sans"
|
||||
dateTime={new Date(event.t).toISOString()}
|
||||
title={new Date(event.t).toLocaleString()}
|
||||
style={{ flex: 'none', color: 'var(--dim)', fontSize: '0.74rem', minWidth: '5.6rem' }}
|
||||
>
|
||||
{clock(event.t)}
|
||||
</time>
|
||||
<span style={{ color: TONE[line.tone] || 'var(--muted)', fontSize: '0.92rem' }}>
|
||||
{line.actor && <strong style={{ color: 'var(--ink)' }}>{line.actor}</strong>}
|
||||
{line.actor && (line.join || ' ')}
|
||||
{line.verb}
|
||||
{line.subject && ' '}
|
||||
{line.subject && <strong style={{ color: 'var(--ink)' }}>{line.subject}</strong>}
|
||||
{line.detail && (
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
{' · '}
|
||||
{line.detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
73
client/src/components/FooterStatus.jsx
Normal file
73
client/src/components/FooterStatus.jsx
Normal file
@@ -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 (
|
||||
<Link to="/rust" style={linkStyle}>
|
||||
{summary.servers === 1 ? '1 server' : `${summary.servers} servers`}
|
||||
{' · '}
|
||||
{summary.players === 1 ? '1 online' : `${summary.players} online`}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
121
client/src/components/Leaderboard.jsx
Normal file
121
client/src/components/Leaderboard.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
// ── 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 { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
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 : []
|
||||
// Present on every row or on none — the server decides per request.
|
||||
const showLastSeen = rows.some((row) => 'lastSeen' in row)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
title="No scores yet"
|
||||
message={
|
||||
wipeId
|
||||
? 'Nobody has done anything countable on this wipe yet.'
|
||||
: 'This server has not reported anything countable yet.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em' }}>
|
||||
<th style={{ ...cell, textTransform: 'uppercase' }}>Player</th>
|
||||
{COLUMNS.map((column) => (
|
||||
<th key={column.key} style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>
|
||||
{column.sort ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column.sort)}
|
||||
aria-label={`Sort by ${column.label}`}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
textTransform: 'inherit',
|
||||
color: column.sort === sort ? 'var(--accent-bright)' : 'var(--dim)',
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
</button>
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
{/* The server withholds `lastSeen` below the operator's presence
|
||||
audience — a gather tally refreshes it every minute somebody plays,
|
||||
so it would name who is online. The column goes with it rather
|
||||
than rendering a row of dashes that look like "never". */}
|
||||
{showLastSeen && (
|
||||
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.steamId} style={{ borderTop: '1px solid var(--line-soft, var(--line))' }}>
|
||||
<td style={cell}>
|
||||
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{index + 1}</span>
|
||||
{/* 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. */}
|
||||
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
|
||||
</td>
|
||||
{COLUMNS.map((column) => (
|
||||
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>
|
||||
{column.value(row)}
|
||||
</td>
|
||||
))}
|
||||
{showLastSeen && (
|
||||
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cell = { padding: '8px 10px', whiteSpace: 'nowrap' }
|
||||
122
client/src/components/Online.jsx
Normal file
122
client/src/components/Online.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
// ── 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 { ErrorState, Loading } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
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 <Loading />
|
||||
if (error && !data) return <ErrorState error={error} />
|
||||
|
||||
// Nothing names who is online by default (the org lead's rule). Below the
|
||||
// operator's audience the server answers a count and no names, and the page
|
||||
// says so — an empty list here would read as "nobody is on", which is a
|
||||
// different claim and a false one.
|
||||
if (data && data.hidden) {
|
||||
const count = Number(data.count) || 0
|
||||
return (
|
||||
<Empty
|
||||
title={online ? `${count.toLocaleString()} ${count === 1 ? 'player' : 'players'} online` : 'The server is offline'}
|
||||
message={hiddenMessage(data.audience)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (players.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
title={online ? 'Nobody is on' : 'The server is offline'}
|
||||
message={
|
||||
online
|
||||
? 'The server is up and the island is empty. Somebody has to be first.'
|
||||
: 'Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 && (
|
||||
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||
This server is offline. Below is the last board it sent, not who is on it now.
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{players.map((player) => (
|
||||
<li
|
||||
key={player.steamId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ color: 'var(--ink)' }}>{player.name || shortId(player.steamId)}</strong>
|
||||
{/* 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 && (
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}> · sleeping</span>
|
||||
)}
|
||||
</span>
|
||||
{/* `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. */}
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
||||
{player.connectedAt ? `on for ${sessionSoFar(player.connectedAt)}` : ''}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why something was withheld, in words a visitor can act on.
|
||||
*
|
||||
* `what` completes the sentence — "who they are", "what players did". The
|
||||
* audience is the operator's (`staff` unless widened), and only `signed_in` is
|
||||
* something a visitor can do anything about.
|
||||
*/
|
||||
export function hiddenMessage(audience, what = 'who they are') {
|
||||
if (audience === 'signed_in') return `Sign in to see ${what}.`
|
||||
if (audience === 'public') return `This site is not showing ${what} right now.`
|
||||
return `Only this site’s staff can see ${what}.`
|
||||
}
|
||||
63
client/src/components/Tabs.jsx
Normal file
63
client/src/components/Tabs.jsx
Normal file
@@ -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 (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
borderBottom: '1px solid var(--line)',
|
||||
paddingBottom: 12,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const selected = tab.id === active
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '6px 14px',
|
||||
borderRadius: 'var(--radius-pill, 999px)',
|
||||
fontSize: '0.82rem',
|
||||
letterSpacing: '0.04em',
|
||||
border: `1px solid ${selected ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: selected ? 'var(--blue)' : 'transparent',
|
||||
color: selected ? 'var(--accent-bright)' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
client/src/components/WipeSelect.jsx
Normal file
54
client/src/components/WipeSelect.jsx
Normal file
@@ -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 (
|
||||
<label className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
|
||||
Wipe{' '}
|
||||
<select
|
||||
value={value || ALL_TIME}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
style={{
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.78rem',
|
||||
}}
|
||||
>
|
||||
<option value={ALL_TIME}>All time</option>
|
||||
{wipes.map((wipe) => (
|
||||
<option key={wipe.wipeId} value={wipe.wipeId}>
|
||||
{day(wipe.saveCreatedAt || wipe.firstSeen)}
|
||||
{wipe.wipeId === currentWipeId ? ' (current)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
86
client/src/components/Wipes.jsx
Normal file
86
client/src/components/Wipes.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
// ── 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 { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
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 <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
if (wipes.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
title="No wipes recorded"
|
||||
message="A wipe appears here once this server has reported something during it."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{wipes.map((wipe) => {
|
||||
const current = wipe.wipeId === currentWipeId
|
||||
const active = wipe.wipeId === selected
|
||||
return (
|
||||
<li key={wipe.wipeId} style={{ borderBottom: '1px solid var(--line-soft, var(--line))' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(wipe.wipeId)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
gap: 12,
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 6px',
|
||||
cursor: 'pointer',
|
||||
background: active ? 'var(--blue)' : 'transparent',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ color: 'var(--ink)' }}>
|
||||
{/* The save's creation time is the wipe's own date; `firstSeen`
|
||||
is when THIS website first heard about it, and they differ
|
||||
by however long the module was not installed. The first is
|
||||
the wipe, so it leads. */}
|
||||
{day(wipe.saveCreatedAt || wipe.firstSeen)}
|
||||
</strong>
|
||||
{current && (
|
||||
<span className="sans" style={{ color: 'var(--mode-live, #5fb98a)', fontSize: '0.74rem' }}>
|
||||
{' · current'}
|
||||
</span>
|
||||
)}
|
||||
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem' }}>
|
||||
{wipe.wipeId}
|
||||
</span>
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
||||
last heard {ago(wipe.lastSeen)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,15 @@
|
||||
import { registry, coreApiVersion } from './core.js'
|
||||
|
||||
import Servers from './routes/public/Servers.jsx'
|
||||
import ServerDetail from './routes/public/ServerDetail.jsx'
|
||||
import Clan from './routes/public/Clan.jsx'
|
||||
import Account from './routes/player/Account.jsx'
|
||||
import Permissions from './routes/admin/Permissions.jsx'
|
||||
import ModConfig from './routes/admin/ModConfig.jsx'
|
||||
import Visibility from './routes/admin/Visibility.jsx'
|
||||
import UserRustSections from './routes/admin/UserRustSections.jsx'
|
||||
import FooterStatus from './components/FooterStatus.jsx'
|
||||
import { IconEye, IconKey, IconLink, IconSliders } from './icons.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 +42,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 +50,61 @@ 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.
|
||||
//
|
||||
// The player route is registered with an empty path for the same reason the
|
||||
// public list is: `/player/rust` is the whole of what this module asks a player
|
||||
// to do, and a landing page above one page is a page nobody wants. Core applies
|
||||
// its own portal chrome and its own auth gate to the tier, so the component
|
||||
// renders no layout and re-implements no check.
|
||||
//
|
||||
// **The admin route arrives in phase 7 and is this module's first.** Everything
|
||||
// before it was configured through the API — the server rows still are — because
|
||||
// nothing until now had to be AUTHORED. A permission model is different in kind:
|
||||
// it is a thing an operator composes and keeps looking at, and there is no
|
||||
// version of "grant somebody VIP" that belongs in a terminal.
|
||||
//
|
||||
// It is registered with an empty path, so it lands at `/admin/rust`, and core
|
||||
// applies the admin tier's own gate. The routes underneath it are stricter than
|
||||
// that gate (`requireRole('admin')` on every one), which is a server-side answer
|
||||
// rather than a client one: a moderator who reached this page would see it fail
|
||||
// honestly rather than be quietly shown a page that cannot save.
|
||||
registry.registerRoutes(ID, {
|
||||
public: [{ path: 'servers', element: <Servers /> }],
|
||||
public: [
|
||||
{ path: '', element: <Servers /> },
|
||||
{ path: 'servers/:id', element: <ServerDetail /> },
|
||||
// Phase 9 (D56). Not nested under its server: core links here from Team
|
||||
// notification email through `pageUrlTemplate`, which substitutes
|
||||
// `{externalId}` and nothing else — and the server is inside that id.
|
||||
{ path: 'clans/:externalId', element: <Clan /> },
|
||||
],
|
||||
player: [{ path: '', element: <Account /> }],
|
||||
admin: [
|
||||
{ path: '', element: <Permissions /> },
|
||||
// Phase 7b (R18). A second admin page rather than a tab on the first: the
|
||||
// permission mirror decides who may do what inside the game, and this edits
|
||||
// the game host's own files. They are neighbours, not halves of one screen,
|
||||
// and the nav says so with two rows.
|
||||
//
|
||||
// A static segment under the module's namespace, so it lands at
|
||||
// `/admin/rust/config` and core's admin gate applies to it exactly as it
|
||||
// does to the page above.
|
||||
{ path: 'config', element: <ModConfig /> },
|
||||
// Who may see who is online — a third neighbour. The org lead's rule is that
|
||||
// nothing names who is online by default; this is where an operator widens
|
||||
// it on purpose, fleet-wide or per server.
|
||||
{ path: 'visibility', element: <Visibility /> },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Nav ───────────────────────────────────────────────────────────────────
|
||||
@@ -67,9 +126,76 @@ 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' }],
|
||||
})
|
||||
|
||||
// The player portal's row. It carries an `icon` because core draws one on every
|
||||
// portal row — a row without one is the only text in a column of glyphs, and
|
||||
// core used to render `<n.icon />` unguarded, which blanked the whole portal.
|
||||
//
|
||||
// No `order`: an unordered row appends after core's own rather than claiming a
|
||||
// position it was not given. Account, appeals and notifications are what a player
|
||||
// came to the portal for; linking a game account is what they do once.
|
||||
registry.registerNav(ID, {
|
||||
area: 'player',
|
||||
items: [{ label: 'Rust', to: '/player/rust', icon: IconLink }],
|
||||
})
|
||||
|
||||
// The admin sidebar's row. `group` names an existing core group — an unknown name
|
||||
// appends a new group at the end rather than dropping the row, which is the
|
||||
// failure mode to avoid here: a row nobody can find is a feature nobody has.
|
||||
//
|
||||
// It carries an icon for the same reason the player row does: core draws one on
|
||||
// every sidebar row, and the one without is the only text in a column of glyphs.
|
||||
registry.registerNav(ID, {
|
||||
area: 'admin',
|
||||
items: [
|
||||
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
|
||||
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
|
||||
{ label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye },
|
||||
],
|
||||
})
|
||||
|
||||
// ── 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)
|
||||
|
||||
// R13's other slot, and the one that IS named in `module.json` — because it has
|
||||
// a server half too (`server/router/admin/usersRust.router.js`). The two halves
|
||||
// carry one name on purpose: a module that adds routes under
|
||||
// `/api/v1/admin/users/:id` is the module with something to show on that page.
|
||||
//
|
||||
// Core passes `userId` and nothing else, so the component builds its own client
|
||||
// for the routes the server half registered. It renders NOTHING for a user with
|
||||
// no linked Steam account, which is most of them.
|
||||
registry.registerExtension(ID, 'admin.users.detail', UserRustSections)
|
||||
|
||||
// ── Inverted slots: core's Team contributions on OUR clan page ─────────────
|
||||
//
|
||||
// §3.7a. A clan is a Team (R5), and core renders no Team page because it does
|
||||
// not own the word "clan". So the page is `routes/public/Clan.jsx` and core
|
||||
// contributes the three things only it can render — into places this module
|
||||
// names, in this module's vocabulary. Core offers a CONTRIBUTION; it never names
|
||||
// a slot, which is what lets a second game use the same contract as module-uo.
|
||||
//
|
||||
// One slot per PLACE (D56): a slot holds one component, and a collapsed slot
|
||||
// would hand core the decision about where each part sits on a page it does not
|
||||
// own. Asking for a contribution core does not offer throws here, at
|
||||
// registration — a typo fails loudly rather than rendering nothing for ever.
|
||||
registry.declareModuleSlot(ID, 'rust.clan.header', { core: 'team.notify' })
|
||||
registry.declareModuleSlot(ID, 'rust.clan.detail', { core: 'team.activity' })
|
||||
registry.declareModuleSlot(ID, 'rust.clan.forum', { core: 'team.forum' })
|
||||
|
||||
// `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
|
||||
|
||||
116
client/src/hooks/usePolled.js
Normal file
116
client/src/hooks/usePolled.js
Normal file
@@ -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 `<Loading />` 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<any>} 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
|
||||
98
client/src/icons.jsx
Normal file
98
client/src/icons.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
// ── The nav glyph for this module's player-portal row ─────────────────────
|
||||
//
|
||||
// `icon` is part of the nav-item contract (MODULE_API.md §3.3, 1.3.0): core
|
||||
// renders whatever component a row carries, exactly as it renders its own rows'
|
||||
// icons — and core's player portal draws a glyph on every row, so a row without
|
||||
// one reads as breakage rather than as a design. The client suite asserts it.
|
||||
//
|
||||
// The public header is text buttons and carries no icons, which is why this file
|
||||
// arrives with the player row and not before it.
|
||||
//
|
||||
// **The frame is copied from core's `PlayerPortalLayout`, deliberately and by
|
||||
// copy rather than by import** — 16px, `currentColor`, stroke 2. Four attributes
|
||||
// of presentation are not a component: putting them in the shared kit would
|
||||
// freeze core's icon sizing into the contract, where changing it later would be a
|
||||
// major bump. A module that wants to look like the nav it is in matches that nav.
|
||||
|
||||
const Icon = ({ children }) => (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* A chain link — what the row is for.
|
||||
*
|
||||
* Not a gem, a person or a server: the portal's rows say what a player does
|
||||
* there, and what a player does at `/player/rust` is link an account. Core's own
|
||||
* neighbours are a gear (account), a shield (appeals) and a bell (notifications),
|
||||
* so the row has to read as a verb in that company.
|
||||
*/
|
||||
export const IconLink = () => (
|
||||
<Icon>
|
||||
<path d="M10 13a5 5 0 007.07 0l2.83-2.83a5 5 0 00-7.07-7.07L11.5 4.5" />
|
||||
<path d="M14 11a5 5 0 00-7.07 0L4.1 13.83a5 5 0 007.07 7.07L12.5 19.5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* A key — the admin sidebar's row for the permission mirror.
|
||||
*
|
||||
* Core's admin groups are labelled by subject and drawn with glyphs of the same
|
||||
* weight, so this is the same 16px frame as the portal's. A key rather than a
|
||||
* shield: a shield is protection from something, and this row is about handing
|
||||
* somebody the right to do something.
|
||||
*/
|
||||
export const IconKey = () => (
|
||||
<Icon>
|
||||
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||
<path d="M10.7 12.3L20 3" />
|
||||
<path d="M17 6l2.5 2.5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* Sliders — the admin sidebar's row for the mod-configuration editor.
|
||||
*
|
||||
* Not a gear: core's account row is a gear, and two gears in one sidebar say
|
||||
* "settings" twice without saying whose. Sliders read as values being tuned,
|
||||
* which is exactly what that page does to somebody else's game host.
|
||||
*/
|
||||
export const IconSliders = () => (
|
||||
<Icon>
|
||||
<path d="M4 6h10" />
|
||||
<path d="M18 6h2" />
|
||||
<circle cx="16" cy="6" r="2" />
|
||||
<path d="M4 12h4" />
|
||||
<path d="M12 12h8" />
|
||||
<circle cx="10" cy="12" r="2" />
|
||||
<path d="M4 18h10" />
|
||||
<path d="M18 18h2" />
|
||||
<circle cx="16" cy="18" r="2" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* An eye — the admin sidebar's row for who may see who is online.
|
||||
*
|
||||
* The page decides what the public can SEE, so the glyph is the act of seeing.
|
||||
*/
|
||||
export const IconEye = () => (
|
||||
<Icon>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export default { IconLink, IconKey, IconSliders, IconEye }
|
||||
178
client/src/lib/feed.js
Normal file
178
client/src/lib/feed.js
Normal file
@@ -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 }
|
||||
136
client/src/lib/format.js
Normal file
136
client/src/lib/format.js
Normal file
@@ -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 }
|
||||
554
client/src/routes/admin/ModConfig.jsx
Normal file
554
client/src/routes/admin/ModConfig.jsx
Normal file
@@ -0,0 +1,554 @@
|
||||
// ── Admin · Rust · Mod configuration ──────────────────────────────────────
|
||||
//
|
||||
// R18. An admin picks a server, a plugin and a file, changes something, and the
|
||||
// plugin reloads. This module's second admin page, and the first that writes to
|
||||
// somebody's filesystem.
|
||||
//
|
||||
// **What is on the screen is decided by what is dangerous about the action.**
|
||||
// Four things are true here that are not true anywhere else in this module, and
|
||||
// each of them is a piece of the page rather than a line in a doc:
|
||||
//
|
||||
// • a save can take a required plugin DOWN. So the reload target is a
|
||||
// deliberate choice with the folder name as a guess, the result is reported
|
||||
// as its own panel, and a rollback shows the server's own log line.
|
||||
// • the form cannot express everything a config holds. A `null`, an empty
|
||||
// array and anything past the depth limit are marked and sent to the raw
|
||||
// tier rather than half-drawn.
|
||||
// • three keys in the bridge's own config would cut the link carrying the
|
||||
// edit, or split the server's history. They render read-only, with the
|
||||
// reason (D38).
|
||||
// • configs hold API keys and Discord webhooks. Those fields render masked
|
||||
// with a reveal, which is about the shoulder rather than the wire: an admin
|
||||
// can already read the file over SSH (D37), and the audit trail never
|
||||
// records the values either way.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
function Card({ title, subtitle, children, actions }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
{actions}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Warn({ children, tone = '#d08a2a' }) {
|
||||
return (
|
||||
<p className="sans" style={{ color: tone, fontSize: '0.78rem', margin: '6px 0 0' }}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/** A value the form can edit: one row, typed by what the file already holds. */
|
||||
function Field({ field, value, onChange, revealed, onReveal }) {
|
||||
const indent = 12 * Math.max(0, field.depth - 1)
|
||||
const label = (
|
||||
<label
|
||||
className="sans"
|
||||
style={{
|
||||
flex: '0 0 300px',
|
||||
paddingLeft: indent,
|
||||
color: field.locked ? 'var(--ink)' : 'var(--head)',
|
||||
fontSize: '0.84rem',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={field.path}
|
||||
>
|
||||
{field.key}
|
||||
{field.locked && (
|
||||
<span className="dim" style={{ fontSize: '0.72rem' }}>
|
||||
{' '}
|
||||
· read-only
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
|
||||
if (field.type === 'object' || field.type === 'array') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 0 2px' }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ paddingLeft: indent, color: 'var(--head)', fontSize: '0.86rem', fontWeight: 500 }}
|
||||
>
|
||||
{field.key || '(the file)'}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
{field.type === 'array' ? `${field.count} entries` : `${field.count} settings`}
|
||||
{field.advanced && field.reason ? ` · ${field.reason}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.advanced) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
{label}
|
||||
<span className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
{field.reason} — edit it in Raw JSON
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
{label}
|
||||
{field.type === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
disabled={field.locked}
|
||||
onChange={(event) => onChange(field, event.target.checked)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
type={field.secret && !revealed ? 'password' : 'text'}
|
||||
value={value === undefined || value === null ? '' : String(value)}
|
||||
disabled={field.locked}
|
||||
onChange={(event) => onChange(field, event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{field.secret && !field.locked && (
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onReveal(field.path)}>
|
||||
{revealed ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** What the game said happened. The rollback case is the one worth reading. */
|
||||
function Report({ report }) {
|
||||
if (!report) return null
|
||||
|
||||
const tone = report.rolledBack ? '#e05a5a' : 'var(--ink)'
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 10, marginTop: 10 }}>
|
||||
<p className="sans" style={{ color: tone, fontSize: '0.84rem', margin: 0 }}>
|
||||
{report.rolledBack
|
||||
? 'The plugin did not come back, so the old file was put back automatically.'
|
||||
: report.reloaded
|
||||
? 'Saved, and the plugin reloaded.'
|
||||
: `Saved. ${report.reason || 'Nothing was reloaded.'}`}
|
||||
</p>
|
||||
{report.rolledBack && report.reason && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '4px 0 0' }}>
|
||||
{report.reason}
|
||||
</p>
|
||||
)}
|
||||
{report.log && (
|
||||
<pre
|
||||
className="sans"
|
||||
style={{
|
||||
background: 'var(--line-soft)',
|
||||
padding: 10,
|
||||
marginTop: 8,
|
||||
fontSize: '0.74rem',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{report.log}
|
||||
</pre>
|
||||
)}
|
||||
{report.files.some((f) => f.rewritten) && (
|
||||
<Warn>
|
||||
The plugin rewrote the file as it loaded — both frameworks add any settings a config is
|
||||
missing and save it back, so what is on disk now is not byte-for-byte what was sent.
|
||||
</Warn>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ModConfig() {
|
||||
const [serverId, setServerId] = useState('')
|
||||
const [path, setPath] = useState('')
|
||||
const [tier, setTier] = useState('form')
|
||||
const [edits, setEdits] = useState({})
|
||||
const [raw, setRaw] = useState('')
|
||||
const [reload, setReload] = useState('')
|
||||
const [revealed, setRevealed] = useState({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [report, setReport] = useState(null)
|
||||
const [fileNonce, setFileNonce] = useState(0)
|
||||
|
||||
const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), [])
|
||||
|
||||
// The tree is asked for per server and never cached across one: what is on a
|
||||
// host's disk has no stale answer worth showing, and a plugin loaded a minute
|
||||
// ago has to be able to appear.
|
||||
const { data: tree, error: treeError } = useAsync(
|
||||
() => (serverId ? api.adminConfig.files(serverId) : Promise.resolve(null)),
|
||||
[serverId],
|
||||
)
|
||||
|
||||
const { data: file, error: fileError } = useAsync(
|
||||
() => (serverId && path ? api.adminConfig.file(serverId, path) : Promise.resolve(null)),
|
||||
[serverId, path, fileNonce],
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setEdits({})
|
||||
setRevealed({})
|
||||
setError('')
|
||||
}, [])
|
||||
|
||||
// A freshly opened file starts from what the host holds: the raw editor's text
|
||||
// and the reload target's guess both come from the answer rather than from
|
||||
// whatever the previous file left behind.
|
||||
//
|
||||
// **The guess is only taken when the dropdown actually offers it.** A `<select>`
|
||||
// whose value matches no `<option>` displays the first one, so a guess of
|
||||
// `RunicGateway` — which is deliberately not offered, because the bridge cannot
|
||||
// reload itself — put "nothing — just write the file" on the screen while the
|
||||
// request carried `reload: RunicGateway`, and every save of our own config was
|
||||
// refused for a reason the page had just said did not apply.
|
||||
useEffect(() => {
|
||||
if (!file) return
|
||||
setRaw(file.text)
|
||||
|
||||
const offered = (tree ? tree.loaded : []).some(
|
||||
(p) => p.name === file.plugin && p.name !== (tree && tree.self),
|
||||
)
|
||||
|
||||
setReload(offered ? file.plugin : '')
|
||||
reset()
|
||||
}, [file, tree, reset])
|
||||
|
||||
useEffect(() => {
|
||||
setPath('')
|
||||
setReport(null)
|
||||
}, [serverId])
|
||||
|
||||
if (serverError) return <ErrorState error={serverError} />
|
||||
if (!servers) return <Loading />
|
||||
|
||||
const rows = servers.servers || servers || []
|
||||
const change = (field, value) => setEdits((current) => ({ ...current, [field.path]: { field, value } }))
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setReport(null)
|
||||
|
||||
try {
|
||||
const body =
|
||||
tier === 'form'
|
||||
? {
|
||||
path,
|
||||
version: file.version,
|
||||
...(reload ? { reload } : {}),
|
||||
// A number goes up as the TEXT that was typed. `2.50` stays
|
||||
// `2.50` and `1.0` stays `1.0`; turning either into a JavaScript
|
||||
// number here is precisely the bug the server half exists to
|
||||
// avoid, and it would be reintroduced in the browser.
|
||||
edits: Object.values(edits).map(({ field, value }) =>
|
||||
field.type === 'number'
|
||||
? { pointer: field.pointer, raw: String(value) }
|
||||
: { pointer: field.pointer, value },
|
||||
),
|
||||
}
|
||||
: { path, version: file.version, ...(reload ? { reload } : {}), text: raw }
|
||||
|
||||
const answer = await api.adminConfig.save(serverId, body)
|
||||
|
||||
setReport(answer.report || null)
|
||||
if (!answer.changed) setError('Nothing changed, so nothing was written.')
|
||||
|
||||
// Re-read either way: a successful reload usually rewrites the file with
|
||||
// the defaults it was missing, and a rollback means what is on disk is no
|
||||
// longer what is on the screen.
|
||||
setFileNonce((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That save did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pending = Object.keys(edits).length
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 980 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
These are the configuration files on the game host itself, read live through the bridge. A
|
||||
save backs the file up, writes it, reloads the plugin you name, and <strong>puts the old
|
||||
file back automatically</strong> if the plugin does not come back. The game’s data
|
||||
directory — kit cooldowns, zone definitions, the permission store — is not settings and is
|
||||
never listed here.
|
||||
</p>
|
||||
|
||||
<Card title="Server" subtitle={`${rows.length} configured`}>
|
||||
<select className="input" value={serverId} onChange={(event) => setServerId(event.target.value)}>
|
||||
<option value="">Choose a server…</option>
|
||||
{rows.map((row) => (
|
||||
<option key={row.id} value={row.id}>
|
||||
{row.name || row.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{tree && tree.root && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
{tree.root}
|
||||
{tree.truncated ? ' · the walk stopped at its limit, so this is not the whole tree' : ''}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{serverId && treeError && <ErrorState error={treeError} />}
|
||||
|
||||
{serverId && !treeError && !tree && <Loading />}
|
||||
|
||||
{tree && (
|
||||
<Card title="Files" subtitle="grouped by the plugin each one probably belongs to">
|
||||
{tree.plugins.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||||
This server reports no configuration files.
|
||||
</p>
|
||||
)}
|
||||
{tree.plugins.map((group) => (
|
||||
<div key={group.plugin} style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||||
<strong className="sans" style={{ fontSize: '0.88rem', fontWeight: 500 }}>
|
||||
{group.title || group.plugin}
|
||||
</strong>
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{group.loaded ? `loaded · ${group.version}` : 'not loaded'}
|
||||
{group.isBridge ? ' · this bridge' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{group.files.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0 4px 12px' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={entry.path === path ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
disabled={!entry.editable}
|
||||
onClick={() => {
|
||||
setPath(entry.path)
|
||||
setReport(null)
|
||||
setTier('form')
|
||||
}}
|
||||
>
|
||||
{entry.path}
|
||||
</button>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
{Math.round(entry.bytes / 102.4) / 10} KB
|
||||
{entry.modified ? ` · changed ${ago(entry.modified)}` : ''}
|
||||
{entry.reason ? ` · ${entry.reason}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!group.loaded && (
|
||||
<Warn>
|
||||
Nothing on this server is loaded under that name, so a save here is written and
|
||||
not reloaded. It applies the next time the plugin loads.
|
||||
</Warn>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{path && fileError && <ErrorState error={fileError} />}
|
||||
{path && !fileError && !file && <Loading />}
|
||||
|
||||
{file && (
|
||||
<Card
|
||||
title={file.path}
|
||||
subtitle={tier === 'form' ? `${pending} unsaved` : 'raw JSON'}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={tier === 'form' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
onClick={() => setTier('form')}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tier === 'raw' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
onClick={() => setTier('raw')}
|
||||
>
|
||||
Raw JSON
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{file.parseError && (
|
||||
<Warn tone="#e05a5a">
|
||||
This file is not valid JSON on the server ({file.parseError}), so there is nothing to
|
||||
draw a form from. Raw JSON is the tier that can fix it.
|
||||
</Warn>
|
||||
)}
|
||||
|
||||
{file.isBridge && (
|
||||
<Warn>
|
||||
This is the bridge’s own configuration. Its address, port and server id are read-only
|
||||
here — changing any of them from the website would cut the link carrying the change,
|
||||
or strand every row this site holds for this server. They are editable on the host
|
||||
itself. This plugin also cannot be reloaded from here.
|
||||
</Warn>
|
||||
)}
|
||||
|
||||
{tier === 'form' && file.fields && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{file.fields
|
||||
.filter((field) => field.path !== '')
|
||||
.map((field) => (
|
||||
<Field
|
||||
key={field.path}
|
||||
field={field}
|
||||
value={
|
||||
edits[field.path]
|
||||
? edits[field.path].value
|
||||
: field.type === 'number'
|
||||
? field.raw
|
||||
: field.value
|
||||
}
|
||||
onChange={change}
|
||||
revealed={Boolean(revealed[field.path])}
|
||||
onReveal={(p) => setRevealed((current) => ({ ...current, [p]: !current[p] }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tier === 'raw' && (
|
||||
<textarea
|
||||
className="input"
|
||||
spellCheck={false}
|
||||
value={raw}
|
||||
onChange={(event) => setRaw(event.target.value)}
|
||||
style={{ width: '100%', minHeight: 360, fontFamily: 'monospace', fontSize: '0.8rem' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
marginTop: 12,
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<label className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
Reload
|
||||
</label>
|
||||
{/* A guess, and it says so. The folder a config sits in is convention
|
||||
rather than contract, so reloading it silently is how the wrong
|
||||
plugin gets reloaded, reports success, and the edited one never
|
||||
re-reads anything. */}
|
||||
<select className="input" value={reload} onChange={(event) => setReload(event.target.value)}>
|
||||
<option value="">nothing — just write the file</option>
|
||||
{(tree ? tree.loaded : [])
|
||||
.filter((p) => p.name !== tree.self)
|
||||
.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
|
||||
onClick={save}
|
||||
>
|
||||
{busy ? 'Saving…' : 'Save and reload'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Beside the button, not at the top of the page. A save is made at the
|
||||
bottom of a long form, and a refusal rendered above the fold is a
|
||||
click that visibly did nothing. */}
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.82rem', margin: '8px 0 0' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Report report={report} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{serverId && <History serverId={serverId} nonce={fileNonce} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Who changed what, including the saves that were refused or undone. */
|
||||
function History({ serverId, nonce }) {
|
||||
const { data } = useAsync(() => api.adminConfig.writes(serverId), [serverId, nonce])
|
||||
|
||||
if (!data || !data.writes || data.writes.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card title="Recent changes" subtitle="every save, including the ones that did not land">
|
||||
{data.writes.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="sans"
|
||||
style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)', fontSize: '0.82rem' }}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
|
||||
<strong style={{ fontWeight: 500 }}>{row.path}</strong>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ fontSize: '0.74rem', color: row.outcome === 'applied' ? 'var(--ink)' : '#d08a2a' }}
|
||||
>
|
||||
{row.outcome}
|
||||
{row.reloaded ? ' · reloaded' : ''}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
{ago(row.createdAt)}
|
||||
{row.tier === 'raw' ? ' · raw' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{(row.changes || []).map((change, index) => (
|
||||
<div key={`${row.id}-${index}`} className="dim" style={{ fontSize: '0.74rem' }}>
|
||||
{change.path}
|
||||
{change.from !== null && change.to !== null ? `: ${change.from} → ${change.to}` : ''}
|
||||
</div>
|
||||
))}
|
||||
{row.detail && (
|
||||
<div className="dim" style={{ fontSize: '0.74rem' }}>
|
||||
{row.detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
627
client/src/routes/admin/Permissions.jsx
Normal file
627
client/src/routes/admin/Permissions.jsx
Normal file
@@ -0,0 +1,627 @@
|
||||
// ── Admin · Rust · Permissions ────────────────────────────────────────────
|
||||
//
|
||||
// R2's authoring surface, and this module's first admin page.
|
||||
//
|
||||
// **What is on it is decided by what an operator can get wrong**, rather than by
|
||||
// what the tables contain. Four states are invisible from the game and from a
|
||||
// list of grants, and every one of them looks exactly like success:
|
||||
//
|
||||
// • a grant against somebody who has linked no Steam account — authored,
|
||||
// stored, pushed nowhere;
|
||||
// • a permission no loaded plugin has registered — the grant lands silently
|
||||
// nowhere, because `GrantUserPermission` no-ops for an unregistered name;
|
||||
// • a group member who has never connected — the store has no user record to
|
||||
// put in a group yet, and the membership waits for their first connection;
|
||||
// • a server whose last sync failed — the site is authoritative and the game
|
||||
// has not heard it.
|
||||
//
|
||||
// So each of those is a sentence on this page rather than a number in a report.
|
||||
//
|
||||
// The screen never writes to a game. Every button here writes to the site and
|
||||
// the mirror's loop reconciles within seconds — except *Sync now*, which runs
|
||||
// that pass immediately because an operator who has just changed something
|
||||
// should not have to trust a timer to find out that a host is unreachable.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const FLEET = '*'
|
||||
|
||||
/** Shared furniture. The kit is nine exports and none of them is a table. */
|
||||
function Card({ title, subtitle, children, actions }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
{actions}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ children, muted = false }) {
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '8px 0',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
fontSize: '0.86rem',
|
||||
color: muted ? 'var(--ink)' : 'var(--head)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Warn({ children }) {
|
||||
return (
|
||||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '6px 0 0' }}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function Scope({ value }) {
|
||||
return (
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{value === FLEET ? 'every server' : value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One server's mirror state.
|
||||
*
|
||||
* `unresolved` and `pending` are rendered as sentences rather than counts
|
||||
* because each is a different problem with a different fix, and both are
|
||||
* invisible everywhere else on this page.
|
||||
*/
|
||||
function ServerState({ row, onSync, busy }) {
|
||||
const report = row.report || {}
|
||||
const unresolved = report.unresolved || []
|
||||
const pending = report.pending || []
|
||||
const notLanded = report.notLanded || []
|
||||
|
||||
return (
|
||||
<div style={{ padding: '10px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||
{row.serverId}
|
||||
</span>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ fontSize: '0.76rem', color: row.inSync ? 'var(--ink)' : '#d08a2a' }}
|
||||
>
|
||||
{row.inSync ? 'in sync' : row.state === 'failed' ? 'out of sync' : 'pending'}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
|
||||
{busy ? 'Syncing…' : 'Sync now'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{row.error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.78rem', margin: '4px 0 0' }}>
|
||||
{row.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{unresolved.length > 0 && (
|
||||
<Warn>
|
||||
{unresolved.join(', ')} — no plugin loaded on this server has registered{' '}
|
||||
{unresolved.length === 1 ? 'that name' : 'those names'}, so a grant naming{' '}
|
||||
{unresolved.length === 1 ? 'it' : 'them'} reaches nobody here. It will land by itself when
|
||||
the plugin is back.
|
||||
</Warn>
|
||||
)}
|
||||
|
||||
{pending.length > 0 && (
|
||||
<Warn>
|
||||
{pending.length} {pending.length === 1 ? 'membership is' : 'memberships are'} waiting on a
|
||||
first connection — this server has never seen those players, so it has no account to put
|
||||
in a group yet.
|
||||
</Warn>
|
||||
)}
|
||||
|
||||
{notLanded.length > 0 && (
|
||||
<Warn>
|
||||
{notLanded.length} {notLanded.length === 1 ? 'grant was' : 'grants were'} sent and not found
|
||||
in the game's permission store afterwards ({notLanded.slice(0, 5).join(', ')}
|
||||
{notLanded.length > 5 ? ', …' : ''}). They are not counted as pushed, and the next sync
|
||||
tries again.
|
||||
</Warn>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A hand edit, with the two answers to it. */
|
||||
function DriftRow({ row, onAdopt, onRevoke, busy }) {
|
||||
const subject = row.username ? `${row.username} (${row.subject})` : row.subject
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<span style={{ minWidth: 0, flex: 1 }}>
|
||||
<strong style={{ fontWeight: 500 }}>{row.object}</strong>{' '}
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>
|
||||
{row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '}
|
||||
{row.serverId} · seen {ago(row.firstSeen)}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onAdopt(row)} disabled={busy}>
|
||||
Adopt
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onRevoke(row)} disabled={busy}>
|
||||
Revoke
|
||||
</button>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The memberships the game could not place yet, as `steamId:group`.
|
||||
*
|
||||
* Read out of each server's own report, because it is the only thing that knows:
|
||||
* a member who has never connected to a server has no user record there to put
|
||||
* in a group (§12.2 rule 4), and from every other angle they look like a member.
|
||||
* The server strip says how many; this is what puts it next to the person.
|
||||
*/
|
||||
function pendingSet(servers) {
|
||||
const pending = new Map()
|
||||
|
||||
for (const server of servers) {
|
||||
for (const entry of (server.report && server.report.pending) || []) {
|
||||
if (!pending.has(entry)) pending.set(entry, [])
|
||||
pending.get(entry).push(server.serverId)
|
||||
}
|
||||
}
|
||||
|
||||
return pending
|
||||
}
|
||||
|
||||
function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [member, setMember] = useState('')
|
||||
const [permission, setPermission] = useState('')
|
||||
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await fn()
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = (permissions) =>
|
||||
act(() =>
|
||||
api.adminPermissions.saveGroup(group.name, {
|
||||
title: group.title,
|
||||
rank: group.rank,
|
||||
scope: group.scope,
|
||||
permissions,
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={group.title || group.name}
|
||||
subtitle={<>{group.name} · <Scope value={group.scope} /></>}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminPermissions.deleteGroup(group.name))}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="field-label">Permissions</div>
|
||||
{group.permissions.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
|
||||
This group carries nothing, so being in it does nothing.
|
||||
</p>
|
||||
)}
|
||||
{group.permissions.map((perm) => (
|
||||
<Row key={perm}>
|
||||
<span style={{ flex: 1 }}>{perm}</span>
|
||||
{!catalogue.some((entry) => entry.permission === perm) && (
|
||||
<span className="sans" style={{ color: '#d08a2a', fontSize: '0.74rem' }}>
|
||||
no server has registered this
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => save(group.permissions.filter((p) => p !== perm))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!permission.trim()) return
|
||||
save([...group.permissions, permission.trim().toLowerCase()])
|
||||
setPermission('')
|
||||
}}
|
||||
>
|
||||
<input
|
||||
list="rust-permission-names"
|
||||
className="input"
|
||||
placeholder="kits.vip"
|
||||
value={permission}
|
||||
onChange={(event) => setPermission(event.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Add permission
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="field-label" style={{ marginTop: 18 }}>
|
||||
Members
|
||||
</div>
|
||||
{group.members.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
|
||||
Nobody is in this group.
|
||||
</p>
|
||||
)}
|
||||
{group.members.map((m) => {
|
||||
const waiting = m.accounts
|
||||
.map((account) => pending.get(`${account.steamId}:${group.name}`))
|
||||
.filter(Boolean)
|
||||
.flat()
|
||||
|
||||
return (
|
||||
<Row key={m.userId}>
|
||||
<span style={{ flex: 1 }}>
|
||||
{m.username}
|
||||
{m.accounts.length > 0 ? (
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· {m.accounts.map((a) => a.name || a.steamId).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· has linked no Steam account, so this reaches nobody
|
||||
</span>
|
||||
)}
|
||||
{waiting.length > 0 && (
|
||||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· waiting on their first connection to {[...new Set(waiting)].join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminPermissions.removeMember(group.name, m.userId))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!member.trim()) return
|
||||
act(() => api.adminPermissions.addMember(group.name, member.trim()))
|
||||
setMember('')
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="website username"
|
||||
value={member}
|
||||
onChange={(event) => setMember(event.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Add member
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{servers.length > 1 && group.scope !== FLEET && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
This group exists on {group.scope} only. The other servers never receive it.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Permissions() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({ name: '', title: '', scope: FLEET })
|
||||
const [grant, setGrant] = useState({ username: '', permission: '', scope: FLEET })
|
||||
|
||||
const { data, error: loadError } = useAsync(() => api.adminPermissions.overview(), [reloads])
|
||||
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await fn()
|
||||
reload()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const servers = data.servers || []
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
{/* No heading of our own: core's admin chrome already draws the route's
|
||||
title above the page, and a second one is the same words twice. */}
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
This site is the author of record. Groups and grants written here are pushed into each
|
||||
server’s own permission store, so every plugin that checks a permission honours them — and a
|
||||
wipe does not lose them, because they are re-pushed when the server comes back.
|
||||
</p>
|
||||
|
||||
{/* The option source, shared by both forms. A datalist rather than a select:
|
||||
a name that no server has registered is still authorable — the plugin
|
||||
may simply not be loaded right now — and the warning beside it is the
|
||||
honest treatment, where a closed list would be a refusal. */}
|
||||
<datalist id="rust-permission-names">
|
||||
{(data.catalogue || []).map((entry) => (
|
||||
<option key={entry.permission} value={entry.permission} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.84rem' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title="Servers"
|
||||
subtitle={`${servers.length} configured`}
|
||||
actions={
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
|
||||
Sync all
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{servers.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||||
No servers are configured yet, so nothing written here reaches a game.
|
||||
</p>
|
||||
)}
|
||||
{servers.map((row) => (
|
||||
<ServerState
|
||||
key={row.serverId}
|
||||
row={row}
|
||||
busy={busy}
|
||||
onSync={(id) => act(() => api.adminPermissions.sync(id))}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{(data.drift || []).length > 0 && (
|
||||
<Card
|
||||
title="Changed in game"
|
||||
subtitle="granted at a console, not by this site"
|
||||
>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 0 }}>
|
||||
Nothing here is undone automatically. <strong>Adopt</strong> records it as the site’s
|
||||
own, so it survives the next wipe; <strong>Revoke</strong> removes it from the game on
|
||||
the next sync.
|
||||
</p>
|
||||
{data.drift.map((row) => (
|
||||
<DriftRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
busy={busy}
|
||||
onAdopt={(d) => act(() => api.adminPermissions.adoptDrift(d.id))}
|
||||
onRevoke={(d) => act(() => api.adminPermissions.revokeDrift(d.id))}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Direct grants" subtitle="one person, one permission">
|
||||
{(data.grants || []).length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||||
Nobody holds a permission of their own yet.
|
||||
</p>
|
||||
)}
|
||||
{(data.grants || []).map((row) => (
|
||||
<Row key={row.id}>
|
||||
<span style={{ flex: 1 }}>
|
||||
{row.username} · <strong style={{ fontWeight: 500 }}>{row.permission}</strong>{' '}
|
||||
<Scope value={row.scope} />
|
||||
{row.accounts.length === 0 && (
|
||||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· has linked no Steam account, so this reaches nobody
|
||||
</span>
|
||||
)}
|
||||
{/* The same warning the group's permission list carries, and it
|
||||
matters more here: a grant naming a permission nothing has
|
||||
registered is the failure the plugin's pre-check exists for,
|
||||
and it is invisible on this row without it. */}
|
||||
{!(data.catalogue || []).some((entry) => entry.permission === row.permission) && (
|
||||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||||
{' '}
|
||||
· no server has registered this permission
|
||||
</span>
|
||||
)}
|
||||
{row.source !== 'admin' && (
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}> · {row.source}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminPermissions.revoke(row.id))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!grant.username.trim() || !grant.permission.trim()) return
|
||||
act(() =>
|
||||
api.adminPermissions.grant({
|
||||
username: grant.username.trim(),
|
||||
permission: grant.permission.trim().toLowerCase(),
|
||||
scope: grant.scope,
|
||||
}),
|
||||
)
|
||||
setGrant({ username: '', permission: '', scope: FLEET })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="website username"
|
||||
value={grant.username}
|
||||
onChange={(event) => setGrant({ ...grant, username: event.target.value })}
|
||||
style={{ flex: '1 1 160px' }}
|
||||
/>
|
||||
<input
|
||||
list="rust-permission-names"
|
||||
className="input"
|
||||
placeholder="kits.vip"
|
||||
value={grant.permission}
|
||||
onChange={(event) => setGrant({ ...grant, permission: event.target.value })}
|
||||
style={{ flex: '1 1 160px' }}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={grant.scope}
|
||||
onChange={(event) => setGrant({ ...grant, scope: event.target.value })}
|
||||
>
|
||||
<option value={FLEET}>every server</option>
|
||||
{servers.map((row) => (
|
||||
<option key={row.serverId} value={row.serverId}>
|
||||
{row.serverId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Grant
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{(data.groups || []).map((group) => (
|
||||
<GroupCard
|
||||
key={group.name}
|
||||
group={group}
|
||||
catalogue={data.catalogue || []}
|
||||
servers={servers}
|
||||
pending={pendingSet(servers)}
|
||||
onChanged={reload}
|
||||
setError={setError}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Card title="New group">
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!form.name.trim()) return
|
||||
act(() =>
|
||||
api.adminPermissions.saveGroup(form.name.trim().toLowerCase(), {
|
||||
title: form.title.trim() || form.name.trim(),
|
||||
scope: form.scope,
|
||||
permissions: [],
|
||||
}),
|
||||
)
|
||||
setForm({ name: '', title: '', scope: FLEET })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="vip"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
style={{ flex: '1 1 140px' }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="VIP"
|
||||
value={form.title}
|
||||
onChange={(event) => setForm({ ...form, title: event.target.value })}
|
||||
style={{ flex: '1 1 140px' }}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={form.scope}
|
||||
onChange={(event) => setForm({ ...form, scope: event.target.value })}
|
||||
>
|
||||
<option value={FLEET}>every server</option>
|
||||
{servers.map((row) => (
|
||||
<option key={row.serverId} value={row.serverId}>
|
||||
{row.serverId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
A group is created in each in-scope game as a real group, so plugins that read group
|
||||
membership see it. A member who has never connected to a server joins it there on their
|
||||
first connection — a direct grant reaches them straight away, which is the difference
|
||||
worth knowing when somebody is waiting.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
client/src/routes/admin/UserRustSections.jsx
Normal file
284
client/src/routes/admin/UserRustSections.jsx
Normal file
@@ -0,0 +1,284 @@
|
||||
// ── This module's fill for `admin.users.detail` ───────────────────────────
|
||||
//
|
||||
// R13's first slot, and the phase criterion as an operator meets it: the Steam
|
||||
// id inside core's own user page, under core's own security panel.
|
||||
//
|
||||
// **The slot hands over `userId` and nothing else** — not a client. So this file
|
||||
// builds its own bindings for the routes the server half registered
|
||||
// (`api.adminUserLinks`), which is §3.5's rule applied to a slot: the two ends of
|
||||
// a call belong to the same module even when the URL between them is core's.
|
||||
//
|
||||
// **Most users have no Rust account, so most of the time this renders nothing.**
|
||||
// A panel that announced "no linked Steam accounts" on every user page in a
|
||||
// community that also runs a UO shard would be noise on the overwhelming
|
||||
// majority of them. Silence is the honest answer to "what does the Rust module
|
||||
// know about this person" when it is nothing.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ago, count, duration } from '../../lib/format.js'
|
||||
import { useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */
|
||||
function SectionTitle({ children }) {
|
||||
return (
|
||||
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One server's all-time totals for this player. */
|
||||
function ServerRow({ server }) {
|
||||
return (
|
||||
<li
|
||||
className="sans"
|
||||
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.86rem', color: 'var(--ink)' }}
|
||||
>
|
||||
<span style={{ minWidth: 0, color: 'var(--head)' }}>{server.serverName}</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||||
{count(server.kills)} kills · {count(server.deaths)} deaths · {duration(server.playtimeSec)}
|
||||
{server.wipes > 1 ? ` · ${server.wipes} wipes` : ''}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** One linked Steam account: who it is, when it was linked, and the way out. */
|
||||
function LinkPanel({ userId, link, onRemoved }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function unlink() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.adminUserLinks.remove(userId, link.steamId)
|
||||
await onRemoved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink that account.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
|
||||
{link.name || link.steamId}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{link.steamId} · linked {ago(link.linkedAt)}
|
||||
{link.serverId ? ` on ${link.serverId}` : ''}
|
||||
{link.lastSeen ? ` · last played ${ago(link.lastSeen)}` : ' · never played'}
|
||||
</div>
|
||||
{/* Worth showing only when they differ: the name on the link is what
|
||||
they were called when they linked, the other is what the game last
|
||||
saw. A rename is the ordinary reason, and an operator reading a
|
||||
support ticket wants both names. */}
|
||||
{link.linkedName && link.name && link.linkedName !== link.name && (
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
Linked as “{link.linkedName}”.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>{error}</p>
|
||||
)}
|
||||
|
||||
{link.servers.length > 0 && (
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
margin: '12px 0 0',
|
||||
padding: '12px 0 0',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{link.servers.map((server) => (
|
||||
<ServerRow key={server.serverId} server={server} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7's half of the panel: what this person may do in game.
|
||||
*
|
||||
* It renders whenever they hold anything, INCLUDING when they have linked no
|
||||
* Steam account — which is the one case worth going out of the way for. A grant
|
||||
* against an unlinked person is authored, stored, pushed nowhere, and identical
|
||||
* to a working one everywhere except here.
|
||||
*/
|
||||
function PermissionsPanel({ userId, data, onChanged }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [permission, setPermission] = useState('')
|
||||
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await fn()
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const nothing = data.groups.length === 0 && data.grants.length === 0
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>
|
||||
Permissions
|
||||
</div>
|
||||
|
||||
{nothing && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
Nothing granted.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.groups.map((group) => (
|
||||
<div key={group.name} className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{group.title || group.name}</span>{' '}
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
group · {group.scope === '*' ? 'every server' : group.scope}
|
||||
{group.permissions.length ? ` · ${group.permissions.join(', ')}` : ' · carries nothing'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data.grants.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="sans"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem', padding: '4px 0' }}
|
||||
>
|
||||
<span style={{ flex: 1, color: 'var(--head)' }}>
|
||||
{row.permission}{' '}
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
{row.scope === '*' ? 'every server' : row.scope}
|
||||
{row.source !== 'admin' ? ` · ${row.source}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminUserPermissions.revoke(userId, row.id))}
|
||||
style={{ flex: 'none' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!nothing && data.reaches.length === 0 && (
|
||||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
|
||||
This account has linked no Steam id, so none of it reaches a game yet. It will apply by
|
||||
itself when they link.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!permission.trim()) return
|
||||
act(() =>
|
||||
api.adminUserPermissions.grant(userId, { permission: permission.trim().toLowerCase() }),
|
||||
)
|
||||
setPermission('')
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="kits.vip"
|
||||
value={permission}
|
||||
onChange={(event) => setPermission(event.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Grant
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserRustSections({ userId }) {
|
||||
// Core's `useAsync` has no refresh, so a counter in the deps is how this
|
||||
// re-reads after its own write (the same shape the player page uses).
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads])
|
||||
const { data: permissions } = useAsync(
|
||||
() => api.adminUserPermissions.list(userId),
|
||||
[userId, reloads],
|
||||
)
|
||||
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
// No `Loading` and no `ErrorState`, deliberately. This is a section inside
|
||||
// somebody else's page: a spinner on every user page for a module most users
|
||||
// have nothing to do with is worse than a section that appears when it has
|
||||
// something, and a failure here must not replace core's own user detail with an
|
||||
// error card.
|
||||
// **Both reads decide whether this section exists**, and the second one is the
|
||||
// reason. A browser walk found it: a person can hold permissions and have
|
||||
// linked no Steam account — which is exactly the state an operator most needs
|
||||
// to see, because it is the one that reaches nobody — and a section gated on
|
||||
// links alone hides it completely.
|
||||
const holdsSomething =
|
||||
permissions && (permissions.groups.length > 0 || permissions.grants.length > 0)
|
||||
|
||||
if (!data || (data.links.length === 0 && !holdsSomething)) return null
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Rust</SectionTitle>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{data.links.map((link) => (
|
||||
<LinkPanel key={link.steamId} userId={userId} link={link} onRemoved={reload} />
|
||||
))}
|
||||
|
||||
{data.links.length > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: 0 }}>
|
||||
A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is
|
||||
recorded in the activity log — it is the way back for a player who linked the wrong
|
||||
account and cannot reach it in game.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Inside the same section rather than beside it: "who is this in game"
|
||||
and "what may they do there" are one question asked twice, and an
|
||||
operator reading a support ticket has both in front of them. The note
|
||||
above belongs to the links, so it sits with them rather than under
|
||||
the panel it would otherwise appear to describe. */}
|
||||
<PermissionsPanel userId={userId} data={permissions} onChanged={reload} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
326
client/src/routes/admin/Visibility.jsx
Normal file
326
client/src/routes/admin/Visibility.jsx
Normal file
@@ -0,0 +1,326 @@
|
||||
// ── Admin · Rust · Visibility ─────────────────────────────────────────────
|
||||
//
|
||||
// Who may see who is online. The org lead's rule (2026-09-22): nothing names who
|
||||
// is online by default — the narrowest audience, staff, unless an operator
|
||||
// deliberately widens it here. A count of players is public at every setting.
|
||||
//
|
||||
// One fleet default and an optional override per server, because a creative or
|
||||
// PvE server may reasonably publish a roll call a PvP server must not — and a
|
||||
// server that has not chosen follows the fleet, so narrowing the fleet narrows
|
||||
// every server that never said otherwise.
|
||||
//
|
||||
// The page says what "who is online" covers, because it is wider than the tab
|
||||
// of the same name: the killfeed, chat and joins in the feed, and the
|
||||
// leaderboard's "last seen" all name a player who was on at a given moment.
|
||||
//
|
||||
// Phase 9 adds a second setting beside it: who may see a CLAN ROSTER (D48). It
|
||||
// defaults to the clan's own members and staff, and widening it widens online
|
||||
// status too, because a roster row carries it — the page says so. The same card
|
||||
// lists each server's clan board: a server whose clans cannot be read, one at
|
||||
// the game's 100-clan ceiling (D55), and one running the uMod Clans plugin,
|
||||
// whose clans are a separate system and never Teams (D47).
|
||||
//
|
||||
// Phase 13b adds a third (D104, D106): whether a published news post is also
|
||||
// said in each server's in-game chat. Off by default, because core sends every
|
||||
// post to every registered leg — without a switch, the day this module updated,
|
||||
// every post would start appearing in every server's chat. It lives here because
|
||||
// this is the one page that lists every server with a setting of its own.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const INHERIT = ''
|
||||
|
||||
const LABEL = {
|
||||
staff: 'Staff only',
|
||||
signed_in: 'Signed-in members',
|
||||
public: 'Everyone',
|
||||
}
|
||||
|
||||
const CLAN_LABEL = {
|
||||
members: 'The clan’s members and staff',
|
||||
signed_in: 'Signed-in members',
|
||||
public: 'Everyone',
|
||||
}
|
||||
|
||||
const CLAN_DESCRIBE = {
|
||||
members: 'Players whose linked Rust account is in the clan, plus admins and moderators. The default.',
|
||||
signed_in: 'Anybody with an account on this site.',
|
||||
public: 'Anybody at all, signed in or not.',
|
||||
}
|
||||
|
||||
const DESCRIBE = {
|
||||
staff: 'Admins and moderators. The default.',
|
||||
signed_in: 'Anybody with an account on this site.',
|
||||
public: 'Anybody at all, signed in or not.',
|
||||
}
|
||||
|
||||
function Card({ title, subtitle, children }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AudienceSelect({ value, onChange, audiences, inherit = null, label }) {
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} style={selectStyle} aria-label={label}>
|
||||
{inherit && <option value={INHERIT}>{inherit}</option>}
|
||||
{audiences.map((a) => (
|
||||
<option key={a} value={a}>{LABEL[a] || a}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Visibility() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, error: loadError } = useAsync(() => api.adminVisibility.read(), [reloads])
|
||||
|
||||
const [fleet, setFleet] = useState('staff')
|
||||
const [clanRoster, setClanRoster] = useState('members')
|
||||
const [servers, setServers] = useState({})
|
||||
const [news, setNews] = useState({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
// The form starts from what the server said and is reset from it after every
|
||||
// save — the answer to a PUT is the new state, so what is on screen is always
|
||||
// the site's word rather than what this page sent.
|
||||
const load = useCallback((state) => {
|
||||
setFleet(state.presence.fleet)
|
||||
setClanRoster((state.clans && state.clans.roster) || 'members')
|
||||
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
|
||||
setNews(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, Boolean(s.on)])))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (data) load(data)
|
||||
}, [data, load])
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const audiences = data.audiences
|
||||
const rows = data.presence.servers
|
||||
|
||||
const dirtyFleet = fleet !== data.presence.fleet
|
||||
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
|
||||
const clans = data.clans || { audiences: [], roster: 'members', servers: [] }
|
||||
const dirtyClans = clanRoster !== clans.roster
|
||||
const newsRows = (data.news && data.news.servers) || []
|
||||
const dirtyNews = newsRows.filter((s) => Boolean(news[s.id]) !== Boolean(s.on))
|
||||
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans || dirtyNews.length > 0
|
||||
|
||||
const effective = (id) => servers[id] || fleet
|
||||
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setSaved(false)
|
||||
try {
|
||||
const body = {}
|
||||
if (dirtyFleet) body.fleet = fleet
|
||||
if (dirtyClans) body.clanRoster = clanRoster
|
||||
if (dirtyServers.length) {
|
||||
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
|
||||
}
|
||||
if (dirtyNews.length) body.news = Object.fromEntries(dirtyNews.map((s) => [s.id, Boolean(news[s.id])]))
|
||||
load(await api.adminVisibility.save(body))
|
||||
setSaved(true)
|
||||
setReloads((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={save} style={{ maxWidth: 900 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
Nothing on this site names who is online unless you choose to show it. That covers more than
|
||||
the Online tab: the joins, deaths and chat in each server’s feed, and the leaderboard’s “last
|
||||
seen”, all say that a named player was on at a given moment. How many players are online is
|
||||
always shown.
|
||||
</p>
|
||||
|
||||
<Card title="Who is online" subtitle="the default for every server">
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.86rem' }}>
|
||||
<AudienceSelect value={fleet} onChange={setFleet} audiences={audiences} label="Fleet default" />
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>{DESCRIBE[fleet]}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Per server" subtitle="an override, or the default above">
|
||||
{rows.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
|
||||
)}
|
||||
{rows.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 180, color: 'var(--head)' }}>
|
||||
{s.name}
|
||||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
|
||||
</span>
|
||||
<AudienceSelect
|
||||
value={servers[s.id] ?? INHERIT}
|
||||
onChange={(v) => setServers((prev) => ({ ...prev, [s.id]: v }))}
|
||||
audiences={audiences}
|
||||
inherit={`Default (${LABEL[fleet] || fleet})`}
|
||||
label={`Who is online on ${s.name}`}
|
||||
/>
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>
|
||||
{servers[s.id] ? 'its own setting' : 'follows the default'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{widened && (
|
||||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.8rem' }}>
|
||||
Wider than staff: on a PvP server, knowing who is on tells a raiding party whose base is
|
||||
undefended.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Card title="Clan rosters" subtitle="who is in each clan, on every server">
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.86rem' }}>
|
||||
<select
|
||||
value={clanRoster}
|
||||
onChange={(e) => setClanRoster(e.target.value)}
|
||||
style={selectStyle}
|
||||
aria-label="Who may see a clan roster"
|
||||
>
|
||||
{clans.audiences.map((a) => (
|
||||
<option key={a} value={a}>{CLAN_LABEL[a] || a}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>{CLAN_DESCRIBE[clanRoster]}</span>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '10px 0 0' }}>
|
||||
Each clan’s name, colour, score and member count are always public.
|
||||
</p>
|
||||
{clanRoster !== 'members' && (
|
||||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.8rem', margin: '8px 0 0' }}>
|
||||
A roster also shows which members are online right now, so this shows who is on to{' '}
|
||||
{clanRoster === 'public' ? 'everyone' : 'every signed-in member'} as well.
|
||||
</p>
|
||||
)}
|
||||
<ClanBoards servers={clans.servers || []} />
|
||||
</Card>
|
||||
|
||||
<Card title="News in game chat" subtitle="a published news post, said in each server’s chat">
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
When a news post is published, its title is said in the chat of every server switched on
|
||||
here. A server that is down when a post is published is skipped rather than told late.
|
||||
</p>
|
||||
{newsRows.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
|
||||
)}
|
||||
{newsRows.map((s) => (
|
||||
<label
|
||||
key={s.id}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
fontSize: '0.86rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(news[s.id])}
|
||||
onChange={(e) => setNews((prev) => ({ ...prev, [s.id]: e.target.checked }))}
|
||||
aria-label={`Say news in ${s.name}’s chat`}
|
||||
/>
|
||||
<span style={{ minWidth: 180, color: 'var(--head)' }}>
|
||||
{s.name}
|
||||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
|
||||
</span>
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>{news[s.id] ? 'says news' : 'off'}</span>
|
||||
</label>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button type="submit" className="btn" disabled={busy || !dirty}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{saved && !dirty && <span className="dim" style={{ fontSize: '0.8rem' }}>Saved.</span>}
|
||||
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What each server's clan board says about itself. Only the servers with
|
||||
* something to report are listed: a board that is current, complete and read
|
||||
* normally is the case that needs no sentence.
|
||||
*/
|
||||
function ClanBoards({ servers }) {
|
||||
const notes = []
|
||||
for (const s of servers) {
|
||||
if (s.umodClans) {
|
||||
notes.push([s, 'is running the uMod Clans plugin. Its clans are a separate system from the game’s own, and only the game’s clans appear on this site.'])
|
||||
}
|
||||
if (!s.supported) {
|
||||
notes.push([s, s.reason ? `cannot report its clans: ${s.reason}.` : 'has not reported its clans yet.'])
|
||||
} else if (s.truncated) {
|
||||
notes.push([s, 'is at the game’s limit of 100 listed clans, so clans beyond the top 100 by score are not shown, and a disbanded clan is not removed until it drops below.'])
|
||||
} else if (!s.fresh) {
|
||||
notes.push([s, 'has not reported its clans recently, so they are shown as last reported.'])
|
||||
}
|
||||
}
|
||||
if (!notes.length) return null
|
||||
return (
|
||||
<ul className="sans" style={{ margin: '12px 0 0', paddingLeft: 18, fontSize: '0.8rem' }}>
|
||||
{notes.map(([s, text], i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<li key={`${s.id}-${i}`} style={{ margin: '4px 0' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{s.name}</strong> {text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
const selectStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '4px 8px',
|
||||
fontSize: '0.84rem',
|
||||
}
|
||||
331
client/src/routes/player/Account.jsx
Normal file
331
client/src/routes/player/Account.jsx
Normal file
@@ -0,0 +1,331 @@
|
||||
// ── The player's own Rust identity ────────────────────────────────────────
|
||||
//
|
||||
// `/player/rust` — where a signed-in player links the Steam account they play
|
||||
// on. It is the one page in this module a player is asked to *do* something on,
|
||||
// and the thing they are doing matters more than it looks: from phase 7 the link
|
||||
// is what in-game permissions are granted against, and from phase 13 it is what
|
||||
// rewards are handed to.
|
||||
//
|
||||
// **A player route renders no layout of its own.** Core wraps `/player/*` in its
|
||||
// own portal chrome, so this page starts at a heading — unlike the public pages
|
||||
// in this module, which render `PublicLayout` themselves.
|
||||
//
|
||||
// The three-step instruction at the top is not decoration. Nothing else on the
|
||||
// site tells a player that the code comes from the game, and a code field with no
|
||||
// explanation is a code field nobody can use.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import { ago, shortId } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
/** The code field, and the four answers it can produce. */
|
||||
function LinkForm({ onLinked }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
if (!code.trim() || busy) return
|
||||
|
||||
setBusy(true)
|
||||
setMessage('')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await api.playerLinks.confirm(code.trim())
|
||||
setMessage(
|
||||
result.already
|
||||
? 'That account was already linked to you.'
|
||||
: `Linked ${result.link.name || shortId(result.link.steamId)}.`,
|
||||
)
|
||||
setCode('')
|
||||
await onLinked()
|
||||
} catch (err) {
|
||||
// Every refusal the server sends is already a sentence aimed at a player —
|
||||
// "run /link again", "run /unlink in game", "try again in a minute" — so
|
||||
// this renders it rather than replacing it with one of its own. The three
|
||||
// are not interchangeable, and a page that flattened them into "could not
|
||||
// link that code" would send a player back to the server that is down.
|
||||
setError(err.message || 'Could not link that code.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ marginTop: 18 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label" style={{ display: 'block', marginBottom: 6 }}>Link code</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="K7M2PQ"
|
||||
// The plugin's alphabet has no O, 0, I or 1, so a player reading a
|
||||
// code off their screen cannot produce one — but they can type a
|
||||
// lowercase one, and the code is matched case-insensitively at the
|
||||
// other end. Upper-casing here makes what they typed look like what
|
||||
// they were shown.
|
||||
maxLength={12}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="input"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.18em', width: 160 }}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn" disabled={busy || !code.trim()}>
|
||||
{busy ? 'Checking…' : 'Link account'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p className="sans" style={{ color: '#7fd0a4', fontSize: '0.86rem', margin: '10px 0 0' }}>{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.86rem', margin: '10px 0 0' }}>{error}</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** One linked account, and the control that releases it. */
|
||||
function LinkRow({ link, onRemoved }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function remove() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.playerLinks.remove(link.steamId)
|
||||
await onRemoved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink that account.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
|
||||
{link.name || shortId(link.steamId)}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{link.steamId} · linked {ago(link.linkedAt)}
|
||||
{link.serverId ? ` on ${link.serverId}` : ''}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an entitlement has actually landed.
|
||||
*
|
||||
* The server resolves the scope and marks each server, so this renders an answer
|
||||
* rather than working one out — `*` means nothing to a player, and a second
|
||||
* implementation of the scope arithmetic on the client is a second thing to keep
|
||||
* true (see `forPlayer` in the permission model).
|
||||
*/
|
||||
function Reach({ reach }) {
|
||||
if (!reach.length) {
|
||||
return (
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||
No servers are configured yet
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, fontSize: '0.76rem' }}>
|
||||
{reach.map((server) => (
|
||||
<span
|
||||
key={server.id}
|
||||
style={{
|
||||
border: '1px solid var(--line, rgba(255,255,255,0.14))',
|
||||
borderRadius: 999,
|
||||
padding: '2px 10px',
|
||||
color: server.live ? 'var(--head)' : undefined,
|
||||
opacity: server.live ? 1 : 0.65,
|
||||
}}
|
||||
>
|
||||
{/* The word, not only the dot. A filled circle beside a hollow one is
|
||||
the whole difference between "you have this in game" and "you do
|
||||
not yet", which is more than a shape should have to carry — and a
|
||||
reader who cannot tell the two apart gets no answer at all. */}
|
||||
{server.live ? '● ' : '○ '}
|
||||
{server.name} · {server.live ? 'has it' : 'waiting'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One group or one direct grant, drawn the same way because they read the same. */
|
||||
function HeldRow({ title, subtitle, permissions, reach }) {
|
||||
return (
|
||||
<li className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{title}</div>
|
||||
|
||||
{subtitle && (
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{subtitle}</div>
|
||||
)}
|
||||
|
||||
{permissions && permissions.length > 0 && (
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
|
||||
{permissions.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Reach reach={reach} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the site has given this player in game.
|
||||
*
|
||||
* Its own read, not part of the links read: an entitlement exists whether or not
|
||||
* a Steam account is linked, and a player who has just been given something and
|
||||
* has not linked yet is exactly the person who needs to see both halves at once.
|
||||
*/
|
||||
function Held({ accounts }) {
|
||||
const { data, loading, error } = useAsync(() => api.playerPermissions.list(), [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
const groups = data.groups || []
|
||||
const grants = data.grants || []
|
||||
|
||||
if (!groups.length && !grants.length) {
|
||||
return (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0, maxWidth: '60ch' }}>
|
||||
Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on
|
||||
the servers they cover.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const waiting = [...groups, ...grants].some((entry) => entry.reach.some((server) => !server.live))
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{groups.map((group) => (
|
||||
<HeldRow
|
||||
key={`group:${group.name}`}
|
||||
title={group.title}
|
||||
subtitle={`Rank · joined ${ago(group.since)}`}
|
||||
permissions={group.permissions}
|
||||
reach={group.reach}
|
||||
/>
|
||||
))}
|
||||
|
||||
{grants.map((grant) => (
|
||||
<HeldRow
|
||||
key={`grant:${grant.permission}:${grant.scope}`}
|
||||
title={grant.permission}
|
||||
subtitle={grant.note || `Granted ${ago(grant.since)}`}
|
||||
reach={grant.reach}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{accounts === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 12, maxWidth: '60ch' }}>
|
||||
None of this reaches the game yet — link a Steam account above and the site pushes it
|
||||
across on its next sync.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{accounts > 0 && waiting && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 12, maxWidth: '60ch' }}>
|
||||
A server marked <em>waiting</em> has not confirmed it yet. One that is offline catches up
|
||||
when it comes back.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Account() {
|
||||
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
|
||||
// the person looking at it changes it, and a page that re-asked every twenty
|
||||
// seconds would be asking a question nobody is waiting on.
|
||||
//
|
||||
// **Core's `useAsync` has no `refresh`** — it re-runs when its deps change and
|
||||
// that is the whole of its interface — so a counter in the deps is how a page
|
||||
// re-reads after its own write. It blanks while it re-reads, which is right
|
||||
// here and is exactly what made it wrong for a poll (see `hooks/usePolled.js`).
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, loading, error } = useAsync(() => api.playerLinks.list(), [reloads])
|
||||
const links = data ? data.links : []
|
||||
|
||||
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="field-label" style={{ marginBottom: 12 }}>Steam accounts</div>
|
||||
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem', maxWidth: '60ch' }}>
|
||||
Linking tells this site which Steam account is yours, so your play on our servers appears
|
||||
under your name here — and so rewards and permissions the site hands out can reach you in
|
||||
game.
|
||||
</p>
|
||||
|
||||
<ol className="sans dim" style={{ fontSize: '0.86rem', marginTop: 14, paddingLeft: 20, maxWidth: '60ch' }}>
|
||||
<li>Join any of our Rust servers and type <code>/link</code> in chat.</li>
|
||||
<li>The server replies with a six-character code, only you can see it, and it lasts five minutes.</li>
|
||||
<li>Type it below. It works once.</li>
|
||||
</ol>
|
||||
|
||||
<LinkForm onLinked={reload} />
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState error={error} />}
|
||||
|
||||
{data && links.length > 0 && (
|
||||
<ul style={{ listStyle: 'none', margin: '22px 0 0', padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{links.map((link) => (
|
||||
<LinkRow key={link.steamId} link={link} onRemoved={reload} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data && links.length > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 14, maxWidth: '60ch' }}>
|
||||
A link covers every server this community runs — a Steam account is one person wherever
|
||||
they play, while stats are kept per server and per wipe. You can also type
|
||||
{' '}<code>/unlink</code> in game to release one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && links.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 18 }}>
|
||||
No Steam account is linked to this profile yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Phase 8. Rendered whether or not anything is linked: an entitlement is
|
||||
authored against the website account, so it exists before a Steam id
|
||||
does — and hiding it until one appears is the mistake the admin user
|
||||
page shipped in phase 7 (PLAN.md §20.5). */}
|
||||
<div className="field-label" style={{ margin: '30px 0 12px' }}>What you can do in game</div>
|
||||
|
||||
{data && <Held accounts={links.length} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
client/src/routes/public/Clan.jsx
Normal file
161
client/src/routes/public/Clan.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
// ── One clan ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A first-party Rust clan is a Team (R5), and this is its page. Core owns the
|
||||
// Team — the reconciler, the access rules, the activity feed, the forum — but
|
||||
// not the word "clan", so it publishes no Team page of its own (MODULE_API.md
|
||||
// §3.7a). The page is this module's, and the three parts only core can render
|
||||
// are contributed into places this page names:
|
||||
//
|
||||
// rust.clan.header ← core's `team.notify` (above the roster: an action ON the page)
|
||||
// rust.clan.detail ← core's `team.activity` (the members-only feed, D49)
|
||||
// rust.clan.forum ← core's `team.forum`
|
||||
//
|
||||
// One slot per PLACE, as module-uo does, so core never decides the layout of a
|
||||
// page it does not own. **Every slot may be empty** — a core without Teams, a
|
||||
// deployment with the forum switched off, a clan whose Team core has not created
|
||||
// yet — and the page has to read correctly anyway. That is the phase criterion,
|
||||
// and it is why nothing here says "see below" about something core may not put
|
||||
// below.
|
||||
//
|
||||
// The roster comes from this module's own board, through the same function core
|
||||
// asks when it projects a roster (D48), so the two cannot disagree about who may
|
||||
// look. Below the audience the clan is still described — its name, score and
|
||||
// count are public (D58) — and the roster says who may see it instead.
|
||||
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
|
||||
import Empty from '../../components/Empty.jsx'
|
||||
import { Swatch } from '../../components/Clans.jsx'
|
||||
import { count, day } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const ID = 'rust'
|
||||
|
||||
export default function Clan() {
|
||||
const { externalId } = useParams()
|
||||
const { data, loading, error } = useAsync(() => api.clans.get(externalId), [externalId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<Loading />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// A mistyped or out-of-date address is not an outage, and must not read as
|
||||
// one — the same rule the server page learned in phase 4.
|
||||
if (error || !data || !data.clan) {
|
||||
const missing = !error || error.status === 404
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<PageHeader
|
||||
title={missing ? 'No such clan' : 'That clan could not be loaded'}
|
||||
lead={
|
||||
missing
|
||||
? 'This address does not name a clan this site knows about.'
|
||||
: 'The site could not read this clan 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>
|
||||
)
|
||||
}
|
||||
|
||||
const { clan, roster } = data
|
||||
const serverLink = `/rust/servers/${encodeURIComponent(clan.serverId)}?tab=clans`
|
||||
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<p className="sans" style={{ margin: '0 0 12px' }}>
|
||||
<Link to={serverLink}>← Clans on {clan.serverName || clan.serverId}</Link>
|
||||
</p>
|
||||
|
||||
<PageHeader eyebrow="Rust clan" title={clan.name} lead={describe(clan)} />
|
||||
|
||||
{clan.gone && (
|
||||
<p className="sans" style={{ color: 'var(--dim)', marginTop: 0 }}>
|
||||
This clan has been disbanded, or has left its server’s clan list. What is shown is the last the site heard.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Slot name="rust.clan.header" externalId={clan.externalId} moduleId={ID} />
|
||||
|
||||
<h2 className="sans" style={{ fontSize: '1rem', margin: '24px 0 8px' }}>Members</h2>
|
||||
<Roster roster={roster} memberCount={clan.memberCount} gone={clan.gone} />
|
||||
|
||||
<Slot name="rust.clan.detail" externalId={clan.externalId} moduleId={ID} />
|
||||
|
||||
<Slot name="rust.clan.forum" externalId={clan.externalId} moduleId={ID} />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function describe(clan) {
|
||||
const parts = [
|
||||
<Swatch key="c" color={clan.color} />,
|
||||
` ${count(clan.memberCount)} ${clan.memberCount === 1 ? 'member' : 'members'}`,
|
||||
clan.maxMembers ? ` of ${count(clan.maxMembers)}` : '',
|
||||
` · ${count(clan.score)} points`,
|
||||
clan.founded ? ` · founded ${day(clan.founded)}` : '',
|
||||
]
|
||||
return <span>{parts}</span>
|
||||
}
|
||||
|
||||
function Roster({ roster, memberCount, gone }) {
|
||||
if (!roster || !roster.visible) {
|
||||
return <Empty title={`${count(memberCount)} ${memberCount === 1 ? 'member' : 'members'}`} message={withheld(roster && roster.audience)} />
|
||||
}
|
||||
|
||||
if (roster.members.length === 0) {
|
||||
return gone
|
||||
? <Empty title="No roster" message="A clan that has left its server’s list has no members to show." />
|
||||
: <Empty title="No roster yet" message="The server has not sent this clan’s members yet." />
|
||||
}
|
||||
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{roster.members.map((m, i) => (
|
||||
<li
|
||||
// The roster carries no identifier on purpose (a Steam id and a site
|
||||
// account are withheld from every public roster), so the row's place is
|
||||
// its key. The list is re-rendered whole, never reordered in place.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--ink)', flex: 1, minWidth: 0 }}>
|
||||
{m.name || 'Unknown player'}
|
||||
{m.leader && (
|
||||
<span className="sans" style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.72rem' }}>Leader</span>
|
||||
)}
|
||||
</strong>
|
||||
{m.role && !m.leader && (
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>{m.role}</span>
|
||||
)}
|
||||
{/* Inside the roster audience by construction (D48): a viewer who may
|
||||
not see the roster sees no row to hang this on. */}
|
||||
<span className="sans" style={{ color: m.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
||||
{m.online ? 'online' : ''}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/** Why the roster was withheld, in words a visitor can act on. */
|
||||
function withheld(audience) {
|
||||
if (audience === 'signed_in') return 'Sign in to see who is in this clan.'
|
||||
if (audience === 'public') return 'This site is not showing clan rosters right now.'
|
||||
return 'Only this clan’s own members, with a linked Rust account, and this site’s staff can see who is in it.'
|
||||
}
|
||||
189
client/src/routes/public/ServerDetail.jsx
Normal file
189
client/src/routes/public/ServerDetail.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
// ── 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 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' },
|
||||
// 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 === '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.'
|
||||
}
|
||||
@@ -1,43 +1,46 @@
|
||||
// ── 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 { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import Empty from '../../components/Empty.jsx'
|
||||
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 : []
|
||||
|
||||
@@ -59,44 +62,61 @@ export default function Servers() {
|
||||
empty game — it is an install that is not finished. Saying so beats a
|
||||
blank page that looks like a failure. */}
|
||||
{data && servers.length === 0 && (
|
||||
<EmptyState
|
||||
<Empty
|
||||
title="No servers yet"
|
||||
message="An administrator adds a Rust server, and its sidecar, from the admin panel."
|
||||
/>
|
||||
)}
|
||||
|
||||
{servers.length > 0 && (
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
// The whole row is the link. A server's name being the only clickable
|
||||
// part is the thing people miss on a list of cards, and `a.card`
|
||||
// already carries core's own hover treatment.
|
||||
<Link
|
||||
key={server.id}
|
||||
to={`/rust/servers/${encodeURIComponent(server.id)}`}
|
||||
className="card"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: '1rem',
|
||||
padding: '0.75rem 0',
|
||||
borderBottom: '1px solid rgba(128,128,128,0.25)',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{server.name}</strong>
|
||||
{server.level ? <span style={{ opacity: 0.7 }}> · {server.level}</span> : null}
|
||||
<div style={{ opacity: 0.7, fontSize: '0.9em' }}>
|
||||
{/* `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.' : '.'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'nowrap' }}>
|
||||
<span>
|
||||
<strong style={{ color: 'var(--ink)' }}>{server.name}</strong>
|
||||
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
{[
|
||||
server.level || null,
|
||||
server.worldSize ? `size ${count(server.worldSize)}` : null,
|
||||
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem', marginTop: 2 }}>
|
||||
{/* `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)}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ whiteSpace: 'nowrap', color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}
|
||||
>
|
||||
{server.online
|
||||
? `${server.players}${server.maxPlayers ? ` / ${server.maxPlayers}` : ''} online`
|
||||
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
||||
: 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
141
client/test/feed.test.js
Normal file
141
client/test/feed.test.js
Normal file
@@ -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: '<img src=x onerror=alert(1)>', channel: 'Global' }))
|
||||
assert.equal(line.verb, '<img src=x onerror=alert(1)>')
|
||||
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)
|
||||
})
|
||||
96
client/test/format.test.js
Normal file
96
client/test/format.test.js
Normal file
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
@@ -226,6 +257,23 @@ it('every declared slot names a core contribution core actually offers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('the clan page gets all three of core’s Team contributions, one per place (phase 9, D56)', () => {
|
||||
// Core contributes three things to a Team page it does not own. Each has its
|
||||
// own place on the clan page, so no contribution is decided by another's
|
||||
// position — and a slot missing here is a clan page with no feed, no forum or
|
||||
// no notification switch, with nothing logged anywhere.
|
||||
const byName = Object.fromEntries(registered.declaredSlots.map((s) => [s.name, s.wants]))
|
||||
assert.deepEqual(byName, {
|
||||
'rust.clan.header': 'team.notify',
|
||||
'rust.clan.detail': 'team.activity',
|
||||
'rust.clan.forum': 'team.forum',
|
||||
})
|
||||
|
||||
// And the page is at the address the Team provider hands core.
|
||||
const paths = registered.routes.public.map((r) => r.path)
|
||||
assert.ok(paths.includes('rust/clans/:externalId'), paths.join(', '))
|
||||
})
|
||||
|
||||
it('registers under exactly one module id, matching the manifest', () => {
|
||||
const owners = new Set([
|
||||
...Object.values(registered.routes).flat().map((r) => r.moduleId),
|
||||
|
||||
49
client/test/uiKitProps.test.js
Normal file
49
client/test/uiKitProps.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// ── The UI kit's props, as core actually reads them ───────────────────────
|
||||
//
|
||||
// React drops an unknown prop without a word, so a UI-kit component called with
|
||||
// the wrong one renders — just not what was written. Two of these have shipped
|
||||
// from this org already: `PageHeader subtitle` (Teams phase 11, the kit's
|
||||
// template) and `EmptyState title/message` (this module, phases 4 to 8 — every
|
||||
// empty panel was a blank box until the presence fix's browser walk).
|
||||
//
|
||||
// A DOM-less runner cannot see a blank box, so this reads the source instead:
|
||||
// it names the props core's components do NOT take and fails on any use of them.
|
||||
// It is a claim about core that must be re-read when core's kit changes —
|
||||
// written down rather than imported, because no core is in this process.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src')
|
||||
|
||||
/** Every .jsx/.js under src/. */
|
||||
function sources(dir = SRC) {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) return sources(full)
|
||||
return /\.(jsx?|mjs)$/.test(entry.name) ? [full] : []
|
||||
})
|
||||
}
|
||||
|
||||
// Core's `components/PageState.jsx` and `PageHeader.jsx`, read 2026-09-23 at the
|
||||
// pinned core (ci/core-ref.json).
|
||||
const REFUSED = {
|
||||
// `EmptyState({ children })` — children only.
|
||||
EmptyState: /<EmptyState\b[^>]*\b(title|message|description|text)\s*=/,
|
||||
// `PageHeader({ eyebrow, title, lead, center })` — there is no `subtitle`.
|
||||
PageHeader: /<PageHeader\b[^>]*\bsubtitle\s*=/,
|
||||
}
|
||||
|
||||
test('no UI-kit component is handed a prop core does not read', () => {
|
||||
const offences = []
|
||||
for (const file of sources()) {
|
||||
const text = fs.readFileSync(file, 'utf8')
|
||||
for (const [component, pattern] of Object.entries(REFUSED)) {
|
||||
if (pattern.test(text)) offences.push(`${path.relative(SRC, file)}: ${component}`)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offences, [], 'use components/Empty.jsx for a titled empty state')
|
||||
})
|
||||
1300
engagement-triggers.json
Normal file
1300
engagement-triggers.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,5 +12,6 @@
|
||||
"admin": ["/rust"],
|
||||
"player": ["/rust"]
|
||||
},
|
||||
"capabilities": ["servers"]
|
||||
"extensions": ["admin.users.detail"],
|
||||
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity"]
|
||||
}
|
||||
|
||||
@@ -1,35 +1,200 @@
|
||||
{
|
||||
"$comment": "Generated inventory of the URLs module-rust serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see server/scripts/frozenManifest.js.",
|
||||
"routes": [
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/permissions/grants/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:name",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:name/members/:userId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/servers/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/rust/links/:steamId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/rust/permissions/grants/:grantId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/rust/links/:steamId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/file",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/files",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/writes",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/permissions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/permissions/catalogue",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/servers",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/visibility",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/rust/links",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/rust/permissions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/links",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/permissions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/servers",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/clans/:externalId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"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/clans",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/events",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/leaderboard",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/online",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/wipes",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/file",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/drift/:id/adopt",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/drift/:id/revoke",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/grants",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:name/members",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/sync",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/servers/:id/test",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/:id/rust/permissions/grants",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/rust/link",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:name",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/servers/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/visibility",
|
||||
"tier": "public"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
198
server/boot.js
198
server/boot.js
@@ -21,25 +21,58 @@
|
||||
// letting that fail the boot would make installing the module before installing
|
||||
// the bridge impossible.
|
||||
//
|
||||
// ── Polling, in phase 1 ───────────────────────────────────────────────────
|
||||
// ── Four timers, and they answer four different questions ─────────────────
|
||||
//
|
||||
// This is a poll, and the live feed it will become is a later phase's work. The
|
||||
// poll is not a placeholder for it: a sidecar's store-backed reads are exactly
|
||||
// what answers while a game server is off, and the module will keep reading them
|
||||
// on an interval to notice a server that went away without saying anything.
|
||||
// What the feed adds is latency, not coverage.
|
||||
// refresh (30s) what is each server, and who is on it — the BOARDS
|
||||
// ingest (5s) what has happened since we last looked — the CURSOR
|
||||
// sweep (1m) which login attempts were never let in (PLAN.md §25)
|
||||
// prune (1h) forgetting the detail we promised not to keep for ever
|
||||
//
|
||||
// The boards poll and the ingest are deliberately separate rather than one loop
|
||||
// reading both. They fail differently and they matter differently: a board that
|
||||
// is 30 seconds stale shows a player count slightly behind, and an ingest that
|
||||
// is 30 seconds behind shows a killfeed that feels broken. Splitting them lets
|
||||
// the cheap one run often and the expensive one run rarely, and it means a
|
||||
// sidecar that answers one and not the other degrades in exactly one place.
|
||||
//
|
||||
// The poll was never a placeholder for a socket: a sidecar's store-backed reads
|
||||
// are what answer while a game server is off, which is most of what this module
|
||||
// renders. See `ingest.js` for why the live feed is a cursor and not a
|
||||
// WebSocket.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const db = require('./model/servers/servers.db')
|
||||
const engagement = require('./engagement/emit')
|
||||
const eventsDb = require('./model/events/events.db')
|
||||
const eventWorld = require('./eventWorld')
|
||||
const ingest = require('./ingest')
|
||||
const permSync = require('./permSync')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const sidecar = require('./sidecarClient')
|
||||
|
||||
const log = core.logger('boot')
|
||||
|
||||
let refreshTimer = null
|
||||
let ingestTimer = null
|
||||
let pruneTimer = null
|
||||
let sweepTimer = null
|
||||
|
||||
const REFRESH_MS = 30 * 1000
|
||||
const INGEST_MS = 5 * 1000
|
||||
const PRUNE_MS = 60 * 60 * 1000
|
||||
const SWEEP_MS = 60 * 1000
|
||||
|
||||
/**
|
||||
* How long this module keeps raw events.
|
||||
*
|
||||
* Longer than the sidecar's 14 days, because this is the richer store and the
|
||||
* one a page reads — and because the sidecar lives on somebody's game host while
|
||||
* this lives on the website's own database. What is NOT bounded by it is the
|
||||
* record: `rust_player_wipe_stats` and `rust_gather_totals` are permanent, which
|
||||
* is the whole of R12's "a wipe does not erase a player's history".
|
||||
*/
|
||||
const EVENT_RETENTION_DAYS = 30
|
||||
|
||||
/**
|
||||
* Ask every configured sidecar how its server is doing, and store what it said.
|
||||
@@ -63,7 +96,18 @@ async function refresh() {
|
||||
|
||||
async function refreshOne(server) {
|
||||
try {
|
||||
const board = await sidecar.serverBoard(server)
|
||||
// One call for both boards. `/server` would answer the same question about
|
||||
// the server itself, but presence would then be a second round trip to the
|
||||
// same process for a fact it already had in hand.
|
||||
//
|
||||
// **And one for `/health`, because the boards cannot say whether the game is
|
||||
// there NOW** (D68, PLAN.md §25.1). The sidecar keeps its last `server.hello`
|
||||
// after the plugin disconnects — that is what lets a page render a server
|
||||
// that is off — so a game that hung, or whose bridge was unloaded, while the
|
||||
// sidecar stayed up read as online here from phase 4 until phase 10. Only
|
||||
// `/health`'s `plugin_connected` answers the question, and the two are asked
|
||||
// together so they describe the same moment.
|
||||
const [board, health] = await Promise.all([sidecar.boards(server), sidecar.health(server)])
|
||||
|
||||
// Three outcomes, and collapsing any two of them loses something an operator
|
||||
// needs:
|
||||
@@ -76,25 +120,53 @@ 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)
|
||||
engagement.serverObserved(server, false)
|
||||
return
|
||||
}
|
||||
|
||||
const frame = board.data
|
||||
const boards = (board.data && board.data.boards) || {}
|
||||
const frame = boards['server.hello']
|
||||
|
||||
if (!frame) {
|
||||
await db.putState({ serverId: server.id, reachable: true, online: false })
|
||||
// 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.markUnreachable(server.id, true)
|
||||
await ingest.applyBoards(server.id, {})
|
||||
engagement.serverObserved(server, false)
|
||||
return
|
||||
}
|
||||
|
||||
// Unknown is not connected. A `/health` that did not answer while `/boards`
|
||||
// did is odd enough to be worth a line, and reporting the server up on the
|
||||
// strength of a board the game may have left behind hours ago is the defect
|
||||
// this call exists to remove.
|
||||
const connected = Boolean(health.ok && health.data && health.data.plugin_connected === true)
|
||||
if (!health.ok) log.warn('the sidecar answered /boards but not /health', { server: server.id })
|
||||
|
||||
// The presence board is the plugin's last word too. While the game is not
|
||||
// connected it names people as online who may have left hours ago, which is
|
||||
// both wrong and — under §23's rule — a claim about named people nobody made.
|
||||
await ingest.applyBoards(
|
||||
server.id,
|
||||
connected ? boards : { ...boards, 'players.online': { players: [] } },
|
||||
)
|
||||
|
||||
await db.putState({
|
||||
serverId: server.id,
|
||||
reachable: true,
|
||||
// A stored `server.hello` means the game connected; whether it is connected
|
||||
// NOW is a different question, and `/health` is what answers it. The board
|
||||
// alone cannot say, which is why `online` is not simply `true` here — it is
|
||||
// decided by freshness in the model, from `updated_at`.
|
||||
online: true,
|
||||
players: Number(frame.players) || 0,
|
||||
// The plugin is connected NOW (D68). A stored `server.hello` only says it
|
||||
// connected once; the model still applies its own freshness on top.
|
||||
online: connected,
|
||||
// A board the game left behind is a description, not a sighting.
|
||||
seen: connected,
|
||||
players: connected ? Number(frame.players) || 0 : 0,
|
||||
maxPlayers: Number(frame.maxPlayers) || 0,
|
||||
hostname: frame.hostname || null,
|
||||
level: frame.level || null,
|
||||
@@ -102,9 +174,20 @@ async function refreshOne(server) {
|
||||
worldSize: frame.worldSize === undefined ? null : Number(frame.worldSize),
|
||||
bootId: frame.bootId || null,
|
||||
saveCreatedAt: frame.saveCreatedAt || null,
|
||||
wipeId: frame.wipeId || null,
|
||||
protocol: frame.protocol === undefined ? null : Number(frame.protocol),
|
||||
raw: frame,
|
||||
})
|
||||
|
||||
// After the write, so a transition announced is one a page already shows.
|
||||
engagement.serverObserved(server, connected)
|
||||
|
||||
// A restart or a wipe under a running event is the moment core must be told
|
||||
// to ask what the world still holds (§11.1). Only a CONNECTED plugin's hello
|
||||
// counts: a board the game left behind says nothing about now.
|
||||
if (connected) {
|
||||
eventWorld.observeServer(server.id, { bootId: frame.bootId, wipeId: frame.wipeId, worldReady: frame.worldReady })
|
||||
}
|
||||
} catch (err) {
|
||||
// A failure here is one server's, and it must not reach `Promise.allSettled`
|
||||
// as a rejection that hides which one. Log with the id and carry on.
|
||||
@@ -119,14 +202,70 @@ async function refreshOne(server) {
|
||||
* built to look like it — so a module that only needs core at boot time can skip
|
||||
* `core.init` entirely and use this argument.
|
||||
*/
|
||||
/** Runs the cursor for every configured server, independently. */
|
||||
async function ingestAll() {
|
||||
let rows
|
||||
|
||||
try {
|
||||
rows = await servers.listForPolling()
|
||||
} catch (err) {
|
||||
log.warn('could not read the server list', { error: err.message })
|
||||
return
|
||||
}
|
||||
|
||||
// `allSettled`, for the same reason the board poll uses it: six servers behind
|
||||
// one unreachable host must not stop the other five being ingested.
|
||||
await Promise.allSettled(rows.map((server) => ingest.ingestServer(server)))
|
||||
}
|
||||
|
||||
async function prune() {
|
||||
try {
|
||||
const gone = await eventsDb.pruneEvents(EVENT_RETENTION_DAYS)
|
||||
if (gone > 0) log.info('pruned old events', { events: gone, days: EVENT_RETENTION_DAYS })
|
||||
} catch (err) {
|
||||
log.warn('could not prune events', { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login attempts that were never approved (D64, PLAN.md §25).
|
||||
*
|
||||
* On its own minute timer rather than the prune's hour: an attempt waits a
|
||||
* minute for its approval, and a staff alert an hour late is not an alert. A
|
||||
* query over stored rows, so it needs nothing kept in memory and a restart loses
|
||||
* nothing; the dedupe key makes a second pass over the same attempt a no-op.
|
||||
*/
|
||||
async function sweep() {
|
||||
let rows
|
||||
try {
|
||||
rows = await servers.listForPolling()
|
||||
} catch (err) {
|
||||
log.warn('could not read the server list', { error: err.message })
|
||||
return
|
||||
}
|
||||
const sent = await engagement.sweepLoginDenied(rows)
|
||||
if (sent > 0) log.info('unapproved logins reported', { attempts: sent })
|
||||
}
|
||||
|
||||
async function onBoot() {
|
||||
await refresh()
|
||||
// The permission mirror owns its own loop and its own cadence (see
|
||||
// `permSync.js`). It is started rather than run here: a first pass would write
|
||||
// to every configured game server before the website had finished booting, and
|
||||
// nothing about R2 is urgent enough to delay a listener for.
|
||||
permSync.start()
|
||||
refreshTimer = setInterval(refresh, REFRESH_MS)
|
||||
ingestTimer = setInterval(ingestAll, INGEST_MS)
|
||||
pruneTimer = setInterval(prune, PRUNE_MS)
|
||||
sweepTimer = setInterval(sweep, SWEEP_MS)
|
||||
// Node keeps the process alive for a pending timer. Core's own intervals are
|
||||
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
|
||||
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
|
||||
if (typeof refreshTimer.unref === 'function') refreshTimer.unref()
|
||||
log.info('booted', { refreshMs: REFRESH_MS })
|
||||
for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
|
||||
if (timer && typeof timer.unref === 'function') timer.unref()
|
||||
}
|
||||
|
||||
log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS, permSyncMs: permSync.TICK_MS })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,9 +277,28 @@ async function onBoot() {
|
||||
* rather than cancelled, since nothing can stop a promise that is still running.
|
||||
*/
|
||||
async function onShutdown() {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
permSync.stop()
|
||||
|
||||
for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
|
||||
if (timer) clearInterval(timer)
|
||||
}
|
||||
|
||||
refreshTimer = null
|
||||
ingestTimer = null
|
||||
pruneTimer = null
|
||||
sweepTimer = null
|
||||
|
||||
log.info('shut down')
|
||||
}
|
||||
|
||||
module.exports = { onBoot, onShutdown, refresh, refreshOne, REFRESH_MS }
|
||||
module.exports = {
|
||||
onBoot,
|
||||
onShutdown,
|
||||
refresh,
|
||||
refreshOne,
|
||||
ingestAll,
|
||||
prune,
|
||||
REFRESH_MS,
|
||||
INGEST_MS,
|
||||
EVENT_RETENTION_DAYS,
|
||||
}
|
||||
|
||||
180
server/catalogue.js
Normal file
180
server/catalogue.js
Normal file
@@ -0,0 +1,180 @@
|
||||
// ── What the bridge can say, and who may hear it ──────────────────────────
|
||||
//
|
||||
// One file, because these two questions have to be answered together or the
|
||||
// second one rots: which frame kinds exist, and which of them a member of the
|
||||
// public may see.
|
||||
//
|
||||
// ── The boundary ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Protocol 2's catalogue includes frames carrying **IP addresses** (a login
|
||||
// attempt, an approval, a ban) and **one player's complaint about another** (a
|
||||
// report), and one — a destroyed structure — that names where somebody lives.
|
||||
// They are stored, because an operator chasing ban evasion needs them and
|
||||
// because the sidecar persists what it is told. They must never reach a public
|
||||
// page.
|
||||
//
|
||||
// **The boundary is enforced HERE, on the side that serves, and not on the wire.**
|
||||
// The plugin could have stamped a `class` on every frame and saved this file the
|
||||
// trouble; it deliberately does not (PROTOCOL.md §8.5). A boundary declared by
|
||||
// the sender is a boundary a compromised — or merely out-of-date — game host can
|
||||
// widen. Core's own shard fan-out works the same way: a public stream with an
|
||||
// allowlist of kinds, and an admin stream that adds the rest.
|
||||
//
|
||||
// ── Default deny, and why it is not paranoia ──────────────────────────────
|
||||
//
|
||||
// `isPublic` answers `false` for a kind it has never heard of. That matters
|
||||
// because of the shape of the mistake it prevents: the next protocol version
|
||||
// adds a kind, this module ingests it happily (`rust_events` stores what it is
|
||||
// given), and a page that filtered by a DENY list would publish it the day it
|
||||
// first arrived — before anybody had decided whether it should be public. With
|
||||
// an allowlist the new kind is invisible until somebody adds it here, which is
|
||||
// the same moment they think about it.
|
||||
//
|
||||
// The test holds this list against `docs/rust-link/PROTOCOL.md` §8.4's table, so
|
||||
// adding a kind to the spec without classifying it fails a build rather than
|
||||
// shipping an address to a public page.
|
||||
|
||||
/**
|
||||
* Kinds a public, signed-out visitor may see.
|
||||
*
|
||||
* Each entry is a decision. `player.chat` is here because a shard's chat is
|
||||
* public by the same logic that makes a killfeed public — it happened in front
|
||||
* of everyone who was on the server — and an operator who disagrees turns the
|
||||
* feature off rather than relying on this list being wrong.
|
||||
*/
|
||||
const PUBLIC_KINDS = Object.freeze([
|
||||
'player.connected',
|
||||
'player.disconnected',
|
||||
'player.respawned',
|
||||
'player.death',
|
||||
'player.chat',
|
||||
'player.tally',
|
||||
'server.wipe',
|
||||
'server.initialized',
|
||||
'server.shutdown',
|
||||
])
|
||||
|
||||
/**
|
||||
* Kinds an admin may see and nobody else.
|
||||
*
|
||||
* Listed rather than implied by absence, so that "we know about this kind and it
|
||||
* is restricted" is distinguishable from "nobody has classified this kind" — the
|
||||
* second is a finding, and a bare allowlist cannot tell you which you are
|
||||
* looking at.
|
||||
*/
|
||||
const STAFF_KINDS = Object.freeze([
|
||||
'entity.destroyed',
|
||||
'player.reported',
|
||||
'player.banned',
|
||||
'player.unbanned',
|
||||
'player.login.attempt',
|
||||
'player.approved',
|
||||
// Protocol 3's two account frames. Neither carries a code — the code travels
|
||||
// through the player, which is what makes typing it proof — but both name a
|
||||
// Steam id ALONGSIDE a website account's activity, which is exactly the join a
|
||||
// public page must not be able to make: "this player is that person" is a fact
|
||||
// about somebody's identity, not about what happened on the server.
|
||||
'account.link.requested',
|
||||
'account.unlinked',
|
||||
// Protocol 4. Who holds which privilege in game, and the fact that somebody
|
||||
// changed it by hand — a question about a person's standing and about an
|
||||
// operator's own console, neither of which is a public page's business.
|
||||
'perm.drift',
|
||||
// Protocol 6. Clan membership, which the org lead made members-only (D49):
|
||||
// who joined which clan, and who threw whom out, is the clan's business. It
|
||||
// reaches a clan's own members through core's Team feed, where core resolves
|
||||
// who is a member, and it reaches the server's public feed not at all.
|
||||
'clan.created',
|
||||
'clan.disbanded',
|
||||
'clan.member.added',
|
||||
'clan.member.left',
|
||||
'clan.member.kicked',
|
||||
])
|
||||
|
||||
/**
|
||||
* The public kinds that say a NAMED player was on the server at a given moment.
|
||||
*
|
||||
* A subset of `PUBLIC_KINDS`, not a third list: these are public-page material
|
||||
* whose audience an operator chooses (`model/visibility`), where the rest of
|
||||
* `PUBLIC_KINDS` is public by construction. The org lead's rule, settled
|
||||
* 2026-09-22: **nothing tells who is online by default** — the narrowest
|
||||
* audience (staff) unless an operator widens it, and a count is never a name.
|
||||
*
|
||||
* `player.death` and `player.chat` are here, and that was decided rather than
|
||||
* overlooked. They are the killfeed and the chat — the content a feed exists
|
||||
* for — and each one says "this person was on at 12:03" as plainly as a connect
|
||||
* frame does. `player.tally` is a per-minute flush that is only ever sent for a
|
||||
* player who is playing, which makes it a roll call with extra steps.
|
||||
*
|
||||
* What is left in the public set once these are removed is the server's own
|
||||
* story — a wipe, a start, a shutdown — which names nobody.
|
||||
*/
|
||||
const PRESENCE_KINDS = Object.freeze([
|
||||
'player.connected',
|
||||
'player.disconnected',
|
||||
'player.respawned',
|
||||
'player.death',
|
||||
'player.chat',
|
||||
'player.tally',
|
||||
])
|
||||
|
||||
/** Every kind the protocol defines, through protocol 6. */
|
||||
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
||||
|
||||
const PUBLIC = new Set(PUBLIC_KINDS)
|
||||
const STAFF = new Set(STAFF_KINDS)
|
||||
const PRESENCE = new Set(PRESENCE_KINDS)
|
||||
|
||||
/**
|
||||
* May a signed-out visitor see this kind?
|
||||
*
|
||||
* Default deny: an unknown kind is not public. Callers pass whatever arrived on
|
||||
* the wire, including a kind from a newer protocol this build has never seen.
|
||||
*/
|
||||
function isPublic(kind) {
|
||||
return PUBLIC.has(kind)
|
||||
}
|
||||
|
||||
/** Is this a kind this build knows about at all? */
|
||||
function isKnown(kind) {
|
||||
return PUBLIC.has(kind) || STAFF.has(kind)
|
||||
}
|
||||
|
||||
/** Does this kind name a player who was on the server at the time? */
|
||||
function isPresence(kind) {
|
||||
return PRESENCE.has(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows a list of requested kinds to the ones a viewer may have.
|
||||
*
|
||||
* Returning the allowlist itself when nothing was requested is what makes the
|
||||
* public route safe by construction rather than by remembering to filter: there
|
||||
* is no code path where "no filter" means "everything".
|
||||
*
|
||||
* `presence` defaults to `false` for the same reason `admin` does: a caller that
|
||||
* forgets to say what the viewer may see gets the narrowest answer. The route
|
||||
* resolves it from the operator's setting (`model/visibility`); nothing else
|
||||
* should be passing `true`.
|
||||
*/
|
||||
function kindsFor({ admin = false, presence = false, requested = null } = {}) {
|
||||
const permitted = admin
|
||||
? ALL_KINDS
|
||||
: PUBLIC_KINDS.filter((k) => presence || !PRESENCE.has(k))
|
||||
|
||||
if (!requested || requested.length === 0) return [...permitted]
|
||||
|
||||
const allowed = new Set(permitted)
|
||||
return requested.filter((k) => allowed.has(k))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PUBLIC_KINDS,
|
||||
STAFF_KINDS,
|
||||
PRESENCE_KINDS,
|
||||
ALL_KINDS,
|
||||
isPublic,
|
||||
isKnown,
|
||||
isPresence,
|
||||
kindsFor,
|
||||
}
|
||||
527
server/configEdit.js
Normal file
527
server/configEdit.js
Normal file
@@ -0,0 +1,527 @@
|
||||
// ── Editing a plugin's config without rewriting the numbers ───────────────
|
||||
//
|
||||
// R18's base tier generates a form from a config file's VALUES — a boolean
|
||||
// becomes a toggle, a number a field, a string a text box — so it works for
|
||||
// whatever plugins an operator happens to have installed, including ones added
|
||||
// after we shipped. This file is the half of that which cannot be done naively.
|
||||
//
|
||||
// ── The trap ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// **JavaScript cannot tell `1` from `1.0`.** `JSON.parse('{"Rate":1.0}')` yields
|
||||
// the number `1`, and `JSON.stringify` writes it back as `1`. Both frameworks
|
||||
// deserialize a config into typed C# classes, so a naive read-modify-write
|
||||
// silently rewrites every whole-numbered float as an integer — **on fields
|
||||
// nobody touched** — and Newtonsoft may coerce it or may throw. A throw at load
|
||||
// means the plugin does not come back, and R6/R17 make four of them required.
|
||||
//
|
||||
// The fields at risk are exactly the ones a Rust server tunes: gather rates,
|
||||
// multipliers, scales.
|
||||
//
|
||||
// ── So nothing here ever parses, mutates and re-serialises ────────────────
|
||||
//
|
||||
// `scan` is a JSON reader that records, for every value, the **span of source
|
||||
// text** it came from. `applyEdits` splices new literals into those spans and
|
||||
// leaves every other byte of the document exactly as it was — including the
|
||||
// author's indentation, key order, and the `.0` on a float nobody edited.
|
||||
//
|
||||
// Two rules fall out of that and both are deliberate:
|
||||
//
|
||||
// 1. **A number's new value arrives as the literal text an admin typed**, never
|
||||
// as a JavaScript number. `2.50` stays `2.50`; `1.0` stays `1.0`. The value
|
||||
// never becomes a `Number` anywhere in this module, which is the only way to
|
||||
// be sure it cannot be re-serialised into something else.
|
||||
// 2. **The generated form is type-preserving.** An edit may change what a value
|
||||
// IS, never what KIND of thing it is; changing a number into a string, or
|
||||
// adding a key, is a structural change and belongs in the raw-JSON tier,
|
||||
// where the admin is editing the document itself.
|
||||
//
|
||||
// Nothing in this file touches the network, a database, or core.
|
||||
|
||||
/** Value kinds this module names, in the language the form speaks. */
|
||||
const KINDS = ['object', 'array', 'string', 'number', 'boolean', 'null']
|
||||
|
||||
/**
|
||||
* A JSON number, by the grammar rather than by `Number()`.
|
||||
*
|
||||
* Used to judge a literal an admin typed. `Number('0x10')`, `Number('')` and
|
||||
* `Number(' 1 ')` are all happily finite and none of the three is JSON, so the
|
||||
* check has to be the grammar — which is also what keeps `1.0` and `1e3`
|
||||
* acceptable, since preserving those is the entire point.
|
||||
*/
|
||||
const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/
|
||||
|
||||
/**
|
||||
* Words that make a value a secret.
|
||||
*
|
||||
* Matched against the key split into WORDS, not as a substring: `Monkey` and
|
||||
* `Keybind` contain "key" and neither is a credential, and a config editor that
|
||||
* masked every third field would teach an operator to ignore the mask.
|
||||
*/
|
||||
const SECRET_WORDS = new Set([
|
||||
'key',
|
||||
'keys',
|
||||
'apikey',
|
||||
'token',
|
||||
'tokens',
|
||||
'secret',
|
||||
'secrets',
|
||||
'password',
|
||||
'passwd',
|
||||
'pass',
|
||||
'webhook',
|
||||
'webhooks',
|
||||
'credential',
|
||||
'credentials',
|
||||
'auth',
|
||||
])
|
||||
|
||||
class JsonScanError extends Error {}
|
||||
|
||||
/**
|
||||
* Reads `text` into a tree of nodes that remember where they came from.
|
||||
*
|
||||
* Every node carries `start` and `end`, the half-open span of the value in the
|
||||
* source. A caller that only wants the data can read `value`; a caller that
|
||||
* wants to CHANGE the data uses the span, because the span is the only thing
|
||||
* that survives a round trip unchanged.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {object} the root node
|
||||
* @throws {JsonScanError} with a position, on anything that is not JSON
|
||||
*/
|
||||
function scan(text) {
|
||||
const src = String(text)
|
||||
let at = 0
|
||||
|
||||
function fail(message) {
|
||||
throw new JsonScanError(`${message} at offset ${at}`)
|
||||
}
|
||||
|
||||
function ws() {
|
||||
while (at < src.length && (src[at] === ' ' || src[at] === '\t' || src[at] === '\n' || src[at] === '\r')) at += 1
|
||||
}
|
||||
|
||||
function literal(word, value) {
|
||||
if (src.startsWith(word, at)) {
|
||||
const start = at
|
||||
at += word.length
|
||||
return { type: word === 'null' ? 'null' : 'boolean', value, start, end: at }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function string() {
|
||||
const start = at
|
||||
at += 1 // the opening quote
|
||||
|
||||
let out = ''
|
||||
|
||||
while (at < src.length) {
|
||||
const ch = src[at]
|
||||
|
||||
if (ch === '"') {
|
||||
at += 1
|
||||
return { type: 'string', value: out, start, end: at }
|
||||
}
|
||||
|
||||
if (ch === '\\') {
|
||||
const esc = src[at + 1]
|
||||
at += 2
|
||||
|
||||
if (esc === 'u') {
|
||||
const hex = src.slice(at, at + 4)
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('bad unicode escape')
|
||||
out += String.fromCharCode(parseInt(hex, 16))
|
||||
at += 4
|
||||
} else if (esc === 'n') out += '\n'
|
||||
else if (esc === 't') out += '\t'
|
||||
else if (esc === 'r') out += '\r'
|
||||
else if (esc === 'b') out += '\b'
|
||||
else if (esc === 'f') out += '\f'
|
||||
else if (esc === '"' || esc === '\\' || esc === '/') out += esc
|
||||
else fail('bad escape')
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
out += ch
|
||||
at += 1
|
||||
}
|
||||
|
||||
return fail('unterminated string')
|
||||
}
|
||||
|
||||
function number() {
|
||||
const start = at
|
||||
if (src[at] === '-') at += 1
|
||||
while (at < src.length && /[0-9]/.test(src[at])) at += 1
|
||||
if (src[at] === '.') {
|
||||
at += 1
|
||||
while (at < src.length && /[0-9]/.test(src[at])) at += 1
|
||||
}
|
||||
if (src[at] === 'e' || src[at] === 'E') {
|
||||
at += 1
|
||||
if (src[at] === '+' || src[at] === '-') at += 1
|
||||
while (at < src.length && /[0-9]/.test(src[at])) at += 1
|
||||
}
|
||||
|
||||
const raw = src.slice(start, at)
|
||||
if (!JSON_NUMBER.test(raw)) fail(`'${raw}' is not a number`)
|
||||
|
||||
// `raw` is the fact; `value` is a convenience for rendering and comparison,
|
||||
// and is never written back to the document.
|
||||
return { type: 'number', value: Number(raw), raw, start, end: at }
|
||||
}
|
||||
|
||||
function value() {
|
||||
ws()
|
||||
const ch = src[at]
|
||||
|
||||
if (ch === '{') return object()
|
||||
if (ch === '[') return array()
|
||||
if (ch === '"') return string()
|
||||
if (ch === '-' || (ch >= '0' && ch <= '9')) return number()
|
||||
|
||||
const lit = literal('true', true) || literal('false', false) || literal('null', null)
|
||||
if (lit) return lit
|
||||
|
||||
return fail('unexpected character')
|
||||
}
|
||||
|
||||
function object() {
|
||||
const start = at
|
||||
at += 1 // {
|
||||
const children = []
|
||||
ws()
|
||||
|
||||
if (src[at] === '}') {
|
||||
at += 1
|
||||
return { type: 'object', children, start, end: at }
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
ws()
|
||||
if (src[at] !== '"') fail('expected a key')
|
||||
const key = string()
|
||||
ws()
|
||||
if (src[at] !== ':') fail('expected a colon')
|
||||
at += 1
|
||||
|
||||
const child = value()
|
||||
child.key = key.value
|
||||
child.keyStart = key.start
|
||||
child.keyEnd = key.end
|
||||
children.push(child)
|
||||
|
||||
ws()
|
||||
if (src[at] === ',') {
|
||||
at += 1
|
||||
continue
|
||||
}
|
||||
if (src[at] === '}') {
|
||||
at += 1
|
||||
return { type: 'object', children, start, end: at }
|
||||
}
|
||||
return fail('expected a comma or a closing brace')
|
||||
}
|
||||
}
|
||||
|
||||
function array() {
|
||||
const start = at
|
||||
at += 1 // [
|
||||
const children = []
|
||||
ws()
|
||||
|
||||
if (src[at] === ']') {
|
||||
at += 1
|
||||
return { type: 'array', children, start, end: at }
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const child = value()
|
||||
child.index = children.length
|
||||
children.push(child)
|
||||
|
||||
ws()
|
||||
if (src[at] === ',') {
|
||||
at += 1
|
||||
continue
|
||||
}
|
||||
if (src[at] === ']') {
|
||||
at += 1
|
||||
return { type: 'array', children, start, end: at }
|
||||
}
|
||||
return fail('expected a comma or a closing bracket')
|
||||
}
|
||||
}
|
||||
|
||||
const root = value()
|
||||
ws()
|
||||
if (at !== src.length) fail('trailing content')
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
/** Splits a config key into words, across camelCase, snake_case, spaces and dots. */
|
||||
function words(key) {
|
||||
return String(key)
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key names a credential. See `SECRET_WORDS`.
|
||||
*
|
||||
* **A field flagged here is not emptied.** D37 decided the raw tier shows real
|
||||
* values — an admin can already read the file over SSH — so the API answers with
|
||||
* the document as it is, the form renders a flagged field masked with a reveal
|
||||
* control, and the flag's load-bearing use is the audit trail, where the values
|
||||
* genuinely never appear.
|
||||
*/
|
||||
function isSecretKey(key) {
|
||||
return words(key).some((w) => SECRET_WORDS.has(w))
|
||||
}
|
||||
|
||||
/** A pointer as a person reads it: `Settings.Rates[0].Wood`. */
|
||||
function pointerPath(pointer) {
|
||||
return pointer
|
||||
.map((step) => (typeof step === 'number' ? `[${step}]` : step))
|
||||
.join('.')
|
||||
.replace(/\.\[/g, '[')
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a scanned tree into the flat description the form is built from.
|
||||
*
|
||||
* **What is NOT here is as deliberate as what is.** There are no descriptions,
|
||||
* no minimums, no maximums and no allowed-value sets, because a config file
|
||||
* carries none: the key name is the entire label. An empty array and a `null`
|
||||
* carry no type at all, so nothing can be inferred for them and they are marked
|
||||
* `advanced` — the raw tier is where a value with no shape gets edited.
|
||||
*
|
||||
* @param {object} root from `scan`
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.maxDepth] past this, a subtree is advanced-only
|
||||
* @param {string[]} [options.locked] top-level keys that may not be edited (D38)
|
||||
*/
|
||||
function describe(root, { maxDepth = 6, locked = [] } = {}) {
|
||||
const lockedSet = new Set(locked.map((k) => String(k).toLowerCase()))
|
||||
const fields = []
|
||||
|
||||
function visit(node, pointer, depth, inheritedSecret, inheritedLock) {
|
||||
const key = pointer.length ? pointer[pointer.length - 1] : ''
|
||||
const secret = inheritedSecret || (typeof key === 'string' && isSecretKey(key))
|
||||
const isLocked =
|
||||
inheritedLock || (pointer.length === 1 && typeof key === 'string' && lockedSet.has(key.toLowerCase()))
|
||||
|
||||
if (node.type === 'object' || node.type === 'array') {
|
||||
const tooDeep = depth >= maxDepth
|
||||
|
||||
fields.push({
|
||||
pointer: [...pointer],
|
||||
path: pointerPath(pointer),
|
||||
key: typeof key === 'number' ? `[${key}]` : key,
|
||||
type: node.type,
|
||||
depth,
|
||||
count: node.children.length,
|
||||
secret,
|
||||
locked: isLocked,
|
||||
// An empty container has nothing to infer a member's shape from, and a
|
||||
// container past the depth limit has more shape than a form should try
|
||||
// to draw. Both are honest reasons to send somebody to the raw tier.
|
||||
advanced: tooDeep || node.children.length === 0,
|
||||
...(tooDeep ? { reason: 'deeper than the form will draw' } : {}),
|
||||
...(node.children.length === 0 ? { reason: 'empty, so there is no shape to read' } : {}),
|
||||
})
|
||||
|
||||
if (tooDeep) return
|
||||
|
||||
node.children.forEach((child, index) => {
|
||||
visit(child, [...pointer, node.type === 'array' ? index : child.key], depth + 1, secret, isLocked)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fields.push({
|
||||
pointer: [...pointer],
|
||||
path: pointerPath(pointer),
|
||||
key: typeof key === 'number' ? `[${key}]` : key,
|
||||
type: node.type,
|
||||
depth,
|
||||
// A number is reported as its LITERAL as well as its value. The literal is
|
||||
// what the form must round-trip; the value is for display and sorting.
|
||||
...(node.type === 'number' ? { raw: node.raw } : {}),
|
||||
value: node.value,
|
||||
secret,
|
||||
locked: isLocked,
|
||||
// `null` has no type, so there is nothing to render but a raw editor.
|
||||
advanced: node.type === 'null',
|
||||
...(node.type === 'null' ? { reason: 'null carries no type to read' } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
visit(root, [], 0, false, false)
|
||||
return fields
|
||||
}
|
||||
|
||||
/** Finds the node a pointer names, or null. */
|
||||
function resolve(root, pointer) {
|
||||
let node = root
|
||||
|
||||
for (const step of pointer) {
|
||||
if (!node || (node.type !== 'object' && node.type !== 'array')) return null
|
||||
|
||||
if (node.type === 'array') {
|
||||
if (typeof step !== 'number') return null
|
||||
node = node.children[step]
|
||||
} else {
|
||||
node = node.children.find((child) => child.key === step)
|
||||
}
|
||||
|
||||
if (!node) return null
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
/** The exact source text a node was read from. */
|
||||
function literalOf(text, node) {
|
||||
return String(text).slice(node.start, node.end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one edit into the literal that will be spliced in, or explains why not.
|
||||
*
|
||||
* `raw` is used verbatim for a number — that is the whole mechanism — and is
|
||||
* validated against the JSON grammar first, because verbatim and unvalidated
|
||||
* would be a way to write anything at all into somebody's config file.
|
||||
*/
|
||||
function literalFor(node, edit) {
|
||||
if (node.type === 'number') {
|
||||
const raw = String(edit.raw !== undefined && edit.raw !== null ? edit.raw : edit.value).trim()
|
||||
if (!JSON_NUMBER.test(raw)) return { error: `'${raw}' is not a number` }
|
||||
return { literal: raw }
|
||||
}
|
||||
|
||||
if (node.type === 'string') {
|
||||
if (typeof edit.value !== 'string') return { error: 'expected text' }
|
||||
return { literal: JSON.stringify(edit.value) }
|
||||
}
|
||||
|
||||
if (node.type === 'boolean') {
|
||||
if (typeof edit.value !== 'boolean') return { error: 'expected true or false' }
|
||||
return { literal: edit.value ? 'true' : 'false' }
|
||||
}
|
||||
|
||||
return { error: `a ${node.type} is edited in the raw tier` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a set of edits to a document and returns the new text.
|
||||
*
|
||||
* Spans are spliced from the **end of the document backwards**, so that an
|
||||
* earlier edit never moves a later edit's offsets. Every edit is resolved and
|
||||
* checked before any splice happens: a refusal leaves the caller with the
|
||||
* original text rather than a partly-edited one.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {Array<{pointer: Array<string|number>, value?: any, raw?: string}>} edits
|
||||
* @returns {{ text?: string, changes?: object[], error?: string }}
|
||||
*/
|
||||
function applyEdits(text, edits, { locked = [] } = {}) {
|
||||
let root
|
||||
|
||||
try {
|
||||
root = scan(text)
|
||||
} catch (err) {
|
||||
return { error: `the file on the server is not valid JSON: ${err.message}` }
|
||||
}
|
||||
|
||||
const lockedSet = new Set(locked.map((k) => String(k).toLowerCase()))
|
||||
const staged = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const edit of edits || []) {
|
||||
const pointer = Array.isArray(edit.pointer) ? edit.pointer : null
|
||||
if (!pointer || pointer.length === 0) return { error: 'an edit must name a field' }
|
||||
|
||||
const path = pointerPath(pointer)
|
||||
if (seen.has(path)) return { error: `'${path}' is edited twice in one save` }
|
||||
seen.add(path)
|
||||
|
||||
if (typeof pointer[0] === 'string' && lockedSet.has(pointer[0].toLowerCase())) {
|
||||
return { error: `'${path}' cannot be edited from the website` }
|
||||
}
|
||||
|
||||
const node = resolve(root, pointer)
|
||||
if (!node) return { error: `'${path}' is not in this file` }
|
||||
|
||||
const { literal, error } = literalFor(node, edit)
|
||||
if (error) return { error: `'${path}': ${error}` }
|
||||
|
||||
staged.push({
|
||||
path,
|
||||
pointer,
|
||||
start: node.start,
|
||||
end: node.end,
|
||||
from: literalOf(text, node),
|
||||
to: literal,
|
||||
secret: pointer.some((step) => typeof step === 'string' && isSecretKey(step)),
|
||||
})
|
||||
}
|
||||
|
||||
// Nothing to do is not an error, but it must not produce a write either: a
|
||||
// save with no changes would spend a reload — and a reload is the one part of
|
||||
// this feature that can take a plugin down.
|
||||
const changed = staged.filter((s) => s.from !== s.to)
|
||||
if (changed.length === 0) return { text: String(text), changes: [] }
|
||||
|
||||
let out = String(text)
|
||||
|
||||
for (const edit of [...changed].sort((a, b) => b.start - a.start)) {
|
||||
out = out.slice(0, edit.start) + edit.to + out.slice(edit.end)
|
||||
}
|
||||
|
||||
// The result must still be JSON. It always is when the pieces are — this is a
|
||||
// guard against a bug in this file, not against the caller.
|
||||
try {
|
||||
scan(out)
|
||||
} catch (err) {
|
||||
return { error: `the edit produced something that is not JSON: ${err.message}` }
|
||||
}
|
||||
|
||||
return { text: out, changes: changed.map(redactChange) }
|
||||
}
|
||||
|
||||
/**
|
||||
* What the audit trail records for one changed field.
|
||||
*
|
||||
* **A secret's values are never written down.** The raw tier shows real values
|
||||
* to an admin who asks for them, which is a deliberate decision (D37) about a
|
||||
* page somebody has to open — but an activity log is read by more people, for
|
||||
* longer, and usually by somebody who was not there. Those are different
|
||||
* exposures and they get different answers.
|
||||
*/
|
||||
function redactChange(change) {
|
||||
return {
|
||||
path: change.path,
|
||||
from: change.secret ? '***' : change.from,
|
||||
to: change.secret ? '***' : change.to,
|
||||
...(change.secret ? { secret: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KINDS,
|
||||
JSON_NUMBER,
|
||||
JsonScanError,
|
||||
scan,
|
||||
describe,
|
||||
resolve,
|
||||
applyEdits,
|
||||
isSecretKey,
|
||||
pointerPath,
|
||||
words,
|
||||
}
|
||||
@@ -86,6 +86,14 @@ module.exports = {
|
||||
// that needs an identity needs to *read* one.
|
||||
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
||||
|
||||
// One user by id (MODULE_API.md §2.3, 1.1.0). Here for the presence gate
|
||||
// (`model/visibility`): `getUserFromRequest` decodes a token and nothing more,
|
||||
// so the role in it is the role the account had when the token was minted. A
|
||||
// moderator demoted this morning would keep reading who is online until their
|
||||
// token expired. Re-reading the row is what makes a demotion — or a ban — take
|
||||
// effect on the next request, the same promise core's admin tier makes.
|
||||
users: { getById: (...args) => need().users.getById(...args) },
|
||||
|
||||
// Core's middleware, taken as values rather than wrapped: express stores the
|
||||
// function reference at mount time, so a wrapper is what would end up in the
|
||||
// stack. Routers are built inside `register()`, so `ctx` is set by then.
|
||||
@@ -141,6 +149,24 @@ module.exports = {
|
||||
// would be worse, since a module has more than one thing it could reconcile.
|
||||
reconcileEvents: () => need().events.reconcile(),
|
||||
|
||||
// Teams (MODULE_API.md §2.3, 1.6.0) — the push half of the provider this
|
||||
// module registers (`model/clans/teamProvider.js`). Three calls, all
|
||||
// fire-and-forget, and core's contract is that none of them can make this
|
||||
// module's call site slow or turn a background failure into its error:
|
||||
//
|
||||
// publish(event) a membership or leadership change, as it happened
|
||||
// reconcile({reason}) "the set may have changed, come and ask" — debounced
|
||||
// pushActivity(items) the per-Team feed, idempotent on each item's dedupeKey
|
||||
//
|
||||
// Correctness comes from reconciliation either way; `publish` only makes a
|
||||
// change visible sooner. Wrapped as calls, like `emit`, so a file that takes
|
||||
// `core.teams` at require time still resolves `ctx` when it is used.
|
||||
teams: {
|
||||
publish: (event) => need().teams.publish(event),
|
||||
reconcile: (options) => need().teams.reconcile(options),
|
||||
pushActivity: (items) => need().teams.activity.push(items),
|
||||
},
|
||||
|
||||
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
||||
// only correct way to find a file you shipped, because the working directory is
|
||||
// core's and the module's location is the loader's business.
|
||||
|
||||
@@ -19,5 +19,34 @@
|
||||
-- it knows this module registered, because it is the side that knows which
|
||||
-- registrant owned what.
|
||||
|
||||
-- Phase 13b.
|
||||
DROP TABLE IF EXISTS rust_perm_run_grants;
|
||||
|
||||
-- Phase 7b.
|
||||
DROP TABLE IF EXISTS rust_clan_boards;
|
||||
DROP TABLE IF EXISTS rust_clan_members;
|
||||
DROP TABLE IF EXISTS rust_clans;
|
||||
DROP TABLE IF EXISTS rust_settings;
|
||||
DROP TABLE IF EXISTS rust_config_writes;
|
||||
|
||||
-- Phase 7. Children before parents: every one of these carries a foreign key
|
||||
-- into `rust_servers`, `users` or `rust_perm_groups`.
|
||||
DROP TABLE IF EXISTS rust_perm_catalogue;
|
||||
DROP TABLE IF EXISTS rust_perm_sync;
|
||||
DROP TABLE IF EXISTS rust_perm_revocations;
|
||||
DROP TABLE IF EXISTS rust_perm_drift;
|
||||
DROP TABLE IF EXISTS rust_perm_pushed;
|
||||
DROP TABLE IF EXISTS rust_perm_grants;
|
||||
DROP TABLE IF EXISTS rust_perm_group_members;
|
||||
DROP TABLE IF EXISTS rust_perm_group_permissions;
|
||||
DROP TABLE IF EXISTS rust_perm_groups;
|
||||
DROP TABLE IF EXISTS rust_account_links;
|
||||
DROP TABLE IF EXISTS rust_ingest_cursor;
|
||||
DROP TABLE IF EXISTS rust_presence;
|
||||
DROP TABLE IF EXISTS rust_events;
|
||||
DROP TABLE IF EXISTS rust_gather_totals;
|
||||
DROP TABLE IF EXISTS rust_player_wipe_stats;
|
||||
DROP TABLE IF EXISTS rust_players;
|
||||
DROP TABLE IF EXISTS rust_wipes;
|
||||
DROP TABLE IF EXISTS rust_server_state;
|
||||
DROP TABLE IF EXISTS rust_servers;
|
||||
|
||||
@@ -14,14 +14,20 @@
|
||||
-- Every table here is prefixed `rust_`, which is this module's id and the only
|
||||
-- prefix it may create under.
|
||||
--
|
||||
-- ── Two tables, and the split between them is the whole design ────────────
|
||||
-- ── Four kinds of table, and the split between them is the whole design ───
|
||||
--
|
||||
-- `rust_servers` is CONFIGURATION: rows an operator writes, from Admin → Rust.
|
||||
-- `rust_server_state` is OBSERVED STATE: rows this module writes from what a
|
||||
-- sidecar reported. They are separate tables rather than columns on one because
|
||||
-- they have different writers, different lifetimes and different audiences —
|
||||
-- and because a purge of observed state while keeping the configuration is a
|
||||
-- thing an operator will eventually want.
|
||||
-- CONFIGURATION `rust_servers` — rows an operator writes, from Admin → Rust.
|
||||
-- OBSERVED STATE `rust_server_state`, `rust_presence` — what a sidecar last
|
||||
-- reported, replaced rather than appended.
|
||||
-- THE RECORD `rust_wipes`, `rust_players`, `rust_player_wipe_stats`,
|
||||
-- `rust_gather_totals` — permanent, and the reason a wipe does
|
||||
-- not erase a player's history.
|
||||
-- THE WINDOW `rust_events` — recent detail, bounded by a sweep.
|
||||
--
|
||||
-- They are separate tables rather than columns on one because they have
|
||||
-- different writers, different lifetimes and different audiences — and because
|
||||
-- a purge of observed state while keeping the configuration is a thing an
|
||||
-- operator will eventually want.
|
||||
--
|
||||
-- Teardown is `purge.sql`, which no boot ever runs.
|
||||
|
||||
@@ -97,3 +103,740 @@ CREATE TABLE IF NOT EXISTS rust_server_state (
|
||||
CONSTRAINT fk_rust_server_state_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── The read path ─────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Protocol 2 turned the bridge from a greeting into a catalogue, and these are
|
||||
-- the tables that hold it. They divide on one line, and it is the line R12 drew:
|
||||
--
|
||||
-- PERMANENT `rust_wipes`, `rust_players`, `rust_player_wipe_stats`,
|
||||
-- `rust_gather_totals` — a player's record, kept for ever. All-time
|
||||
-- is a SUM across wipes rather than a second set of counters, so
|
||||
-- there is no second number that can disagree with the first.
|
||||
--
|
||||
-- BOUNDED `rust_events` — the recent raw window the killfeed reads, pruned
|
||||
-- on a sweep. It is detail, not record: losing last month's
|
||||
-- individual deaths costs a scroll-back, losing last month's
|
||||
-- totals costs a player their history.
|
||||
--
|
||||
-- DERIVED `rust_presence` — who is on right now, replaced wholesale from
|
||||
-- the `players.online` board. Never a history, never appended.
|
||||
--
|
||||
-- The sidecar keeps its own bounded copy of the same events (default 14 days),
|
||||
-- so shortening either window loses recent detail and neither loses a total.
|
||||
|
||||
|
||||
-- ── Wipes ─────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- One row per (server, wipe). The id is the plugin's, derived from the save's
|
||||
-- creation time and stamped on every frame (PROTOCOL.md §8.2) — this module
|
||||
-- never derives one, because two derivations of one fact eventually disagree
|
||||
-- about a boundary.
|
||||
--
|
||||
-- Rows appear by being MENTIONED: the first frame carrying a wipe id this module
|
||||
-- has not seen creates it. There is no "start a wipe" call and there must not be
|
||||
-- one, because the website is not present when a wipe happens — a wipe is a fact
|
||||
-- about a world that was restarted while nobody was watching.
|
||||
CREATE TABLE IF NOT EXISTS rust_wipes (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
wipe_id VARCHAR(48) NOT NULL,
|
||||
save_created_at VARCHAR(32) NULL,
|
||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (server_id, wipe_id),
|
||||
CONSTRAINT fk_rust_wipes_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Players ───────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Identity, and deliberately nothing else. It is keyed on the Steam id alone
|
||||
-- and carries no server: a player is the same person on all six of a community's
|
||||
-- servers, and everything that is per-server lives in the stats table.
|
||||
--
|
||||
-- `user_id` is NOT here. Linking a Steam id to a website account is phase 6's
|
||||
-- work (R1), and a column waiting for it would be a column every read has to
|
||||
-- remember is always null.
|
||||
CREATE TABLE IF NOT EXISTS rust_players (
|
||||
steam_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(191) NULL,
|
||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
-- ── The permanent record ──────────────────────────────────────────────────
|
||||
--
|
||||
-- One row per player per wipe per server, and the only counters this module
|
||||
-- keeps. R12's "per-wipe detail plus all-time rollups" is satisfied by SUMming
|
||||
-- this rather than by maintaining a second all-time row, because two counters
|
||||
-- for one fact drift the first time an ingest is replayed.
|
||||
--
|
||||
-- Every column is a COUNT that only ever goes up within a wipe, which is what
|
||||
-- makes ingest idempotent-ish in the only way that matters: the cursor advances
|
||||
-- only after the batch commits, so a crash re-reads a batch it has not counted.
|
||||
--
|
||||
-- `playtime_sec` comes from `sessionSec` on a disconnect, and a session whose
|
||||
-- start this module never saw contributes NOTHING rather than zero — the plugin
|
||||
-- omits the field, the ingest skips it, and the number stays honestly short
|
||||
-- instead of quietly wrong.
|
||||
CREATE TABLE IF NOT EXISTS rust_player_wipe_stats (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
wipe_id VARCHAR(48) NOT NULL,
|
||||
steam_id VARCHAR(32) NOT NULL,
|
||||
kills INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
deaths INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
suicides INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
npc_kills INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
structures INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
sessions INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
playtime_sec BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_seen DATETIME NULL,
|
||||
PRIMARY KEY (server_id, wipe_id, steam_id),
|
||||
KEY idx_rust_stats_kills (server_id, wipe_id, kills DESC),
|
||||
KEY idx_rust_stats_player (steam_id)
|
||||
);
|
||||
|
||||
|
||||
-- ── What they gathered ────────────────────────────────────────────────────
|
||||
--
|
||||
-- A row per resource rather than a JSON blob on the stats row, for one reason:
|
||||
-- the leaderboard question is "who gathered the most sulfur this wipe", and that
|
||||
-- is an ORDER BY over a column in every SQL engine and a JSON function call in
|
||||
-- exactly one. The resource name is the game's own shortname, unknown in advance
|
||||
-- and not worth a lookup table.
|
||||
CREATE TABLE IF NOT EXISTS rust_gather_totals (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
wipe_id VARCHAR(48) NOT NULL,
|
||||
steam_id VARCHAR(32) NOT NULL,
|
||||
resource VARCHAR(64) NOT NULL,
|
||||
amount BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, wipe_id, steam_id, resource),
|
||||
KEY idx_rust_gather_top (server_id, wipe_id, resource, amount DESC)
|
||||
);
|
||||
|
||||
|
||||
-- ── The recent raw window ─────────────────────────────────────────────────
|
||||
--
|
||||
-- Every ingested event, whole, for as long as the retention sweep keeps it. The
|
||||
-- killfeed reads this; so does an admin looking at what happened.
|
||||
--
|
||||
-- `raw` holds the entire frame and the columns beside it are only what a query
|
||||
-- needs to reach — the same rule the sidecar's own store follows, one hop along:
|
||||
-- a protocol version that adds a field needs no migration here.
|
||||
--
|
||||
-- **`kind` is a security boundary, not a label.** Some kinds carry IP addresses
|
||||
-- and player reports (PROTOCOL.md §8.4), and what makes them safe is that the
|
||||
-- public read is filtered by an allowlist this module holds, default-deny. The
|
||||
-- rows are stored either way, because an operator chasing ban evasion needs them.
|
||||
CREATE TABLE IF NOT EXISTS rust_events (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
wipe_id VARCHAR(48) NULL,
|
||||
kind VARCHAR(64) NOT NULL,
|
||||
t BIGINT NOT NULL,
|
||||
steam_id VARCHAR(32) NULL,
|
||||
raw LONGTEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_rust_events_server (server_id, id DESC),
|
||||
KEY idx_rust_events_kind (server_id, kind, id DESC),
|
||||
KEY idx_rust_events_wipe (server_id, wipe_id, id DESC),
|
||||
KEY idx_rust_events_created (created_at)
|
||||
);
|
||||
|
||||
|
||||
-- ── Who is on right now ───────────────────────────────────────────────────
|
||||
--
|
||||
-- Replaced wholesale every time the `players.online` board arrives, which is on
|
||||
-- every bridge connect and every 60 seconds. It is a BOARD, and the reason it is
|
||||
-- its own table rather than rows in `rust_events` is that a board answers "now"
|
||||
-- and an event answers "then"; storing a board as history is the mistake the
|
||||
-- wire's `type` field exists to prevent, and it would be a shame to make it here
|
||||
-- after the sidecar went to the trouble of not making it there.
|
||||
CREATE TABLE IF NOT EXISTS rust_presence (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
steam_id VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(191) NULL,
|
||||
sleeping TINYINT(1) NOT NULL DEFAULT 0,
|
||||
connected_at DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (server_id, steam_id)
|
||||
);
|
||||
|
||||
|
||||
-- ── The ingest cursor ─────────────────────────────────────────────────────
|
||||
--
|
||||
-- Where this module has read up to in each sidecar's feed. One row per server.
|
||||
--
|
||||
-- It is persisted rather than held in memory because the alternative is a module
|
||||
-- that re-reads everything on every boot or nothing at all, and both are wrong in
|
||||
-- a way that only shows up in production. The cursor advances **after** the batch
|
||||
-- is written, never before: a crash mid-batch re-reads rows it has not counted,
|
||||
-- which is the safe direction to be wrong in.
|
||||
--
|
||||
-- A NEW server starts at the sidecar's current end rather than at zero (see
|
||||
-- `GET /feed` with no `since`). A module installed today against a sidecar that
|
||||
-- has been running a month wants what happens next — replaying a fortnight of
|
||||
-- deaths into stats whose wipes it never saw is not a catch-up, it is a
|
||||
-- fabrication of history it was not present for.
|
||||
CREATE TABLE IF NOT EXISTS rust_ingest_cursor (
|
||||
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
last_event_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
events_seen BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_cursor_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Who owns which Steam account ──────────────────────────────────────────
|
||||
--
|
||||
-- R1's identity link, and the reason it is a table rather than a column on
|
||||
-- `rust_players`: a link is a fact about a WEBSITE USER that happens to be keyed
|
||||
-- by a Steam id, and it outlives every row this module writes about play. A
|
||||
-- column here would be null for the overwhelming majority of players and would
|
||||
-- be deleted by any sweep that pruned inactive ones.
|
||||
--
|
||||
-- **Keyed on `steam_id` alone, fleet-wide.** `rust_players` already made that
|
||||
-- call in protocol 2 and it is the truth of the thing: a Steam account is one
|
||||
-- person across every server an operator runs, where stats are per server and
|
||||
-- per wipe. Linking on one server links for the fleet, because there is nothing
|
||||
-- else it could honestly mean.
|
||||
--
|
||||
-- **One Steam id, at most one user** — that is what the primary key buys, and it
|
||||
-- is load-bearing rather than tidy. Phase 7 makes the site the author of who may
|
||||
-- do what in game and phase 13 makes it the thing that hands out loot; both are
|
||||
-- grants against a Steam id, and both assume the question "whose is this?" has
|
||||
-- exactly one answer.
|
||||
--
|
||||
-- The reverse is deliberately NOT constrained: one website user may hold several
|
||||
-- Steam accounts. People have a second account, or a family shares a site login,
|
||||
-- and refusing that would be inventing a rule the game does not have.
|
||||
--
|
||||
-- `ON DELETE CASCADE` from `users`: a deleted account's links go with it. The
|
||||
-- alternative is a row naming a user id that resolves to nobody, which every
|
||||
-- read would then have to defend against.
|
||||
CREATE TABLE IF NOT EXISTS rust_account_links (
|
||||
steam_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
-- What the player was called in game when they linked. A display name, kept
|
||||
-- so an operator reading the admin panel sees a person rather than a number;
|
||||
-- never used to identify anybody, because a Rust name changes on a whim.
|
||||
name VARCHAR(191) NULL,
|
||||
-- Which server minted the code. Not part of the identity — the link is
|
||||
-- fleet-wide — but an operator asking "where did this come from" has no other
|
||||
-- way to find out, and a support conversation starts there.
|
||||
server_id VARCHAR(64) NULL,
|
||||
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_links_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
KEY idx_rust_links_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ── Site-owned permissions (phase 7, R2) ──────────────────────────────────
|
||||
--
|
||||
-- The website is the author of record for who may do what in game, and the
|
||||
-- framework's own permission store is an ENFORCEMENT CACHE. That is one
|
||||
-- sentence with three consequences, and the tables below are shaped by them:
|
||||
--
|
||||
-- • Every third-party plugin honours a site grant with no adapter, because
|
||||
-- they all already call `UserHasPermission`. Nothing here is read by the
|
||||
-- game directly; it is pushed into the store the game already consults.
|
||||
-- • A wipe stops being a data-loss event. The game forgets and the site does
|
||||
-- not, so the next sync puts it all back.
|
||||
-- • A hand edit is REPORTED, never silently overwritten (D31). Which means
|
||||
-- the site has to be able to tell a grant it made from one somebody typed
|
||||
-- at a console — and that is a fact only the site can hold, because the
|
||||
-- store records who granted a permission nowhere.
|
||||
--
|
||||
-- ── A grant is against a WEBSITE USER (D28) ───────────────────────────────
|
||||
--
|
||||
-- Not against a Steam id, though a Steam id is what reaches the game. The site
|
||||
-- authors privilege for a PERSON: phase 13's earned entitlements follow whoever
|
||||
-- earned them, and an account unlinked from a person takes their privileges
|
||||
-- with it. The Steam ids are resolved from `rust_account_links` at push time,
|
||||
-- so a player who links a second account gets what they hold on both — which is
|
||||
-- the honest reading of "this person may do this".
|
||||
--
|
||||
-- A user with no linked account is authored against perfectly well and simply
|
||||
-- reaches nobody until they link. That is visible on the admin screen rather
|
||||
-- than silent, because a grant that reaches nothing looks identical to a grant
|
||||
-- that worked from every other angle.
|
||||
--
|
||||
-- ── Scope (D29) ───────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Every authored row carries one: a server id, or `*` for the whole fleet. The
|
||||
-- game stores permissions per server (each has its own store), an operator
|
||||
-- running a modded server and a vanilla one will not want one set on both, and
|
||||
-- a single-server community never has to think about it.
|
||||
|
||||
|
||||
-- ── Groups ────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Mirrored into the game as REAL groups (D30) rather than flattened into
|
||||
-- per-player grants. Third-party plugins read group membership, BetterChat's
|
||||
-- group API (R15, phase 17) has something to hang on, and an operator reading
|
||||
-- `oxide.show groups` sees what the website shows.
|
||||
--
|
||||
-- The cost of that fidelity is written down in PLAN.md §12.2 rule 4 and does
|
||||
-- not go away: **a player the store has never seen cannot be put in a group**,
|
||||
-- while a direct grant to the same id works immediately. The sync reports those
|
||||
-- members as pending and the membership lands on their first connection.
|
||||
--
|
||||
-- The name is the primary key, fleet-wide, even though the row carries a scope:
|
||||
-- one `vip` on the site is one `vip` in the game, pushed to the servers its
|
||||
-- scope names. Two groups of the same name with different scopes would be two
|
||||
-- definitions of one name in every store that received both.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_groups (
|
||||
name VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
title VARCHAR(120) NOT NULL DEFAULT '',
|
||||
rank INT NOT NULL DEFAULT 0,
|
||||
scope VARCHAR(64) NOT NULL DEFAULT '*',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
-- What each group carries. A row per permission rather than a list on the group
|
||||
-- for the ordinary reason: "which groups grant kits.vip" is the question an
|
||||
-- operator asks when they are about to remove a plugin, and that is a WHERE
|
||||
-- clause here and a scan of every row in the other shape.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_group_permissions (
|
||||
group_name VARCHAR(64) NOT NULL,
|
||||
permission VARCHAR(128) NOT NULL,
|
||||
PRIMARY KEY (group_name, permission),
|
||||
CONSTRAINT fk_rust_perm_group_permissions_group
|
||||
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- Who is in each group — by website user, like every other authored row.
|
||||
--
|
||||
-- `added_by` is an admin's user id and deliberately carries NO foreign key: a
|
||||
-- staff member's account being deleted must not delete the record of what they
|
||||
-- did, and `ON DELETE SET NULL` would quietly rewrite history to "nobody".
|
||||
-- The activity log is the audit trail; this column is a convenience beside it.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_group_members (
|
||||
group_name VARCHAR(64) NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
added_by INT NULL,
|
||||
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (group_name, user_id),
|
||||
KEY idx_rust_perm_members_user (user_id),
|
||||
CONSTRAINT fk_rust_perm_members_group
|
||||
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_rust_perm_members_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Direct grants ─────────────────────────────────────────────────────────
|
||||
--
|
||||
-- A permission held by one person, without a group. It is not a lesser version
|
||||
-- of membership: it is the shape that reaches a player who has never connected
|
||||
-- to that server, which is exactly what an entitlement earned on the website at
|
||||
-- three in the morning has to do (R16).
|
||||
--
|
||||
-- `source` is why this table does not need changing in phase 13. Every later
|
||||
-- author — an event action granting the right to redeem a kit, a lease handing
|
||||
-- out a weekend group — writes a row here with its own source rather than a
|
||||
-- store of its own, so there is one answer to "why does this player have this"
|
||||
-- and one place the push reads.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_grants (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
permission VARCHAR(128) NOT NULL,
|
||||
scope VARCHAR(64) NOT NULL DEFAULT '*',
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'admin',
|
||||
note VARCHAR(255) NULL,
|
||||
granted_by INT NULL,
|
||||
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rust_perm_grant (user_id, permission, scope),
|
||||
KEY idx_rust_perm_grant_user (user_id),
|
||||
CONSTRAINT fk_rust_perm_grants_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── What this site has actually put in each game ──────────────────────────
|
||||
--
|
||||
-- The site's memory of its own authorship, one row per thing it has confirmed
|
||||
-- into one server's store. It is the table that makes D31 possible at all.
|
||||
--
|
||||
-- Three sets, and every interesting question is the difference between two of
|
||||
-- them:
|
||||
--
|
||||
-- desired − pushed what to apply
|
||||
-- pushed − desired what to RETIRE, because the site put it there and has
|
||||
-- since withdrawn it
|
||||
-- present − desired drift: somebody else put it there
|
||||
--
|
||||
-- Without the middle row a withdrawn grant is indistinguishable from a hand
|
||||
-- edit, and those two have opposite correct answers. Inferring it from absence
|
||||
-- is the mistake this table exists to prevent.
|
||||
--
|
||||
-- It is keyed by Steam id rather than by user, because it records what is in the
|
||||
-- GAME, and the game has never heard of a website account. Unlinking an account
|
||||
-- therefore leaves its row here until the next sync retires it — which is the
|
||||
-- correct behaviour and would be impossible to express keyed the other way.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_pushed (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
-- `grant` | `member` | `group-permission` | `group`
|
||||
kind VARCHAR(24) NOT NULL,
|
||||
-- a Steam id, or a group name
|
||||
subject VARCHAR(64) NOT NULL,
|
||||
-- a permission, a group name, or '' for the existence of a group
|
||||
object VARCHAR(128) NOT NULL,
|
||||
pushed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (server_id, kind, subject, object),
|
||||
CONSTRAINT fk_rust_perm_pushed_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Drift ─────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- What a sync found in a server's store that the site did not author, within
|
||||
-- the namespace the site claims. Rows appear and disappear with the report:
|
||||
-- this is the CURRENT difference, not a history of differences, and a hand edit
|
||||
-- that somebody has since removed should stop being on the screen.
|
||||
--
|
||||
-- Nothing here is ever removed from the game by the sync itself. An operator
|
||||
-- typing `oxide.grant` during an incident is drift, not an error, and the two
|
||||
-- answers offered to them — adopt it, or revoke it — are both a person's
|
||||
-- decision.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_drift (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
kind VARCHAR(24) NOT NULL,
|
||||
subject VARCHAR(64) NOT NULL,
|
||||
object VARCHAR(128) NOT NULL,
|
||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rust_perm_drift (server_id, kind, subject, object),
|
||||
CONSTRAINT fk_rust_perm_drift_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Removing something the site never put there ───────────────────────────
|
||||
--
|
||||
-- Revoking a drift row cannot go through `rust_perm_pushed`, because the whole
|
||||
-- point of a drift row is that it was never pushed. It cannot go through the
|
||||
-- authored tables either: a foreign grant often names a Steam id that belongs
|
||||
-- to no website account at all, and there is no user to author it against.
|
||||
--
|
||||
-- So a revoke is its own instruction with its own lifetime: queued by a person,
|
||||
-- carried in the next sync's retire list, and deleted once a report says the
|
||||
-- game no longer has it. A server that is offline keeps the instruction until
|
||||
-- it comes back, which is the behaviour an operator expects from a website that
|
||||
-- claims to be the author of record.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_revocations (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
kind VARCHAR(24) NOT NULL,
|
||||
subject VARCHAR(64) NOT NULL,
|
||||
object VARCHAR(128) NOT NULL,
|
||||
requested_by INT NULL,
|
||||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rust_perm_revocation (server_id, kind, subject, object),
|
||||
CONSTRAINT fk_rust_perm_revocations_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── The state of the mirror, per server ───────────────────────────────────
|
||||
--
|
||||
-- One row per configured server: whether its store currently matches what the
|
||||
-- site authors, when that was last true, and what the last report said.
|
||||
--
|
||||
-- `dirty` is how everything that should provoke a sync says so without knowing
|
||||
-- anything about syncing: an admin writing a grant, a drift hook firing in the
|
||||
-- game, a server reporting a new boot id or a new wipe. The loop owns WHEN, and
|
||||
-- every other part of the module owns WHETHER.
|
||||
--
|
||||
-- `desired_hash` and `synced_hash` are the cheap half of that question. A loop
|
||||
-- that pushed the whole set every tick would work and would also write to six
|
||||
-- game servers every thirty seconds for ever; comparing a hash costs one query
|
||||
-- and skips the round trip when nothing has changed. The periodic audit below
|
||||
-- is what keeps that from being a way to never notice drift.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_sync (
|
||||
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
-- `pending` | `ok` | `failed`
|
||||
state VARCHAR(24) NOT NULL DEFAULT 'pending',
|
||||
dirty TINYINT(1) NOT NULL DEFAULT 1,
|
||||
desired_hash VARCHAR(64) NULL,
|
||||
synced_hash VARCHAR(64) NULL,
|
||||
boot_id VARCHAR(64) NULL,
|
||||
wipe_id VARCHAR(48) NULL,
|
||||
last_attempt_at DATETIME NULL,
|
||||
last_ok_at DATETIME NULL,
|
||||
report LONGTEXT NULL,
|
||||
error VARCHAR(191) NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_perm_sync_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── What each server's plugins have registered ────────────────────────────
|
||||
--
|
||||
-- The option source the authoring form offers (D33), cached from the live read
|
||||
-- so that opening the form is not six round trips to six game hosts.
|
||||
--
|
||||
-- It is a cache of a fact that changes when an operator loads a plugin, and it
|
||||
-- is refreshed on every sync — which is also why a name that has stopped being
|
||||
-- registered disappears from the form rather than lingering as a choice that
|
||||
-- silently does nothing.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_catalogue (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
permission VARCHAR(128) NOT NULL,
|
||||
seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (server_id, permission),
|
||||
CONSTRAINT fk_rust_perm_catalogue_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- ── Changes to tables that already shipped ────────────────────────────────
|
||||
--
|
||||
-- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS`
|
||||
-- does nothing against a database that already has the table, so an edited column
|
||||
-- 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;
|
||||
|
||||
|
||||
-- ── Configuration written from the site (phase 7b, R18) ───────────────────
|
||||
--
|
||||
-- The audit trail for the most powerful thing this website can do to somebody's
|
||||
-- game host: write a file on it. One row per save attempt, including the ones
|
||||
-- that were refused and the ones the plugin rolled back — a write that did not
|
||||
-- land is exactly the row an operator asking "why is ZoneManager down" needs to
|
||||
-- find.
|
||||
--
|
||||
-- **No file bodies.** `changes` holds the fields that changed and their before
|
||||
-- and after LITERALS, which is what a person reading this wants, and secrets are
|
||||
-- redacted on the way in (`configEdit.redactChange`). D37 lets an admin read a
|
||||
-- credential on the page they opened deliberately; this table is read by more
|
||||
-- people, for longer, and usually by somebody who was not there.
|
||||
--
|
||||
-- The versions bracket the write: `version_before` is what the plugin said the
|
||||
-- file was when it was read, `version_after` what it is now. They are the
|
||||
-- plugin's own hashes, echoed — this module never computes one.
|
||||
CREATE TABLE IF NOT EXISTS rust_config_writes (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
path VARCHAR(255) NOT NULL,
|
||||
plugin VARCHAR(128) NULL,
|
||||
-- What the admin asked us to reload. NULL is an honest value: a file whose
|
||||
-- plugin is not loaded is written and not reloaded, and saying so is the
|
||||
-- difference between "saved" and "in effect".
|
||||
reload_target VARCHAR(128) NULL,
|
||||
-- `form` or `raw`. Which tier an edit came through changes how it should be
|
||||
-- read: a form edit is type-preserving and narrow, a raw edit replaced the
|
||||
-- whole document.
|
||||
tier VARCHAR(16) NOT NULL DEFAULT 'form',
|
||||
user_id INT NULL,
|
||||
-- `applied` | `rolled-back` | `refused` | `unreachable`
|
||||
outcome VARCHAR(24) NOT NULL,
|
||||
reloaded TINYINT(1) NOT NULL DEFAULT 0,
|
||||
changes LONGTEXT NULL,
|
||||
version_before VARCHAR(64) NULL,
|
||||
version_after VARCHAR(64) NULL,
|
||||
detail VARCHAR(500) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_config_writes_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE,
|
||||
-- A deleted account must not delete the record that they changed a setting.
|
||||
-- The row stays and the name goes; the alternative is an audit trail that a
|
||||
-- person can erase by closing their account.
|
||||
CONSTRAINT fk_rust_config_writes_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
KEY idx_rust_config_writes_server (server_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ── Who may see who is online (the presence fix, 2026-09-22) ──────────────
|
||||
--
|
||||
-- The org lead's rule: **nothing tells who is online by default.** The Online
|
||||
-- list, the killfeed, chat and every other frame that says a named player was on
|
||||
-- the server reach STAFF unless an operator deliberately widens them. A count is
|
||||
-- not a name and stays public.
|
||||
--
|
||||
-- Two places, because the decision has two shapes:
|
||||
--
|
||||
-- • `rust_settings` holds the FLEET default — one row per key. A key/value
|
||||
-- table rather than a column per setting, because phase 9's clan-roster
|
||||
-- audience is the next key and a table that grows a column per setting grows
|
||||
-- an ALTER per setting.
|
||||
-- • `rust_servers.presence_audience` is an optional PER-SERVER override. NULL
|
||||
-- means "inherit the fleet default", which is not the same as any audience —
|
||||
-- an operator who later narrows the fleet must narrow every server that never
|
||||
-- chose otherwise.
|
||||
--
|
||||
-- The stored value is a word (`staff` · `signed_in` · `public`) and an unknown
|
||||
-- word reads as `staff` (`model/visibility`): a typo in a row must narrow, never
|
||||
-- widen.
|
||||
CREATE TABLE IF NOT EXISTS rust_settings (
|
||||
setting_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
value VARCHAR(255) NOT NULL,
|
||||
updated_by INT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_settings_user
|
||||
FOREIGN KEY (updated_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS presence_audience VARCHAR(16) NULL;
|
||||
|
||||
|
||||
-- ── Clans (phase 9, protocol 6) ───────────────────────────────────────────
|
||||
--
|
||||
-- Rust's FIRST-PARTY clans, which this module answers core's Team questions
|
||||
-- from (R5, PLAN.md §24). Three tables, and the split is the same one the rest
|
||||
-- of this file makes: what a board said (`rust_clans`, `rust_clan_members`),
|
||||
-- and what this module knows about the board itself (`rust_clan_boards`).
|
||||
--
|
||||
-- **`external_id` is the Team's identity, and it is NOT the game's clan id.**
|
||||
-- It is `<serverId>:<clanId>:<createdMs>` (D52). The game keeps clans in
|
||||
-- `clans.<version>.db` with the version hard-coded, so a game update that bumps
|
||||
-- it starts a fresh file whose ids restart at 1. Keyed on the id alone, the new
|
||||
-- clan #1 would inherit the old clan #1's Team — its forum, its members-only
|
||||
-- history — and core would read the swap as a rename.
|
||||
--
|
||||
-- **A clan that leaves the board is marked gone, not deleted.** `gone_at` is set
|
||||
-- only when a board that is COMPLETE for its server no longer carries it: a
|
||||
-- board truncated at the game's 100-clan ceiling (D55) proves nothing about a
|
||||
-- clan it does not list. A gone clan is not offered to core, which is what lets
|
||||
-- core archive its Team.
|
||||
CREATE TABLE IF NOT EXISTS rust_clans (
|
||||
external_id VARCHAR(160) NOT NULL PRIMARY KEY,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
clan_id BIGINT NOT NULL,
|
||||
created_ms BIGINT NOT NULL,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
-- `#rrggbb`, as the plugin spells it. Stored as sent rather than parsed, and
|
||||
-- re-checked on the way out (`model/clans`), because it ends up in a style.
|
||||
color VARCHAR(16) NULL,
|
||||
score BIGINT NOT NULL DEFAULT 0,
|
||||
member_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
max_members INT UNSIGNED NULL,
|
||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
gone_at DATETIME NULL,
|
||||
CONSTRAINT fk_rust_clans_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE,
|
||||
KEY idx_rust_clans_server (server_id, gone_at),
|
||||
KEY idx_rust_clans_game_id (server_id, clan_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One row per member per clan, replaced whole from each board.
|
||||
--
|
||||
-- `rank` is the role's rank, and **rank 1 is leader** — the game's own rule, and
|
||||
-- several members may hold it. It is NULL when the member's role id matched no
|
||||
-- role on the board: "not known" must never be read as "leads this clan".
|
||||
--
|
||||
-- There is deliberately no `last_seen`. The game has one; the plugin does not
|
||||
-- send it, because when somebody was last on is presence (PLAN.md §23).
|
||||
CREATE TABLE IF NOT EXISTS rust_clan_members (
|
||||
external_id VARCHAR(160) NOT NULL,
|
||||
steam_id VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(191) NULL,
|
||||
role_rank INT NULL,
|
||||
role_name VARCHAR(64) NULL,
|
||||
joined_ms BIGINT NULL,
|
||||
PRIMARY KEY (external_id, steam_id),
|
||||
CONSTRAINT fk_rust_clan_members_clan
|
||||
FOREIGN KEY (external_id) REFERENCES rust_clans (external_id) ON DELETE CASCADE,
|
||||
KEY idx_rust_clan_members_steam (steam_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- What this module knows about each server's clan board, as opposed to what the
|
||||
-- board said.
|
||||
--
|
||||
-- **Freshness is judged by THIS side's clock.** `board_t` is the plugin's own
|
||||
-- timestamp on the board; `seen_at` is when this module first saw that value.
|
||||
-- A board whose `t` stops advancing is a game that stopped talking, and the
|
||||
-- age of `seen_at` is how long ago that was — comparing `board_t` to the
|
||||
-- website's clock instead would let a game host whose clock runs ahead make a
|
||||
-- stale board look current for as long as the skew lasts.
|
||||
--
|
||||
-- `umod_clans` is whether the optional uMod Clans plugin is loaded on that
|
||||
-- server (D47): its clans are a separate system and never Teams, and the admin
|
||||
-- page says so.
|
||||
CREATE TABLE IF NOT EXISTS rust_clan_boards (
|
||||
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
board_t BIGINT NULL,
|
||||
seen_at DATETIME NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
supported TINYINT(1) NOT NULL DEFAULT 0,
|
||||
truncated TINYINT(1) NOT NULL DEFAULT 0,
|
||||
backend VARCHAR(64) NULL,
|
||||
reason VARCHAR(255) NULL,
|
||||
umod_clans TINYINT(1) NOT NULL DEFAULT 0,
|
||||
clan_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_clan_boards_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ── What an event granted (phase 13b, protocol 10) ────────────────────────
|
||||
--
|
||||
-- `rust.kit.entitle`'s ledger: one row per run, step, website user and
|
||||
-- permission (PLAN.md §29, D84). It is a table of its own, and not rows in
|
||||
-- `rust_perm_grants`, because that table is UNIQUE on (user, permission,
|
||||
-- scope): an admin grant of the same kit would collide with an event's, and a
|
||||
-- revert deleting "the" row would take the admin's grant with it. The push reads
|
||||
-- the UNION of the two, so a permission held both ways survives either being
|
||||
-- withdrawn.
|
||||
--
|
||||
-- The grant reaches every account the user has linked (D28), like any other.
|
||||
-- The CREDIT does not: `steam_id` is the account that took part, and one win is
|
||||
-- one extra use of the kit on that account (D103). `permission` is empty for a
|
||||
-- kit anybody may redeem — the credit is then the whole reward.
|
||||
--
|
||||
-- `idem_key` is core's idempotency key for the step. It is what a revert has
|
||||
-- when core lost the answer and holds no resource: without it the rows a lost
|
||||
-- answer wrote would be a grant nothing could ever withdraw.
|
||||
--
|
||||
-- `server_id` is the kit's server and the only one the grant reaches (D102).
|
||||
-- There is no foreign key to `rust_servers`: a server deleted mid-event must not
|
||||
-- delete the ledger a revert needs to find.
|
||||
CREATE TABLE IF NOT EXISTS rust_perm_run_grants (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id VARCHAR(64) NOT NULL,
|
||||
step_id VARCHAR(64) NOT NULL,
|
||||
idem_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
user_id INT NOT NULL,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
steam_id VARCHAR(32) NOT NULL,
|
||||
permission VARCHAR(128) NOT NULL DEFAULT '',
|
||||
kit VARCHAR(128) NOT NULL,
|
||||
credit TINYINT(1) NOT NULL DEFAULT 1,
|
||||
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rust_perm_run_grant (run_id, step_id, user_id, permission),
|
||||
KEY idx_rust_perm_run_grant_server (server_id),
|
||||
KEY idx_rust_perm_run_grant_key (run_id, idem_key),
|
||||
CONSTRAINT fk_rust_perm_run_grants_user
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The news switch (D104): whether a published news post is also said in this
|
||||
-- server's chat. Off, because core enqueues every registered leg for every
|
||||
-- post, and without it the day this module updates every post would start
|
||||
-- appearing in every server's chat.
|
||||
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS announce_news TINYINT(1) NOT NULL DEFAULT 0;
|
||||
|
||||
107
server/engagement/audiences.js
Normal file
107
server/engagement/audiences.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// ── Named sets of people, over this module's own data ─────────────────────
|
||||
//
|
||||
// `registerAudiences` (MODULE_API.md §2.4; PLAN.md §25.2). An operator points a
|
||||
// rule or a segment at one of these; core calls `resolve` when a rule fires.
|
||||
//
|
||||
// Three properties, each the contract rather than a style:
|
||||
//
|
||||
// • **A resolver returns website user ids and nothing else.** Never an
|
||||
// address, a channel or a Steam id: core maps ids to people after the
|
||||
// preferences, the suppression list and the verification gate, and a module
|
||||
// that could hand it anything else would have a way to send mail.
|
||||
// • **One that fails answers NOBODY** — never everybody, never its last good
|
||||
// answer. A throw here is caught and returned as `[]`, and core treats a
|
||||
// throw the same way; both are here so the property does not rest on
|
||||
// either side alone.
|
||||
// • **Params are constants**, fixed when an operator saves the rule. "The clan
|
||||
// this event was about" is therefore not expressible as an audience — a
|
||||
// clan trigger carries its own recipients instead (`emit.js`).
|
||||
|
||||
const core = require('../core')
|
||||
|
||||
const log = core.logger('audiences')
|
||||
|
||||
const LINKS = 'rust_account_links'
|
||||
|
||||
/** Wraps a resolver so a failure is an empty set, logged, and never a throw. */
|
||||
function safe(id, fn) {
|
||||
return async (params) => {
|
||||
try {
|
||||
const rows = await fn(params || {})
|
||||
return rows.map((r) => Number(r.userId)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
} catch (err) {
|
||||
log.warn('an audience could not be resolved; it answers nobody', { audience: id, error: err.message })
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
|
||||
const AUDIENCES = Object.freeze([
|
||||
{
|
||||
id: 'rust.clan.members',
|
||||
label: 'Members of a clan',
|
||||
params: [{ id: 'clan', type: 'string', required: true }],
|
||||
ceiling: 'members',
|
||||
// A clan's LINKED members, as the store holds them now. A clan that has
|
||||
// been disbanded has no roster, so a rule saved against it resolves to
|
||||
// nobody — which is the truth, and not the same as the audience being gone.
|
||||
resolve: safe('rust.clan.members', async ({ clan }) => {
|
||||
const key = text(clan)
|
||||
if (!key) return []
|
||||
return core.query(
|
||||
`SELECT DISTINCT l.user_id AS userId
|
||||
FROM rust_clan_members m
|
||||
JOIN rust_clans c ON c.external_id = m.external_id AND c.gone_at IS NULL
|
||||
JOIN ${LINKS} l ON l.steam_id = m.steam_id
|
||||
WHERE m.external_id = ?`,
|
||||
[key],
|
||||
)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'rust.server.players',
|
||||
label: 'Everyone who has played on a server',
|
||||
params: [{ id: 'serverId', type: 'string', required: true }],
|
||||
ceiling: 'authenticated',
|
||||
// Linked accounts with a stats row on this server in ANY wipe. The stats
|
||||
// table is the record of having played, and it outlives both wipes and the
|
||||
// raw event history (R12).
|
||||
resolve: safe('rust.server.players', async ({ serverId }) => {
|
||||
const id = text(serverId)
|
||||
if (!id) return []
|
||||
return core.query(
|
||||
`SELECT DISTINCT l.user_id AS userId
|
||||
FROM rust_player_wipe_stats s
|
||||
JOIN ${LINKS} l ON l.steam_id = s.steam_id
|
||||
WHERE s.server_id = ?`,
|
||||
[id],
|
||||
)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'rust.wipe.participants',
|
||||
label: 'Everyone playing a server\'s current wipe',
|
||||
params: [{ id: 'serverId', type: 'string', required: true }],
|
||||
ceiling: 'authenticated',
|
||||
// The same, narrowed to the wipe the server is on NOW. Resolved at send
|
||||
// time, so a rule saved last month reaches this month's players — which is
|
||||
// what "current" has to mean for a parameter fixed when the rule was saved.
|
||||
// A server with no known wipe resolves to nobody rather than to every wipe.
|
||||
resolve: safe('rust.wipe.participants', async ({ serverId }) => {
|
||||
const id = text(serverId)
|
||||
if (!id) return []
|
||||
return core.query(
|
||||
`SELECT DISTINCT l.user_id AS userId
|
||||
FROM rust_server_state st
|
||||
JOIN rust_player_wipe_stats s ON s.server_id = st.server_id AND s.wipe_id = st.wipe_id
|
||||
JOIN ${LINKS} l ON l.steam_id = s.steam_id
|
||||
WHERE st.server_id = ? AND st.wipe_id IS NOT NULL`,
|
||||
[id],
|
||||
)
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
module.exports = { AUDIENCES }
|
||||
594
server/engagement/emit.js
Normal file
594
server/engagement/emit.js
Normal file
@@ -0,0 +1,594 @@
|
||||
// ── What happened, told to core's engagement engine ───────────────────────
|
||||
//
|
||||
// The fan-out behind `triggers.js` (PLAN.md §25). Ingest calls `onEvent` for
|
||||
// every frame it stores; the refresh calls `serverObserved` for every poll;
|
||||
// ingest calls `checkLeader` after a batch; the prune timer calls
|
||||
// `sweepLoginDenied`; the link route calls `linked`.
|
||||
//
|
||||
// **Nothing here decides who is told.** It says what happened and, for a
|
||||
// personal or clan event, who it is ABOUT. Core applies the rule, the ceiling,
|
||||
// the preference, the suppression list and the verification gate. A module
|
||||
// cannot send mail (MODULE_API.md §2.7), and this file is not the back door.
|
||||
//
|
||||
// **Nothing here throws into its caller.** Every entry point catches, because
|
||||
// its callers are the ingest cursor and the refresh loop — a malformed frame or
|
||||
// a core-side contract problem must cost one notification and never the feed.
|
||||
//
|
||||
// ── Three rules the whole file follows ────────────────────────────────────
|
||||
//
|
||||
// 1. **Emit on the transition, never on the poll** (R7). A server that is
|
||||
// still up is not news. Transitions are tracked in memory, and a FIRST
|
||||
// sighting is never one — so a website restart announces nothing.
|
||||
//
|
||||
// 2. **A replayed event notifies only while it is still news** (D63). After an
|
||||
// outage the cursor replays hours of frames. A broadcast older than 15
|
||||
// minutes tells nobody; a personal or staff event is kept for 24 hours,
|
||||
// because "your base was raided at 03:10" is still true and still wanted.
|
||||
//
|
||||
// 3. **Every emit carries a dedupe key made from the EVENT, not the store.**
|
||||
// Core's outbox is unique on (rule, user, channel, key), so the same frame
|
||||
// replayed after a crash is a no-op. The key is built from what the event
|
||||
// says — its server, time and subject — rather than from the sidecar's row
|
||||
// id, because a sidecar whose database is replaced starts its ids again
|
||||
// and would otherwise have every new alert swallowed as a repeat of an old
|
||||
// one.
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const core = require('../core')
|
||||
|
||||
const clans = require('../model/clans/clans.model')
|
||||
const clansDb = require('../model/clans/clans.db')
|
||||
const eventsDb = require('../model/events/events.db')
|
||||
const linksDb = require('../model/links/links.db')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const { TRIGGER_IDS: T, PATHS, serverPath, leaderboardPath, clanPath } = require('./triggers')
|
||||
|
||||
const log = core.logger('engagement')
|
||||
|
||||
/** How old a broadcast may be and still be news (D63). */
|
||||
const BROADCAST_MAX_AGE_MS = 15 * 60 * 1000
|
||||
|
||||
/** How old a personal or staff event may be and still be worth telling (D63). */
|
||||
const PERSONAL_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* How long a login attempt waits for its approval before it counts as denied
|
||||
* (D64, PLAN.md §16.5). The game raises no rejection hook, so a denial is the
|
||||
* ABSENCE of an approval — which is only knowable after a wait.
|
||||
*/
|
||||
const LOGIN_APPROVAL_WINDOW_MS = 60 * 1000
|
||||
|
||||
/** How far BEFORE an attempt an approval may be stamped and still answer it — clock grain, not policy. */
|
||||
const LOGIN_APPROVAL_SLACK_MS = 5 * 1000
|
||||
|
||||
const STRUCTURE_LABELS = Object.freeze({
|
||||
block: 'building block',
|
||||
door: 'door',
|
||||
wall: 'external wall',
|
||||
cupboard: 'tool cupboard',
|
||||
})
|
||||
|
||||
// ── Small helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const str = (value) => (value === undefined || value === null || value === '' ? undefined : String(value))
|
||||
|
||||
/** `rust:<what>:<sha1 of the parts>` — readable prefix, bounded length. */
|
||||
function dedupeKey(what, ...parts) {
|
||||
const digest = crypto.createHash('sha1').update(parts.map((p) => String(p ?? '')).join('\u0000')).digest('hex')
|
||||
return `rust:${what}:${digest}`
|
||||
}
|
||||
|
||||
function frameTime(item, frame) {
|
||||
const t = Number(frame && frame.t) || Number(item && item.t)
|
||||
return Number.isFinite(t) && t > 0 ? t : Date.now()
|
||||
}
|
||||
|
||||
/** D63, as a question: is an event from `t` still worth telling, for this family? */
|
||||
function stillNews(t, maxAgeMs, now = Date.now()) {
|
||||
return now - t <= maxAgeMs
|
||||
}
|
||||
|
||||
function serverVars(server) {
|
||||
const serverId = String(server.id)
|
||||
return { serverId, server: server.name || serverId, serverUrl: serverPath(serverId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline every trigger carries (`triggers.js` HEADLINE).
|
||||
*
|
||||
* Core's generic bodies fall back to a trigger's LABEL and DESCRIPTION when the
|
||||
* payload has no `title`/`intro`, and on a multi-server site that fallback says
|
||||
* "A server came online" without ever saying which. So the sentence is written
|
||||
* here, from the payload, and core renders it. Plain register, no conditionals:
|
||||
* a missing part falls back to a neutral word rather than leaving a hole.
|
||||
*/
|
||||
const HEADLINES = Object.freeze({
|
||||
'rust.base.destroyed': (d) => ({
|
||||
title: `Your base on ${d.server} is being raided`,
|
||||
intro: `A ${d.structure} was destroyed${d.atGrid || ''} on ${d.server}.`,
|
||||
}),
|
||||
'rust.wipe.started': (d) => ({
|
||||
title: `${d.server} has wiped`,
|
||||
intro: `A new wipe has started on ${d.server}: a fresh map, and a fresh start for everyone.`,
|
||||
}),
|
||||
'rust.server.online': (d) => ({
|
||||
title: `${d.server} is online`,
|
||||
intro: `${d.server} is back up and talking to the website.`,
|
||||
}),
|
||||
'rust.server.offline': (d) => ({
|
||||
title: `${d.server} is offline`,
|
||||
intro: `${d.server} stopped, or stopped talking to the website.`,
|
||||
}),
|
||||
'rust.leaderboard.topped': (d) => ({
|
||||
title: `${d.leader} leads ${d.server}`,
|
||||
intro: `${d.leader} now leads this wipe's kills on ${d.server}, with ${d.kills}.`,
|
||||
}),
|
||||
'rust.player.linked': (d) => ({
|
||||
title: 'A Steam account was linked to your account',
|
||||
intro: `The Steam account ${d.player || d.steamId} was linked with an in-game code. `
|
||||
+ 'If that was not you, unlink it from your Rust account page.',
|
||||
}),
|
||||
'rust.kit.entitled': (d) => ({
|
||||
title: `You earned ${d.kit} on ${d.server}`,
|
||||
intro: `An event on ${d.server} rewarded you: the ${d.kit} kit is waiting in the Kits menu, `
|
||||
+ 'with one extra use. Redeem it in game.',
|
||||
}),
|
||||
'rust.clan.member.left': (d) => ({
|
||||
title: `${d.member || 'A member'} left ${d.clan}`,
|
||||
intro: `${d.member || 'A member'} left ${d.clan} on ${d.server}.`,
|
||||
}),
|
||||
'rust.clan.member.kicked': (d) => ({
|
||||
title: `${d.member || 'A member'} was removed from ${d.clan}`,
|
||||
intro: `${d.by || 'A clan leader'} removed ${d.member || 'a member'} from ${d.clan} on ${d.server}.`,
|
||||
}),
|
||||
'rust.clan.disbanded': (d) => ({
|
||||
title: `${d.clan} was disbanded`,
|
||||
intro: `${d.by || 'A clan leader'} disbanded ${d.clan} on ${d.server}.`,
|
||||
}),
|
||||
'rust.player.reported': (d) => ({
|
||||
title: `${d.player || d.steamId} was reported on ${d.server}`,
|
||||
intro: `${d.reporter || 'A player'} reported ${d.player || d.steamId}`
|
||||
+ `${d.reportType ? ` (${d.reportType})` : ''}${d.topic ? `: ${d.topic}` : '.'}`,
|
||||
}),
|
||||
'rust.player.banned': (d) => ({
|
||||
title: `${d.player || d.steamId} was banned on ${d.server}`,
|
||||
intro: d.reason ? `Reason given: ${d.reason}` : 'No reason was given.',
|
||||
}),
|
||||
'rust.player.unbanned': (d) => ({
|
||||
title: `${d.player || d.steamId} was unbanned on ${d.server}`,
|
||||
intro: `The ban on ${d.player || d.steamId} (${d.steamId}) was lifted.`,
|
||||
}),
|
||||
'rust.login.denied': (d) => ({
|
||||
title: `A login to ${d.server} was not approved`,
|
||||
intro: `${d.player || 'Someone'} (${d.steamId}) tried to join ${d.server} and was not let in within a minute.`,
|
||||
}),
|
||||
})
|
||||
|
||||
function headline(triggerId, data) {
|
||||
const make = HEADLINES[triggerId]
|
||||
return make ? make(data || {}) : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands one event to core. Never throws.
|
||||
*
|
||||
* Core throws on a contract mismatch outside production, which is how a
|
||||
* declaration and an emitter drifting apart is meant to be found. It is logged
|
||||
* at `error` here rather than re-thrown, because the caller is the ingest
|
||||
* cursor — and an `error` line is what a rig walk reads.
|
||||
*/
|
||||
function fire(triggerId, envelope) {
|
||||
try {
|
||||
const data = envelope.data || {}
|
||||
core.emit(triggerId, { ...envelope, data: { ...headline(triggerId, data), ...data } })
|
||||
return true
|
||||
} catch (err) {
|
||||
log.error('core refused an emit', { trigger: triggerId, error: err.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Steam id -> website user id, for the ids that are linked. Unlinked ones are simply absent. */
|
||||
async function usersFor(steamIds) {
|
||||
const ids = [...new Set((steamIds || []).map(String).filter(Boolean))]
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await linksDb.userIdsForSteamIds(ids)
|
||||
return new Map(rows.map((r) => [String(r.steamId), Number(r.userId)]))
|
||||
}
|
||||
|
||||
// ── Per-kind handlers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The raid alert (D59-D61, D66, D67).
|
||||
*
|
||||
* One emit per authorised, LINKED person, each with `ownerUserId` — so the
|
||||
* `owner` ceiling holds per emit and "nobody else" is structural rather than a
|
||||
* filter somebody could forget. Two Steam accounts held by one website user are
|
||||
* one person: they get one alert, online if either account is.
|
||||
*/
|
||||
async function onRaid(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
|
||||
|
||||
// D67: no cupboard, nobody to tell. Absent — not empty — is also what a
|
||||
// protocol-6 plugin sends, so a half-upgraded deployment alerts nobody rather
|
||||
// than guessing an owner from the placer.
|
||||
if (!frame.buildingId || !Array.isArray(frame.authorized)) return 0
|
||||
|
||||
const authorized = frame.authorized.filter((a) => a && a.steamId)
|
||||
const attacker = str(frame.attackerId)
|
||||
|
||||
// An authorised attacker is demolishing their own base, or a teammate's.
|
||||
if (attacker && authorized.some((a) => String(a.steamId) === attacker)) return 0
|
||||
|
||||
const users = await usersFor(authorized.map((a) => a.steamId))
|
||||
if (!users.size) return 0
|
||||
|
||||
const byUser = new Map()
|
||||
for (const a of authorized) {
|
||||
const userId = users.get(String(a.steamId))
|
||||
if (!userId) continue
|
||||
byUser.set(userId, byUser.get(userId) === true || a.online === true)
|
||||
}
|
||||
|
||||
const base = {
|
||||
...serverVars(server),
|
||||
building: String(frame.buildingId),
|
||||
structure: STRUCTURE_LABELS[frame.structure] || 'structure',
|
||||
grid: str(frame.grid),
|
||||
atGrid: str(frame.grid) ? ` in ${frame.grid}` : undefined,
|
||||
}
|
||||
const key = dedupeKey('raid', server.id, frame.buildingId, t, frame.prefab)
|
||||
|
||||
let sent = 0
|
||||
for (const [userId, online] of byUser) {
|
||||
if (fire(T['rust.base.destroyed'], {
|
||||
data: { ...base, ownerOnline: online },
|
||||
ownerUserId: userId,
|
||||
dedupeKey: key,
|
||||
occurredAt: t,
|
||||
})) sent += 1
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
async function onWipe(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
|
||||
const wipeId = str(frame.wipeId)
|
||||
if (!wipeId) return 0
|
||||
|
||||
return fire(T['rust.wipe.started'], {
|
||||
data: { ...serverVars(server), wipeId },
|
||||
dedupeKey: dedupeKey('wipe', server.id, wipeId),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Clan departures and disbands. Recipients travel on the envelope, because
|
||||
* "the clan this was about" is a different set every firing.
|
||||
*
|
||||
* Nobody is told about what they did themselves: the leaver is not told they
|
||||
* left, the one who kicked is not told they kicked, the one who disbanded is not
|
||||
* told they disbanded. The one KICKED is told — it happened to them.
|
||||
*/
|
||||
async function onClan(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
|
||||
|
||||
const externalId = await clans.resolveExternalId(server.id, frame)
|
||||
if (!externalId) return 0
|
||||
|
||||
const kind = frame.kind
|
||||
const subject = str(frame.steamId)
|
||||
let steamIds
|
||||
let actor
|
||||
|
||||
if (kind === 'clan.disbanded') {
|
||||
// From the frame (protocol 7): by the time this runs the next board may
|
||||
// already have removed the roster the store would answer with.
|
||||
steamIds = Array.isArray(frame.members) ? frame.members.map(String) : null
|
||||
if (!steamIds) steamIds = (await clansDb.listMembers(externalId)).map((m) => String(m.steamId))
|
||||
actor = subject
|
||||
} else {
|
||||
steamIds = (await clansDb.listMembers(externalId)).map((m) => String(m.steamId))
|
||||
if (kind === 'clan.member.kicked') {
|
||||
if (subject) steamIds.push(subject)
|
||||
actor = str(frame.bySteamId)
|
||||
} else {
|
||||
actor = subject
|
||||
}
|
||||
}
|
||||
|
||||
const users = await usersFor(steamIds.filter((id) => id !== actor))
|
||||
const recipientUserIds = [...new Set(users.values())]
|
||||
if (!recipientUserIds.length) return 0
|
||||
|
||||
const data = {
|
||||
...serverVars(server),
|
||||
clanKey: externalId,
|
||||
clan: str(frame.clanName) || 'your clan',
|
||||
clanUrl: clanPath(externalId),
|
||||
}
|
||||
if (kind === 'clan.member.left') data.member = str(frame.name)
|
||||
if (kind === 'clan.member.kicked') {
|
||||
data.member = str(frame.name)
|
||||
data.by = str(frame.byName)
|
||||
}
|
||||
if (kind === 'clan.disbanded') data.by = str(frame.name)
|
||||
|
||||
const triggerId = kind === 'clan.disbanded' ? T['rust.clan.disbanded'] : T[`rust.${kind}`]
|
||||
|
||||
return fire(triggerId, {
|
||||
data,
|
||||
recipientUserIds,
|
||||
dedupeKey: dedupeKey(kind, server.id, externalId, subject, t),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
async function onReported(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
|
||||
const steamId = str(frame.targetId)
|
||||
if (!steamId) return 0
|
||||
|
||||
return fire(T['rust.player.reported'], {
|
||||
data: {
|
||||
...serverVars(server),
|
||||
steamId,
|
||||
player: str(frame.targetName),
|
||||
reporter: str(frame.reporterName),
|
||||
reportType: str(frame.reportType),
|
||||
topic: str(frame.subject),
|
||||
message: str(frame.message),
|
||||
},
|
||||
dedupeKey: dedupeKey('reported', server.id, steamId, frame.reporterId, t),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
async function onBan(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
|
||||
const steamId = str(frame.steamId)
|
||||
if (!steamId) return 0
|
||||
|
||||
const banned = frame.kind === 'player.banned'
|
||||
const data = { ...serverVars(server), steamId, player: str(frame.name) }
|
||||
// The address the frame carries is deliberately NOT copied: no trigger
|
||||
// declares one, so no template can ever put it in a mail.
|
||||
if (banned) data.reason = str(frame.reason)
|
||||
|
||||
return fire(banned ? T['rust.player.banned'] : T['rust.player.unbanned'], {
|
||||
data,
|
||||
dedupeKey: dedupeKey(frame.kind, server.id, steamId, t),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
const HANDLERS = Object.freeze({
|
||||
'entity.destroyed': onRaid,
|
||||
'server.wipe': onWipe,
|
||||
'clan.member.left': onClan,
|
||||
'clan.member.kicked': onClan,
|
||||
'clan.disbanded': onClan,
|
||||
'player.reported': onReported,
|
||||
'player.banned': onBan,
|
||||
'player.unbanned': onBan,
|
||||
})
|
||||
|
||||
/**
|
||||
* One stored frame. Called by ingest BEFORE the frame is applied, because
|
||||
* applying a disband deletes the roster a clan notification is sent to.
|
||||
*
|
||||
* @returns {Promise<number>} emits handed to core, for the log and the tests
|
||||
*/
|
||||
async function onEvent(server, item) {
|
||||
const frame = (item && item.frame) || {}
|
||||
const kind = (item && item.kind) || frame.kind
|
||||
const handler = HANDLERS[kind]
|
||||
if (!handler || !server) return 0
|
||||
|
||||
try {
|
||||
return await handler(server, item, { ...frame, kind })
|
||||
} catch (err) {
|
||||
log.warn('could not raise a notification', { server: server.id, kind, error: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transitions tracked in memory ──────────────────────────────────────────
|
||||
//
|
||||
// Deliberately NOT persisted. The question each one answers is "has THIS
|
||||
// process seen a previous value", and a value restored from the database would
|
||||
// make the first poll after a restart a transition against state the game may
|
||||
// have left hours ago.
|
||||
|
||||
const tracker = { online: new Map(), leader: new Map() }
|
||||
|
||||
/** Forget every tracked value. For the tests. */
|
||||
function reset() {
|
||||
tracker.online.clear()
|
||||
tracker.leader.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll's verdict on one server (D68): is its game connected now?
|
||||
*
|
||||
* Synchronous and fire-and-forget — the refresh must not wait on core.
|
||||
*/
|
||||
function serverObserved(server, connected) {
|
||||
try {
|
||||
if (!server) return 0
|
||||
const id = String(server.id)
|
||||
const now = Boolean(connected)
|
||||
const before = tracker.online.get(id)
|
||||
tracker.online.set(id, now)
|
||||
|
||||
// First sight is never a transition: a restart announces nothing.
|
||||
if (before === undefined || before === now) return 0
|
||||
|
||||
return fire(now ? T['rust.server.online'] : T['rust.server.offline'], {
|
||||
data: serverVars(server),
|
||||
// A transition observed by a poll is observed NOW, so it needs no age check;
|
||||
// the key is per minute so that one real flap is one event even if two
|
||||
// polls land either side of a restart of this process.
|
||||
dedupeKey: dedupeKey(now ? 'online' : 'offline', id, Math.floor(Date.now() / 60000)),
|
||||
}) ? 1 : 0
|
||||
} catch (err) {
|
||||
log.warn('could not raise a server transition', { server: server && server.id, error: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a batch: has somebody new taken the lead in this wipe's kills? (D64)
|
||||
*
|
||||
* Only a STRICT lead counts. The leaderboard breaks a tie on who was seen last,
|
||||
* so two players level on kills trade the top row every time either one moves —
|
||||
* and reading the top row alone would announce a new leader each time.
|
||||
*/
|
||||
async function checkLeader(server) {
|
||||
try {
|
||||
const state = await serversDb.getState(server.id)
|
||||
const wipeId = state && state.wipeId
|
||||
if (!wipeId) return 0
|
||||
|
||||
const rows = await eventsDb.leaderboard({ serverId: server.id, wipeId, sort: 'kills', limit: 2 })
|
||||
const top = rows[0]
|
||||
const kills = top ? Number(top.kills) || 0 : 0
|
||||
const id = String(server.id)
|
||||
const before = tracker.leader.get(id)
|
||||
|
||||
if (!top || kills <= 0) {
|
||||
tracker.leader.set(id, { wipeId, steamId: null })
|
||||
return 0
|
||||
}
|
||||
|
||||
const tied = rows[1] && Number(rows[1].kills) === kills
|
||||
const steamId = String(top.steamId)
|
||||
|
||||
// First sight, or a new wipe: remember, announce nothing.
|
||||
if (!before || before.wipeId !== wipeId) {
|
||||
tracker.leader.set(id, { wipeId, steamId: tied ? null : steamId })
|
||||
return 0
|
||||
}
|
||||
|
||||
if (tied || before.steamId === steamId) return 0
|
||||
|
||||
tracker.leader.set(id, { wipeId, steamId })
|
||||
|
||||
return fire(T['rust.leaderboard.topped'], {
|
||||
data: {
|
||||
...serverVars(server),
|
||||
leader: str(top.name) || 'A player',
|
||||
kills,
|
||||
leaderboardUrl: leaderboardPath(id),
|
||||
},
|
||||
dedupeKey: dedupeKey('leader', id, wipeId, steamId, kills),
|
||||
}) ? 1 : 0
|
||||
} catch (err) {
|
||||
log.warn('could not check the leaderboard', { server: server && server.id, error: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login attempts that were never approved (D64).
|
||||
*
|
||||
* A query over what is stored rather than a timer per attempt, so a restart
|
||||
* loses nothing and running it twice is a no-op (the key is the attempt's own
|
||||
* server, Steam id and time). Bounded by D63's personal age: an attempt a day
|
||||
* old is not worth a staff mail.
|
||||
*/
|
||||
async function sweepLoginDenied(servers, now = Date.now()) {
|
||||
let sent = 0
|
||||
for (const server of servers || []) {
|
||||
try {
|
||||
const rows = await eventsDb.unapprovedLogins({
|
||||
serverId: server.id,
|
||||
from: now - PERSONAL_MAX_AGE_MS,
|
||||
to: now - LOGIN_APPROVAL_WINDOW_MS,
|
||||
windowMs: LOGIN_APPROVAL_WINDOW_MS,
|
||||
slackMs: LOGIN_APPROVAL_SLACK_MS,
|
||||
})
|
||||
for (const row of rows) {
|
||||
const t = Number(row.t)
|
||||
if (fire(T['rust.login.denied'], {
|
||||
data: {
|
||||
...serverVars(server),
|
||||
steamId: String(row.steamId),
|
||||
player: str(row.name),
|
||||
attemptedAt: new Date(t),
|
||||
},
|
||||
dedupeKey: dedupeKey('login-denied', server.id, row.steamId, t),
|
||||
occurredAt: t,
|
||||
})) sent += 1
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('could not sweep login attempts', { server: server && server.id, error: err.message })
|
||||
}
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
/** A Steam account was just linked (R1). Called by the link route, once, on a NEW link. */
|
||||
function linked({ userId, steamId, name }) {
|
||||
try {
|
||||
const uid = Number(userId)
|
||||
if (!Number.isInteger(uid) || uid < 1 || !steamId) return 0
|
||||
return fire(T['rust.player.linked'], {
|
||||
data: { steamId: String(steamId), player: str(name), accountUrl: PATHS.account },
|
||||
ownerUserId: uid,
|
||||
dedupeKey: dedupeKey('linked', steamId, uid),
|
||||
}) ? 1 : 0
|
||||
} catch (err) {
|
||||
log.warn('could not raise the link notification', { error: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `rust.kit.entitled` for each user one reward step granted (phase 13b). Never
|
||||
* throws: a notification that could not be raised must not fail the grant it
|
||||
* is about. Returns how many were raised.
|
||||
*/
|
||||
function entitled({ userIds, kit, server, mode, runId, stepId }) {
|
||||
let raised = 0
|
||||
try {
|
||||
const rewardKey = `${runId}:${stepId}`
|
||||
for (const userId of new Set(userIds || [])) {
|
||||
const uid = Number(userId)
|
||||
if (!Number.isInteger(uid) || uid < 1) continue
|
||||
const ok = fire(T['rust.kit.entitled'], {
|
||||
data: { rewardKey, kit: String(kit), ...serverVars(server), ...(mode ? { mode: String(mode) } : {}), accountUrl: PATHS.account },
|
||||
ownerUserId: uid,
|
||||
dedupeKey: dedupeKey('entitled', runId, stepId, uid),
|
||||
})
|
||||
if (ok) raised++
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('could not raise the reward notification', { error: err.message })
|
||||
}
|
||||
return raised
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
onEvent,
|
||||
serverObserved,
|
||||
checkLeader,
|
||||
sweepLoginDenied,
|
||||
linked,
|
||||
entitled,
|
||||
reset,
|
||||
dedupeKey,
|
||||
headline,
|
||||
stillNews,
|
||||
BROADCAST_MAX_AGE_MS,
|
||||
PERSONAL_MAX_AGE_MS,
|
||||
LOGIN_APPROVAL_WINDOW_MS,
|
||||
STRUCTURE_LABELS,
|
||||
}
|
||||
334
server/engagement/seeds.js
Normal file
334
server/engagement/seeds.js
Normal file
@@ -0,0 +1,334 @@
|
||||
// ── What the notifications read like, and the rules that use them ─────────
|
||||
//
|
||||
// `registerEngagementSeeds` (MODULE_API.md §2.4 and §1.1 under 1.9.0; PLAN.md
|
||||
// §25.2). Data only: nothing here names a recipient, and nothing here turns a
|
||||
// rule on.
|
||||
//
|
||||
// ── Two bespoke bodies, and why only two ──────────────────────────────────
|
||||
//
|
||||
// A body earns its place when the message has something to say that core's
|
||||
// structural projection cannot. The raid alert does — it is the one message
|
||||
// here somebody acts on at 3am, and it must say WHERE and WHAT in the first
|
||||
// line. The wipe does — it is the one broadcast a whole community waits for.
|
||||
// Everything else is "this happened, here is the link", which is exactly what
|
||||
// core's `notify.event` / `inapp.event` already say, so it points at those and
|
||||
// authors nothing (§4.6.1 property 1).
|
||||
//
|
||||
// The register is plain, not in-universe. Rust has no court or herald to write
|
||||
// in the voice of, and a raid alert dressed as fiction is a raid alert read a
|
||||
// second later than it should be.
|
||||
//
|
||||
// ── Three rules for editing a body ────────────────────────────────────────
|
||||
//
|
||||
// 1. **No conditionals, and never an optional inside a clause.** An unset
|
||||
// optional interpolates to the EMPTY STRING. `atGrid` is a fragment that
|
||||
// carries its own leading space for exactly that reason; `grid` on its own
|
||||
// belongs on a line of its own or nowhere.
|
||||
// 2. **No brand.** `siteName` and friends are supplied by the renderer, so one
|
||||
// image mails as whichever site it is running as.
|
||||
// 3. **Bump `seedVersion` when a body changes, never for a comment.** It is how
|
||||
// a better default reaches deployments whose operators did not edit it.
|
||||
//
|
||||
// ── One rule group per family ─────────────────────────────────────────────
|
||||
//
|
||||
// A group is seeded ONCE (per deployment, per key), so a rule appended to a
|
||||
// group in a later version reaches fresh installs only. Eight families, eight
|
||||
// keys: a future raid rule takes `raid-v2` without disturbing anybody's clan
|
||||
// rules. Every rule is disabled — core ignores `enabled` rather than trusting it
|
||||
// — so installing this module mails nobody until an operator decides it should.
|
||||
|
||||
// ── Block helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const text = (id, body, opts = {}) => ({
|
||||
id,
|
||||
type: 'email.text',
|
||||
props: opts.muted ? { text: body, muted: true } : { text: body },
|
||||
})
|
||||
const heading = (id, body, level = 'h1') => ({ id, type: 'email.heading', props: { level, text: body } })
|
||||
const button = (id, label, url, textLead) => ({
|
||||
id,
|
||||
type: 'email.button',
|
||||
props: textLead ? { label, url, textLead } : { label, url },
|
||||
})
|
||||
const divider = (id) => ({ id, type: 'email.divider', props: {} })
|
||||
|
||||
// Every email ends with the unsubscribe pair; `unsubscribeUrl` is core's
|
||||
// per-delivery variable, not something a trigger declares.
|
||||
const unsubscribe = () => [
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these messages, use this link:'),
|
||||
]
|
||||
|
||||
const email = (key, name, triggerId, subject, blocks) => ({
|
||||
key,
|
||||
name,
|
||||
channel: 'email',
|
||||
triggerId,
|
||||
triggerVersion: 1,
|
||||
seedVersion: 1,
|
||||
subject,
|
||||
blocks: [...blocks, ...unsubscribe()],
|
||||
})
|
||||
|
||||
/** In-app: heading = the row's title, button = its one action, the rest = its body. */
|
||||
const inapp = (key, name, triggerId, title, body, action, url) => ({
|
||||
key,
|
||||
name,
|
||||
channel: 'inapp',
|
||||
triggerId,
|
||||
triggerVersion: 1,
|
||||
seedVersion: 1,
|
||||
subject: null,
|
||||
blocks: [heading('h', title, 'h3'), text('intro', body), button('cta', action, url)],
|
||||
})
|
||||
|
||||
const TEMPLATES = Object.freeze([
|
||||
email(
|
||||
'rust.base.destroyed',
|
||||
'Rust — your base was raided',
|
||||
'rust.base.destroyed',
|
||||
'Your base on {{server}} is being raided',
|
||||
[
|
||||
heading('h', 'Your base is being raided'),
|
||||
text('p1', 'A {{structure}} of a base you are authorised on was destroyed{{atGrid}} on {{server}}.'),
|
||||
text('p2',
|
||||
'You are getting this because you are on the base\'s tool cupboard. Further damage to the '
|
||||
+ 'same base will not send another alert for a while.', { muted: true }),
|
||||
button('cta', 'Open the server page', '{{serverUrl}}'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'rust.base.destroyed-inapp',
|
||||
'Rust — your base was raided (in-app)',
|
||||
'rust.base.destroyed',
|
||||
'Your base is being raided',
|
||||
'A {{structure}} was destroyed{{atGrid}} on {{server}}.',
|
||||
'Open the server',
|
||||
'{{serverUrl}}',
|
||||
),
|
||||
email(
|
||||
'rust.wipe.started',
|
||||
'Rust — a server wiped',
|
||||
'rust.wipe.started',
|
||||
'{{server}} has wiped',
|
||||
[
|
||||
heading('h', '{{server}} has wiped'),
|
||||
text('p1', 'A new wipe has started on {{server}}: a fresh map, and a fresh start for everyone.'),
|
||||
button('cta', 'Open the server page', '{{serverUrl}}'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'rust.wipe.started-inapp',
|
||||
'Rust — a server wiped (in-app)',
|
||||
'rust.wipe.started',
|
||||
'{{server}} has wiped',
|
||||
'A new wipe has started: a fresh map, and a fresh start for everyone.',
|
||||
'Open the server',
|
||||
'{{serverUrl}}',
|
||||
),
|
||||
])
|
||||
|
||||
// ── The rules — every one of them off ──────────────────────────────────────
|
||||
|
||||
/** Core's generic bodies (§4.6.1 property 1). */
|
||||
const GENERIC = { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' }
|
||||
|
||||
/** This module's bodies for a trigger, and core's digest. */
|
||||
const bodies = (key) => ({ email: key, inapp: `${key}-inapp`, digest: 'notify.digest' })
|
||||
|
||||
const RULE_GROUPS = Object.freeze([
|
||||
{
|
||||
key: 'raid-v1',
|
||||
note: 'module-rust: the raid alert (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.base.destroyed',
|
||||
name: 'Raid alert — offline owners',
|
||||
audience: 'owner',
|
||||
// Push is allowed because this trigger is also a stream (D65); the
|
||||
// tickle carries no content, and the app pulls the inbox row.
|
||||
channels: ['email', 'inapp', 'push'],
|
||||
template_keys: bodies('rust.base.destroyed'),
|
||||
// Per BUILDING (the subjectKey): a raid is dozens of walls and one alert.
|
||||
cooldown_seconds: 1800,
|
||||
max_sends_per_hour: 500,
|
||||
// D61: "offline raid alert" is this condition, not code. An operator who
|
||||
// wants online raids too deletes it.
|
||||
conditions: { variable: 'ownerOnline', cmp: 'eq', value: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'wipe-v1',
|
||||
note: 'module-rust: wipe announcements (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.wipe.started',
|
||||
name: 'Server wiped',
|
||||
audience: 'subscribers',
|
||||
channels: ['email', 'inapp', 'push'],
|
||||
template_keys: bodies('rust.wipe.started'),
|
||||
cooldown_seconds: 6 * 3600,
|
||||
max_sends_per_hour: 2000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'server-v1',
|
||||
note: 'module-rust: server up and down (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.server.online',
|
||||
name: 'Server came online',
|
||||
audience: 'subscribers',
|
||||
channels: ['inapp', 'push'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 2000,
|
||||
},
|
||||
{
|
||||
trigger_id: 'rust.server.offline',
|
||||
name: 'Server went offline',
|
||||
audience: 'subscribers',
|
||||
channels: ['inapp', 'push'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 2000,
|
||||
// A plugin reload, or a restart that is back within five minutes, is not
|
||||
// an outage anybody needs to hear about. `cancel_on` withdraws the
|
||||
// pending notice when the server comes back inside the window.
|
||||
delay_seconds: 300,
|
||||
cancel_on: ['rust.server.online'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'leaderboard-v1',
|
||||
note: 'module-rust: a new kills leader (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.leaderboard.topped',
|
||||
name: 'New kills leader',
|
||||
audience: 'subscribers',
|
||||
channels: ['inapp'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 2000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'account-v1',
|
||||
note: 'module-rust: a Steam account was linked (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.player.linked',
|
||||
name: 'Steam account linked',
|
||||
audience: 'owner',
|
||||
// Email as well as in-app: the case this exists for is a link the person
|
||||
// did NOT make, and they will not be looking at the site's inbox for it.
|
||||
channels: ['email', 'inapp'],
|
||||
template_keys: GENERIC,
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Its own group, not a rule appended to `account-v1`: a group is seeded once,
|
||||
// so an appended rule would reach fresh installs only (R7).
|
||||
key: 'rewards-v1',
|
||||
note: 'module-rust: an event rewarded you a kit (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.kit.entitled',
|
||||
name: 'Kit reward earned',
|
||||
audience: 'owner',
|
||||
// Email as well: a reward granted at 03:00 is news the person reads the
|
||||
// next morning, before they are next in game or on the site.
|
||||
channels: ['email', 'inapp'],
|
||||
template_keys: GENERIC,
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'clans-v1',
|
||||
note: 'module-rust: clan departures and disbands (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.clan.member.left',
|
||||
name: 'Clan — a member left',
|
||||
audience: 'members',
|
||||
channels: ['inapp'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'rust.clan.member.kicked',
|
||||
name: 'Clan — a member was removed',
|
||||
audience: 'members',
|
||||
channels: ['inapp'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'rust.clan.disbanded',
|
||||
name: 'Clan — disbanded',
|
||||
audience: 'members',
|
||||
channels: ['email', 'inapp'],
|
||||
template_keys: GENERIC,
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'moderation-v1',
|
||||
note: 'module-rust: reports, bans and unapproved logins, to staff (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.player.reported',
|
||||
name: 'Player reported',
|
||||
audience: 'staff',
|
||||
channels: ['email', 'inapp'],
|
||||
template_keys: GENERIC,
|
||||
// Per REPORTED player: a pile-on of ten reports is one notice an hour.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'rust.player.banned',
|
||||
name: 'Player banned',
|
||||
audience: 'staff',
|
||||
channels: ['inapp'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'rust.player.unbanned',
|
||||
name: 'Player unbanned',
|
||||
audience: 'staff',
|
||||
channels: ['inapp'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'rust.login.denied',
|
||||
name: 'Login not approved',
|
||||
audience: 'staff',
|
||||
channels: ['inapp'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
module.exports = { TEMPLATES, RULE_GROUPS }
|
||||
53
server/engagement/streams.js
Normal file
53
server/engagement/streams.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// ── The push facet: which triggers may reach a phone ──────────────────────
|
||||
//
|
||||
// `registerNotificationStreams` (MODULE_API.md §2.4). A stream is what a device
|
||||
// subscribes to, and **core delivers an engagement rule's push only to devices
|
||||
// subscribed to a stream whose id IS the trigger id** (`pushChannel.deliver` ->
|
||||
// `publishToUsers(row.trigger_id)`). So a trigger with no stream here can never
|
||||
// buzz a phone, however its rule is set — which is exactly how the families
|
||||
// that should not are kept off it (D65).
|
||||
//
|
||||
// Every id here is ALSO a trigger in `triggers.js`. That is the one namespace
|
||||
// core enforces across both facets: one event, with a payload contract and a
|
||||
// subscription toggle, owned by one module. An id that appeared only here would
|
||||
// be a toggle nothing could ever fire.
|
||||
//
|
||||
// **The tickle carries nothing.** A push is `{ stream, ref }` and the app pulls
|
||||
// the real item over the authenticated inbox API, so a leaked relay topic says
|
||||
// that something happened and not what. That is core's guarantee and it is why
|
||||
// a raid alert may be a push at all.
|
||||
|
||||
const STREAMS = Object.freeze([
|
||||
{
|
||||
id: 'rust.base.destroyed',
|
||||
label: 'Your base was raided',
|
||||
description: 'Part of a base you are authorised on was destroyed by another player.',
|
||||
// Delivered only to the owner's devices, never fanned out: `owner` ceiling,
|
||||
// one emit per authorised person (D59).
|
||||
personal: true,
|
||||
requiresLinkedAccount: true,
|
||||
},
|
||||
{
|
||||
id: 'rust.server.online',
|
||||
label: 'A server came online',
|
||||
description: 'A Rust server started or came back.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'rust.server.offline',
|
||||
label: 'A server went offline',
|
||||
description: 'A Rust server stopped or stopped answering.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'rust.wipe.started',
|
||||
label: 'A server wiped',
|
||||
description: 'A Rust server started a new wipe.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
])
|
||||
|
||||
module.exports = { STREAMS }
|
||||
414
server/engagement/triggers.js
Normal file
414
server/engagement/triggers.js
Normal file
@@ -0,0 +1,414 @@
|
||||
// ── What can happen, as core's engagement engine is told it ───────────────
|
||||
//
|
||||
// The payload contracts behind every notification this module can cause
|
||||
// (MODULE_API.md §2.4, `registerEventTriggers`; PLAN.md §25). A trigger says
|
||||
// what an event IS, what a template may interpolate, and — the part that is a
|
||||
// security boundary — the widest audience a rule on it may EVER be given.
|
||||
//
|
||||
// ── The ceiling is containment, not size ──────────────────────────────────
|
||||
//
|
||||
// `owner` is not a small `staff`, and `staff` does not permit `owner`. For the
|
||||
// raid alert "one person" is the person whose base it was; for a ban it is
|
||||
// nobody outside the staff room. Each ceiling below is chosen against that
|
||||
// lattice and not against a ladder, and core refuses a rule that widens one.
|
||||
//
|
||||
// ── What no variable here carries, on purpose ─────────────────────────────
|
||||
//
|
||||
// • An IP address. The login and ban frames carry one; the triggers do not,
|
||||
// so no template an operator writes can put an address in a mail. The
|
||||
// admin feed still shows it, to staff, where it is useful.
|
||||
// • The raider (D66). The raid alert says what was destroyed, where and
|
||||
// when. Who did it is gameplay intelligence the game does not hand the
|
||||
// victim, and a variable that is not declared cannot be interpolated.
|
||||
// • A Steam id other than the subject's own.
|
||||
//
|
||||
// ── Why `subjectKey` is what it is ────────────────────────────────────────
|
||||
//
|
||||
// Core's cooldown is per (rule, user, subject, channel). So the subject is the
|
||||
// thing a recipient should hear about once per cooldown: a BUILDING for a raid
|
||||
// (however many walls fall), a SERVER for a broadcast (however often it
|
||||
// bounces), a CLAN for a membership change. A subject that changed every firing
|
||||
// — a boot id, a timestamp — would make every cooldown a no-op.
|
||||
//
|
||||
// ── `version` ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The prop-schema version a template records it was authored against. Bump one
|
||||
// on a rename or a type change, never for a label.
|
||||
|
||||
const ID = 'rust'
|
||||
|
||||
/** Site-relative paths, built the way the client registers them. */
|
||||
const PATHS = {
|
||||
servers: `/${ID}`,
|
||||
account: `/player/${ID}`,
|
||||
}
|
||||
|
||||
// A server id is VARCHAR(64) of the operator's choosing, and a clan's
|
||||
// `externalId` is `<serverId>:<clanId>:<createdMs>`. Core validates a `url`
|
||||
// variable against a character class with no `:` in it, so every id that goes
|
||||
// into a path is percent-encoded — without it the clan link would be dropped at
|
||||
// emit in production, silently, for every clan there is.
|
||||
const serverPath = (serverId) => `/${ID}/servers/${encodeURIComponent(serverId)}`
|
||||
const leaderboardPath = (serverId) => `${serverPath(serverId)}?tab=leaderboard`
|
||||
const clanPath = (externalId) => `/${ID}/clans/${encodeURIComponent(externalId)}`
|
||||
|
||||
const V1 = 1
|
||||
|
||||
// ── Shared variables ───────────────────────────────────────────────────────
|
||||
|
||||
// **Every trigger carries its own headline.** Most rules here point at core's
|
||||
// generic `notify.event` / `inapp.event`, and core's structural projection fills
|
||||
// `title` and `intro` from the trigger's LABEL and DESCRIPTION only when the
|
||||
// payload does not define them — "the payload wins, the projection fills gaps"
|
||||
// (ENGAGEMENT.md §4.6.1). Without these two, the phase-10 walk rendered a
|
||||
// multi-server site's notice as "A server came online. A server's game
|
||||
// started…" — true, and useless, because it never said which. The emitter
|
||||
// writes the sentence (`emit.js` `headline`); an operator's own template can
|
||||
// still ignore it and interpolate the parts.
|
||||
const HEADLINE = [
|
||||
{ name: 'title', type: 'string', required: false, example: 'Main is back online',
|
||||
description: 'A one-line headline naming what happened and where. Core generic bodies use it as the title.' },
|
||||
{ name: 'intro', type: 'string', required: false, example: 'Main is back up and taking players.',
|
||||
description: 'One sentence of detail. Core generic bodies use it as the body.' },
|
||||
]
|
||||
|
||||
const SERVER = [
|
||||
{ name: 'serverId', type: 'string', required: true, example: 'main',
|
||||
description: 'The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts.' },
|
||||
{ name: 'server', type: 'string', required: true, example: 'Runic Gateway | Main',
|
||||
description: 'The server\'s display name.' },
|
||||
{ name: 'serverUrl', type: 'url', required: false, example: '/rust/servers/main',
|
||||
description: 'Site-relative path to the server\'s page.' },
|
||||
]
|
||||
|
||||
const CLAN = [
|
||||
{ name: 'clanKey', type: 'string', required: true, example: 'main:12:1790142840000',
|
||||
description: 'The clan\'s stable identity. The cooldown subject; not meant for display.' },
|
||||
{ name: 'clan', type: 'string', required: true, example: 'The Rust Belt',
|
||||
description: 'The clan\'s name.' },
|
||||
{ name: 'clanUrl', type: 'url', required: false, example: '/rust/clans/main%3A12%3A1790142840000',
|
||||
description: 'Site-relative path to the clan\'s page.' },
|
||||
]
|
||||
|
||||
// ── The raid alert ─────────────────────────────────────────────────────────
|
||||
|
||||
const RAID = {
|
||||
id: 'rust.base.destroyed',
|
||||
label: 'Your base was raided',
|
||||
description:
|
||||
'Part of a base you are authorised on was destroyed by another player: a wall, a door, ' +
|
||||
'an external wall or gate, or the tool cupboard.',
|
||||
kind: 'event',
|
||||
// One per base per cooldown, however many walls fall. The building is the
|
||||
// tool cupboard's id — the game's own answer to "which base is this".
|
||||
subjectKey: 'building',
|
||||
// One emit per authorised, linked person, each with `ownerUserId` set (D59).
|
||||
// `owner` is the ceiling AND the default: there is nobody else this may reach.
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'building', type: 'string', required: true, example: '8113',
|
||||
description: 'The base, as the id of its tool cupboard. The cooldown subject.' },
|
||||
{ name: 'structure', type: 'string', required: true, example: 'door',
|
||||
description: 'What was destroyed: "building block", "door", "external wall" or "tool cupboard".' },
|
||||
{ name: 'grid', type: 'string', required: false, example: 'H7',
|
||||
description: 'The map grid square. Absent when the server could not work one out.' },
|
||||
// A FRAGMENT, for use inside a sentence. An unset optional interpolates to
|
||||
// the empty string, so "your door in {{grid}} was destroyed" reads "your
|
||||
// door in was destroyed" when the grid is unknown; this carries its own
|
||||
// leading space and vanishes cleanly instead.
|
||||
{ name: 'atGrid', type: 'string', required: false, example: ' in H7',
|
||||
description: 'Sentence fragment: " in H7" with its own leading space, or nothing when the grid is unknown.' },
|
||||
{ name: 'ownerOnline', type: 'boolean', required: true, example: false,
|
||||
description: 'Whether YOU were online when it happened. The seeded rule alerts only when this is false.' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Server lifecycle ───────────────────────────────────────────────────────
|
||||
//
|
||||
// `everyone` because a server being up is what a server page already says to
|
||||
// anyone. The DEFAULT is `subscribers` — the people who asked — and an operator
|
||||
// widens deliberately.
|
||||
|
||||
const BROADCASTS = [
|
||||
{
|
||||
id: 'rust.wipe.started',
|
||||
label: 'A server wiped',
|
||||
description: 'A server started a new wipe: a fresh map, and everything built on the old one gone.',
|
||||
kind: 'event',
|
||||
subjectKey: 'serverId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'wipeId', type: 'string', required: true, example: '1790142840-3000-1234',
|
||||
description: 'The new wipe\'s identity.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.server.online',
|
||||
label: 'A server came online',
|
||||
description: 'A server\'s game started, or came back after being unreachable.',
|
||||
kind: 'event',
|
||||
subjectKey: 'serverId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE],
|
||||
},
|
||||
{
|
||||
id: 'rust.server.offline',
|
||||
label: 'A server went offline',
|
||||
description: 'A server\'s game stopped, crashed, or stopped talking to the website.',
|
||||
kind: 'event',
|
||||
subjectKey: 'serverId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE],
|
||||
},
|
||||
{
|
||||
id: 'rust.leaderboard.topped',
|
||||
label: 'A new kills leader',
|
||||
description: 'Somebody new leads the current wipe\'s kills on a server.',
|
||||
kind: 'event',
|
||||
subjectKey: 'serverId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'leader', type: 'string', required: true, example: 'Marisol',
|
||||
description: 'The new leader\'s in-game name.' },
|
||||
{ name: 'kills', type: 'int', required: true, example: 42,
|
||||
description: 'Their kills this wipe.' },
|
||||
{ name: 'leaderboardUrl', type: 'url', required: false, example: '/rust/servers/main?tab=leaderboard',
|
||||
description: 'Site-relative path to the server\'s leaderboard.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── The player's own account ───────────────────────────────────────────────
|
||||
|
||||
const ACCOUNT = {
|
||||
id: 'rust.player.linked',
|
||||
label: 'A Steam account was linked',
|
||||
description: 'A Steam account was linked to your website account with an in-game code.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
// PLAN.md §10 said `self`; core has no such ceiling (§25.1). `owner` with the
|
||||
// linking user as `ownerUserId` is the value that exists and means the same.
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000001',
|
||||
description: 'The Steam account that was linked. Also the cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'The in-game name the game reported when it was linked.' },
|
||||
{ name: 'accountUrl', type: 'url', required: false, example: '/player/rust',
|
||||
description: 'Site-relative path to your Rust account page.' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── A reward (phase 13b) ───────────────────────────────────────────────────
|
||||
//
|
||||
// Deferred from phase 10 (D64) to the phase that grants something. Emitted once
|
||||
// per recipient USER when an event's `rust.kit.entitle` writes their rows, with
|
||||
// that user as `ownerUserId` — so, like the link notice, `owner` is both the
|
||||
// ceiling and the only audience there is: a reward is nobody else's news.
|
||||
//
|
||||
// The subject is the run and the step, so one award is one notification however
|
||||
// often a retried step writes the same rows.
|
||||
|
||||
const REWARD = {
|
||||
id: 'rust.kit.entitled',
|
||||
label: 'An event rewarded you a kit',
|
||||
description: 'An event on a Rust server rewarded you: a kit is waiting in the in-game Kits menu, with one extra use.',
|
||||
kind: 'event',
|
||||
subjectKey: 'rewardKey',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
...HEADLINE,
|
||||
{ name: 'rewardKey', type: 'string', required: true, example: '41:7',
|
||||
description: 'The run and step that awarded it. The cooldown subject; not meant for display.' },
|
||||
{ name: 'kit', type: 'string', required: true, example: 'vip-starter',
|
||||
description: 'The kit name, as the server Kits plugin has it.' },
|
||||
...SERVER,
|
||||
{ name: 'mode', type: 'string', required: false, example: 'top',
|
||||
description: 'How the recipients were chosen: everyone, top, minScore, random or topPercent.' },
|
||||
{ name: 'accountUrl', type: 'url', required: false, example: '/player/rust',
|
||||
description: 'Site-relative path to your Rust account page.' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Clans ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `members` ceiling — clan membership is the clan's business (D49). Recipients
|
||||
// travel on the envelope as `recipientUserIds`, because "the clan this was
|
||||
// about" is a different answer every firing and cannot be a saved audience.
|
||||
//
|
||||
// No `rust.clan.member.added`: core already fires `team.member.joined` for our
|
||||
// clans through the Team sync, and a second trigger would notify twice (D64).
|
||||
|
||||
const CLANS = [
|
||||
{
|
||||
id: 'rust.clan.member.left',
|
||||
label: 'Someone left your clan',
|
||||
description: 'A member left a clan you are in.',
|
||||
kind: 'event',
|
||||
subjectKey: 'clanKey',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: V1,
|
||||
variables: [
|
||||
...CLAN,
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'member', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'Who left.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.clan.member.kicked',
|
||||
label: 'Someone was removed from your clan',
|
||||
description: 'A member was removed from a clan you are in — or you were.',
|
||||
kind: 'event',
|
||||
subjectKey: 'clanKey',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: V1,
|
||||
variables: [
|
||||
...CLAN,
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'member', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'Who was removed.' },
|
||||
{ name: 'by', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'Who removed them.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.clan.disbanded',
|
||||
label: 'Your clan was disbanded',
|
||||
description: 'A clan you were in was disbanded.',
|
||||
kind: 'event',
|
||||
subjectKey: 'clanKey',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: V1,
|
||||
variables: [
|
||||
...CLAN,
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'by', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'Who disbanded it.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Moderation — staff, and never wider ────────────────────────────────────
|
||||
|
||||
const MODERATION = [
|
||||
{
|
||||
id: 'rust.player.reported',
|
||||
label: 'A player was reported',
|
||||
description: 'A player filed an in-game report against another.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
|
||||
description: 'The reported player\'s Steam id. The cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'The reported player\'s name.' },
|
||||
{ name: 'reporter', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'Who filed the report.' },
|
||||
{ name: 'reportType', type: 'string', required: false, example: 'cheat',
|
||||
description: 'The category the reporter chose.' },
|
||||
{ name: 'topic', type: 'string', required: false, example: 'Aimbot at the dome',
|
||||
description: 'The report\'s subject line.' },
|
||||
{ name: 'message', type: 'string', required: false, example: 'Headshots through two walls.',
|
||||
description: 'The report\'s text.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.player.banned',
|
||||
label: 'A player was banned',
|
||||
description: 'A player was banned on a server.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
|
||||
description: 'The banned player\'s Steam id. The cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'The banned player\'s name.' },
|
||||
{ name: 'reason', type: 'string', required: false, example: 'Cheating',
|
||||
description: 'The reason given.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.player.unbanned',
|
||||
label: 'A player was unbanned',
|
||||
description: 'A ban on a server was lifted.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
|
||||
description: 'The player\'s Steam id. The cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'The player\'s name.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.login.denied',
|
||||
label: 'A login was not approved',
|
||||
description:
|
||||
'Somebody tried to join a server and was not let in within a minute: a ban, a failed ' +
|
||||
'authentication, or a player who gave up while connecting.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
|
||||
description: 'The Steam id that tried to connect. The cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'The name it connected with.' },
|
||||
{ name: 'attemptedAt', type: 'datetime', required: true, example: '2026-09-23T03:10:00Z',
|
||||
description: 'When the attempt was made.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION])
|
||||
|
||||
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))
|
||||
|
||||
module.exports = { TRIGGERS, TRIGGER_IDS, PATHS, serverPath, leaderboardPath, clanPath }
|
||||
440
server/eventLeases.js
Normal file
440
server/eventLeases.js
Normal file
@@ -0,0 +1,440 @@
|
||||
// ── What an event may BORROW on a Rust server (PLAN.md §27, protocol 8) ─────
|
||||
//
|
||||
// The module never takes a lease and never bounds one. An author puts core's
|
||||
// `core.lease` in a step naming a lease, a target, a value and a number of
|
||||
// minutes; core reads the baseline, reserves `<lease id>#<target>` against the
|
||||
// two-events-one-target index, applies the value with its deadline and restores
|
||||
// it at teardown. What is here is the four callables each lease ships, and the
|
||||
// option sources that fill its target field.
|
||||
//
|
||||
// ── The target names the server (D73) ─────────────────────────────────────
|
||||
//
|
||||
// `core.lease` hands a lease only `{ target }` — never the run's scope — and
|
||||
// reserves `<id>#<target>`. So every lease here is TARGETED and every target
|
||||
// begins with the server id: `srv-a` for a single value, `srv-a/bear.population`
|
||||
// or `srv-a/default/kits.vip` for a family. That makes the ledger's unique index
|
||||
// bite at exactly the granularity Rust has: two runs on two servers never
|
||||
// collide, and one value on one server has one holder.
|
||||
//
|
||||
// ── Game convars only (D74) ───────────────────────────────────────────────
|
||||
//
|
||||
// Vanilla Rust has no gather, craft or smelt rate convar; what it has, and what
|
||||
// the plugin's allowlist lends, is decay, the population system, and its two
|
||||
// minimum scalars — plus a group's permissions, the "weekend VIP" (D75). The
|
||||
// plugin holds the allowlist, the bounds, the seven-day ceiling and the deadline
|
||||
// timer. The bounds are declared here AS WELL, because this pair is what core
|
||||
// checks when an author saves — a bad value is a refusal on a form rather than a
|
||||
// step failing unattended at four in the morning.
|
||||
|
||||
const core = require('./core')
|
||||
const client = require('./sidecarClient')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
|
||||
const log = core.logger('leases')
|
||||
|
||||
/** Seven days (D77). The plugin holds the same ceiling independently and refuses past it. */
|
||||
const MAX_LEASE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
/** Core's bound on one option source's answer. A source that would exceed it says so in the log. */
|
||||
const MAX_OPTIONS = 2000
|
||||
|
||||
/** The wire key of the one lease that is not a convar. */
|
||||
const GROUP_PERMISSION_KEY = 'group.permission'
|
||||
|
||||
/**
|
||||
* Split a target into its server and the rest (D73).
|
||||
*
|
||||
* At the FIRST slash: a server id is `[a-z0-9-]` and never contains one, while
|
||||
* what follows may (a group name is free text an operator typed).
|
||||
*/
|
||||
function splitTarget(target) {
|
||||
const text = String(target || '').trim()
|
||||
const slash = text.indexOf('/')
|
||||
if (slash < 0) return { serverId: text, rest: '' }
|
||||
return { serverId: text.slice(0, slash), rest: text.slice(slash + 1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The server a target names, with its token — or a refusal.
|
||||
*
|
||||
* **`retry: false`**, because the second attempt carries the same params: a
|
||||
* target naming a server that is not configured (or is switched off) is an
|
||||
* authoring mistake or a deleted server, and neither is fixed by waiting.
|
||||
*/
|
||||
async function serverFor(serverId) {
|
||||
if (!serverId) return { ok: false, retry: false, error: 'the target does not name a server' }
|
||||
const row = await serversDb.getServer(serverId)
|
||||
if (!row) return { ok: false, retry: false, error: `there is no Rust server "${serverId}" on this site` }
|
||||
if (!row.enabled) return { ok: false, retry: false, error: `the Rust server "${row.name || serverId}" is switched off` }
|
||||
return { ok: true, server: servers.withToken(row) }
|
||||
}
|
||||
|
||||
/** The sentence for a transport failure, naming the server — every notice says which (§25.6). */
|
||||
function transportError(server, result, what) {
|
||||
const name = (server && (server.name || server.id)) || 'the server'
|
||||
switch (result.status) {
|
||||
case 'http-503':
|
||||
return `${name} has no game connected, so its ${what} could not be reached`
|
||||
case 'http-504':
|
||||
case 'timeout':
|
||||
return `${name} did not answer about its ${what} in time`
|
||||
case 'protocol-mismatch':
|
||||
return `${name}'s sidecar speaks a different protocol — update the module or the sidecar`
|
||||
default:
|
||||
return `${name} could not be reached about its ${what} (${result.status})`
|
||||
}
|
||||
}
|
||||
|
||||
/** A plugin's own refusal, which carries a sentence of its own. */
|
||||
function pluginError(data, fallback) {
|
||||
return (data && (data.message || data.reason)) || fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the four callables one lease shares with every other.
|
||||
*
|
||||
* `wire(rest)` turns what follows the server id into the plugin's `{ key,
|
||||
* target }`, or a refusal. The callables differ in nothing else, so they are
|
||||
* built rather than repeated: four copies of this would be four chances for one
|
||||
* of them to forget the drift check, which is the one thing §F says a lease must
|
||||
* not be allowed to skip.
|
||||
*/
|
||||
function lease({ id, label, description, type, min, max, family, targetLabel, source, example, wire }) {
|
||||
async function resolve(target) {
|
||||
const { serverId, rest } = splitTarget(target)
|
||||
const found = await serverFor(serverId)
|
||||
if (!found.ok) return found
|
||||
const w = wire(rest)
|
||||
if (!w.ok) return { ok: false, retry: false, error: w.error }
|
||||
return { ok: true, server: found.server, key: w.key, target: w.target || undefined }
|
||||
}
|
||||
|
||||
/** The plugin's row for this key and target, or a refusal. */
|
||||
async function row(r) {
|
||||
const result = await client.leaseList(r.server, { key: r.key, target: r.target })
|
||||
if (!result.ok) return { ok: false, error: transportError(r.server, result, 'lease catalogue') }
|
||||
const rows = (result.data && result.data.leases) || []
|
||||
const found = rows.find((x) => x && x.key === r.key && (r.target === undefined || x.target === r.target))
|
||||
if (!found) return { ok: false, retry: false, error: `${r.server.name || r.server.id} does not lend ${r.key}` }
|
||||
if (family && found.family !== family) {
|
||||
return { ok: false, retry: false, error: `${r.key} is not a ${family} value` }
|
||||
}
|
||||
return { ok: true, row: found, data: result.data }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
type,
|
||||
...(min === undefined ? {} : { min }),
|
||||
...(max === undefined ? {} : { max }),
|
||||
maxDurationMs: MAX_LEASE_MS,
|
||||
target: { label: targetLabel, source, example },
|
||||
|
||||
async read({ target } = {}) {
|
||||
const r = await resolve(target)
|
||||
if (!r.ok) return r
|
||||
const found = await row(r)
|
||||
if (!found.ok) return found
|
||||
|
||||
// **A key the plugin already holds reads as its BASELINE, not its
|
||||
// current value.** Core's reservation means a second run can never get
|
||||
// this far, so a hold core does not know about is the first attempt of
|
||||
// THIS run whose answer was lost — and the baseline to give back at the
|
||||
// end is what was there before anybody borrowed it, not that attempt's
|
||||
// value. Recording the current value here would restore the event's own
|
||||
// change at teardown and call it baseline.
|
||||
if (found.row.held && found.row.baseline !== undefined && found.row.baseline !== null) {
|
||||
return { ok: true, value: String(found.row.baseline) }
|
||||
}
|
||||
|
||||
if (found.row.unreadable) return { ok: false, retry: false, error: found.row.unreadable }
|
||||
if (found.row.current === undefined || found.row.current === null) {
|
||||
return { ok: false, error: `${r.server.name || r.server.id} could not read ${r.key}` }
|
||||
}
|
||||
return { ok: true, value: String(found.row.current) }
|
||||
},
|
||||
|
||||
async apply(value, until, { target } = {}) {
|
||||
const r = await resolve(target)
|
||||
if (!r.ok) return r
|
||||
|
||||
// **A duration, not the deadline.** `until` is an absolute time computed
|
||||
// here and honoured there, which is a deadline measured against two
|
||||
// clocks; a game host ten minutes fast would end a ten-minute lease the
|
||||
// instant it took it. The absolute time still rides along, for display.
|
||||
const untilMs = new Date(until).getTime()
|
||||
const holdMs = untilMs - Date.now()
|
||||
if (!Number.isFinite(holdMs) || holdMs <= 0) {
|
||||
return { ok: false, error: 'the lease deadline has already passed' }
|
||||
}
|
||||
|
||||
const body = {
|
||||
key: r.key,
|
||||
...(r.target === undefined ? {} : { target: r.target }),
|
||||
...(family ? { family } : {}),
|
||||
value: String(value),
|
||||
holdMs: Math.round(holdMs),
|
||||
untilMs,
|
||||
}
|
||||
|
||||
const result = await client.leaseApply(r.server, body)
|
||||
|
||||
if (!result.ok) {
|
||||
// **An apply this end gave up on may still land.** The client's lease
|
||||
// timeout is below the sidecar's own, so the command can still reach
|
||||
// the game after core has been told it failed — and core then releases
|
||||
// its reservation, believing nothing was taken. A release follows it
|
||||
// down the same link, which the plugin handles in order: if the apply
|
||||
// landed, the hold's own baseline goes back; if it never did, the
|
||||
// compare finds nothing held and changes nothing. Not awaited: its
|
||||
// answer changes nothing about this one.
|
||||
if (result.status === 'timeout' || result.status === 'http-504') {
|
||||
client
|
||||
.leaseRelease(r.server, { key: r.key, target: r.target, expected: String(value) })
|
||||
.catch(() => {})
|
||||
}
|
||||
return { ok: false, error: transportError(r.server, result, 'lease') }
|
||||
}
|
||||
|
||||
const data = result.data || {}
|
||||
if (data.kind === 'lease.ok') return { ok: true }
|
||||
|
||||
// A refusal the second attempt would repeat is `retry: false` — the
|
||||
// switch is off, the key is not lent, the value is out of range. One that
|
||||
// might pass later (a value the game could not read this second) is left
|
||||
// to core's default.
|
||||
const permanent = ['events-disabled', 'unknown-key', 'out-of-range', 'too-long', 'unresolved', 'target-gone', 'malformed']
|
||||
return {
|
||||
ok: false,
|
||||
...(permanent.includes(data.reason) ? { retry: false } : {}),
|
||||
error: pluginError(data, `${r.server.name || r.server.id} refused the lease`),
|
||||
}
|
||||
},
|
||||
|
||||
async restore(baseline, { expected, target } = {}) {
|
||||
const r = await resolve(target)
|
||||
if (!r.ok) return r
|
||||
|
||||
const result = await client.leaseRelease(r.server, {
|
||||
key: r.key,
|
||||
...(r.target === undefined ? {} : { target: r.target }),
|
||||
expected: expected === undefined || expected === null ? undefined : String(expected),
|
||||
baseline: baseline === undefined || baseline === null ? undefined : String(baseline),
|
||||
})
|
||||
|
||||
if (!result.ok) return { ok: false, error: transportError(r.server, result, 'lease release') }
|
||||
|
||||
const data = result.data || {}
|
||||
|
||||
// **Drift is a 200 carrying `lease.drifted`, not a failure of the call.**
|
||||
// The plugin did what it was asked: it compared, and declined to
|
||||
// overwrite somebody's deliberate change. Core records that as its own
|
||||
// outcome, with the current value beside it.
|
||||
if (data.kind === 'lease.drifted') return { ok: false, drifted: true, current: data.current }
|
||||
|
||||
// A group deleted mid-hold has nothing to give back and nothing owed: a
|
||||
// successful release, not a failure that would leave a ledger row
|
||||
// unresolved for ever over something that is gone.
|
||||
if (data.kind === 'lease.ok') return { ok: true }
|
||||
|
||||
return { ok: false, error: pluginError(data, `${r.server.name || r.server.id} could not give ${r.key} back`) }
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether the plugin still has a record of the hold.
|
||||
*
|
||||
* **Never a comparison with `read()`** (MODULE_API §1.1). A value that
|
||||
* differs from what the run applied is DRIFT, which `restore()` reports so
|
||||
* the row lands `drifted`; answering "not in force" here would orphan the
|
||||
* row first. A convar hold is memory-only on the game, so a restart ends it
|
||||
* and this answers `held: false` — exactly the case core cannot otherwise
|
||||
* see.
|
||||
*/
|
||||
async inForce({ target } = {}) {
|
||||
const r = await resolve(target)
|
||||
if (!r.ok) return r
|
||||
const result = await client.leaseList(r.server, { key: r.key, target: r.target })
|
||||
if (!result.ok) return { ok: false, error: transportError(r.server, result, 'lease catalogue') }
|
||||
const holds = (result.data && result.data.holds) || []
|
||||
const held = holds.some((h) => h && h.key === r.key && String(h.target || '') === String(r.target || ''))
|
||||
return { ok: true, held }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A convar named in the target, of this family. */
|
||||
const convarIn = (family) => (rest) =>
|
||||
rest ? { ok: true, key: rest.toLowerCase() } : { ok: false, error: `name the ${family} value after the server, as server/convar` }
|
||||
|
||||
const LEASES = [
|
||||
lease({
|
||||
id: 'rust.decay.scale',
|
||||
label: 'Decay rate',
|
||||
description:
|
||||
'How fast unprotected buildings decay. 1 is normal, 0 switches decay off, 2 doubles it. Read on every decay tick, so it takes effect at the next one.',
|
||||
type: 'float',
|
||||
min: 0,
|
||||
max: 10,
|
||||
family: 'decay',
|
||||
targetLabel: 'Which server',
|
||||
source: 'rust.options.servers',
|
||||
example: 'main',
|
||||
wire: (rest) => (rest ? { ok: false, error: 'the decay rate takes only a server as its target' } : { ok: true, key: 'decay.scale' }),
|
||||
}),
|
||||
lease({
|
||||
id: 'rust.population',
|
||||
label: 'Population',
|
||||
description:
|
||||
'How many of one animal or vehicle the game keeps topped up, per square kilometre. Applied on the next spawn tick, so the world fills toward the new number rather than jumping to it.',
|
||||
type: 'float',
|
||||
min: 0,
|
||||
max: 50,
|
||||
family: 'population',
|
||||
targetLabel: 'Which server and population',
|
||||
source: 'rust.options.populations',
|
||||
example: 'main/bear.population',
|
||||
wire: convarIn('population'),
|
||||
}),
|
||||
lease({
|
||||
id: 'rust.spawn.scalar',
|
||||
label: 'Spawn scalar',
|
||||
description:
|
||||
"The population system's minimum spawn rate or density — what it runs at on an empty or quiet server, scaling up toward the maximum as players arrive.",
|
||||
type: 'float',
|
||||
min: 0,
|
||||
max: 10,
|
||||
family: 'spawn',
|
||||
targetLabel: 'Which server and scalar',
|
||||
source: 'rust.options.spawnscalars',
|
||||
example: 'main/spawn.min_rate',
|
||||
wire: convarIn('spawn'),
|
||||
}),
|
||||
lease({
|
||||
id: 'rust.group.permission',
|
||||
label: 'Group permission',
|
||||
description:
|
||||
"Whether a permission group carries a permission — \"group default holds kits.vip until Monday\" makes everybody VIP for the weekend. Given back at the end whether or not the site is still up; the game holds the deadline.",
|
||||
type: 'bool',
|
||||
family: null,
|
||||
targetLabel: 'Which server, group and permission',
|
||||
source: 'rust.options.grouppermissions',
|
||||
example: 'main/default/kits.vip',
|
||||
wire: (rest) => {
|
||||
const slash = rest.lastIndexOf('/')
|
||||
if (slash <= 0 || slash >= rest.length - 1) {
|
||||
return { ok: false, error: 'a group permission is named as server/group/permission' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
key: GROUP_PERMISSION_KEY,
|
||||
target: `${rest.slice(0, slash).trim().toLowerCase()}/${rest.slice(slash + 1).trim().toLowerCase()}`,
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
// ── Option sources (D78: only what this phase's leases read) ─────────────────
|
||||
//
|
||||
// Every one resolves live, and a server that does not answer contributes
|
||||
// nothing rather than failing the whole answer — one server being down must
|
||||
// never blank the form for the other five (§9). A source that returns `[]`
|
||||
// degrades its field to free text on core's side, which is the right failure:
|
||||
// the operator very often already knows the value.
|
||||
|
||||
/** Bound one source's answer, and say so in the log when there was more. */
|
||||
function bounded(rows, sourceId) {
|
||||
if (rows.length <= MAX_OPTIONS) return rows
|
||||
log.warn('option source truncated', { source: sourceId, available: rows.length, served: MAX_OPTIONS })
|
||||
return rows.slice(0, MAX_OPTIONS)
|
||||
}
|
||||
|
||||
/** Every enabled server's own answer, in parallel, skipping the ones that fail. */
|
||||
async function perServer(ask) {
|
||||
const list = await servers.listForPolling()
|
||||
const settled = await Promise.allSettled(list.map(async (server) => ({ server, result: await ask(server) })))
|
||||
return settled.filter((s) => s.status === 'fulfilled' && s.value.result && s.value.result.ok).map((s) => s.value)
|
||||
}
|
||||
|
||||
/** The convars one family lends, per server, as whole targets. */
|
||||
async function familyOptions(family, sourceId) {
|
||||
const answers = await perServer((server) => client.leaseList(server))
|
||||
const rows = []
|
||||
for (const { server, result } of answers) {
|
||||
for (const r of (result.data && result.data.leases) || []) {
|
||||
if (!r || r.family !== family || r.unreadable) continue
|
||||
rows.push({ value: `${server.id}/${r.key}`, label: r.key, group: server.name || server.id })
|
||||
}
|
||||
}
|
||||
return bounded(rows, sourceId)
|
||||
}
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'rust.options.servers',
|
||||
label: 'Rust servers',
|
||||
description: 'Every enabled server on this site. A lease holds a value on one of them (D73).',
|
||||
async resolve() {
|
||||
const list = await servers.listForPolling()
|
||||
return list.map((s) => ({ value: s.id, label: s.name || s.id }))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rust.options.populations',
|
||||
label: 'Populations',
|
||||
description: 'The animal and vehicle populations each server lends, read live from the game.',
|
||||
async resolve() {
|
||||
return familyOptions('population', 'rust.options.populations')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rust.options.spawnscalars',
|
||||
label: 'Spawn scalars',
|
||||
description: "The population system's rate and density scalars each server lends.",
|
||||
async resolve() {
|
||||
return familyOptions('spawn', 'rust.options.spawnscalars')
|
||||
},
|
||||
},
|
||||
{
|
||||
// Groups times registered permissions is a catalogue bigger than a dropdown
|
||||
// holds on any server with a few plugins, so it narrows by the term.
|
||||
id: 'rust.options.grouppermissions',
|
||||
label: 'Group permissions',
|
||||
description: 'A permission group and a permission some loaded plugin registered, on each server.',
|
||||
searchable: true,
|
||||
async resolve({ q } = {}) {
|
||||
const term = String(q || '').trim().toLowerCase()
|
||||
const answers = await perServer((server) => client.permCatalogue(server))
|
||||
const rows = []
|
||||
for (const { server, result } of answers) {
|
||||
const data = result.data || {}
|
||||
const perms = (data.permissions || []).map((p) => String(p).toLowerCase())
|
||||
for (const g of data.groups || []) {
|
||||
const group = g && g.name ? String(g.name).toLowerCase() : null
|
||||
if (!group) continue
|
||||
for (const perm of perms) {
|
||||
const value = `${server.id}/${group}/${perm}`
|
||||
if (term && !value.includes(term)) continue
|
||||
rows.push({ value, label: `${group} · ${perm}`, group: server.name || server.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
return bounded(rows, 'rust.options.grouppermissions')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = {
|
||||
MAX_LEASE_MS,
|
||||
MAX_OPTIONS,
|
||||
LEASES,
|
||||
OPTION_SOURCES,
|
||||
splitTarget,
|
||||
serverFor,
|
||||
transportError,
|
||||
pluginError,
|
||||
perServer,
|
||||
bounded,
|
||||
}
|
||||
854
server/eventRewards.js
Normal file
854
server/eventRewards.js
Normal file
@@ -0,0 +1,854 @@
|
||||
// ── What an event GIVES on a Rust server (PLAN.md §29, protocol 10) ───────
|
||||
//
|
||||
// 13a made things in the world. This file records who was there, gives them
|
||||
// something they can redeem, and can tell the server. Four verbs:
|
||||
//
|
||||
// rust.participation.open the game starts counting who takes part (D81)
|
||||
// rust.participation.collect core files the count as the run's participants
|
||||
// rust.kit.entitle the right to redeem a kit, and one more use of
|
||||
// it (R16, D103), for the people a mode picks
|
||||
// rust.announce one line in a server's chat, or every server's
|
||||
//
|
||||
// …and the announce leg, `rust.chat`, which says a published news post in the
|
||||
// chat of every server whose switch is on (D104).
|
||||
//
|
||||
// ── Who decides what ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The GAME counts: presence, kills, the score (D81, D99). The SITE picks the
|
||||
// recipients and holds the reward: the tally is read, a mode chosen per event
|
||||
// picks from it (D101), and a row per recipient goes into
|
||||
// `rust_perm_run_grants`, which the permission mirror pushes like any other
|
||||
// grant (D84). So a reward granted at 03:00 to somebody offline is waiting when
|
||||
// they next log in, and a wipe cannot take it away: the site re-pushes it.
|
||||
//
|
||||
// ── An action is never handed the participants ──────────────────────────────
|
||||
//
|
||||
// Core records participants from `collect`'s answer, but does not give them to
|
||||
// a later step. So `kit.entitle` reads the tally from the plugin itself, as
|
||||
// `uo.item.grant` reads it from the shard, and does not depend on a collect step
|
||||
// having run.
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const core = require('./core')
|
||||
const client = require('./sidecarClient')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const permDb = require('./model/permissions/permissions.db')
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const emit = require('./engagement/emit')
|
||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
|
||||
const log = core.logger('rewards')
|
||||
|
||||
// Mirrors of the plugin's bounds (§29.5). The plugin's are authoritative, and
|
||||
// an operator may set them lower; these price a step and refuse a bad one on
|
||||
// the authoring form rather than at four in the morning.
|
||||
const MAX_RECIPIENTS = 100
|
||||
const MAX_CHAT = 256
|
||||
const TALLY_MAX_MINUTES = 7 * 24 * 60
|
||||
const MAX_KILL_WEIGHT = 1000
|
||||
const DEFAULT_KILL_WEIGHT = 5
|
||||
|
||||
const SCORES = ['seconds', 'kills', 'both']
|
||||
const KILLS_OF = ['players', 'npcs', 'both']
|
||||
const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent']
|
||||
|
||||
/** The fleet, in `rust.announce`'s `server` param (D105). */
|
||||
const EVERY_SERVER = '*'
|
||||
|
||||
/** The plugin's refusals a second attempt would repeat. */
|
||||
const PERMANENT = new Set([
|
||||
'events-disabled',
|
||||
'malformed',
|
||||
'out-of-range',
|
||||
'already-open',
|
||||
'no-zone',
|
||||
'ambiguous-zone',
|
||||
'too-many',
|
||||
'too-long',
|
||||
'kits-missing',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.grants',
|
||||
label: 'Kit rewards',
|
||||
unit: 'rewards',
|
||||
description:
|
||||
'Kits an event rewards: one per recipient, each the right to redeem the kit and one more use of it. A mode that is not a count is priced at the most it could grant.',
|
||||
},
|
||||
{
|
||||
id: 'rust.announcements',
|
||||
label: 'Chat announcements',
|
||||
unit: 'lines',
|
||||
description: 'Lines an event says in a server\'s chat: one per server reached.',
|
||||
},
|
||||
]
|
||||
|
||||
/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
|
||||
function transportFailure(server, result, what) {
|
||||
const permanent = result.status === 'not-configured' || result.status === 'no-token'
|
||||
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) }
|
||||
}
|
||||
|
||||
/** A plugin refusal carried in a 200, classified by its reason. */
|
||||
function refusal(server, data, what) {
|
||||
return {
|
||||
ok: false,
|
||||
...(PERMANENT.has(data && data.reason) ? { retry: false } : {}),
|
||||
error: pluginError(data, `${server.name || server.id} refused the ${what}`),
|
||||
}
|
||||
}
|
||||
|
||||
/** One word from a fixed set, or null. Compared without case: an author types these. */
|
||||
function oneOf(raw, allowed) {
|
||||
const text = String(raw === undefined || raw === null ? '' : raw).trim().toLowerCase()
|
||||
return allowed.find((a) => a.toLowerCase() === text) || null
|
||||
}
|
||||
|
||||
/** `<server>:<runId>` for a tally, `<server>:<runId>:<stepId>` for a reward (§29.3). */
|
||||
const tallyRef = (serverId, runId) => `${serverId}:${runId}`
|
||||
const entitlementRef = (serverId, runId, stepId) => `${serverId}:${runId}:${stepId}`
|
||||
|
||||
/** A ref's parts, split at every colon — a server id has none and core's ids are numbers. */
|
||||
function refParts(ref) {
|
||||
const [serverId, runId, stepId] = String(ref || '').split(':')
|
||||
return { serverId: serverId || null, runId: runId || null, stepId: stepId || null }
|
||||
}
|
||||
|
||||
// ── Picking recipients (D101) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The mode's `count`, checked. Returns `{ ok, value }` or a refusal sentence.
|
||||
* `everyone` takes none.
|
||||
*/
|
||||
function checkCount(mode, raw) {
|
||||
if (mode === 'everyone') return { ok: true, value: null }
|
||||
|
||||
const value = Number(raw)
|
||||
if (mode === 'top' || mode === 'random') {
|
||||
if (!Number.isInteger(value) || value < 1 || value > MAX_RECIPIENTS) {
|
||||
return { ok: false, error: `${mode} names 1 to ${MAX_RECIPIENTS} people, and "${raw}" is not that` }
|
||||
}
|
||||
} else if (mode === 'topPercent') {
|
||||
if (!Number.isFinite(value) || value <= 0 || value > 100) {
|
||||
return { ok: false, error: `topPercent is a percentage above 0 and at most 100, not "${raw}"` }
|
||||
}
|
||||
} else if (!Number.isFinite(value) || value < 0) {
|
||||
return { ok: false, error: `minScore is a score of 0 or more, not "${raw}"` }
|
||||
}
|
||||
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
/** Highest first; a tie keeps the order the game joined them in, so a list reads the same twice. */
|
||||
function ranked(people) {
|
||||
return [...people].sort((a, b) => b.score - a.score || a.joinedAt - b.joinedAt || a.steamId.localeCompare(b.steamId))
|
||||
}
|
||||
|
||||
/** The first `n` of a ranked list, and everybody tied with the last one in (D101). */
|
||||
function withTies(list, n) {
|
||||
if (n <= 0 || !list.length) return []
|
||||
if (n >= list.length) return list
|
||||
const floor = list[n - 1].score
|
||||
return list.filter((p, i) => i < n || p.score === floor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a mode picks from a tally. Pure, and the whole of D101:
|
||||
*
|
||||
* everyone every participant who scored above zero
|
||||
* top the N highest scores, ties in
|
||||
* minScore a score of at least X
|
||||
* random N drawn from everyone who took part, seeded by the step's key
|
||||
* topPercent the highest X per cent, rounded up, ties in
|
||||
*
|
||||
* A score of zero earns nothing in the ranked modes: "the highest scores" of a
|
||||
* tally where nobody scored is nobody. `random` draws from everyone present,
|
||||
* which is the point of a raffle.
|
||||
*
|
||||
* **The draw is seeded by the idempotency key**, so a retry after a lost answer
|
||||
* draws the same winners — each person's place is a hash of the key and their
|
||||
* Steam id, which needs no generator state to reproduce.
|
||||
*/
|
||||
function pickRecipients(people, mode, count, seedKey) {
|
||||
const scored = ranked(people.filter((p) => p.score > 0))
|
||||
|
||||
switch (mode) {
|
||||
case 'everyone':
|
||||
return scored
|
||||
case 'top':
|
||||
return withTies(scored, count)
|
||||
case 'minScore':
|
||||
return ranked(people.filter((p) => p.score >= count))
|
||||
case 'topPercent':
|
||||
return withTies(scored, Math.ceil((scored.length * count) / 100))
|
||||
case 'random': {
|
||||
const draw = (p) => crypto.createHash('sha256').update(`${seedKey}\u0000${p.steamId}`).digest('hex')
|
||||
return [...people].sort((a, b) => draw(a).localeCompare(draw(b))).slice(0, count)
|
||||
}
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** The tally's rows as numbers, whatever the wire carried. */
|
||||
function peopleOf(data) {
|
||||
return ((data && data.people) || [])
|
||||
.filter((p) => p && p.steamId)
|
||||
.map((p) => ({
|
||||
steamId: String(p.steamId),
|
||||
name: p.name ? String(p.name) : String(p.steamId),
|
||||
seconds: Number(p.seconds) || 0,
|
||||
kills: Number(p.kills) || 0,
|
||||
score: Number(p.score) || 0,
|
||||
joinedAt: Number(p.joinedAt) || 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Steam id -> website user, for the ids that are linked. */
|
||||
async function usersFor(steamIds) {
|
||||
const ids = [...new Set(steamIds.map(String))]
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await linksDb.userIdsForSteamIds(ids)
|
||||
return new Map(rows.map((r) => [String(r.steamId), Number(r.userId)]))
|
||||
}
|
||||
|
||||
/** The tally for a run on one server, or a classified failure. */
|
||||
async function readTally(server, runId) {
|
||||
const result = await client.tallySnapshot(server, runId)
|
||||
if (!result.ok) return transportFailure(server, result, 'tally')
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'tally.snapshot') {
|
||||
// `no-tally` is permanent for THIS step: the tally it reads was never opened
|
||||
// on this server, or teardown already closed it.
|
||||
if (data.reason === 'no-tally') {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${server.name || server.id} holds no tally for this run — open one with rust.participation.open on the same server first`,
|
||||
}
|
||||
}
|
||||
return refusal(server, data, 'tally')
|
||||
}
|
||||
return { ok: true, data }
|
||||
}
|
||||
|
||||
// ── The kit, as its server's Kits plugin describes it ───────────────────────
|
||||
|
||||
/**
|
||||
* `<serverId>/<kit>` split at the FIRST slash: a server id never contains one,
|
||||
* and a kit name is whatever an operator typed into Kits.
|
||||
*/
|
||||
function splitKit(value) {
|
||||
const text = String(value || '').trim()
|
||||
const slash = text.indexOf('/')
|
||||
if (slash <= 0 || slash === text.length - 1) return null
|
||||
return { serverId: text.slice(0, slash), kit: text.slice(slash + 1) }
|
||||
}
|
||||
|
||||
/** What a kit rewards, as the source's label says it and the verb checks it (R16, D103). */
|
||||
function kitReward(row) {
|
||||
const permission = String(row.permission || '').trim().toLowerCase()
|
||||
const max = Number(row.max) || 0
|
||||
return { permission, max, rewardsNothing: !permission && max <= 0 }
|
||||
}
|
||||
|
||||
async function readKit(server, kit) {
|
||||
const result = await client.kits(server)
|
||||
if (!result.ok) return transportFailure(server, result, 'kits')
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'kits.list') return refusal(server, data, 'kit list')
|
||||
|
||||
const row = (data.kits || []).find((k) => k && String(k.name).toLowerCase() === kit.toLowerCase())
|
||||
if (!row) return { ok: false, retry: false, error: `${server.name || server.id} has no kit called "${kit}"` }
|
||||
|
||||
const reward = kitReward(row)
|
||||
if (reward.rewardsNothing) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `the kit "${row.name}" is open to everyone and has no use limit, so a reward of it gives nobody anything — give it a permission or a maximum number of uses in Kits`,
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, kit: String(row.name), ...reward, maxRecipients: Number(data.maxRecipients) || MAX_RECIPIENTS }
|
||||
}
|
||||
|
||||
// ── The verbs ────────────────────────────────────────────────────────────────
|
||||
|
||||
const participationOpen = {
|
||||
id: 'rust.participation.open',
|
||||
label: 'Start counting participants',
|
||||
description:
|
||||
'The game counts who takes part from here on: time present, kills, or both — in a zone this run opened, or on the whole server. It stops after its minutes; teardown forgets it.',
|
||||
// It watches rather than changes anything, but it is ledgered, like
|
||||
// `uo.participation.open`: the game holds a tally for the run, and teardown
|
||||
// gives it back.
|
||||
risk: 'inspect',
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'Which server counts.' },
|
||||
{ name: 'zone', type: 'string', required: false, example: 'Airfield brawl', source: 'rust.options.runzones',
|
||||
description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts (D100).' },
|
||||
{ name: 'score', type: 'string', required: true, example: 'both', source: 'rust.options.scoremodes',
|
||||
description: 'What earns a place: seconds present, kills, or both.' },
|
||||
{ name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsof',
|
||||
description: 'Whose deaths count as a kill: players, NPCs (animals included), or both. The last hit gets it. Needed unless the score is seconds.' },
|
||||
{ name: 'killWeight', type: 'float', required: false, example: DEFAULT_KILL_WEIGHT,
|
||||
description: `For a score of both: how many minutes one kill is worth. Left blank, ${DEFAULT_KILL_WEIGHT}.` },
|
||||
{ name: 'minutes', type: 'int', required: false, example: 60,
|
||||
description: `How long it counts, up to ${TALLY_MAX_MINUTES} (seven days). Left blank, seven days. The game forgets a tally seven days after it opened, however long it counted.` },
|
||||
],
|
||||
cost: () => ({}),
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const score = oneOf(params.score, SCORES)
|
||||
if (!score) return { ok: false, retry: false, error: `a tally scores seconds, kills or both, not "${params.score}"` }
|
||||
|
||||
const killsOf = score === 'seconds' ? null : oneOf(params.killsOf, KILLS_OF)
|
||||
if (score !== 'seconds' && !killsOf) {
|
||||
return { ok: false, retry: false, error: 'a tally that counts kills says whose: players, npcs or both' }
|
||||
}
|
||||
|
||||
let killWeight
|
||||
if (score === 'both') {
|
||||
const raw = params.killWeight
|
||||
killWeight = raw === undefined || raw === null || raw === '' ? DEFAULT_KILL_WEIGHT : Number(raw)
|
||||
if (!Number.isFinite(killWeight) || killWeight < 0 || killWeight > MAX_KILL_WEIGHT) {
|
||||
return { ok: false, retry: false, error: `a kill is worth 0 to ${MAX_KILL_WEIGHT} minutes, not "${raw}"` }
|
||||
}
|
||||
}
|
||||
|
||||
let minutes
|
||||
if (params.minutes !== undefined && params.minutes !== null && params.minutes !== '') {
|
||||
minutes = Number(params.minutes)
|
||||
if (!Number.isInteger(minutes) || minutes < 1 || minutes > TALLY_MAX_MINUTES) {
|
||||
return { ok: false, retry: false, error: `a tally counts for 1 to ${TALLY_MAX_MINUTES} minutes, not "${params.minutes}"` }
|
||||
}
|
||||
}
|
||||
|
||||
const zone = String(params.zone || '').trim()
|
||||
const found = await serverFor(String(params.server || '').trim())
|
||||
if (!found.ok) return found
|
||||
|
||||
// Whether the zone exists is not asked in a dry run: it is opened by an
|
||||
// earlier step of the same run, so before the run it never does.
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await client.tallyOpen(found.server, {
|
||||
runId: String(runId),
|
||||
key: idempotencyKey,
|
||||
score,
|
||||
...(killsOf ? { killsOf } : {}),
|
||||
...(killWeight === undefined ? {} : { killWeight }),
|
||||
...(minutes === undefined ? {} : { holdMs: minutes * 60000 }),
|
||||
...(zone ? { zone } : {}),
|
||||
})
|
||||
if (!result.ok) return transportFailure(found.server, result, 'tally')
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'tally.ok') return refusal(found.server, data, 'tally')
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
resources: [
|
||||
{
|
||||
kind: 'tally',
|
||||
ref: tallyRef(found.server.id, runId),
|
||||
payload: { serverId: found.server.id, score, ...(zone ? { zone } : {}) },
|
||||
},
|
||||
],
|
||||
detail: {
|
||||
server: found.server.name || found.server.id,
|
||||
counting: zone ? `in the zone "${zone}"` : 'on the whole server',
|
||||
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; the tally was already open' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Forget the tally on every server the ledger names — or, when core lost the
|
||||
* answer and holds none, on every server, since `runId` is all a tally is
|
||||
* keyed by. A tally already gone is a success.
|
||||
*/
|
||||
async revert({ runId, resources }) {
|
||||
const targets = resources && resources.length
|
||||
? [...new Set(resources.map((r) => (r.payload && r.payload.serverId) || refParts(r.ref).serverId))]
|
||||
: (await servers.listForPolling()).map((s) => s.id)
|
||||
|
||||
const failed = []
|
||||
const errors = []
|
||||
for (const serverId of targets) {
|
||||
const found = await serverFor(serverId)
|
||||
if (!found.ok) {
|
||||
// A server deleted or switched off cannot be asked, and its tally ends on
|
||||
// its own seven days after it opened; the ledger row is not held for it.
|
||||
continue
|
||||
}
|
||||
const result = await client.tallyClose(found.server, { runId: String(runId) })
|
||||
const refused = result.ok && (!result.data || result.data.kind !== 'tally.ok')
|
||||
if (!result.ok || refused) {
|
||||
failed.push(...(resources || []).filter((r) => refParts(r.ref).serverId === serverId).map((r) => r.ref))
|
||||
errors.push(result.ok ? pluginError(result.data, `${found.server.name || found.server.id} refused to close the tally`) : transportError(found.server, result, 'tally'))
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.length) return { ok: true }
|
||||
if (!resources || !resources.length || failed.length === resources.length) return { ok: false, error: errors.join('; ') }
|
||||
return { ok: true, failed }
|
||||
},
|
||||
|
||||
/** A tally is in force while its server still holds it. A server that cannot be asked has said nothing. */
|
||||
async reconcile({ runId, resources }) {
|
||||
const inForce = []
|
||||
for (const r of resources || []) {
|
||||
const found = await serverFor(refParts(r.ref).serverId)
|
||||
if (!found.ok) {
|
||||
inForce.push(r.ref)
|
||||
continue
|
||||
}
|
||||
const result = await client.tallySnapshot(found.server, runId)
|
||||
const gone = result.ok && result.data && result.data.kind !== 'tally.snapshot' && result.data.reason === 'no-tally'
|
||||
if (!gone) inForce.push(r.ref)
|
||||
}
|
||||
return { ok: true, inForce }
|
||||
},
|
||||
}
|
||||
|
||||
const participationCollect = {
|
||||
id: 'rust.participation.collect',
|
||||
label: 'Record participants',
|
||||
description:
|
||||
'Files everybody the tally counted as this run\'s participants, with their score, time and kills. The tally keeps counting if its minutes are not up.',
|
||||
risk: 'inspect',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'The server whose tally to read.' },
|
||||
],
|
||||
cost: () => ({}),
|
||||
|
||||
async perform({ runId, params, verify }) {
|
||||
const found = await serverFor(String(params.server || '').trim())
|
||||
if (!found.ok) return found
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const tally = await readTally(found.server, runId)
|
||||
if (!tally.ok) return tally
|
||||
|
||||
const people = peopleOf(tally.data)
|
||||
const users = await usersFor(people.map((p) => p.steamId))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
// The member vocabulary is the Steam id, as the team provider's is.
|
||||
participants: people.map((p) => ({
|
||||
memberKey: p.steamId,
|
||||
...(users.has(p.steamId) ? { userId: users.get(p.steamId) } : {}),
|
||||
score: p.score,
|
||||
...(p.joinedAt > 0 ? { joinedAt: new Date(p.joinedAt).toISOString() } : {}),
|
||||
meta: { name: p.name, seconds: p.seconds, kills: p.kills },
|
||||
})),
|
||||
detail: {
|
||||
server: found.server.name || found.server.id,
|
||||
participants: people.length,
|
||||
linked: users.size,
|
||||
...(Number(tally.data.overflow) > 0 ? { overflow: Number(tally.data.overflow) } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const kitEntitle = {
|
||||
id: 'rust.kit.entitle',
|
||||
label: 'Reward a kit',
|
||||
description:
|
||||
'Gives the people a mode picks from this run\'s tally the right to redeem a kit on its server, and one more use of it. Waits for them if they are offline. Teardown withdraws what is not yet redeemed.',
|
||||
risk: 'change',
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'kit', type: 'string', required: true, example: 'main/vip-starter', source: 'rust.options.kits',
|
||||
description: 'The kit, as server/kit. The reward reaches only that server (D102).' },
|
||||
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
|
||||
description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, or the top X per cent.' },
|
||||
{ name: 'count', type: 'float', required: false, example: 3,
|
||||
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone.' },
|
||||
],
|
||||
|
||||
// Priced before the tally is read, so at the most it could grant: the count
|
||||
// for a count, and the server's recipient bound for every other mode. An
|
||||
// author who wants a tight cap picks a count.
|
||||
cost: (p) => {
|
||||
const mode = oneOf(p.recipients, MODES)
|
||||
const n = Math.round(Number(p.count) || 0)
|
||||
return { 'rust.grants': mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS }
|
||||
},
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const parsed = splitKit(params.kit)
|
||||
if (!parsed) return { ok: false, retry: false, error: `"${params.kit}" is not a kit — pick one from the list, as server/kit` }
|
||||
|
||||
const mode = oneOf(params.recipients, MODES)
|
||||
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
|
||||
|
||||
const count = checkCount(mode, params.count)
|
||||
if (!count.ok) return { ok: false, retry: false, error: count.error }
|
||||
|
||||
const found = await serverFor(parsed.serverId)
|
||||
if (!found.ok) return found
|
||||
const server = found.server
|
||||
|
||||
const kit = await readKit(server, parsed.kit)
|
||||
if (!kit.ok) return kit
|
||||
|
||||
if ((mode === 'top' || mode === 'random') && count.value > kit.maxRecipients) {
|
||||
return { ok: false, retry: false, error: `${server.name || server.id} rewards at most ${kit.maxRecipients} people in one step, not ${count.value}` }
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const ref = entitlementRef(server.id, runId, stepId)
|
||||
const resource = { kind: 'entitlement', ref, payload: { serverId: server.id, kit: kit.kit } }
|
||||
|
||||
// A repeated key finds its rows already written and changes nothing — the
|
||||
// rows ARE the grant, and a set written twice is the same set.
|
||||
const existing = await permDb.listRunGrantsForStep(runId, stepId)
|
||||
if (existing.length) {
|
||||
return {
|
||||
ok: true,
|
||||
resources: [resource],
|
||||
detail: { repeat: true, granted: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was granted' },
|
||||
}
|
||||
}
|
||||
|
||||
const tally = await readTally(server, runId)
|
||||
if (!tally.ok) return tally
|
||||
|
||||
const picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
|
||||
const bound = Number(tally.data.maxRecipients) || kit.maxRecipients
|
||||
if (picked.length > bound) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${picked.length} people qualify, and ${server.name || server.id} rewards at most ${bound} in one step — pick a count-based mode or a higher bar`,
|
||||
}
|
||||
}
|
||||
|
||||
const users = await usersFor(picked.map((p) => p.steamId))
|
||||
const rows = []
|
||||
const byUser = new Set()
|
||||
const missed = []
|
||||
|
||||
for (const p of picked) {
|
||||
const userId = users.get(p.steamId)
|
||||
if (!userId) {
|
||||
missed.push(p.name)
|
||||
continue
|
||||
}
|
||||
// One reward per website user: two linked accounts that both took part are
|
||||
// one person, and one win is one use (D103). The higher score, being
|
||||
// earlier in the list, is the account that gets the credit.
|
||||
if (byUser.has(userId)) continue
|
||||
byUser.add(userId)
|
||||
rows.push({
|
||||
runId,
|
||||
stepId,
|
||||
idemKey: idempotencyKey,
|
||||
userId,
|
||||
serverId: server.id,
|
||||
steamId: p.steamId,
|
||||
permission: kit.permission,
|
||||
kit: kit.kit,
|
||||
credit: kit.max > 0,
|
||||
})
|
||||
}
|
||||
|
||||
await permDb.insertRunGrants(rows)
|
||||
await permDb.markDirty(server.id)
|
||||
|
||||
emit.entitled({ userIds: [...byUser], kit: kit.kit, server, mode, runId, stepId })
|
||||
|
||||
log.info('kit rewarded', { server: server.id, run: runId, step: stepId, kit: kit.kit, mode, granted: rows.length, missed: missed.length })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
resources: [resource],
|
||||
detail: {
|
||||
kit: kit.kit,
|
||||
server: server.name || server.id,
|
||||
mode,
|
||||
...(count.value === null ? {} : { count: count.value }),
|
||||
granted: rows.length,
|
||||
...(missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Withdraw a step's rows and push. The permission goes and each unredeemed
|
||||
* credit is put back by the plugin; a redemption already made stands (R16).
|
||||
* A row already gone is a success.
|
||||
*/
|
||||
async revert({ runId, resources, idempotencyKey }) {
|
||||
const touched = new Set()
|
||||
|
||||
if (!resources || !resources.length) {
|
||||
for (const serverId of await permDb.deleteRunGrantsForKey(runId, idempotencyKey)) touched.add(serverId)
|
||||
} else {
|
||||
for (const r of resources) {
|
||||
const { runId: refRun, stepId } = refParts(r.ref)
|
||||
if (!stepId) continue
|
||||
for (const serverId of await permDb.deleteRunGrantsForStep(refRun || runId, stepId)) touched.add(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const serverId of touched) await permDb.markDirty(serverId)
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
/** The site holds the entitlement and re-pushes it, so a restart or a wipe cannot take it away. */
|
||||
async reconcile({ resources }) {
|
||||
return { ok: true, inForce: (resources || []).map((r) => r.ref) }
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Say one line on one server, and classify the answer. `repeat` is a success:
|
||||
* the plugin remembered the key, and the line was already said.
|
||||
*/
|
||||
async function sayOn(server, body) {
|
||||
const result = await client.chat(server, body)
|
||||
if (!result.ok) return { state: 'down', result }
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'chat.ok') return { state: 'refused', data }
|
||||
return { state: data.said === false ? 'repeat' : 'said', data }
|
||||
}
|
||||
|
||||
const announce = {
|
||||
id: 'rust.announce',
|
||||
label: 'Say it in game chat',
|
||||
description: 'One line in a Rust server\'s chat, or in every server\'s. A line said cannot be taken back.',
|
||||
risk: 'notify',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.chatservers',
|
||||
description: 'Which server, or * for every server (D105).' },
|
||||
{ name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!',
|
||||
description: `The line, up to ${MAX_CHAT} characters.` },
|
||||
],
|
||||
|
||||
// One per server reached. `*` is priced at the enabled servers when core asks,
|
||||
// which is synchronous — so at the count this module last saw.
|
||||
cost: (p) => ({ 'rust.announcements': String(p.server || '').trim() === EVERY_SERVER ? Math.max(1, servers.lastEnabledCount()) : 1 }),
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const message = String(params.message || '').replace(/\s+/g, ' ').trim()
|
||||
if (!message) return { ok: false, retry: false, error: 'a chat line needs a message' }
|
||||
if (message.length > MAX_CHAT) {
|
||||
return { ok: false, retry: false, error: `a chat line is at most ${MAX_CHAT} characters, and this one is ${message.length}` }
|
||||
}
|
||||
|
||||
const target = String(params.server || '').trim()
|
||||
let list
|
||||
if (target === EVERY_SERVER) {
|
||||
list = await servers.listForPolling()
|
||||
if (!list.length) return { ok: false, retry: false, error: 'there are no enabled Rust servers to say it on' }
|
||||
} else {
|
||||
const found = await serverFor(target)
|
||||
if (!found.ok) return found
|
||||
list = [found.server]
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const body = { key: idempotencyKey || `run:${runId}`, message, event: true }
|
||||
const outcomes = await Promise.all(list.map(async (server) => ({ server, ...(await sayOn(server, body)) })))
|
||||
const name = (o) => o.server.name || o.server.id
|
||||
|
||||
// One server named: its answer is the step's.
|
||||
if (target !== EVERY_SERVER) {
|
||||
const o = outcomes[0]
|
||||
if (o.state === 'down') return transportFailure(o.server, o.result, 'chat')
|
||||
if (o.state === 'refused') return refusal(o.server, o.data, 'chat line')
|
||||
return { ok: true, detail: { said: [name(o)], ...(o.state === 'repeat' ? { repeat: true } : {}) } }
|
||||
}
|
||||
|
||||
// Every server: a success for each that took the line, and the rest named
|
||||
// (D104's reason — a line said an hour late in a restarted server is noise).
|
||||
const said = outcomes.filter((o) => o.state === 'said' || o.state === 'repeat').map(name)
|
||||
const down = outcomes.filter((o) => o.state === 'down').map(name)
|
||||
const refused = outcomes.filter((o) => o.state === 'refused').map((o) => `${name(o)}: ${pluginError(o.data, 'refused')}`)
|
||||
|
||||
if (!said.length && refused.length) return { ok: false, retry: false, error: refused.join('; ') }
|
||||
if (!said.length) return { ok: false, error: `no server could be reached: ${down.join(', ')}` }
|
||||
|
||||
return { ok: true, detail: { said, ...(down.length ? { down } : {}), ...(refused.length ? { refused } : {}) } }
|
||||
},
|
||||
}
|
||||
|
||||
// ── The announce leg (D104) ──────────────────────────────────────────────────
|
||||
|
||||
/** A news post as one chat line: its title, or failing that its excerpt, flattened and bounded. */
|
||||
function chatLine(post) {
|
||||
const text = String((post && (post.title || post.excerpt)) || '').replace(/\s+/g, ' ').trim()
|
||||
return text.length > MAX_CHAT ? `${text.slice(0, MAX_CHAT - 1)}…` : text
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin's memory of recent keys, keyed off the post: its id when core's
|
||||
* news path gives one, else what it says — `core.announce` hands a leg a post
|
||||
* with no id. Either way a retried leg never says the same line twice.
|
||||
*/
|
||||
function chatKey(post, line) {
|
||||
if (post && post.id !== undefined && post.id !== null) return `news:${post.id}`
|
||||
return `news:${crypto.createHash('sha1').update(line).digest('hex')}`
|
||||
}
|
||||
|
||||
const LEG = {
|
||||
leg: 'rust.chat',
|
||||
label: 'Rust in-game chat',
|
||||
|
||||
/**
|
||||
* Say a post in the chat of every server whose switch is on. Never throws, as
|
||||
* every leg client must not. The answer is one outcome per switched-on
|
||||
* server, for `classify`.
|
||||
*/
|
||||
async dispatch(post) {
|
||||
try {
|
||||
const line = chatLine(post)
|
||||
if (!line) return { ok: false, empty: true, outcomes: [] }
|
||||
|
||||
const list = (await servers.listForPolling()).filter((s) => s.announceNews)
|
||||
const key = chatKey(post, line)
|
||||
const outcomes = await Promise.all(
|
||||
list.map(async (server) => ({ server: server.name || server.id, ...(await sayOn(server, { key, message: line })) })),
|
||||
)
|
||||
return { ok: true, outcomes }
|
||||
} catch (err) {
|
||||
log.warn('news chat leg failed', { error: err.message })
|
||||
return { ok: false, error: err.message, outcomes: [] }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* `done` when every switched-on server that is up took the line — or when no
|
||||
* server is switched on, since there is nothing to deliver. `retry` only when
|
||||
* every switched-on server is down. A server that refused is named; one that
|
||||
* was down is skipped, never queued (D104).
|
||||
*/
|
||||
classify(result) {
|
||||
if (!result || (!result.ok && !result.empty && !result.outcomes)) return { outcome: 'retry', error: (result && result.error) || 'no answer' }
|
||||
if (result.empty) return { outcome: 'terminal', error: 'the post has no title or excerpt to say' }
|
||||
if (!result.ok) return { outcome: 'retry', error: result.error || 'the leg failed' }
|
||||
|
||||
const outcomes = result.outcomes || []
|
||||
if (!outcomes.length) return { outcome: 'done' }
|
||||
|
||||
const took = outcomes.filter((o) => o.state === 'said' || o.state === 'repeat')
|
||||
const down = outcomes.filter((o) => o.state === 'down').map((o) => o.server)
|
||||
const refused = outcomes.filter((o) => o.state === 'refused').map((o) => `${o.server}: ${pluginError(o.data, 'refused')}`)
|
||||
|
||||
if (down.length === outcomes.length) return { outcome: 'retry', error: `every server is down: ${down.join(', ')}` }
|
||||
if (!took.length) return { outcome: 'terminal', error: refused.join('; ') }
|
||||
|
||||
const notes = [...(down.length ? [`skipped (down): ${down.join(', ')}`] : []), ...refused]
|
||||
return notes.length ? { outcome: 'done', error: notes.join('; ') } : { outcome: 'done' }
|
||||
},
|
||||
}
|
||||
|
||||
// ── Option sources ───────────────────────────────────────────────────────────
|
||||
|
||||
/** A fixed choice as a dropdown — core has no enum type, so a source is how a field offers words. */
|
||||
const fixed = (id, label, description, rows) => ({ id, label, description, async resolve() { return rows } })
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
// Live from each server's Kits, so the form offers only kits that exist. The
|
||||
// label says what a reward of each one gives (R16, D103).
|
||||
id: 'rust.options.kits',
|
||||
label: 'Kits',
|
||||
description: "Each server's Kits, flagged by what a reward of one gives.",
|
||||
searchable: true,
|
||||
async resolve({ q } = {}) {
|
||||
const term = String(q || '').trim().toLowerCase()
|
||||
const answers = await perServer((server) => client.kits(server))
|
||||
const rows = []
|
||||
for (const { server, result } of answers) {
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'kits.list') continue
|
||||
for (const k of data.kits || []) {
|
||||
if (!k || !k.name) continue
|
||||
const value = `${server.id}/${k.name}`
|
||||
if (term && !value.toLowerCase().includes(term)) continue
|
||||
const reward = kitReward(k)
|
||||
const flags = [
|
||||
reward.permission ? null : 'open to everyone',
|
||||
reward.max > 0 ? `${reward.max} use${reward.max === 1 ? '' : 's'}` : null,
|
||||
reward.rewardsNothing ? 'rewards nothing' : null,
|
||||
].filter(Boolean)
|
||||
rows.push({ value, label: flags.length ? `${k.name} · ${flags.join(' · ')}` : String(k.name), group: server.name || server.id })
|
||||
}
|
||||
}
|
||||
return bounded(rows, 'rust.options.kits')
|
||||
},
|
||||
},
|
||||
// Free text: the zones a run will open do not exist when it is authored, and
|
||||
// the name is checked when the step runs (D100). Declared so the field is
|
||||
// documented rather than a bare box, and answers nothing.
|
||||
fixed('rust.options.runzones', 'Zones this run opens', 'The name an earlier "Open a zone" step of the same run gave its zone. Type it; it is checked when the step runs.', []),
|
||||
fixed('rust.options.scoremodes', 'Score', 'What earns a place in a tally.', [
|
||||
{ value: 'seconds', label: 'Seconds present' },
|
||||
{ value: 'kills', label: 'Kills' },
|
||||
{ value: 'both', label: 'Both — minutes plus a weight per kill' },
|
||||
]),
|
||||
fixed('rust.options.killsof', 'Kills of', 'Whose deaths count as a kill.', [
|
||||
{ value: 'players', label: 'Players' },
|
||||
{ value: 'npcs', label: 'NPCs, animals included' },
|
||||
{ value: 'both', label: 'Players and NPCs' },
|
||||
]),
|
||||
fixed('rust.options.recipientmodes', 'Recipients', 'Who a reward goes to (D101).', [
|
||||
{ value: 'everyone', label: 'Everyone who scored' },
|
||||
{ value: 'top', label: 'The top N (ties in)' },
|
||||
{ value: 'minScore', label: 'A score of at least X' },
|
||||
{ value: 'random', label: 'N drawn at random' },
|
||||
{ value: 'topPercent', label: 'The top X per cent (ties in)' },
|
||||
]),
|
||||
{
|
||||
id: 'rust.options.chatservers',
|
||||
label: 'Chat servers',
|
||||
description: 'Every enabled server, or * for all of them.',
|
||||
async resolve() {
|
||||
const list = await servers.listForPolling()
|
||||
return [{ value: EVERY_SERVER, label: 'Every server' }, ...list.map((s) => ({ value: s.id, label: s.name || s.id }))]
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const ACTIONS = [participationOpen, participationCollect, kitEntitle, announce]
|
||||
|
||||
module.exports = {
|
||||
MAX_RECIPIENTS,
|
||||
MAX_CHAT,
|
||||
EVERY_SERVER,
|
||||
BUDGETS,
|
||||
ACTIONS,
|
||||
LEG,
|
||||
OPTION_SOURCES,
|
||||
pickRecipients,
|
||||
checkCount,
|
||||
splitKit,
|
||||
kitReward,
|
||||
chatLine,
|
||||
chatKey,
|
||||
refParts,
|
||||
}
|
||||
662
server/eventWorld.js
Normal file
662
server/eventWorld.js
Normal file
@@ -0,0 +1,662 @@
|
||||
// ── What an event MAKES on a Rust server (PLAN.md §28, protocol 9) ────────
|
||||
//
|
||||
// A lease borrows a value that was already there. These three verbs make
|
||||
// something that was not — a zone, crates, NPCs — and
|
||||
// give it back at teardown. Everything that decides what is allowed lives on the
|
||||
// plugin: the allowlist, the bounds, the monument vocabulary, the registry of
|
||||
// what each run owns. What is here is the contract's half: declarations core can
|
||||
// check an author's step against, and the three callables core calls.
|
||||
//
|
||||
// ── Four facts from the rig shape all of it (§28.1) ──────────────────────────
|
||||
//
|
||||
// * A restart is NOT proof a placed thing is gone. Crates are saved by the
|
||||
// game and come back with the same net id; NPCs are not. So `reconcile` asks
|
||||
// the plugin, which looks — `module-uo`'s `reconcileByBootId` trick would
|
||||
// orphan every crate on every restart.
|
||||
// * A wipe IS proof everything is gone, and the plugin drops its registry.
|
||||
// * The bridge has no at-most-once store, so its registry is keyed by core's
|
||||
// idempotency key: a retried step is answered with the first call's ids.
|
||||
// * Monument names repeat, so a monument is named by kind and instance (D93).
|
||||
//
|
||||
// ── The ref names the server ─────────────────────────────────────────────────
|
||||
//
|
||||
// Every resource is `<serverId>:<id>`. `revert` and `reconcile` are handed
|
||||
// resources and not the step's params, and a run may reach six servers; the ref
|
||||
// is the only place the server can travel with the thing.
|
||||
|
||||
const core = require('./core')
|
||||
const client = require('./sidecarClient')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||
|
||||
const log = core.logger('world')
|
||||
|
||||
/**
|
||||
* The budget every verb here declares. It must EXCEED the client's own timeout
|
||||
* (`TIMEOUT_MS`, 12 s), which in turn exceeds the sidecar's ten-second reply
|
||||
* timeout — otherwise core gives up first and a `retry: false` this module
|
||||
* answered is unreachable (MODULE_API §2.4). `world.test.js` asserts the order.
|
||||
*/
|
||||
const BUDGET_MS = 15000
|
||||
|
||||
// Mirrors of the plugin's bounds (D95, D96). The plugin's are authoritative and
|
||||
// an operator may set them lower, in which case its refusal is the one that
|
||||
// lands; these exist so a bad step is a refusal on the AUTHORING FORM and in a
|
||||
// dry run, rather than a step failing unattended at four in the morning.
|
||||
const MAX_CRATES = 25
|
||||
const MAX_NPCS = 20
|
||||
const MAX_SPREAD = 50
|
||||
const MAX_OFFSET = 150
|
||||
const ZONE_MIN_RADIUS = 5
|
||||
const ZONE_MAX_RADIUS = 150
|
||||
const ZONE_MAX_MINUTES = 7 * 24 * 60
|
||||
|
||||
/** The ledger kind both verbs file under. */
|
||||
const OWNED_KIND = 'world'
|
||||
|
||||
/**
|
||||
* What the plugin will place, mirroring its allowlist (D88).
|
||||
*
|
||||
* **Two copies of a short list, deliberately** — `module-uo`'s `GRANTABLE`
|
||||
* argument. This one prices a step (`cost()` is synchronous and cannot ask a
|
||||
* game) and fills the dropdown with every server off; the plugin's is what is
|
||||
* true when this one is wrong.
|
||||
*/
|
||||
const PLACEABLE = [
|
||||
{ key: 'crate.basic', kind: 'crate', label: 'Basic crate' },
|
||||
{ key: 'crate.normal', kind: 'crate', label: 'Military crate' },
|
||||
{ key: 'crate.normal2', kind: 'crate', label: 'Crate' },
|
||||
{ key: 'crate.elite', kind: 'crate', label: 'Elite crate' },
|
||||
{ key: 'crate.tools', kind: 'crate', label: 'Tool box' },
|
||||
{ key: 'crate.hackable', kind: 'crate', label: 'Locked crate (hackable)' },
|
||||
{ key: 'supply.drop', kind: 'crate', label: 'Supply drop' },
|
||||
{ key: 'barrel.loot', kind: 'crate', label: 'Loot barrel' },
|
||||
{ key: 'npc.scientist', kind: 'npc', label: 'Scientist' },
|
||||
{ key: 'npc.scientist.heavy', kind: 'npc', label: 'Heavy scientist' },
|
||||
{ key: 'npc.scientist.tethered', kind: 'npc', label: 'Scientist (stays put)' },
|
||||
{ key: 'npc.bandit.guard', kind: 'npc', label: 'Bandit guard' },
|
||||
]
|
||||
|
||||
/** The plugin's refusals a second attempt would repeat. Anything else is left to core's default. */
|
||||
const PERMANENT = new Set([
|
||||
'events-disabled',
|
||||
'malformed',
|
||||
'unknown-prefab',
|
||||
'out-of-range',
|
||||
'no-monument',
|
||||
'off-map',
|
||||
'zonemanager-missing',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.prefabs',
|
||||
label: 'Crates placed',
|
||||
unit: 'crates',
|
||||
description: 'Crates, barrels and supply drops an event puts in the world. Counted per server a run reaches.',
|
||||
},
|
||||
{
|
||||
id: 'rust.npcs',
|
||||
label: 'NPCs placed',
|
||||
unit: 'NPCs',
|
||||
description: 'Scientists and guards an event puts in the world — its own dial, so fights can be capped apart from loot (D89).',
|
||||
},
|
||||
{
|
||||
id: 'rust.zone.minutes',
|
||||
label: 'Zone time',
|
||||
unit: 'minutes',
|
||||
description: 'How long the zones an event opens stand, added up. Every zone declares its minutes, and the game erases it when they run out (D96).',
|
||||
},
|
||||
]
|
||||
|
||||
/** A number param, or undefined when left blank. */
|
||||
function num(raw) {
|
||||
if (raw === undefined || raw === null || raw === '') return undefined
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) ? value : NaN
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a step puts its thing — a monument plus an offset, or raw coordinates,
|
||||
* and exactly one of the two (D87) — and which server that is on.
|
||||
*
|
||||
* A monument value carries its server (`srv-a/harbor_1#2`, D93), so a monument
|
||||
* step needs no `server`; one that gives both must agree. Raw coordinates name
|
||||
* nothing, so they need `server`. Every refusal is `retry: false`: the second
|
||||
* attempt has the same params.
|
||||
*/
|
||||
function location(params) {
|
||||
const monument = String(params.monument || '').trim()
|
||||
const x = num(params.x)
|
||||
const z = num(params.z)
|
||||
const y = num(params.y)
|
||||
const byCoords = x !== undefined || z !== undefined
|
||||
let serverId = String(params.server || '').trim()
|
||||
|
||||
if (Boolean(monument) === byCoords) {
|
||||
return { ok: false, error: 'a location is a monument or x and z, and exactly one of them' }
|
||||
}
|
||||
|
||||
if (monument) {
|
||||
const slash = monument.indexOf('/')
|
||||
if (slash <= 0 || slash === monument.length - 1) {
|
||||
return { ok: false, error: `"${monument}" is not a monument — pick one from the list, as server/monument` }
|
||||
}
|
||||
const onServer = monument.slice(0, slash)
|
||||
if (serverId && serverId !== onServer) {
|
||||
return { ok: false, error: `that monument is on ${onServer}, not ${serverId}` }
|
||||
}
|
||||
serverId = onServer
|
||||
|
||||
const offsetX = num(params.offsetX) ?? 0
|
||||
const offsetZ = num(params.offsetZ) ?? 0
|
||||
if (Number.isNaN(offsetX) || Number.isNaN(offsetZ)) return { ok: false, error: 'an offset is a number of metres' }
|
||||
if (Math.hypot(offsetX, offsetZ) > MAX_OFFSET) {
|
||||
return { ok: false, error: `an offset from a monument is at most ${MAX_OFFSET} m` }
|
||||
}
|
||||
|
||||
return { ok: true, serverId, wire: { monument: monument.slice(slash + 1), offsetX, offsetZ } }
|
||||
}
|
||||
|
||||
if (x === undefined || z === undefined || Number.isNaN(x) || Number.isNaN(z)) {
|
||||
return { ok: false, error: 'coordinates need both x and z, as numbers' }
|
||||
}
|
||||
if (Number.isNaN(y)) return { ok: false, error: 'y is a number of metres, or left blank for the ground' }
|
||||
if (!serverId) return { ok: false, error: 'coordinates do not say which server — pick one' }
|
||||
|
||||
return { ok: true, serverId, wire: { x, z, ...(y === undefined ? {} : { y }) } }
|
||||
}
|
||||
|
||||
/** `<serverId>:<id>` — see the header. */
|
||||
const refOf = (serverId, id) => `${serverId}:${id}`
|
||||
|
||||
/** A ref split back into its server and id, at the FIRST colon (a server id has none). */
|
||||
function splitRef(ref) {
|
||||
const text = String(ref || '')
|
||||
const colon = text.indexOf(':')
|
||||
return colon <= 0 ? { serverId: null, id: text } : { serverId: text.slice(0, colon), id: text.slice(colon + 1) }
|
||||
}
|
||||
|
||||
/** Resources grouped by the server each one is on. */
|
||||
function byServer(resources) {
|
||||
const groups = new Map()
|
||||
for (const resource of resources || []) {
|
||||
const serverId = (resource.payload && resource.payload.serverId) || splitRef(resource.ref).serverId
|
||||
if (!groups.has(serverId)) groups.set(serverId, [])
|
||||
groups.get(serverId).push(resource)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
|
||||
function transportFailure(server, result, what) {
|
||||
const permanent = result.status === 'not-configured' || result.status === 'no-token'
|
||||
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one world write and file what came back.
|
||||
*
|
||||
* One resource per id — per crate, per NPC, per zone — like `module-uo`'s one
|
||||
* per serial, so a group half of which players looted reconciles per crate
|
||||
* rather than all or nothing.
|
||||
*/
|
||||
async function place(server, send, body, what) {
|
||||
const result = await send(server, body)
|
||||
if (!result.ok) return transportFailure(server, result, what)
|
||||
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'world.ok') {
|
||||
return {
|
||||
ok: false,
|
||||
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
|
||||
error: pluginError(data, `${server.name || server.id} refused the ${what}`),
|
||||
}
|
||||
}
|
||||
|
||||
const placed = Array.isArray(data.placed) ? data.placed : []
|
||||
return {
|
||||
ok: true,
|
||||
resources: placed.map((row) => ({
|
||||
kind: OWNED_KIND,
|
||||
ref: refOf(server.id, row.id),
|
||||
payload: {
|
||||
serverId: server.id,
|
||||
what: row.kind,
|
||||
...(row.prefab ? { prefab: row.prefab } : {}),
|
||||
...(row.name ? { name: row.name } : {}),
|
||||
},
|
||||
})),
|
||||
...(data.repeat ? { detail: { repeat: true, note: 'answered from the first attempt; nothing new was placed' } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give back what a step made.
|
||||
*
|
||||
* **No idempotency key goes with it.** `module-uo` shipped exactly that
|
||||
* mistake: its despawn carried the key the spawn went out under, the shard
|
||||
* recognised a repeat of the DO and answered with the spawn's reply, and every
|
||||
* teardown was a no-op that reported success (MODULE_API §2.4). A repeated
|
||||
* revert is safe here without one — the second finds everything `gone`.
|
||||
*
|
||||
* The one case the key IS for is the lost answer: core knows a dispatch went
|
||||
* out under it and never learned what it made, so `resources` is empty. The
|
||||
* step's server is not known then either — it was a param, and params do not
|
||||
* reach `revert` — so every enabled server is asked to give back whatever this
|
||||
* run placed under that key. A server that cannot be asked leaves the row
|
||||
* visible rather than guessing.
|
||||
*/
|
||||
async function revert({ runId, resources, idempotencyKey }) {
|
||||
const failed = []
|
||||
const errors = []
|
||||
|
||||
if (!resources || resources.length === 0) {
|
||||
if (!idempotencyKey) return { ok: true }
|
||||
for (const server of await servers.listForPolling()) {
|
||||
const result = await client.worldRevert(server, { runId: String(runId), key: idempotencyKey })
|
||||
if (!result.ok) errors.push(transportError(server, result, 'revert'))
|
||||
else if (!result.data || result.data.kind !== 'world.ok') errors.push(pluginError(result.data, `${server.name || server.id} refused the revert`))
|
||||
}
|
||||
return errors.length ? { ok: false, error: errors.join('; ') } : { ok: true }
|
||||
}
|
||||
|
||||
for (const [serverId, group] of byServer(resources)) {
|
||||
const found = await serverFor(serverId)
|
||||
if (!found.ok) {
|
||||
failed.push(...group.map((r) => r.ref))
|
||||
errors.push(found.error)
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await client.worldRevert(found.server, {
|
||||
runId: String(runId),
|
||||
ids: group.map((r) => splitRef(r.ref).id),
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
failed.push(...group.map((r) => r.ref))
|
||||
errors.push(transportError(found.server, result, 'revert'))
|
||||
continue
|
||||
}
|
||||
|
||||
// **A 200 is not a success on this bridge** — a refusal comes back as one,
|
||||
// carrying `world.error` (`not-ready` while the world is still loading).
|
||||
// Read as success it would mark every row reverted while the game still
|
||||
// held every crate.
|
||||
if (!result.data || result.data.kind !== 'world.ok') {
|
||||
failed.push(...group.map((r) => r.ref))
|
||||
errors.push(pluginError(result.data, `${found.server.name || found.server.id} refused the revert`))
|
||||
continue
|
||||
}
|
||||
|
||||
// `gone` is not reported: a crate a player looted is the point of having
|
||||
// placed it. `refused` IS — the plugin found something there that this run
|
||||
// did not make, and nothing will ever remove it through this path.
|
||||
const refused = new Set(((result.data && result.data.refused) || []).map(String))
|
||||
for (const r of group) if (refused.has(splitRef(r.ref).id)) failed.push(r.ref)
|
||||
}
|
||||
|
||||
if (!failed.length) return { ok: true }
|
||||
if (failed.length === resources.length && errors.length) return { ok: false, error: errors.join('; ') }
|
||||
return { ok: true, failed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of these does the world still hold?
|
||||
*
|
||||
* The plugin LOOKS for each one, by net id or zone id. A server that cannot be
|
||||
* asked has said nothing, so its resources are all reported in force — "I do
|
||||
* not know" is never "it is gone" (MODULE_API §1.1).
|
||||
*/
|
||||
async function reconcile({ runId, resources }) {
|
||||
const inForce = []
|
||||
|
||||
for (const [serverId, group] of byServer(resources)) {
|
||||
const found = await serverFor(serverId)
|
||||
const result = found.ok ? await client.worldOwned(found.server, { runId: String(runId) }) : null
|
||||
|
||||
if (!result || !result.ok || !result.data || !Array.isArray(result.data.owned)) {
|
||||
inForce.push(...group.map((r) => r.ref))
|
||||
continue
|
||||
}
|
||||
|
||||
const held = new Set(result.data.owned.map((row) => String(row.id)))
|
||||
for (const r of group) if (held.has(splitRef(r.ref).id)) inForce.push(r.ref)
|
||||
}
|
||||
|
||||
return { ok: true, inForce }
|
||||
}
|
||||
|
||||
/** The location params both verbs share, so two declarations cannot drift apart. */
|
||||
const LOCATION_PARAMS = [
|
||||
{
|
||||
name: 'monument',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'main/powerplant_1',
|
||||
source: 'rust.options.monuments',
|
||||
description: 'Where, by monument. Give this OR x and z. Names the server too.',
|
||||
},
|
||||
{
|
||||
name: 'offsetX',
|
||||
type: 'float',
|
||||
required: false,
|
||||
example: 20,
|
||||
description: `Metres east of the monument's centre (negative is west). Up to ${MAX_OFFSET} m from it in all.`,
|
||||
},
|
||||
{
|
||||
name: 'offsetZ',
|
||||
type: 'float',
|
||||
required: false,
|
||||
example: -15,
|
||||
description: "Metres north of the monument's centre (negative is south).",
|
||||
},
|
||||
{
|
||||
name: 'server',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'main',
|
||||
source: 'rust.options.servers',
|
||||
description: 'Which server, when the location is coordinates. A monument already says.',
|
||||
},
|
||||
{ name: 'x', type: 'float', required: false, example: -604, description: 'World x, instead of a monument.' },
|
||||
{ name: 'z', type: 'float', required: false, example: -342, description: 'World z, instead of a monument.' },
|
||||
{
|
||||
name: 'y',
|
||||
type: 'float',
|
||||
required: false,
|
||||
example: 30,
|
||||
description: 'Height. Left blank, the ground at x and z.',
|
||||
},
|
||||
]
|
||||
|
||||
const WORLD_COMMON = {
|
||||
// Something appears where there was nothing. §K puts the default-off line
|
||||
// between `inspect` and `change`, so an operator switches these on
|
||||
// deliberately — the right consent for an unattended change to a live world.
|
||||
risk: 'change',
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
revert,
|
||||
reconcile,
|
||||
}
|
||||
|
||||
/**
|
||||
* One placing verb per KIND (D97), not one verb for both.
|
||||
*
|
||||
* Core learns which caps an action accepts by pricing that action's declared
|
||||
* EXAMPLES once, and drops a dimension priced at zero. So a single verb whose
|
||||
* cost moved between `rust.prefabs` and `rust.npcs` by its `prefab` param could
|
||||
* only ever show the operator the crates cap, and D89's separate dial for fights
|
||||
* would be unreachable. Two verbs, each pricing exactly one dimension, is also
|
||||
* what lets the switchboard allow crates and leave NPCs off.
|
||||
*/
|
||||
function placeVerb({ id, kind, budget, max, label, description, source, example }) {
|
||||
const noun = kind === 'npc' ? 'NPCs' : 'crates'
|
||||
return {
|
||||
...WORLD_COMMON,
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
cost: (p) => ({ [budget]: Math.max(0, Math.round(Number(p.count) || 0)) }),
|
||||
params: [
|
||||
{
|
||||
name: 'prefab',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example,
|
||||
source,
|
||||
description: `Which of the server's own ${noun} to place.`,
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 3,
|
||||
description: `How many — 1 to ${max} at a time.`,
|
||||
},
|
||||
{
|
||||
name: 'spread',
|
||||
type: 'float',
|
||||
required: false,
|
||||
example: 10,
|
||||
description: `How widely to scatter a group, up to ${MAX_SPREAD} m. Left blank, 10.`,
|
||||
},
|
||||
...LOCATION_PARAMS,
|
||||
],
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const known = PLACEABLE.find((x) => x.key === String(params.prefab || '').trim())
|
||||
if (!known || known.kind !== kind) {
|
||||
return { ok: false, retry: false, error: `"${params.prefab}" is not one of the ${noun} a Rust server places for events` }
|
||||
}
|
||||
|
||||
const count = Number(params.count)
|
||||
if (!Number.isInteger(count) || count < 1 || count > max) {
|
||||
return { ok: false, retry: false, error: `place 1 to ${max} ${noun} at a time, and "${params.count}" is not that` }
|
||||
}
|
||||
|
||||
const spread = num(params.spread)
|
||||
if (Number.isNaN(spread) || (spread !== undefined && (spread < 0 || spread > MAX_SPREAD))) {
|
||||
return { ok: false, retry: false, error: `a scatter is 0 to ${MAX_SPREAD} m, not "${params.spread}"` }
|
||||
}
|
||||
|
||||
const where = location(params)
|
||||
if (!where.ok) return { ok: false, retry: false, error: where.error }
|
||||
|
||||
const found = await serverFor(where.serverId)
|
||||
if (!found.ok) return found
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
return place(
|
||||
found.server,
|
||||
client.worldPlace,
|
||||
{
|
||||
runId: String(runId),
|
||||
key: idempotencyKey,
|
||||
prefab: known.key,
|
||||
count,
|
||||
...(spread === undefined ? {} : { spread }),
|
||||
...where.wire,
|
||||
},
|
||||
known.label.toLowerCase(),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{
|
||||
...WORLD_COMMON,
|
||||
id: 'rust.zone.open',
|
||||
label: 'Open a zone',
|
||||
description:
|
||||
'A ZoneManager zone at a monument or a point, for a set number of minutes. The game erases it when they run out, even if this site is down; teardown erases it sooner.',
|
||||
cost: (p) => ({ 'rust.zone.minutes': Math.max(0, Math.round(Number(p.minutes) || 0)) }),
|
||||
params: [
|
||||
...LOCATION_PARAMS,
|
||||
{
|
||||
name: 'radius',
|
||||
type: 'float',
|
||||
required: true,
|
||||
example: 40,
|
||||
description: `How far the zone reaches, ${ZONE_MIN_RADIUS} to ${ZONE_MAX_RADIUS} m.`,
|
||||
},
|
||||
{
|
||||
name: 'minutes',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 120,
|
||||
description: `How long it stands, up to ${ZONE_MAX_MINUTES} (seven days). Counted against zone time.`,
|
||||
},
|
||||
{ name: 'name', type: 'string', required: false, example: 'Airfield brawl', description: 'What the zone is called.' },
|
||||
],
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const where = location(params)
|
||||
if (!where.ok) return { ok: false, retry: false, error: where.error }
|
||||
|
||||
const radius = Number(params.radius)
|
||||
if (!Number.isFinite(radius) || radius < ZONE_MIN_RADIUS || radius > ZONE_MAX_RADIUS) {
|
||||
return { ok: false, retry: false, error: `a zone's radius is ${ZONE_MIN_RADIUS} to ${ZONE_MAX_RADIUS} m, not "${params.radius}"` }
|
||||
}
|
||||
|
||||
const minutes = Number(params.minutes)
|
||||
if (!Number.isInteger(minutes) || minutes < 1 || minutes > ZONE_MAX_MINUTES) {
|
||||
return { ok: false, retry: false, error: `a zone stands for 1 to ${ZONE_MAX_MINUTES} minutes, not "${params.minutes}"` }
|
||||
}
|
||||
|
||||
const found = await serverFor(where.serverId)
|
||||
if (!found.ok) return found
|
||||
|
||||
// The dry run stops here, and has checked everything it can without the
|
||||
// game. It does not ask whether the monument exists: a step authored for
|
||||
// next wipe's map would fail every dry run until the wipe.
|
||||
if (verify) return { ok: true }
|
||||
|
||||
return place(
|
||||
found.server,
|
||||
client.worldZone,
|
||||
{
|
||||
runId: String(runId),
|
||||
key: idempotencyKey,
|
||||
...where.wire,
|
||||
radius,
|
||||
holdMs: minutes * 60000,
|
||||
...(params.name ? { name: String(params.name).slice(0, 64) } : {}),
|
||||
},
|
||||
'zone',
|
||||
)
|
||||
},
|
||||
},
|
||||
placeVerb({
|
||||
id: 'rust.crate.place',
|
||||
kind: 'crate',
|
||||
budget: 'rust.prefabs',
|
||||
max: MAX_CRATES,
|
||||
label: 'Place crates',
|
||||
description:
|
||||
'Crates, barrels or a supply drop at a monument or a point, scattered a little. Taken away at teardown; a crate somebody looted is simply gone.',
|
||||
source: 'rust.options.crates',
|
||||
example: 'crate.elite',
|
||||
}),
|
||||
placeVerb({
|
||||
id: 'rust.npc.place',
|
||||
kind: 'npc',
|
||||
budget: 'rust.npcs',
|
||||
max: MAX_NPCS,
|
||||
label: 'Place NPCs',
|
||||
description:
|
||||
'Scientists or guards at a monument or a point. Taken away at teardown. The game does not save NPCs, so a restart ends them; the ledger then says so.',
|
||||
source: 'rust.options.npcs',
|
||||
example: 'npc.scientist',
|
||||
}),
|
||||
]
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
// Every server's map, live. A procedural map changes at every wipe, so a
|
||||
// cached list would offer monuments that are not there any more.
|
||||
id: 'rust.options.monuments',
|
||||
label: 'Monuments',
|
||||
description: "Each server's monuments on its current map. A kind that repeats is numbered, #1 first (D93).",
|
||||
searchable: true,
|
||||
async resolve({ q } = {}) {
|
||||
const term = String(q || '').trim().toLowerCase()
|
||||
const answers = await perServer((server) => client.worldMonuments(server))
|
||||
const rows = []
|
||||
for (const { server, result } of answers) {
|
||||
for (const m of (result.data && result.data.monuments) || []) {
|
||||
if (!m || !m.value) continue
|
||||
const label = `${m.label}${m.of > 1 ? ` #${m.instance}` : ''}${m.grid ? ` · ${m.grid}` : ''}`
|
||||
const value = `${server.id}/${m.value}`
|
||||
if (term && !value.toLowerCase().includes(term) && !label.toLowerCase().includes(term)) continue
|
||||
rows.push({ value, label, group: server.name || server.id })
|
||||
}
|
||||
}
|
||||
return bounded(rows, 'rust.options.monuments')
|
||||
},
|
||||
},
|
||||
// From the mirror, so both answer with every server off (the field they fill
|
||||
// must never be taken away by an outage, MODULE_API §2.4). One per verb (D97).
|
||||
...['crate', 'npc'].map((kind) => ({
|
||||
id: kind === 'npc' ? 'rust.options.npcs' : 'rust.options.crates',
|
||||
label: kind === 'npc' ? 'NPCs' : 'Crates',
|
||||
description: `The ${kind === 'npc' ? 'NPCs' : 'crates'} a Rust server places for events.`,
|
||||
async resolve() {
|
||||
return PLACEABLE.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label }))
|
||||
},
|
||||
})),
|
||||
]
|
||||
|
||||
// ── The watch (§11.1) ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Core asks the module what the world still holds once, at its own boot, and
|
||||
// otherwise waits to be told. A game that restarted or wiped under a running
|
||||
// event is the moment to tell it: the boot id changes on a restart, the wipe
|
||||
// id on a wipe, and neither changes on a sidecar reconnect — which loses
|
||||
// nothing and must not provoke a sweep.
|
||||
|
||||
const lastSeen = new Map()
|
||||
|
||||
/**
|
||||
* Note a server's identity as the refresh saw it, and ask core to reconcile when
|
||||
* it moved — once its world is loaded. The first sighting after this module boots is a baseline, not a
|
||||
* change: core's own boot reconcile already covered it.
|
||||
*/
|
||||
function observeServer(serverId, { bootId, wipeId, worldReady } = {}) {
|
||||
if (!serverId || (!bootId && !wipeId)) return false
|
||||
|
||||
// **Not until the world is loaded** (§28.6). The plugin connects before the
|
||||
// save loads, so the new boot id arrives while every crate still looks gone;
|
||||
// asked then, reconcile would orphan the lot. The plugin says when it is
|
||||
// ready, and the change is noticed on that hello instead. An older plugin
|
||||
// that never says is taken as ready, as it always was.
|
||||
if (worldReady === false) return false
|
||||
|
||||
const previous = lastSeen.get(serverId)
|
||||
lastSeen.set(serverId, { bootId: bootId || null, wipeId: wipeId || null })
|
||||
if (!previous) return false
|
||||
|
||||
const restarted = Boolean(bootId && previous.bootId && bootId !== previous.bootId)
|
||||
const wiped = Boolean(wipeId && previous.wipeId && wipeId !== previous.wipeId)
|
||||
if (!restarted && !wiped) return false
|
||||
|
||||
log.info('game changed under the events ledger; asking core to reconcile', {
|
||||
server: serverId,
|
||||
...(restarted ? { restarted: { from: previous.bootId, to: bootId } } : {}),
|
||||
...(wiped ? { wiped: { from: previous.wipeId, to: wipeId } } : {}),
|
||||
})
|
||||
|
||||
try {
|
||||
Promise.resolve(core.reconcileEvents()).catch((err) => log.warn('reconcile failed', { error: err.message }))
|
||||
} catch (err) {
|
||||
log.warn('reconcile failed', { error: err.message })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
function resetWatch() {
|
||||
lastSeen.clear()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BUDGET_MS,
|
||||
MAX_CRATES,
|
||||
MAX_NPCS,
|
||||
ZONE_MAX_MINUTES,
|
||||
PLACEABLE,
|
||||
BUDGETS,
|
||||
ACTIONS,
|
||||
OPTION_SOURCES,
|
||||
location,
|
||||
splitRef,
|
||||
revert,
|
||||
reconcile,
|
||||
observeServer,
|
||||
resetWatch,
|
||||
}
|
||||
115
server/index.js
115
server/index.js
@@ -50,6 +50,15 @@ module.exports = function register(ctx, api) {
|
||||
const publicRust = require('./router/public/rust.router')
|
||||
const playerRust = require('./router/player/rust.router')
|
||||
const adminRust = require('./router/admin/rust.router')
|
||||
const usersRust = require('./router/admin/usersRust.router')
|
||||
const teamProvider = require('./model/clans/teamProvider')
|
||||
const { TRIGGERS } = require('./engagement/triggers')
|
||||
const { STREAMS } = require('./engagement/streams')
|
||||
const { AUDIENCES } = require('./engagement/audiences')
|
||||
const seeds = require('./engagement/seeds')
|
||||
const eventLeases = require('./eventLeases')
|
||||
const eventWorld = require('./eventWorld')
|
||||
const eventRewards = require('./eventRewards')
|
||||
const boot = require('./boot')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -78,6 +87,57 @@ module.exports = function register(ctx, api) {
|
||||
admin: { '/rust': adminRust },
|
||||
})
|
||||
|
||||
// R13's first extension slot (§2.4). Core declares `admin.users.detail` on
|
||||
// `/api/v1/admin/users/:id` and we fill it; the router receives the parent's
|
||||
// `req.params.id` through `mergeParams`. Core's own routes on the resource are
|
||||
// declared before the slot is mounted, so core wins any path conflict — it owns
|
||||
// the user, and this module owns what it can say about one.
|
||||
//
|
||||
// **It is declared twice, in two different places, on purpose.** This call is
|
||||
// the SERVER half and `module.json`'s `extensions` array is held against it by
|
||||
// the loader. The CLIENT half is `registry.registerExtension(ID,
|
||||
// 'admin.users.detail', …)` in `entry.jsx` and must NOT appear in that array —
|
||||
// phase 1 found that the hard way with `site.footer.status`, which is a client
|
||||
// slot and fails the load outright when named there.
|
||||
api.registerExtension('admin.users.detail', usersRust)
|
||||
|
||||
// Teams (R5, PLAN.md §24). A first-party Rust clan is a Team, and this module
|
||||
// becomes the deployment's one authoritative source of them. Core asks; the
|
||||
// provider answers from the clan boards (`model/clans`), and refuses rather
|
||||
// than guessing whenever no board is current.
|
||||
//
|
||||
// **One provider per deployment**, so a site running module-uo as well cannot
|
||||
// have both — the second registration is a collision core reports against the
|
||||
// module that made it. That is core's rule and a real constraint on a mixed
|
||||
// UO + Rust site; it is recorded in §24 rather than worked around here.
|
||||
api.registerTeamProvider(teamProvider)
|
||||
|
||||
// Notifications and engagement (R7, PLAN.md §25). Four registrations that are
|
||||
// one decision, because they only mean something together:
|
||||
//
|
||||
// triggers what can happen, what a template may say about it, and the
|
||||
// widest audience a rule on it may EVER have — the security
|
||||
// boundary; core refuses a rule that widens a ceiling
|
||||
// streams which of those may reach a phone. Core pushes an engagement
|
||||
// rule only to devices subscribed to a stream of the SAME id, so
|
||||
// a trigger missing here can never buzz anybody (D65)
|
||||
// audiences named sets of people over this module's data, for an operator
|
||||
// to point a rule at; each answers user ids and nothing else
|
||||
// seeds the two bodies worth writing, and one disabled rule group per
|
||||
// family — installing this module mails nobody
|
||||
//
|
||||
// What fires them is `engagement/emit.js`, off the ingest cursor and the
|
||||
// refresh. Registration is a claim, not a call: nothing here touches the
|
||||
// database, and the seeds are written by core after the schema is up.
|
||||
//
|
||||
// The announce leg arrived with phase 13b (D62, D104), when there was a chat
|
||||
// verb to deliver through; it is registered below with the rewards. There is
|
||||
// still no post hook: nothing in game mirrors a post as state.
|
||||
api.registerEventTriggers(TRIGGERS)
|
||||
api.registerNotificationStreams(STREAMS)
|
||||
api.registerAudiences(AUDIENCES)
|
||||
api.registerEngagementSeeds({ templates: seeds.TEMPLATES, ruleGroups: seeds.RULE_GROUPS })
|
||||
|
||||
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
|
||||
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
|
||||
// that must not serve traffic until it has warmed a cache gets that for free.
|
||||
@@ -90,16 +150,57 @@ module.exports = function register(ctx, api) {
|
||||
api.onBoot(boot.onBoot)
|
||||
api.onShutdown(boot.onShutdown)
|
||||
|
||||
// Everything else this module will register — the Team provider, the event
|
||||
// triggers and audiences, the engagement seeds, the four event catalogues, the
|
||||
// notification streams, the slash commands and the two extension slots — is
|
||||
// deliberately absent. Each arrives with the phase that has something real to
|
||||
// put in it. A registration with nothing behind it is worse than a missing one:
|
||||
// a declared trigger nothing emits and a declared slot nothing fills are both
|
||||
// surfaces an operator can configure and then wait on.
|
||||
// The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server
|
||||
// and must give back. Core's `core.lease` is the verb; these are the values it
|
||||
// may name and the four callables each ships. Every lease is targeted and the
|
||||
// target names the server (D73), which is how one value on one server gets
|
||||
// exactly one holder without core learning what a server is.
|
||||
//
|
||||
// The option sources are the three targets' own (D78).
|
||||
api.registerEventLeases(eventLeases.LEASES)
|
||||
|
||||
// The world verbs (PLAN.md §28, protocol 9): what an event MAKES and gives
|
||||
// back — a zone, crates, NPCs — and the budgets that price them, each declared
|
||||
// beside the verb that spends it (D79, D89). A lease spends none of them.
|
||||
//
|
||||
// The rewards (PLAN.md §29, protocol 10) join them: the tally, the kit reward
|
||||
// and the chat line, with the two budgets they spend. Registered in the same
|
||||
// calls' neighbours, not merged into eventWorld's arrays, so each file keeps
|
||||
// its own statement of what it declares.
|
||||
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS])
|
||||
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS])
|
||||
|
||||
// News in game chat (D104). Core enqueues every registered leg for every
|
||||
// published post, so the leg itself sends only to the servers whose switch an
|
||||
// operator turned on — off by default, and a server that is down is skipped.
|
||||
api.registerAnnounceLeg(eventRewards.LEG)
|
||||
|
||||
// ONE call for every option source: core takes a batch once, as this module's
|
||||
// complete statement, and refuses a second.
|
||||
api.registerEventOptionSources([
|
||||
...eventLeases.OPTION_SOURCES,
|
||||
...eventWorld.OPTION_SOURCES,
|
||||
...eventRewards.OPTION_SOURCES,
|
||||
])
|
||||
|
||||
// Everything else this module will register — the slash commands — is
|
||||
// deliberately absent, and arrives with the phase that has something real to
|
||||
// put in it. A registration with nothing behind it is worse than a missing
|
||||
// one: a declared trigger nothing emits and a declared slot nothing fills are
|
||||
// both surfaces an operator can configure and then wait on.
|
||||
|
||||
log.info('registered', {
|
||||
version: require('../module.json').version,
|
||||
routes: 'public:/rust player:/rust admin:/rust',
|
||||
extensions: 'admin.users.detail',
|
||||
teams: 'first-party clans',
|
||||
triggers: TRIGGERS.length,
|
||||
streams: STREAMS.length,
|
||||
audiences: AUDIENCES.length,
|
||||
leases: eventLeases.LEASES.length,
|
||||
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length,
|
||||
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length,
|
||||
announceLeg: eventRewards.LEG.leg,
|
||||
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length,
|
||||
})
|
||||
}
|
||||
|
||||
335
server/ingest.js
Normal file
335
server/ingest.js
Normal file
@@ -0,0 +1,335 @@
|
||||
// ── Reading a sidecar's feed, and turning it into a record ────────────────
|
||||
//
|
||||
// One job: move each server's cursor forward, and apply what it passed.
|
||||
//
|
||||
// ── Why a cursor and not a socket ─────────────────────────────────────────
|
||||
//
|
||||
// The obvious design is a WebSocket — the sidecar has one, and module-uo takes
|
||||
// exactly that route for the UO bridge. This module polls a cursor instead, and
|
||||
// the reason is not laziness about latency.
|
||||
//
|
||||
// Core runs on Node 20, where a global `WebSocket` is still behind a flag, so a
|
||||
// socket means taking `ws` as a runtime dependency — and this module's release
|
||||
// asserts that it has none (D5: everything it needs arrives on `ctx`, and the
|
||||
// bundle ships no `node_modules`). That is a cost worth paying for latency, but
|
||||
// the deciding argument is the other one: **a socket needs a cursor anyway.**
|
||||
// Whatever a feed misses while a module is restarting has to be caught up from
|
||||
// somewhere, and the catch-up path is the one that must be right. A socket on
|
||||
// top of a cursor is two mechanisms where the second is load-bearing; a cursor
|
||||
// alone is one mechanism that is exercised every few seconds rather than only
|
||||
// after an outage nobody planned.
|
||||
//
|
||||
// What it costs is seconds of latency on a killfeed. What it buys is that the
|
||||
// path which recovers from a five-hour outage is the same path that ran a moment
|
||||
// ago.
|
||||
//
|
||||
// ── The ordering the whole thing rests on ─────────────────────────────────
|
||||
//
|
||||
// **The cursor advances after the batch is written, never before.** A crash
|
||||
// between the two re-reads events already counted, which inflates a total; a
|
||||
// crash the other way round loses them silently and for ever. Neither is good and
|
||||
// they are not equally bad — one is visible and bounded, the other is invisible
|
||||
// and permanent — so the code is arranged to fail in the visible direction.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const clans = require('./model/clans/clans.model')
|
||||
const db = require('./model/events/events.db')
|
||||
const engagement = require('./engagement/emit')
|
||||
const links = require('./model/links/links.model')
|
||||
const permissionsDb = require('./model/permissions/permissions.db')
|
||||
const sidecar = require('./sidecarClient')
|
||||
|
||||
const log = core.logger('ingest')
|
||||
|
||||
/** How many events to ask for at once. */
|
||||
const BATCH = 200
|
||||
|
||||
/**
|
||||
* How many batches one tick will drain before letting the loop breathe.
|
||||
*
|
||||
* A module that has been down for a day has thousands of events waiting, and
|
||||
* draining them in one unbounded loop would hold the tick — and a pool
|
||||
* connection — for as long as that takes. Bounded, it catches up over several
|
||||
* ticks and the site stays responsive while it does.
|
||||
*/
|
||||
const MAX_BATCHES_PER_TICK = 10
|
||||
|
||||
/**
|
||||
* Applies one feed item.
|
||||
*
|
||||
* Every frame is stored raw, and only some of them move a counter. That split is
|
||||
* deliberate: the raw row is what an admin reads and what a later phase can
|
||||
* re-derive from, and the counters are what a leaderboard sums. A kind this
|
||||
* build has never heard of still lands in `rust_events` — it costs nothing and
|
||||
* the alternative is losing the one copy of an event the next version will know
|
||||
* how to read.
|
||||
*/
|
||||
async function apply(serverId, item, server = null) {
|
||||
const frame = (item && item.frame) || {}
|
||||
const kind = item.kind || frame.kind
|
||||
const wipeId = frame.wipeId || null
|
||||
|
||||
// A wipe exists because something mentioned it. There is no "a wipe started"
|
||||
// call and there must not be one: the website is not there when a wipe happens.
|
||||
await db.touchWipe(serverId, wipeId, frame.saveCreatedAt || null)
|
||||
|
||||
await db.insertEvent({
|
||||
serverId,
|
||||
wipeId,
|
||||
kind,
|
||||
t: Number(frame.t) || item.t || Date.now(),
|
||||
steamId: frame.steamId || null,
|
||||
raw: frame,
|
||||
})
|
||||
|
||||
// What core's engagement engine is told (PLAN.md §25). BEFORE the frame is
|
||||
// applied, because applying a disband deletes the roster the notification is
|
||||
// for. Never throws, and does not hold the cursor on core: `onEvent` resolves
|
||||
// who a frame is about and hands it over, and delivery is core's own time.
|
||||
if (server) await engagement.onEvent(server, item)
|
||||
|
||||
const at = { serverId, wipeId, steamId: frame.steamId }
|
||||
|
||||
switch (kind) {
|
||||
case 'player.connected':
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
break
|
||||
|
||||
case 'player.disconnected': {
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
|
||||
// `sessionSec` is ABSENT when the plugin never saw the connect — a player
|
||||
// already on the server when it loaded. Absent is not zero: adding a zero
|
||||
// would be recording a session of no length, which is a different claim
|
||||
// from recording no session, and it is the one that quietly under-reports
|
||||
// playtime for ever.
|
||||
const seconds = Number(frame.sessionSec)
|
||||
await db.addStats(at, {
|
||||
sessions: Number.isFinite(seconds) ? 1 : 0,
|
||||
playtimeSec: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'player.death': {
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
|
||||
// A suicide is a death AND a suicide, not one instead of the other: the
|
||||
// deaths column is "how many times did this player die", and a leaderboard
|
||||
// that silently omitted self-inflicted ones would disagree with the
|
||||
// killfeed sitting next to it on the same page.
|
||||
await db.addStats(at, { deaths: 1, suicides: frame.attackerType === 'self' ? 1 : 0 })
|
||||
|
||||
// Only a real player's kill counts. `npc` and `environment` have no
|
||||
// attacker to credit, and `self` must not credit the victim with a kill —
|
||||
// which is the one line here that would look right in review and produce a
|
||||
// leaderboard topped by whoever died the most.
|
||||
if (frame.attackerType === 'player' && frame.attackerId) {
|
||||
await db.touchPlayer(frame.attackerId, frame.attackerName || null)
|
||||
await db.addStats({ ...at, steamId: frame.attackerId }, { kills: 1 })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'player.tally': {
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
await db.addStats(at, {
|
||||
npcKills: Number(frame.npcKills) || 0,
|
||||
structures: Number(frame.structures) || 0,
|
||||
})
|
||||
|
||||
// A tally is a DELTA since the last flush, which is what makes adding it
|
||||
// correct. If it ever becomes a running total this loop doubles every
|
||||
// number in it, slowly, and looks right the whole time.
|
||||
const gathered = frame.gathered || {}
|
||||
for (const [resource, amount] of Object.entries(gathered)) {
|
||||
await db.addGathered(at, resource, Number(amount) || 0)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'player.chat':
|
||||
case 'player.respawned':
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
break
|
||||
|
||||
// ── Protocol 3: the one frame that changes something other than a counter ──
|
||||
//
|
||||
// `/unlink` in game severs the site's link, and it is the only way out of a
|
||||
// link on the wrong account: the site REFUSES to move a Steam id another
|
||||
// website account already holds (D23), so without this a player who linked
|
||||
// while signed in as the wrong account would need staff.
|
||||
//
|
||||
// It arrives here rather than through a route because the plugin has nothing
|
||||
// to delete — the site is the author of record and the game holds no link —
|
||||
// so `/unlink` is the game reporting what the player asked for, applied off
|
||||
// the feed like every other frame.
|
||||
//
|
||||
// **The authority is the Steam account itself.** Whoever is connected to the
|
||||
// game as it is who it is, which is a stronger proof of ownership than the
|
||||
// site can obtain any other way, so this is not scoped by website user.
|
||||
case 'account.unlinked':
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
await links.unlinkFromGame(frame.steamId)
|
||||
break
|
||||
|
||||
// Stored and counted as a sighting, nothing more. The code is deliberately
|
||||
// NOT on this frame — it travels through the player — so there is nothing
|
||||
// here to redeem and no pending state for the site to hold. It exists so an
|
||||
// operator can see linking being used at all.
|
||||
case 'account.link.requested':
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
break
|
||||
|
||||
// ── Protocol 4: somebody changed the permission store, and it was not us ──
|
||||
//
|
||||
// The plugin raises this only for writes it did not make itself — its own
|
||||
// sync suppresses the hooks while it applies (PROTOCOL.md §10.4). What
|
||||
// arrives here is therefore a hand edit, a console command, or another
|
||||
// plugin granting something.
|
||||
//
|
||||
// **It is a reason to reconcile, not the reconciliation.** This frame cannot
|
||||
// say whether the change is foreign: only the desired set can, and that
|
||||
// comparison happens in the sync. So the server is marked dirty and the next
|
||||
// tick produces the authoritative answer — which means a hook that stops
|
||||
// firing on a framework upgrade costs latency and nothing else. The audit
|
||||
// interval finds the same drift within fifteen minutes either way.
|
||||
case 'perm.drift':
|
||||
await permissionsDb.markDirty(serverId)
|
||||
break
|
||||
|
||||
// ── Protocol 6: first-party clans ──────────────────────────────────────
|
||||
//
|
||||
// Each one is told to core as it happens (`ctx.teams.publish`) and written
|
||||
// to the clan's Team feed as a members-only line (D49). Neither is the
|
||||
// record: the `clans` board the plugin re-sends a few seconds later is what
|
||||
// the store is rebuilt from, so an event this module never saw costs a
|
||||
// feed line and nothing else.
|
||||
//
|
||||
// No `touchPlayer` here, on purpose: it moves `last_seen`, and a kick is
|
||||
// done TO somebody who may be offline. `model/clans` notes names without it.
|
||||
case 'clan.created':
|
||||
case 'clan.disbanded':
|
||||
case 'clan.member.added':
|
||||
case 'clan.member.left':
|
||||
case 'clan.member.kicked':
|
||||
await clans.applyEvent(serverId, frame)
|
||||
break
|
||||
|
||||
default:
|
||||
// Stored, not counted. Moderation frames, the server lifecycle, and
|
||||
// anything a newer protocol sends that this build does not understand.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings one server's cursor up to date.
|
||||
*
|
||||
* Returns the number of events applied, for the log and for the tests.
|
||||
*/
|
||||
async function ingestServer(server) {
|
||||
const cursor = await db.getCursor(server.id)
|
||||
|
||||
// A server this module has never ingested starts at the sidecar's CURRENT end,
|
||||
// not at zero. A module installed today against a sidecar that has been running
|
||||
// for a month should read what happens next — replaying a fortnight of deaths
|
||||
// into stats for wipes it never saw is not a catch-up, it is inventing a
|
||||
// history it was not present for. `/feed` with no `since` asks exactly that
|
||||
// question, which is why the sidecar answers it that way.
|
||||
if (!cursor) {
|
||||
const tail = await sidecar.feedTail(server)
|
||||
|
||||
if (!tail.ok || !tail.data) {
|
||||
// Unreachable. Write nothing: a cursor of 0 written now would replay the
|
||||
// whole retained history the moment the sidecar came back.
|
||||
return 0
|
||||
}
|
||||
|
||||
await db.setCursor(server.id, Number(tail.data.lastId) || 0, 0)
|
||||
log.info('cursor started at the feed tail', { server: server.id, at: tail.data.lastId })
|
||||
return 0
|
||||
}
|
||||
|
||||
let since = Number(cursor.lastEventId) || 0
|
||||
let applied = 0
|
||||
|
||||
for (let batch = 0; batch < MAX_BATCHES_PER_TICK; batch += 1) {
|
||||
const res = await sidecar.feed(server, since, BATCH)
|
||||
|
||||
if (!res.ok || !res.data) return applied
|
||||
|
||||
const items = Array.isArray(res.data.items) ? res.data.items : []
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
await apply(server.id, item, server)
|
||||
applied += 1
|
||||
} catch (err) {
|
||||
// One malformed event must not wedge a server's cursor for ever. It is
|
||||
// logged with its id so it can be found, and the cursor moves past it:
|
||||
// the alternative is an ingest that stops at a single bad row and then
|
||||
// silently stops being a feed at all.
|
||||
log.warn('could not apply an event', {
|
||||
server: server.id,
|
||||
id: item && item.id,
|
||||
kind: item && item.kind,
|
||||
error: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const lastId = Number(res.data.lastId)
|
||||
|
||||
if (Number.isFinite(lastId) && lastId > since) {
|
||||
// AFTER the batch. See the header.
|
||||
await db.setCursor(server.id, lastId, items.length)
|
||||
since = lastId
|
||||
}
|
||||
|
||||
if (!res.data.more) break
|
||||
}
|
||||
|
||||
if (applied > 0) {
|
||||
log.info('ingested', { server: server.id, events: applied, cursor: since })
|
||||
// A leader can only change when something was applied. Never throws.
|
||||
await engagement.checkLeader(server)
|
||||
}
|
||||
|
||||
return applied
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the boards: what is true right now, rather than what happened.
|
||||
*
|
||||
* `players.online` replaces the presence rows wholesale, because that is what a
|
||||
* board is. Storing it as history is the mistake the wire's `type` field exists
|
||||
* to prevent, and it would be a poor return for the sidecar's trouble to make it
|
||||
* here after it went out of its way not to make it there.
|
||||
*/
|
||||
async function applyBoards(serverId, boards) {
|
||||
const presence = boards && boards['players.online']
|
||||
|
||||
if (presence && Array.isArray(presence.players)) {
|
||||
await db.replacePresence(serverId, presence.players)
|
||||
}
|
||||
|
||||
// Clans only once the game has spoken at all. A sidecar that has never heard
|
||||
// from its plugin holds no boards, and recording "no clan board" then would
|
||||
// blame the plugin's protocol for a game server that is simply not up. Left
|
||||
// alone, the stored board ages past fresh on its own, which is the true answer.
|
||||
if (boards && boards['server.hello']) {
|
||||
// Fenced: clans are the one board here that core's Teams depend on, and a
|
||||
// failure applying them must cost the clans rather than the presence board
|
||||
// above or the server state the caller writes next.
|
||||
try {
|
||||
await clans.applyBoard(serverId, boards.clans)
|
||||
await clans.reofferActivity(serverId)
|
||||
} catch (err) {
|
||||
log.warn('could not apply the clan board', { server: serverId, error: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { apply, applyBoards, ingestServer, BATCH, MAX_BATCHES_PER_TICK }
|
||||
298
server/model/clans/clans.db.js
Normal file
298
server/model/clans/clans.db.js
Normal file
@@ -0,0 +1,298 @@
|
||||
// ── SQL for first-party clans ─────────────────────────────────────────────
|
||||
//
|
||||
// Three tables (see `schema.sql`): the clans a board carried, their members, and
|
||||
// what this module knows about each server's board. Raw parameterised SQL, as
|
||||
// everywhere in this module; the model decides what any of it means.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const CLANS = 'rust_clans'
|
||||
const MEMBERS = 'rust_clan_members'
|
||||
const BOARDS = 'rust_clan_boards'
|
||||
const LINKS = 'rust_account_links'
|
||||
const PLAYERS = 'rust_players'
|
||||
const SERVERS = 'rust_servers'
|
||||
|
||||
// ── Boards ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One server's board record, or null when it has never sent one. */
|
||||
async function getBoard(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT server_id AS serverId, board_t AS boardT, seen_at AS seenAt, enabled, supported,
|
||||
truncated, backend, reason, umod_clans AS umodClans, clan_count AS clanCount
|
||||
FROM ${BOARDS} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** Every configured server beside its board record, which may be absent. */
|
||||
async function listBoards() {
|
||||
return core.query(
|
||||
`SELECT s.id AS serverId, s.name AS serverName, s.enabled AS serverEnabled,
|
||||
b.board_t AS boardT, b.seen_at AS seenAt, b.enabled, b.supported, b.truncated,
|
||||
b.backend, b.reason, b.umod_clans AS umodClans, b.clan_count AS clanCount
|
||||
FROM ${SERVERS} s
|
||||
LEFT JOIN ${BOARDS} b ON b.server_id = s.id
|
||||
ORDER BY s.sort_order ASC, s.id ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records what a board said about itself.
|
||||
*
|
||||
* `seenAt` is passed only when the board's `t` ADVANCED, and is then the
|
||||
* website's own now; otherwise the stored one is kept. That is the whole of the
|
||||
* freshness rule (see `schema.sql`), so it is done in SQL rather than trusted to
|
||||
* every caller to read-then-write.
|
||||
*/
|
||||
async function putBoard({ serverId, boardT, advanced, enabled, supported, truncated, backend, reason, umodClans, clanCount }) {
|
||||
await core.query(
|
||||
`INSERT INTO ${BOARDS}
|
||||
(server_id, board_t, seen_at, enabled, supported, truncated, backend, reason, umod_clans, clan_count, updated_at)
|
||||
VALUES (?, ?, ${advanced ? 'CURRENT_TIMESTAMP' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
board_t = VALUES(board_t),
|
||||
seen_at = ${advanced ? 'CURRENT_TIMESTAMP' : 'seen_at'},
|
||||
enabled = VALUES(enabled), supported = VALUES(supported), truncated = VALUES(truncated),
|
||||
backend = VALUES(backend), reason = VALUES(reason), umod_clans = VALUES(umod_clans),
|
||||
clan_count = VALUES(clan_count), updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
serverId,
|
||||
boardT,
|
||||
enabled ? 1 : 0,
|
||||
supported ? 1 : 0,
|
||||
truncated ? 1 : 0,
|
||||
backend || null,
|
||||
reason ? String(reason).slice(0, 255) : null,
|
||||
umodClans ? 1 : 0,
|
||||
clanCount || 0,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
// ── Clans ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every clan this module holds for one server, gone or not. */
|
||||
async function listClansForServer(serverId) {
|
||||
return core.query(
|
||||
`SELECT external_id AS externalId, clan_id AS clanId, created_ms AS createdMs, name,
|
||||
member_count AS memberCount, gone_at AS goneAt
|
||||
FROM ${CLANS} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every member of one server's current clans, as the board last stated them,
|
||||
* for diffing the next board against. The name is the BOARD's, not the player
|
||||
* table's, because it is compared with the board.
|
||||
*/
|
||||
async function listMembersForServer(serverId) {
|
||||
return core.query(
|
||||
`SELECT m.external_id AS externalId, m.steam_id AS steamId, m.role_rank AS rank,
|
||||
m.role_name AS role, m.name
|
||||
FROM ${MEMBERS} m
|
||||
JOIN ${CLANS} c ON c.external_id = m.external_id
|
||||
WHERE c.server_id = ? AND c.gone_at IS NULL`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
async function upsertClan({ externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers }) {
|
||||
await core.query(
|
||||
`INSERT INTO ${CLANS}
|
||||
(external_id, server_id, clan_id, created_ms, name, color, score, member_count, max_members,
|
||||
first_seen, updated_at, gone_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name), color = VALUES(color), score = VALUES(score),
|
||||
member_count = VALUES(member_count), max_members = VALUES(max_members),
|
||||
updated_at = CURRENT_TIMESTAMP, gone_at = NULL`,
|
||||
[externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one clan's members.
|
||||
*
|
||||
* Delete then insert, not wrapped in a transaction — the same trade the presence
|
||||
* board makes (`events.db.replacePresence`): a fraction of a second in which a
|
||||
* roster read might come back short, against holding a lock on a table that core's
|
||||
* reconciler and two public routes read.
|
||||
*/
|
||||
async function replaceMembers(externalId, members) {
|
||||
await core.query(`DELETE FROM ${MEMBERS} WHERE external_id = ?`, [externalId])
|
||||
|
||||
for (const m of members) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await core.query(
|
||||
`INSERT INTO ${MEMBERS} (external_id, steam_id, name, role_rank, role_name, joined_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), role_rank = VALUES(role_rank),
|
||||
role_name = VALUES(role_name), joined_ms = VALUES(joined_ms)`,
|
||||
[externalId, m.steamId, m.name, m.rank, m.role, m.joinedMs],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks clans gone. Their members are removed with them; a gone clan has no roster. */
|
||||
async function markGone(externalIds) {
|
||||
if (!externalIds.length) return
|
||||
const marks = externalIds.map(() => '?').join(', ')
|
||||
await core.query(
|
||||
`UPDATE ${CLANS} SET gone_at = CURRENT_TIMESTAMP WHERE external_id IN (${marks}) AND gone_at IS NULL`,
|
||||
externalIds,
|
||||
)
|
||||
await core.query(`DELETE FROM ${MEMBERS} WHERE external_id IN (${marks})`, externalIds)
|
||||
}
|
||||
|
||||
/** One clan by its Team identity, with its server's name, or null. */
|
||||
async function findClan(externalId) {
|
||||
const rows = await core.query(
|
||||
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
|
||||
c.clan_id AS clanId, c.created_ms AS createdMs, c.name, c.color, c.score,
|
||||
c.member_count AS memberCount, c.max_members AS maxMembers,
|
||||
c.first_seen AS firstSeen, c.updated_at AS updatedAt, c.gone_at AS goneAt
|
||||
FROM ${CLANS} c
|
||||
JOIN ${SERVERS} s ON s.id = c.server_id
|
||||
WHERE c.external_id = ?`,
|
||||
[externalId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest clan this module holds under a game id on one server, or null.
|
||||
*
|
||||
* The fallback for the one event that can arrive without a creation time
|
||||
* (`clan.member.added`, when the plugin could not read the clan back). Newest,
|
||||
* because an id that the game has re-used belongs to the clan that re-used it.
|
||||
*/
|
||||
async function findByGameId(serverId, clanId) {
|
||||
const rows = await core.query(
|
||||
`SELECT external_id AS externalId, name
|
||||
FROM ${CLANS} WHERE server_id = ? AND clan_id = ?
|
||||
ORDER BY created_ms DESC LIMIT 1`,
|
||||
[serverId, clanId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** Every clan still on a board, for core's `getTeams`. */
|
||||
async function listActiveClans() {
|
||||
return core.query(
|
||||
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
|
||||
c.name, c.color, c.score, c.member_count AS memberCount
|
||||
FROM ${CLANS} c
|
||||
JOIN ${SERVERS} s ON s.id = c.server_id
|
||||
WHERE c.gone_at IS NULL
|
||||
ORDER BY c.server_id ASC, c.score DESC, c.name ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/** One server's clans still on its board, for the public Clans tab. Best first. */
|
||||
async function listPublicForServer(serverId) {
|
||||
return core.query(
|
||||
`SELECT external_id AS externalId, name, color, score, member_count AS memberCount,
|
||||
max_members AS maxMembers
|
||||
FROM ${CLANS}
|
||||
WHERE server_id = ? AND gone_at IS NULL
|
||||
ORDER BY score DESC, name ASC`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One clan's roster, with the website account behind each member when there is
|
||||
* one and whether they are on the clan's server right now.
|
||||
*
|
||||
* Three joins, all of this module's own tables: the link (a Steam id to a user),
|
||||
* the player table (the newest name the game has sent for them) and the presence
|
||||
* board. Presence is joined on the CLAN's server — a member on another server of
|
||||
* the fleet is not online here.
|
||||
*/
|
||||
async function listMembers(externalId) {
|
||||
return core.query(
|
||||
`SELECT m.steam_id AS steamId, COALESCE(p.name, m.name) AS name, m.role_rank AS rank,
|
||||
m.role_name AS role, m.joined_ms AS joinedMs, l.user_id AS userId,
|
||||
(pr.steam_id IS NOT NULL) AS online
|
||||
FROM ${MEMBERS} m
|
||||
JOIN ${CLANS} c ON c.external_id = m.external_id
|
||||
LEFT JOIN ${LINKS} l ON l.steam_id = m.steam_id
|
||||
LEFT JOIN ${PLAYERS} p ON p.steam_id = m.steam_id
|
||||
LEFT JOIN rust_presence pr ON pr.server_id = c.server_id AND pr.steam_id = m.steam_id
|
||||
WHERE m.external_id = ?
|
||||
ORDER BY (m.role_rank IS NULL) ASC, m.role_rank ASC, name ASC`,
|
||||
[externalId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether a website user holds a linked Steam account that is a member of this clan. */
|
||||
async function userIsMember(externalId, userId) {
|
||||
const rows = await core.query(
|
||||
`SELECT 1 AS yes
|
||||
FROM ${MEMBERS} m
|
||||
JOIN ${LINKS} l ON l.steam_id = m.steam_id
|
||||
WHERE m.external_id = ? AND l.user_id = ?
|
||||
LIMIT 1`,
|
||||
[externalId, userId],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent clan events for one server, oldest first, for re-offering their feed
|
||||
* items to core until the Team they name exists (see `model/clans`).
|
||||
*/
|
||||
async function recentClanEvents(serverId, sinceMs) {
|
||||
return core.query(
|
||||
`SELECT id, kind, t, raw
|
||||
FROM rust_events
|
||||
WHERE server_id = ? AND kind LIKE 'clan.%' AND t >= ?
|
||||
ORDER BY t ASC, id ASC
|
||||
LIMIT 200`,
|
||||
[serverId, sinceMs],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes a player's name WITHOUT touching `last_seen`.
|
||||
*
|
||||
* `events.db.touchPlayer` also moves `last_seen`, which is right for a frame that
|
||||
* says a player was on and wrong for a clan frame: a kick is done TO somebody who
|
||||
* may be offline, and a leaderboard's "last seen" would then read as a presence
|
||||
* signal for a player who never connected (PLAN.md §23).
|
||||
*
|
||||
* A player this module has never heard of still gets a on the new row,
|
||||
* because the column is NOT NULL; what matters is that an existing row's is left
|
||||
* alone, and every surface that reads it is behind the presence gate anyway.
|
||||
*/
|
||||
async function rememberName(steamId, name) {
|
||||
if (!steamId) return
|
||||
await core.query(
|
||||
`INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE name = COALESCE(VALUES(name), name)`,
|
||||
[steamId, name || null],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBoard,
|
||||
listBoards,
|
||||
putBoard,
|
||||
listClansForServer,
|
||||
listMembersForServer,
|
||||
upsertClan,
|
||||
replaceMembers,
|
||||
markGone,
|
||||
findClan,
|
||||
findByGameId,
|
||||
listActiveClans,
|
||||
listPublicForServer,
|
||||
listMembers,
|
||||
userIsMember,
|
||||
recentClanEvents,
|
||||
rememberName,
|
||||
}
|
||||
574
server/model/clans/clans.model.js
Normal file
574
server/model/clans/clans.model.js
Normal file
@@ -0,0 +1,574 @@
|
||||
// ── First-party clans: the board, the events, and who may see a roster ────
|
||||
//
|
||||
// Rust's OWN clan system, which this module turns into core's Teams (R5,
|
||||
// PLAN.md §24). Three jobs, one file, because all three have to agree on what a
|
||||
// clan's identity is:
|
||||
//
|
||||
// applyBoard a `clans` snapshot → the store, plus what changed
|
||||
// applyEvent a `clan.*` event → core (publish) and the Team feed
|
||||
// canSeeRoster D48's audience, for core's `projectRoster` and our own page
|
||||
//
|
||||
// ── The identity (D52) ────────────────────────────────────────────────────
|
||||
//
|
||||
// `<serverId>:<clanId>:<createdMs>`. The game's clan id alone is not one: its
|
||||
// database file carries a hard-coded version, so a game update that bumps it
|
||||
// starts a fresh file and ids restart at 1. Keyed on the id, the new clan #1
|
||||
// would inherit the old clan #1's Team, forum and history.
|
||||
//
|
||||
// ── What a board may conclude, and what it may not ───────────────────────
|
||||
//
|
||||
// A board is authoritative for the clans it CARRIES. It is authoritative about
|
||||
// the clans it does NOT carry only when it is complete: a board truncated at the
|
||||
// game's 100-clan ceiling (D55), or one with a row this build could not read,
|
||||
// proves nothing about a clan it leaves out, and marking that clan gone would
|
||||
// hand core an archive on no evidence.
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const db = require('./clans.db')
|
||||
const visibility = require('../visibility/visibility.model')
|
||||
|
||||
const log = core.logger('clans')
|
||||
|
||||
/**
|
||||
* How long a board may go without its `t` advancing and still count as current.
|
||||
*
|
||||
* The plugin re-sends it every 60 seconds and this module reads it every 30, so
|
||||
* three minutes tolerates two missed boards before a server stops vouching for
|
||||
* its clans.
|
||||
*/
|
||||
const FRESH_MS = 3 * 60 * 1000
|
||||
|
||||
/**
|
||||
* How far back a clan event's feed item is offered to core again.
|
||||
*
|
||||
* Core writes an item only for a Team it already holds, and a clan founded a
|
||||
* moment ago is not one yet: its Team appears on core's next reconcile, which is
|
||||
* debounced by up to 30 seconds. So the "founded" line — the first line of every
|
||||
* clan's feed — would always be dropped if it were offered once. It is offered
|
||||
* on every board refresh for this long instead, and core's dedupe key makes every
|
||||
* offer after the first that lands a no-op.
|
||||
*/
|
||||
const REOFFER_MS = 10 * 60 * 1000
|
||||
|
||||
/** Team kinds core's `publish` takes, by the clan event that produces them. */
|
||||
const PUBLISH = Object.freeze({
|
||||
'clan.created': 'team.created',
|
||||
'clan.disbanded': 'team.disbanded',
|
||||
'clan.member.added': 'team.member.added',
|
||||
'clan.member.left': 'team.member.removed',
|
||||
'clan.member.kicked': 'team.member.removed',
|
||||
})
|
||||
|
||||
/**
|
||||
* The feed items D49 allows: membership, and nothing else. Every one is
|
||||
* members-only. A disband is not here — it was not one of the four the org lead
|
||||
* chose, and the Team it would be written to is about to be archived anyway.
|
||||
*/
|
||||
const ACTIVITY = Object.freeze({
|
||||
'clan.created': 'rust.clan.founded',
|
||||
'clan.member.added': 'rust.clan.joined',
|
||||
'clan.member.left': 'rust.clan.left',
|
||||
'clan.member.kicked': 'rust.clan.removed',
|
||||
})
|
||||
|
||||
const CLAN_KINDS = Object.freeze(Object.keys(PUBLISH))
|
||||
|
||||
const STEAM_ID = /^\d{1,32}$/
|
||||
const COLOR = /^#[0-9a-f]{6}$/i
|
||||
|
||||
/** The Team identity (D52). */
|
||||
function externalIdOf(serverId, clanId, createdMs) {
|
||||
return `${serverId}:${clanId}:${createdMs}`
|
||||
}
|
||||
|
||||
const text = (value, max) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null)
|
||||
const int = (value) => (Number.isInteger(Number(value)) && value !== null && value !== '' ? Number(value) : null)
|
||||
|
||||
/**
|
||||
* One board row as this module stores it, or null when it cannot be read.
|
||||
*
|
||||
* A member whose Steam id is not a Steam id is dropped rather than failing the
|
||||
* clan: the roster is still true about everybody else. A clan with no id, no
|
||||
* creation time or no name fails as a whole, because it has no identity to
|
||||
* store it under.
|
||||
*/
|
||||
function normaliseClan(serverId, raw) {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
|
||||
const clanId = int(raw.clanId)
|
||||
const createdMs = int(raw.createdMs)
|
||||
const name = text(raw.name, 191)
|
||||
if (clanId == null || createdMs == null || createdMs <= 0 || !name) return null
|
||||
|
||||
const members = []
|
||||
for (const m of Array.isArray(raw.members) ? raw.members : []) {
|
||||
const steamId = m && typeof m.steamId === 'string' && STEAM_ID.test(m.steamId) ? m.steamId : null
|
||||
if (!steamId) continue
|
||||
members.push({
|
||||
steamId,
|
||||
name: text(m.name, 191),
|
||||
rank: int(m.rank),
|
||||
role: text(m.role, 64),
|
||||
joinedMs: int(m.joinedMs),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
externalId: externalIdOf(serverId, clanId, createdMs),
|
||||
serverId,
|
||||
clanId,
|
||||
createdMs,
|
||||
name,
|
||||
color: typeof raw.color === 'string' && COLOR.test(raw.color) ? raw.color.toLowerCase() : null,
|
||||
score: int(raw.score) || 0,
|
||||
maxMembers: int(raw.maxMembers),
|
||||
memberCount: members.length,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
/** A member signature, so an unchanged roster is not rewritten every minute. */
|
||||
const signature = (members) =>
|
||||
members
|
||||
.map((m) => `${m.steamId}|${m.rank == null ? '' : m.rank}|${m.role || ''}|${m.name || ''}`)
|
||||
.sort()
|
||||
.join('\n')
|
||||
|
||||
const leadersOf = (members) => new Set(members.filter((m) => Number(m.rank) === 1).map((m) => m.steamId))
|
||||
|
||||
/**
|
||||
* Tells core something, and never lets core's answer become this module's
|
||||
* problem. Both calls are fire-and-forget by contract; the catch is for a core
|
||||
* that throws synchronously all the same.
|
||||
*/
|
||||
function publish(event) {
|
||||
try {
|
||||
Promise.resolve(core.teams.publish(event)).catch((err) => {
|
||||
log.warn('teams publish failed', { kind: event.kind, externalId: event.externalId, error: err.message })
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('teams publish threw', { kind: event.kind, externalId: event.externalId, error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function requestReconcile(reason) {
|
||||
try {
|
||||
core.teams.reconcile({ reason })
|
||||
} catch (err) {
|
||||
log.warn('teams reconcile request threw', { reason, error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function pushActivity(items) {
|
||||
if (!items.length) return
|
||||
try {
|
||||
Promise.resolve(core.teams.pushActivity(items)).catch((err) => {
|
||||
log.warn('teams activity push failed', { items: items.length, error: err.message })
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('teams activity push threw', { items: items.length, error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// ── The board ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Applies one server's `clans` board.
|
||||
*
|
||||
* `board` is undefined when the sidecar holds none — a plugin older than
|
||||
* protocol 6, or one that has not connected since it was upgraded. That is
|
||||
* recorded as unsupported, and the clans already stored are left exactly as they
|
||||
* are: a missing board is the absence of an answer, not an answer of absence.
|
||||
*
|
||||
* Returns what happened, for the log and the tests.
|
||||
*/
|
||||
async function applyBoard(serverId, board) {
|
||||
if (!board || typeof board !== 'object') {
|
||||
await db.putBoard({
|
||||
serverId,
|
||||
boardT: null,
|
||||
advanced: false,
|
||||
enabled: true,
|
||||
supported: false,
|
||||
truncated: false,
|
||||
backend: null,
|
||||
reason: "this server has not sent a clan board; its plugin may predate protocol 6",
|
||||
umodClans: false,
|
||||
clanCount: 0,
|
||||
})
|
||||
return { applied: false, reason: 'no board' }
|
||||
}
|
||||
|
||||
const previous = await db.getBoard(serverId)
|
||||
const boardT = Number(board.t)
|
||||
const known = previous && previous.boardT != null ? Number(previous.boardT) : null
|
||||
const advanced = Number.isFinite(boardT) && (known == null || boardT > known)
|
||||
|
||||
const supported = board.supported === true
|
||||
const raw = supported && Array.isArray(board.clans) ? board.clans : null
|
||||
|
||||
const clans = []
|
||||
let unreadable = 0
|
||||
for (const row of raw || []) {
|
||||
const clan = normaliseClan(serverId, row)
|
||||
if (clan) clans.push(clan)
|
||||
else unreadable += 1
|
||||
}
|
||||
|
||||
// A row this build could not read is treated like the ceiling: the board no
|
||||
// longer vouches for what it leaves out.
|
||||
const truncated = board.truncated === true || unreadable > 0
|
||||
|
||||
await db.putBoard({
|
||||
serverId,
|
||||
boardT: Number.isFinite(boardT) ? boardT : null,
|
||||
advanced,
|
||||
enabled: board.enabled !== false,
|
||||
supported,
|
||||
truncated,
|
||||
backend: text(board.backend, 64),
|
||||
reason: supported ? null : text(board.reason, 255) || 'the plugin could not read this server\'s clans',
|
||||
umodClans: board.umodClans === true,
|
||||
clanCount: clans.length,
|
||||
})
|
||||
|
||||
if (unreadable) log.warn('clan board carried rows this build could not read', { server: serverId, unreadable })
|
||||
|
||||
// A board whose `t` has not moved is the one already applied. Re-applying it
|
||||
// would rewrite every roster every 30 seconds to say what it already says.
|
||||
if (!advanced || !raw) return { applied: false, reason: advanced ? 'unsupported' : 'unchanged' }
|
||||
|
||||
const [before, beforeMembers] = await Promise.all([
|
||||
db.listClansForServer(serverId),
|
||||
db.listMembersForServer(serverId),
|
||||
])
|
||||
|
||||
const wasActive = new Map(before.filter((c) => !c.goneAt).map((c) => [c.externalId, c]))
|
||||
const rosterBefore = new Map()
|
||||
for (const m of beforeMembers) {
|
||||
if (!rosterBefore.has(m.externalId)) rosterBefore.set(m.externalId, [])
|
||||
rosterBefore.get(m.externalId).push(m)
|
||||
}
|
||||
|
||||
let created = 0
|
||||
let rosterChanged = 0
|
||||
const leaderEvents = []
|
||||
|
||||
for (const clan of clans) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.upsertClan(clan)
|
||||
|
||||
const old = rosterBefore.get(clan.externalId) || []
|
||||
if (!wasActive.has(clan.externalId)) created += 1
|
||||
|
||||
if (signature(old) !== signature(clan.members)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.replaceMembers(clan.externalId, clan.members)
|
||||
rosterChanged += 1
|
||||
}
|
||||
|
||||
// Leadership is only ever learned here (D54): the game raises no hook when
|
||||
// somebody is promoted. Published only for a clan that was already on the
|
||||
// previous board — a brand-new clan's leaders reach core with the Team.
|
||||
if (wasActive.has(clan.externalId)) {
|
||||
const was = leadersOf(old)
|
||||
const now = leadersOf(clan.members)
|
||||
for (const key of now) if (!was.has(key)) leaderEvents.push({ kind: 'team.leader.added', externalId: clan.externalId, memberKey: key })
|
||||
for (const key of was) if (!now.has(key)) leaderEvents.push({ kind: 'team.leader.removed', externalId: clan.externalId, memberKey: key })
|
||||
}
|
||||
}
|
||||
|
||||
// Only a complete board may say a clan is gone.
|
||||
const onBoard = new Set(clans.map((c) => c.externalId))
|
||||
const gone = truncated ? [] : [...wasActive.keys()].filter((id) => !onBoard.has(id))
|
||||
await db.markGone(gone)
|
||||
|
||||
for (const event of leaderEvents) publish(event)
|
||||
|
||||
if (created || gone.length || rosterChanged) {
|
||||
requestReconcile('rust clans board changed')
|
||||
}
|
||||
|
||||
if (created || gone.length || rosterChanged || leaderEvents.length) {
|
||||
log.info('clan board applied', {
|
||||
server: serverId, clans: clans.length, created, gone: gone.length, rosterChanged,
|
||||
leaderChanges: leaderEvents.length, truncated,
|
||||
})
|
||||
}
|
||||
|
||||
return { applied: true, clans: clans.length, created, gone: gone.length, rosterChanged, leaderChanges: leaderEvents.length }
|
||||
}
|
||||
|
||||
// ── The events ─────────────────────────────────────────────────────────────
|
||||
|
||||
const nameOr = (name) => name || 'A player'
|
||||
|
||||
/** The feed line for one clan event, as core stores it verbatim. */
|
||||
function summaryOf(kind, frame) {
|
||||
switch (kind) {
|
||||
case 'clan.created':
|
||||
return `${nameOr(frame.name)} founded the clan.`
|
||||
case 'clan.member.added':
|
||||
return `${nameOr(frame.name)} joined the clan.`
|
||||
case 'clan.member.left':
|
||||
return `${nameOr(frame.name)} left the clan.`
|
||||
case 'clan.member.kicked':
|
||||
return frame.byName
|
||||
? `${nameOr(frame.name)} was removed from the clan by ${frame.byName}.`
|
||||
: `${nameOr(frame.name)} was removed from the clan.`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A key core can dedupe on, from the frame's own content.
|
||||
*
|
||||
* Content rather than this module's event row id, so that the same frame read
|
||||
* twice — a cursor replayed after a crash, or the re-offer below — is the same
|
||||
* item. **Hashed, because core clamps a dedupe key to 40 characters**, and a
|
||||
* readable key long enough to be unique (server, clan, creation time, kind,
|
||||
* player, instant) would be cut short into collisions without a word.
|
||||
*/
|
||||
function dedupeKeyOf(serverId, kind, frame) {
|
||||
const parts = [serverId, frame.clanId, frame.createdMs, kind, frame.steamId || '', frame.t]
|
||||
return crypto.createHash('sha1').update(parts.join('|')).digest('hex')
|
||||
}
|
||||
|
||||
/** One clan event as a Team feed item, or null when D49 does not allow it. */
|
||||
function activityItem(serverId, externalId, kind, frame) {
|
||||
const itemKind = ACTIVITY[kind]
|
||||
const summary = itemKind && summaryOf(kind, frame)
|
||||
if (!summary) return null
|
||||
|
||||
const t = Number(frame.t)
|
||||
return {
|
||||
externalId,
|
||||
kind: itemKind,
|
||||
summary,
|
||||
occurredAt: Number.isFinite(t) ? t : Date.now(),
|
||||
visibility: 'members',
|
||||
actorMemberKey: kind === 'clan.member.kicked' ? frame.bySteamId || null : frame.steamId || null,
|
||||
payload: { serverId, steamId: frame.steamId || null },
|
||||
dedupeKey: dedupeKeyOf(serverId, kind, frame),
|
||||
}
|
||||
}
|
||||
|
||||
/** The Team identity a clan event names, or null when it cannot be worked out. */
|
||||
async function resolveExternalId(serverId, frame) {
|
||||
const clanId = int(frame.clanId)
|
||||
const createdMs = int(frame.createdMs)
|
||||
if (clanId == null) return null
|
||||
if (createdMs != null && createdMs > 0) return externalIdOf(serverId, clanId, createdMs)
|
||||
|
||||
// `clan.member.added` can arrive without a creation time when the plugin could
|
||||
// not read the clan back. Matched on the game id, newest first.
|
||||
const known = await db.findByGameId(serverId, clanId)
|
||||
return known ? known.externalId : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one `clan.*` event: tells core, and writes the Team feed.
|
||||
*
|
||||
* Called from ingest, after the raw frame is stored. The board that follows
|
||||
* every one of these (the plugin re-sends it a few seconds later) is what the
|
||||
* store is rebuilt from; this only makes the change visible sooner and records
|
||||
* the line for the feed.
|
||||
*/
|
||||
async function applyEvent(serverId, frame) {
|
||||
const kind = frame && frame.kind
|
||||
if (!PUBLISH[kind]) return { applied: false }
|
||||
|
||||
if (frame.steamId) await db.rememberName(frame.steamId, text(frame.name, 191))
|
||||
if (frame.bySteamId) await db.rememberName(frame.bySteamId, text(frame.byName, 191))
|
||||
|
||||
const externalId = await resolveExternalId(serverId, frame)
|
||||
if (!externalId) {
|
||||
log.info('clan event names a clan this module has never seen', { server: serverId, kind, clanId: frame.clanId })
|
||||
return { applied: false }
|
||||
}
|
||||
|
||||
// The game said it: this clan is gone. Recorded here as well as by the next
|
||||
// board, because a board truncated at the ceiling would never say so.
|
||||
if (kind === 'clan.disbanded') await db.markGone([externalId])
|
||||
|
||||
const event = { kind: PUBLISH[kind], externalId }
|
||||
if (event.kind.startsWith('team.member.')) {
|
||||
if (!frame.steamId) return { applied: false }
|
||||
event.memberKey = String(frame.steamId)
|
||||
}
|
||||
publish(event)
|
||||
|
||||
const item = activityItem(serverId, externalId, kind, frame)
|
||||
if (item) pushActivity([item])
|
||||
|
||||
return { applied: true, externalId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers the last few minutes of one server's clan feed items to core again.
|
||||
*
|
||||
* See `REOFFER_MS`. Called after each board refresh; idempotent by construction.
|
||||
*/
|
||||
async function reofferActivity(serverId, now = Date.now()) {
|
||||
const rows = await db.recentClanEvents(serverId, now - REOFFER_MS)
|
||||
const items = []
|
||||
|
||||
for (const row of rows) {
|
||||
let frame
|
||||
try {
|
||||
frame = typeof row.raw === 'string' ? JSON.parse(row.raw) : row.raw
|
||||
} catch (err) {
|
||||
continue
|
||||
}
|
||||
if (!frame || !ACTIVITY[frame.kind]) continue
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const externalId = await resolveExternalId(serverId, frame)
|
||||
const item = externalId && activityItem(serverId, externalId, frame.kind, frame)
|
||||
if (item) items.push(item)
|
||||
}
|
||||
|
||||
pushActivity(items)
|
||||
return items.length
|
||||
}
|
||||
|
||||
// ── Who may see a roster (D48) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* May this viewer see this clan's roster?
|
||||
*
|
||||
* `viewer` is `{ userId, role }` or null — the shape core hands `projectRoster`,
|
||||
* so core's roster and this module's page decide it with one function.
|
||||
*
|
||||
* The viewer's standing is re-read from the `users` row, never taken from what
|
||||
* the caller says, for the same reason the presence gate does it: a moderator
|
||||
* demoted this morning, or an account banned, must lose the roster on the next
|
||||
* request. Everything that cannot be answered answers no.
|
||||
*/
|
||||
async function canSeeRoster(viewer, externalId) {
|
||||
const audience = await visibility.clanRosterAudience()
|
||||
if (audience === 'public') return true
|
||||
if (!viewer || viewer.userId == null) return false
|
||||
|
||||
const user = await core.users.getById(viewer.userId)
|
||||
if (!user || (user.status && user.status !== 'active')) return false
|
||||
|
||||
if (audience === 'signed_in') return true
|
||||
if (user.role === 'admin' || user.role === 'moderator') return true
|
||||
return db.userIsMember(externalId, user.id)
|
||||
}
|
||||
|
||||
// ── The public reads ───────────────────────────────────────────────────────
|
||||
|
||||
const shapeBoard = (board, now = Date.now()) => {
|
||||
if (!board || board.supported == null) {
|
||||
return { supported: false, fresh: false, truncated: false, enabled: true, reason: 'this server has not sent a clan board yet' }
|
||||
}
|
||||
const seenAt = board.seenAt ? new Date(board.seenAt).getTime() : null
|
||||
return {
|
||||
supported: Boolean(board.supported),
|
||||
enabled: Boolean(board.enabled),
|
||||
truncated: Boolean(board.truncated),
|
||||
fresh: Boolean(board.supported) && seenAt != null && now - seenAt < FRESH_MS,
|
||||
reason: board.reason || null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The Clans tab (D58): every clan on one server's board, best first. Public. */
|
||||
async function listForServer(serverId, now = Date.now()) {
|
||||
const [clans, board] = await Promise.all([db.listPublicForServer(serverId), db.getBoard(serverId)])
|
||||
return {
|
||||
clans: clans.map((c) => ({
|
||||
externalId: c.externalId,
|
||||
name: c.name,
|
||||
color: c.color || null,
|
||||
score: Number(c.score) || 0,
|
||||
memberCount: Number(c.memberCount) || 0,
|
||||
maxMembers: c.maxMembers == null ? null : Number(c.maxMembers),
|
||||
})),
|
||||
board: shapeBoard(board, now),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One clan, and its roster if the viewer may see it.
|
||||
*
|
||||
* The roster carries no Steam id and no website account id — the same two fields
|
||||
* core withholds from every public roster. `online` is inside the audience by
|
||||
* construction (D48): a viewer who may not see the roster sees no names at all.
|
||||
*/
|
||||
async function getForViewer(externalId, viewer) {
|
||||
const clan = await db.findClan(externalId)
|
||||
if (!clan) return null
|
||||
|
||||
const allowed = await canSeeRoster(viewer, externalId)
|
||||
const audience = await visibility.clanRosterAudience()
|
||||
|
||||
const members = allowed && !clan.goneAt ? await db.listMembers(externalId) : []
|
||||
|
||||
return {
|
||||
clan: {
|
||||
externalId: clan.externalId,
|
||||
name: clan.name,
|
||||
color: clan.color || null,
|
||||
score: Number(clan.score) || 0,
|
||||
memberCount: Number(clan.memberCount) || 0,
|
||||
maxMembers: clan.maxMembers == null ? null : Number(clan.maxMembers),
|
||||
serverId: clan.serverId,
|
||||
serverName: clan.serverName,
|
||||
founded: Number(clan.createdMs) || null,
|
||||
gone: Boolean(clan.goneAt),
|
||||
},
|
||||
roster: {
|
||||
visible: allowed,
|
||||
audience,
|
||||
members: members.map((m) => ({
|
||||
name: m.name || null,
|
||||
role: m.role || null,
|
||||
leader: Number(m.rank) === 1,
|
||||
online: Boolean(Number(m.online)),
|
||||
joined: m.joinedMs == null ? null : Number(m.joinedMs),
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every configured server's clan board as the admin page shows it: whether it is
|
||||
* current, whether it is at the ceiling (D55), why it cannot be read, and
|
||||
* whether the uMod Clans plugin is loaded there (D47) — whose clans are a
|
||||
* separate system and never Teams.
|
||||
*/
|
||||
async function boardsForAdmin(now = Date.now()) {
|
||||
const rows = await db.listBoards()
|
||||
return rows.map((row) => ({
|
||||
id: row.serverId,
|
||||
name: row.serverName,
|
||||
...shapeBoard(row.supported == null ? null : row, now),
|
||||
clans: Number(row.clanCount) || 0,
|
||||
umodClans: Boolean(row.umodClans),
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FRESH_MS,
|
||||
boardsForAdmin,
|
||||
REOFFER_MS,
|
||||
CLAN_KINDS,
|
||||
externalIdOf,
|
||||
normaliseClan,
|
||||
applyBoard,
|
||||
applyEvent,
|
||||
resolveExternalId,
|
||||
reofferActivity,
|
||||
activityItem,
|
||||
dedupeKeyOf,
|
||||
canSeeRoster,
|
||||
shapeBoard,
|
||||
listForServer,
|
||||
getForViewer,
|
||||
}
|
||||
202
server/model/clans/teamProvider.js
Normal file
202
server/model/clans/teamProvider.js
Normal file
@@ -0,0 +1,202 @@
|
||||
// ── module-rust's Team provider ────────────────────────────────────────────
|
||||
//
|
||||
// The questions core asks this module about Teams (MODULE_API.md
|
||||
// `api.registerTeamProvider`, TEAMS.md §2.3). A first-party Rust clan is a Team
|
||||
// (R5); this file is the whole of the translation, and `model/clans` is where
|
||||
// the clans themselves are kept.
|
||||
//
|
||||
// ── The envelope is the contract ──────────────────────────────────────────
|
||||
//
|
||||
// Every method answers `{ ok, ... }` and `{ ok: false, reason }` is an ordinary
|
||||
// answer. Core reads it as "keep what you have" — staleness, never emptiness —
|
||||
// and there is no shape a failure can take that core reads as "zero Teams". An
|
||||
// empty array is the one thing this file must never say while it does not know.
|
||||
//
|
||||
// ── Many servers, one answer (D53) ─────────────────────────────────────────
|
||||
//
|
||||
// `module-uo` has one shard and one socket, so "is the board current" has one
|
||||
// answer. This module has a fleet, and the answer is per server. `getTeams` is
|
||||
// therefore:
|
||||
//
|
||||
// • `complete: true` only when EVERY configured server's board is fresh,
|
||||
// supported and untruncated — then core may archive a
|
||||
// Team that is missing;
|
||||
// • `complete: false` when at least one is current and some are not — core
|
||||
// adds and updates, and removes nothing. One server being
|
||||
// off for a patch must never archive its clans;
|
||||
// • a refusal when none is current.
|
||||
//
|
||||
// A clan is only ever as current as its own server's board, so the roster
|
||||
// methods ask about that server alone.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const db = require('./clans.db')
|
||||
const clans = require('./clans.model')
|
||||
const servers = require('../servers/servers.model')
|
||||
|
||||
const log = core.logger('teams')
|
||||
|
||||
const refuse = (reason) => ({ ok: false, reason })
|
||||
|
||||
/** Is this board record current? The rule `model/clans` states, applied to one row. */
|
||||
function isFresh(board, now = Date.now()) {
|
||||
return clans.shapeBoard(board, now).fresh
|
||||
}
|
||||
|
||||
/**
|
||||
* `getTeams()` — every clan on every server's board.
|
||||
*
|
||||
* `meta` carries the server and the clan's colour and score, opaquely: core
|
||||
* stores and shows it and never branches on it.
|
||||
*/
|
||||
async function getTeams(now = Date.now()) {
|
||||
try {
|
||||
const configured = await servers.listForPolling()
|
||||
if (!configured.length) return refuse('no Rust servers are configured')
|
||||
|
||||
const boards = await db.listBoards()
|
||||
const byServer = new Map(boards.map((b) => [b.serverId, b]))
|
||||
|
||||
const fresh = []
|
||||
const behind = []
|
||||
for (const server of configured) {
|
||||
const board = byServer.get(server.id)
|
||||
if (isFresh(board, now)) fresh.push(server.id)
|
||||
else behind.push(server.id)
|
||||
}
|
||||
|
||||
if (!fresh.length) {
|
||||
return refuse(`no server has sent a current clan board (${behind.join(', ')})`)
|
||||
}
|
||||
|
||||
// Complete only when nothing is behind, and nothing is at the ceiling. A
|
||||
// server that is configured but switched off in this module is "behind" by
|
||||
// construction — its board is never read — which is the conservative answer:
|
||||
// switching a server off is not a statement that its clans are gone.
|
||||
const truncated = fresh.filter((id) => byServer.get(id).truncated)
|
||||
const complete = behind.length === 0 && truncated.length === 0
|
||||
|
||||
const rows = await db.listActiveClans()
|
||||
const known = new Set(configured.map((s) => s.id))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
complete,
|
||||
teams: rows
|
||||
.filter((row) => known.has(row.serverId))
|
||||
.map((row) => ({
|
||||
externalId: row.externalId,
|
||||
name: row.name,
|
||||
abbr: null,
|
||||
meta: {
|
||||
server: row.serverName || row.serverId,
|
||||
serverId: row.serverId,
|
||||
color: row.color || null,
|
||||
score: Number(row.score) || 0,
|
||||
},
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('getTeams failed', { error: err.message })
|
||||
return refuse(`clans unreadable: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** A clan and whether its server's board vouches for it right now, or a refusal. */
|
||||
async function currentClan(externalId, now) {
|
||||
const clan = await db.findClan(externalId)
|
||||
if (!clan) return { refusal: refuse(`clan ${externalId} is not on any board`) }
|
||||
if (clan.goneAt) return { refusal: refuse(`clan ${externalId} has left its server's board`) }
|
||||
|
||||
const board = await db.getBoard(clan.serverId)
|
||||
if (!isFresh(board, now)) {
|
||||
return { refusal: refuse(`server ${clan.serverId} has not sent a current clan board`) }
|
||||
}
|
||||
return { clan }
|
||||
}
|
||||
|
||||
/**
|
||||
* `getTeamMembers(externalId)` — one clan's roster.
|
||||
*
|
||||
* **A clan with no roster rows is refused, not reported empty**, unless the board
|
||||
* said it has none. A clan always has at least its leader, so an empty roster
|
||||
* beside a non-zero count is a read that happened between two writes, and
|
||||
* reporting it would tell core every member left.
|
||||
*/
|
||||
async function getTeamMembers(externalId, now = Date.now()) {
|
||||
try {
|
||||
const { clan, refusal } = await currentClan(externalId, now)
|
||||
if (refusal) return refusal
|
||||
|
||||
const rows = await db.listMembers(externalId)
|
||||
if (!rows.length && Number(clan.memberCount) > 0) {
|
||||
return refuse(`roster for clan ${externalId} is not stored yet (board says ${clan.memberCount} members)`)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
complete: true,
|
||||
members: rows.map((row) => ({
|
||||
memberKey: row.steamId,
|
||||
displayName: row.name || null,
|
||||
rankLabel: row.role || null,
|
||||
// Rank 1 is leader and several may hold it. A NULL rank — a role id the
|
||||
// board could not match — is not a leader: "not known" must never read
|
||||
// as "leads this clan".
|
||||
leader: Number(row.rank) === 1,
|
||||
online: Boolean(Number(row.online)),
|
||||
userId: Number.isInteger(Number(row.userId)) && Number(row.userId) > 0 ? Number(row.userId) : null,
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('getTeamMembers failed', { externalId, error: err.message })
|
||||
return refuse(`roster unreadable: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** `getTeamLeaders(externalId)` — everyone at rank 1, which may be several. */
|
||||
async function getTeamLeaders(externalId, now = Date.now()) {
|
||||
try {
|
||||
const { refusal } = await currentClan(externalId, now)
|
||||
if (refusal) return refusal
|
||||
|
||||
const rows = await db.listMembers(externalId)
|
||||
return { ok: true, leaders: rows.filter((row) => Number(row.rank) === 1).map((row) => row.steamId) }
|
||||
} catch (err) {
|
||||
log.warn('getTeamLeaders failed', { externalId, error: err.message })
|
||||
return refuse(`leadership unreadable: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which roster rows a viewer may see (D48, MODULE_API 1.6.0).
|
||||
*
|
||||
* The one provider method core calls on a REQUEST path, and the one that fails
|
||||
* CLOSED: core serves an empty roster when this refuses, because for a
|
||||
* visibility question "keep what you have" would mean publishing the roster to
|
||||
* whoever asked. So every path that cannot reach a confident answer refuses.
|
||||
*
|
||||
* All or nothing, and that is the model rather than a shortcut: the audience is
|
||||
* a property of the ROSTER, not of a member. There is no setting in which some
|
||||
* of a clan's members are visible and others are not.
|
||||
*/
|
||||
async function projectRoster(externalId, members, viewer) {
|
||||
try {
|
||||
const allowed = await clans.canSeeRoster(viewer, externalId)
|
||||
if (!allowed) return { ok: true, members: [] }
|
||||
return { ok: true, members: (members || []).map((m) => m.member_key).filter(Boolean) }
|
||||
} catch (err) {
|
||||
log.warn('projectRoster could not resolve the audience; withholding the roster', {
|
||||
externalId, error: err.message,
|
||||
})
|
||||
return refuse(`the roster audience could not be resolved: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Where core should point a link at a clan (MODULE_API 1.6.0, TEAMS.md §6.4).
|
||||
// Core substitutes `{externalId}` and nothing else, which is why the page is not
|
||||
// nested under its server (D56): the server is inside the id already.
|
||||
const pageUrlTemplate = '/rust/clans/{externalId}'
|
||||
|
||||
module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, pageUrlTemplate, isFresh }
|
||||
79
server/model/config/config.db.js
Normal file
79
server/model/config/config.db.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// ── The SQL half of the configuration audit ───────────────────────────────
|
||||
//
|
||||
// One table, two questions: record what a save did, and show an operator what
|
||||
// has been done to a server lately.
|
||||
//
|
||||
// Nothing here talks to a game. The game half is `sidecarClient`, and the two
|
||||
// are deliberately not mixed: this file is what remains true after the plugin
|
||||
// has been reloaded, rolled back, or lost.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
/**
|
||||
* Records one save attempt — including the ones that never reached a file.
|
||||
*
|
||||
* A refusal is written for the same reason a success is: an operator asking why
|
||||
* a setting is not what they set has to be able to see that somebody tried and
|
||||
* was told no, and a table that only holds successes answers that question with
|
||||
* silence.
|
||||
*/
|
||||
async function recordWrite(row) {
|
||||
await core.query(
|
||||
`INSERT INTO rust_config_writes
|
||||
(server_id, path, plugin, reload_target, tier, user_id, outcome, reloaded,
|
||||
changes, version_before, version_after, detail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.serverId,
|
||||
row.path,
|
||||
row.plugin || null,
|
||||
row.reloadTarget || null,
|
||||
row.tier || 'form',
|
||||
row.userId || null,
|
||||
row.outcome,
|
||||
row.reloaded ? 1 : 0,
|
||||
row.changes ? JSON.stringify(row.changes) : null,
|
||||
row.versionBefore || null,
|
||||
row.versionAfter || null,
|
||||
row.detail ? String(row.detail).slice(0, 500) : null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The recent history for one server, newest first.
|
||||
*
|
||||
* `changes` comes back parsed, and a row whose JSON will not parse comes back
|
||||
* with `null` rather than throwing — a corrupt audit row must not be able to
|
||||
* break the page that displays the rest of them.
|
||||
*/
|
||||
async function recentWrites(serverId, limit = 50) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, server_id AS serverId, path, plugin, reload_target AS reloadTarget, tier,
|
||||
user_id AS userId, outcome, reloaded, changes,
|
||||
version_before AS versionBefore, version_after AS versionAfter, detail, created_at AS createdAt
|
||||
FROM rust_config_writes
|
||||
WHERE server_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`,
|
||||
[serverId, Math.max(1, Math.min(Number(limit) || 50, 200))],
|
||||
)
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
reloaded: Boolean(row.reloaded),
|
||||
changes: parseChanges(row.changes),
|
||||
}))
|
||||
}
|
||||
|
||||
function parseChanges(raw) {
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { recordWrite, recentWrites, parseChanges }
|
||||
229
server/model/config/config.model.js
Normal file
229
server/model/config/config.model.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// ── The logic half of configuration-from-the-site ─────────────────────────
|
||||
//
|
||||
// Everything here is about the difference between what a game host reports and
|
||||
// what an admin should be shown. Three jobs:
|
||||
//
|
||||
// 1. **Group a flat file list by plugin**, because one plugin can own several
|
||||
// files and a form that lists 40 paths is not a settings screen.
|
||||
// 2. **Say which file is ours, and which keys inside it are locked** (D38). The
|
||||
// plugin names itself in the catalogue rather than us matching a filename,
|
||||
// so renaming the file cannot quietly unlock the three keys that would cut
|
||||
// the link or split a server's history.
|
||||
// 3. **Decide nothing about paths.** The only process that can say whether a
|
||||
// path resolves inside a configuration directory is the one holding the
|
||||
// directory. This file checks SHAPE, so an obviously malformed request is
|
||||
// refused before it costs a round trip — never as a substitute for the real
|
||||
// check on the host.
|
||||
|
||||
const configEdit = require('../../configEdit')
|
||||
|
||||
/**
|
||||
* Keys in the bridge plugin's own config that the website may not change (D38).
|
||||
*
|
||||
* `Host` and `Port` are the link this edit is travelling over, and `ServerId` is
|
||||
* how every row this module has ever stored is keyed — changing it does not
|
||||
* rename a server, it strands its history and starts a new one under a name
|
||||
* nobody chose deliberately. All three are editable on the host, by a person
|
||||
* who is standing on it.
|
||||
*/
|
||||
const LOCKED_KEYS = ['Host', 'Port', 'ServerId']
|
||||
|
||||
/** What a locked field says for itself, on the screen and in a refusal. */
|
||||
const LOCKED_REASON = {
|
||||
host: 'the website reaches this server through this address',
|
||||
port: 'the website reaches this server through this port',
|
||||
serverid: 'every row this site holds for this server is keyed to this id',
|
||||
}
|
||||
|
||||
/**
|
||||
* A path shaped like something the host could plausibly have listed.
|
||||
*
|
||||
* Deliberately narrow and deliberately **not** the security boundary: no `..`,
|
||||
* nothing absolute, no drive letter, forward slashes, and it ends in `.json`.
|
||||
*/
|
||||
const PATH_SHAPE = /^(?!.*\.\.)(?!\/)[A-Za-z0-9 _.\-()[\]]+(?:\/[A-Za-z0-9 _.\-()[\]]+)*\.json$/
|
||||
|
||||
function isPlausiblePath(path) {
|
||||
return typeof path === 'string' && path.length > 0 && path.length <= 255 && PATH_SHAPE.test(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shapes the plugin's catalogue into the screen's shape: plugins, each with its
|
||||
* files, each file saying whether it can be edited and why not.
|
||||
*
|
||||
* A file whose guessed plugin is not loaded is kept and **marked**, not dropped.
|
||||
* An operator whose config for an unloaded plugin vanished from the page would
|
||||
* conclude the bridge cannot see it, which is a different and much more alarming
|
||||
* problem than the true one.
|
||||
*/
|
||||
function shapeCatalogue(catalogue) {
|
||||
if (!catalogue || typeof catalogue !== 'object') return null
|
||||
|
||||
const loaded = Array.isArray(catalogue.plugins) ? catalogue.plugins : []
|
||||
const byName = new Map(loaded.map((p) => [String(p.name).toLowerCase(), p]))
|
||||
const self = catalogue.self ? String(catalogue.self) : null
|
||||
|
||||
const groups = new Map()
|
||||
|
||||
for (const file of Array.isArray(catalogue.files) ? catalogue.files : []) {
|
||||
const plugin = String(file.plugin || 'unknown')
|
||||
const key = plugin.toLowerCase()
|
||||
|
||||
if (!groups.has(key)) {
|
||||
const match = byName.get(key)
|
||||
groups.set(key, {
|
||||
plugin,
|
||||
loaded: Boolean(match),
|
||||
title: match ? match.title : null,
|
||||
version: match ? match.version : null,
|
||||
// The bridge plugin cannot reload itself — the reload would close the
|
||||
// link carrying the answer — so the screen says so up front rather than
|
||||
// offering a button that always refuses.
|
||||
isBridge: self != null && plugin.toLowerCase() === self.toLowerCase(),
|
||||
files: [],
|
||||
})
|
||||
}
|
||||
|
||||
groups.get(key).files.push({
|
||||
path: String(file.path),
|
||||
bytes: Number(file.bytes) || 0,
|
||||
modified: file.modified ? Number(file.modified) : null,
|
||||
editable: file.editable !== false,
|
||||
...(file.reason ? { reason: String(file.reason) } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
root: catalogue.root ? String(catalogue.root) : null,
|
||||
self,
|
||||
truncated: Boolean(catalogue.truncated),
|
||||
limits: catalogue.limits || null,
|
||||
plugins: [...groups.values()].sort((a, b) => a.plugin.localeCompare(b.plugin)),
|
||||
loaded: loaded
|
||||
.map((p) => ({ name: String(p.name), title: p.title || null, version: p.version || null }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this file is the bridge's own config, by the name the plugin gave. */
|
||||
function isBridgeConfig(path, self) {
|
||||
if (!self) return false
|
||||
const plugin = String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
|
||||
return plugin.toLowerCase() === String(self).toLowerCase()
|
||||
}
|
||||
|
||||
/** The locked keys for a file: three of them in our own config, none anywhere else. */
|
||||
function lockedKeysFor(path, self) {
|
||||
return isBridgeConfig(path, self) ? LOCKED_KEYS : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one file the host sent into what the form renders.
|
||||
*
|
||||
* The text is passed through untouched. What is added is the READING of it: the
|
||||
* field list, which fields are locked, and which hold something a browser should
|
||||
* mask by default.
|
||||
*/
|
||||
function shapeFile(file, { self = null, maxDepth = 6 } = {}) {
|
||||
if (!file || typeof file.text !== 'string') return null
|
||||
|
||||
const locked = lockedKeysFor(file.path, self)
|
||||
let fields = null
|
||||
let parseError = null
|
||||
|
||||
try {
|
||||
fields = configEdit.describe(configEdit.scan(file.text), { maxDepth, locked })
|
||||
} catch (err) {
|
||||
// A config already broken on disk still opens — in the raw tier, which is
|
||||
// the only thing that can fix it. A page that refused to show a broken file
|
||||
// would send somebody to SSH for the one job this feature exists to do.
|
||||
parseError = err.message
|
||||
}
|
||||
|
||||
return {
|
||||
path: String(file.path),
|
||||
plugin: file.plugin ? String(file.plugin) : null,
|
||||
version: String(file.version),
|
||||
bytes: Number(file.bytes) || 0,
|
||||
modified: file.modified ? Number(file.modified) : null,
|
||||
text: file.text,
|
||||
fields,
|
||||
parseError,
|
||||
locked: locked.map((key) => ({ key, reason: LOCKED_REASON[key.toLowerCase()] || null })),
|
||||
isBridge: isBridgeConfig(file.path, self),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which locked keys differ between two versions of a document.
|
||||
*
|
||||
* The form refuses a locked field by pointer, but the **raw tier submits a whole
|
||||
* document**, and a document can change `Port` without anything resembling an
|
||||
* edit to a field. So the raw tier is checked the only way it can be: by
|
||||
* comparing the literals before and after.
|
||||
*
|
||||
* A file that will not parse is not a way around this — an unparseable document
|
||||
* is refused before it gets here.
|
||||
*/
|
||||
function lockedChanges(before, after, locked) {
|
||||
if (!locked || locked.length === 0) return []
|
||||
|
||||
let a
|
||||
let b
|
||||
|
||||
try {
|
||||
a = configEdit.scan(before)
|
||||
b = configEdit.scan(after)
|
||||
} catch {
|
||||
// Nothing can be compared, so nothing is cleared. The caller refuses.
|
||||
return locked.slice()
|
||||
}
|
||||
|
||||
const literal = (root, key) => {
|
||||
if (root.type !== 'object') return null
|
||||
const node = root.children.find((c) => String(c.key).toLowerCase() === key.toLowerCase())
|
||||
return node ? JSON.stringify(node.value) + ':' + (node.raw || '') : null
|
||||
}
|
||||
|
||||
return locked.filter((key) => literal(a, key) !== literal(b, key))
|
||||
}
|
||||
|
||||
/** Every change a report says landed, as one line per file. */
|
||||
function summariseReport(report) {
|
||||
if (!report || typeof report !== 'object') return null
|
||||
|
||||
const files = Array.isArray(report.files) ? report.files : []
|
||||
|
||||
return {
|
||||
ok: report.ok !== false,
|
||||
reloaded: Boolean(report.reloaded),
|
||||
rolledBack: Boolean(report.rolledBack),
|
||||
reason: report.reason ? String(report.reason) : null,
|
||||
// The plugin only reads this on the failure path, and it is the difference
|
||||
// between "your change was undone" and "your change was undone BECAUSE line
|
||||
// 14 is not valid for that field".
|
||||
log: report.log ? String(report.log).slice(-4000) : null,
|
||||
files: files.map((f) => ({
|
||||
path: String(f.path),
|
||||
version: f.version ? String(f.version) : null,
|
||||
bytes: f.bytes != null ? Number(f.bytes) : null,
|
||||
// Both frameworks merge missing defaults on load and save the file back,
|
||||
// so the file after a successful reload is regularly not the file we
|
||||
// wrote. Saying so keeps an operator from reading it as our bug.
|
||||
rewritten: Boolean(f.rewritten),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LOCKED_KEYS,
|
||||
LOCKED_REASON,
|
||||
PATH_SHAPE,
|
||||
isPlausiblePath,
|
||||
shapeCatalogue,
|
||||
shapeFile,
|
||||
isBridgeConfig,
|
||||
lockedKeysFor,
|
||||
lockedChanges,
|
||||
summariseReport,
|
||||
}
|
||||
322
server/model/events/events.db.js
Normal file
322
server/model/events/events.db.js
Normal file
@@ -0,0 +1,322 @@
|
||||
// ── SQL for the read path ─────────────────────────────────────────────────
|
||||
//
|
||||
// Writes come from one caller (`server/ingest.js`) and reads from the routers.
|
||||
// They live together because they are the same tables and the invariants are
|
||||
// easier to keep true when the UPDATE and the SELECT are on the same screen.
|
||||
//
|
||||
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always —
|
||||
// except for one place where a list of kinds is expanded into placeholders, and
|
||||
// that expansion is checked in `events.model.js` before it ever reaches here.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const EVENTS = 'rust_events'
|
||||
const STATS = 'rust_player_wipe_stats'
|
||||
const GATHER = 'rust_gather_totals'
|
||||
const PLAYERS = 'rust_players'
|
||||
const WIPES = 'rust_wipes'
|
||||
const PRESENCE = 'rust_presence'
|
||||
const CURSOR = 'rust_ingest_cursor'
|
||||
|
||||
// ── The cursor ────────────────────────────────────────────────────────────
|
||||
|
||||
async function getCursor(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT server_id AS serverId, last_event_id AS lastEventId, events_seen AS eventsSeen
|
||||
FROM ${CURSOR} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a server's cursor forward, counting what it passed.
|
||||
*
|
||||
* **Called only after the batch it describes has been written.** The whole
|
||||
* correctness of the ingest is in that ordering: if this ran first, a crash
|
||||
* between the two would skip events for ever, silently, with no way to notice.
|
||||
* Running it last means a crash re-reads events it has already counted at worst
|
||||
* — see `ingest.js` for what makes that survivable.
|
||||
*/
|
||||
async function setCursor(serverId, lastEventId, seen = 0) {
|
||||
await core.query(
|
||||
`INSERT INTO ${CURSOR} (server_id, last_event_id, events_seen, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_event_id = VALUES(last_event_id),
|
||||
events_seen = events_seen + VALUES(events_seen),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[serverId, lastEventId, seen],
|
||||
)
|
||||
}
|
||||
|
||||
// ── Writes ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function insertEvent({ serverId, wipeId, kind, t, steamId, raw }) {
|
||||
await core.query(
|
||||
`INSERT INTO ${EVENTS} (server_id, wipe_id, kind, t, steam_id, raw)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[serverId, wipeId || null, kind, t, steamId || null, JSON.stringify(raw)],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes that a wipe exists, from any frame that mentions it.
|
||||
*
|
||||
* There is no "a wipe started" call, because the website is not there when one
|
||||
* does — a wipe happens to a game server that was restarted while nobody was
|
||||
* watching. A wipe is therefore created by being mentioned, and `last_seen`
|
||||
* moves every time it is mentioned again.
|
||||
*/
|
||||
async function touchWipe(serverId, wipeId, saveCreatedAt = null) {
|
||||
if (!wipeId) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${WIPES} (server_id, wipe_id, save_created_at, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
save_created_at = COALESCE(VALUES(save_created_at), save_created_at)`,
|
||||
[serverId, wipeId, saveCreatedAt],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes that a player exists and what they were last called.
|
||||
*
|
||||
* `name` is COALESCEd rather than overwritten so that a frame which carries no
|
||||
* name — a ban by id, a tally — cannot blank out the name every other frame
|
||||
* supplied.
|
||||
*/
|
||||
async function touchPlayer(steamId, name = null) {
|
||||
if (!steamId) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = COALESCE(VALUES(name), name),
|
||||
last_seen = CURRENT_TIMESTAMP`,
|
||||
[steamId, name],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds to one player's counters for one wipe.
|
||||
*
|
||||
* Every column is a running total that only rises within a wipe, so this is an
|
||||
* upsert that ADDS rather than sets. `deltas` names only what moved; a `+ 0` on
|
||||
* everything else is what keeps the caller from having to read the row first.
|
||||
*/
|
||||
async function addStats({ serverId, wipeId, steamId }, deltas = {}) {
|
||||
if (!serverId || !steamId) return
|
||||
|
||||
const cols = ['kills', 'deaths', 'suicides', 'npc_kills', 'structures', 'sessions', 'playtime_sec']
|
||||
const values = {
|
||||
kills: deltas.kills || 0,
|
||||
deaths: deltas.deaths || 0,
|
||||
suicides: deltas.suicides || 0,
|
||||
npc_kills: deltas.npcKills || 0,
|
||||
structures: deltas.structures || 0,
|
||||
sessions: deltas.sessions || 0,
|
||||
playtime_sec: deltas.playtimeSec || 0,
|
||||
}
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${STATS} (server_id, wipe_id, steam_id, ${cols.join(', ')}, last_seen)
|
||||
VALUES (?, ?, ?, ${cols.map(() => '?').join(', ')}, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
${cols.map((c) => `${c} = ${c} + VALUES(${c})`).join(',\n ')},
|
||||
last_seen = CURRENT_TIMESTAMP`,
|
||||
[serverId, wipeId || '', steamId, ...cols.map((c) => values[c])],
|
||||
)
|
||||
}
|
||||
|
||||
async function addGathered({ serverId, wipeId, steamId }, resource, amount) {
|
||||
if (!serverId || !steamId || !resource || !(amount > 0)) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${GATHER} (server_id, wipe_id, steam_id, resource, amount)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE amount = amount + VALUES(amount)`,
|
||||
[serverId, wipeId || '', steamId, resource, amount],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a server's presence rows with exactly what the board said.
|
||||
*
|
||||
* Two statements, delete then insert, because a board is a REPLACEMENT: a player
|
||||
* who left between two boards has to disappear, and an upsert alone would leave
|
||||
* them online for ever. It is not wrapped in a transaction on purpose — the
|
||||
* window between the two is a fraction of a second of a page possibly showing an
|
||||
* empty player list, against holding a lock on a table two routes read.
|
||||
*/
|
||||
async function replacePresence(serverId, players = []) {
|
||||
await core.query(`DELETE FROM ${PRESENCE} WHERE server_id = ?`, [serverId])
|
||||
|
||||
for (const p of players) {
|
||||
if (!p || !p.steamId) continue
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${PRESENCE} (server_id, steam_id, name, sleeping, connected_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ${p.connectedAt ? 'FROM_UNIXTIME(? / 1000)' : 'NULL'}, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name), sleeping = VALUES(sleeping), updated_at = CURRENT_TIMESTAMP`,
|
||||
p.connectedAt
|
||||
? [serverId, p.steamId, p.name || null, p.sleeping ? 1 : 0, p.connectedAt]
|
||||
: [serverId, p.steamId, p.name || null, p.sleeping ? 1 : 0],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes raw events older than `days`. Totals are never touched — that is the point of them. */
|
||||
async function pruneEvents(days) {
|
||||
if (!(days > 0)) return 0
|
||||
|
||||
const res = await core.query(
|
||||
`DELETE FROM ${EVENTS} WHERE created_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? DAY)`,
|
||||
[days],
|
||||
)
|
||||
return (res && res.affectedRows) || 0
|
||||
}
|
||||
|
||||
// ── Reads ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recent events, newest first, restricted to `kinds`.
|
||||
*
|
||||
* **`kinds` is never optional.** A default of "all kinds" is one forgotten
|
||||
* argument away from publishing an IP address, so the caller is made to say it
|
||||
* every time; `events.model.js` builds the list from the catalogue's allowlist
|
||||
* and an empty list answers with no rows rather than with everything.
|
||||
*/
|
||||
async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
|
||||
if (!Array.isArray(kinds) || kinds.length === 0) return []
|
||||
|
||||
const holes = kinds.map(() => '?').join(', ')
|
||||
const params = [serverId, ...kinds]
|
||||
|
||||
let sql = `SELECT id, server_id AS serverId, wipe_id AS wipeId, kind, t, steam_id AS steamId, raw
|
||||
FROM ${EVENTS}
|
||||
WHERE server_id = ? AND kind IN (${holes})`
|
||||
|
||||
if (wipeId) {
|
||||
sql += ' AND wipe_id = ?'
|
||||
params.push(wipeId)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY id DESC LIMIT ?'
|
||||
params.push(limit)
|
||||
|
||||
return core.query(sql, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* The leaderboard for one wipe, or across every wipe when `wipeId` is null.
|
||||
*
|
||||
* All-time is a SUM over the per-wipe rows rather than a separate set of
|
||||
* counters, which is what makes it impossible for the two to disagree — there
|
||||
* is only ever one number, added up differently.
|
||||
*/
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 }) {
|
||||
const column = { kills: 'kills', deaths: 'deaths', npcKills: 'npc_kills', playtime: 'playtime_sec' }[sort] || 'kills'
|
||||
|
||||
const params = [serverId]
|
||||
let where = 's.server_id = ?'
|
||||
|
||||
if (wipeId) {
|
||||
where += ' AND s.wipe_id = ?'
|
||||
params.push(wipeId)
|
||||
}
|
||||
|
||||
params.push(limit)
|
||||
|
||||
return core.query(
|
||||
`SELECT s.steam_id AS steamId,
|
||||
p.name AS name,
|
||||
SUM(s.kills) AS kills,
|
||||
SUM(s.deaths) AS deaths,
|
||||
SUM(s.npc_kills) AS npcKills,
|
||||
SUM(s.structures) AS structures,
|
||||
SUM(s.playtime_sec) AS playtimeSec,
|
||||
MAX(s.last_seen) AS lastSeen
|
||||
FROM ${STATS} s
|
||||
LEFT JOIN ${PLAYERS} p ON p.steam_id = s.steam_id
|
||||
WHERE ${where}
|
||||
GROUP BY s.steam_id, p.name
|
||||
ORDER BY SUM(s.${column}) DESC, MAX(s.last_seen) DESC
|
||||
LIMIT ?`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
async function listWipes(serverId) {
|
||||
return core.query(
|
||||
`SELECT wipe_id AS wipeId, save_created_at AS saveCreatedAt,
|
||||
first_seen AS firstSeen, last_seen AS lastSeen
|
||||
FROM ${WIPES}
|
||||
WHERE server_id = ?
|
||||
ORDER BY wipe_id DESC`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
async function presenceFor(serverId) {
|
||||
return core.query(
|
||||
`SELECT steam_id AS steamId, name, sleeping, connected_at AS connectedAt
|
||||
FROM ${PRESENCE}
|
||||
WHERE server_id = ?
|
||||
ORDER BY name ASC`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Login attempts in `[from, to]` that no approval answered (D64).
|
||||
*
|
||||
* An attempt is answered by a `player.approved` for the same Steam id on the
|
||||
* same server stamped from `slackMs` before it to `windowMs` after it. The
|
||||
* slack is clock grain: both frames come off one game thread, and an approval
|
||||
* stamped a millisecond "early" is still the answer.
|
||||
*
|
||||
* Grouped on (steam id, t) because a cursor replayed after a crash can store the
|
||||
* same attempt twice, and one attempt is one denial however often it was
|
||||
* written down.
|
||||
*/
|
||||
async function unapprovedLogins({ serverId, from, to, windowMs, slackMs }) {
|
||||
return core.query(
|
||||
`SELECT a.steam_id AS steamId, a.t AS t,
|
||||
MAX(JSON_UNQUOTE(JSON_EXTRACT(a.raw, '$.name'))) AS name
|
||||
FROM ${EVENTS} a
|
||||
WHERE a.server_id = ? AND a.kind = 'player.login.attempt'
|
||||
AND a.steam_id IS NOT NULL AND a.t BETWEEN ? AND ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ${EVENTS} b
|
||||
WHERE b.server_id = a.server_id AND b.kind = 'player.approved'
|
||||
AND b.steam_id = a.steam_id
|
||||
AND b.t BETWEEN a.t - ? AND a.t + ?
|
||||
)
|
||||
GROUP BY a.steam_id, a.t
|
||||
ORDER BY a.t ASC
|
||||
LIMIT 200`,
|
||||
[serverId, from, to, slackMs, windowMs],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCursor,
|
||||
setCursor,
|
||||
insertEvent,
|
||||
touchWipe,
|
||||
touchPlayer,
|
||||
addStats,
|
||||
addGathered,
|
||||
replacePresence,
|
||||
pruneEvents,
|
||||
recentEvents,
|
||||
leaderboard,
|
||||
listWipes,
|
||||
presenceFor,
|
||||
unapprovedLogins,
|
||||
}
|
||||
166
server/model/events/events.model.js
Normal file
166
server/model/events/events.model.js
Normal file
@@ -0,0 +1,166 @@
|
||||
// ── The read path's logic ─────────────────────────────────────────────────
|
||||
//
|
||||
// Everything that decides WHAT a caller gets, separated from the SQL that
|
||||
// fetches it, so this file can be tested with no database and `events.db.js` has
|
||||
// no branching to test.
|
||||
//
|
||||
// The decision that matters here is not a business rule, it is a boundary: what
|
||||
// a signed-out visitor may see. Protocol 2 carries IP addresses and player
|
||||
// reports, and the only thing standing between them and a public page is
|
||||
// `catalogue.js`'s allowlist and the fact that **every read on this file takes an
|
||||
// explicit viewer**. There is no default, because a default is what a caller
|
||||
// gets when they forget — and the safe value is never the one that is easier to
|
||||
// type.
|
||||
|
||||
const catalogue = require('../../catalogue')
|
||||
const db = require('./events.db')
|
||||
|
||||
/** Hard ceiling on a page, whatever a caller asks for. */
|
||||
const MAX_LIMIT = 200
|
||||
|
||||
function boundedLimit(requested, fallback = 50) {
|
||||
const n = Number(requested)
|
||||
if (!Number.isFinite(n) || n <= 0) return fallback
|
||||
return Math.min(Math.trunc(n), MAX_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a `kind` query parameter into a list.
|
||||
*
|
||||
* Accepts `?kind=player.death` and `?kind=player.death,player.chat`, and answers
|
||||
* `null` for anything empty — which means "whatever this viewer may see" rather
|
||||
* than "nothing", and is then narrowed by the catalogue.
|
||||
*/
|
||||
function parseKinds(raw) {
|
||||
if (!raw) return null
|
||||
|
||||
const list = String(raw)
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return list.length > 0 ? list : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent events for one server, already narrowed to what this viewer may see.
|
||||
*
|
||||
* **`admin` is a parameter, not a default.** A route that forgets it gets the
|
||||
* public list, which is the direction it is safe to be wrong in. And a kind the
|
||||
* caller asked for that they may not see is dropped silently rather than
|
||||
* refused: naming it in an error would confirm the kind exists, which is a small
|
||||
* thing to leak and a free one to avoid.
|
||||
*/
|
||||
async function recent({ serverId, admin = false, presence = false, kind = null, wipeId = null, limit }) {
|
||||
const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
|
||||
|
||||
// Every requested kind was refused. Answering with an empty list is right —
|
||||
// the events they asked for are, as far as they are concerned, not there.
|
||||
if (kinds.length === 0) return []
|
||||
|
||||
const rows = await db.recentEvents({
|
||||
serverId,
|
||||
kinds,
|
||||
wipeId,
|
||||
limit: boundedLimit(limit),
|
||||
})
|
||||
|
||||
return rows.map(shape)
|
||||
}
|
||||
|
||||
/**
|
||||
* One stored row as an API object.
|
||||
*
|
||||
* `raw` comes back from the database as text and is parsed here rather than in
|
||||
* the db layer, because a row whose JSON will not parse is a reporting problem
|
||||
* and not a query problem: it answers with the envelope it does know and an
|
||||
* empty body, instead of failing a whole page over one bad row.
|
||||
*/
|
||||
function shape(row) {
|
||||
let frame = {}
|
||||
|
||||
try {
|
||||
frame = typeof row.raw === 'string' ? JSON.parse(row.raw) : row.raw || {}
|
||||
} catch {
|
||||
frame = {}
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(row.id),
|
||||
kind: row.kind,
|
||||
t: Number(row.t),
|
||||
wipeId: row.wipeId || null,
|
||||
steamId: row.steamId || null,
|
||||
frame,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The leaderboard for a server, per wipe or all-time.
|
||||
*
|
||||
* All-time is the same rows summed differently rather than a second set of
|
||||
* counters, so the two can never disagree — which is the whole reason R12's
|
||||
* "per-wipe detail plus all-time rollups" is one table and not two.
|
||||
*/
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false }) {
|
||||
const rows = await db.leaderboard({
|
||||
serverId,
|
||||
wipeId,
|
||||
sort,
|
||||
limit: boundedLimit(limit, 25),
|
||||
})
|
||||
|
||||
return rows.map((r) => ({
|
||||
steamId: r.steamId,
|
||||
name: r.name || null,
|
||||
kills: Number(r.kills) || 0,
|
||||
deaths: Number(r.deaths) || 0,
|
||||
npcKills: Number(r.npcKills) || 0,
|
||||
structures: Number(r.structures) || 0,
|
||||
playtimeSec: Number(r.playtimeSec) || 0,
|
||||
// Withheld below the presence audience. A tally refreshes it every minute a
|
||||
// player is on, so a `lastSeen` of forty seconds ago is the Online tab by
|
||||
// another name. The ORDER still uses it as a tie-break — that says who was
|
||||
// on more recently, never whether anybody is on now.
|
||||
...(presence ? { lastSeen: r.lastSeen || null } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every wipe this server has had, newest first.
|
||||
*
|
||||
* The list is what makes the per-wipe view navigable, and it is also the proof
|
||||
* R12 asks for: a wipe that ended is still here, with its stats still attached.
|
||||
*/
|
||||
async function wipes(serverId) {
|
||||
const rows = await db.listWipes(serverId)
|
||||
|
||||
return rows.map((r) => ({
|
||||
wipeId: r.wipeId,
|
||||
saveCreatedAt: r.saveCreatedAt || null,
|
||||
firstSeen: r.firstSeen,
|
||||
lastSeen: r.lastSeen,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is on the server right now.
|
||||
*
|
||||
* Read from the presence board rather than counted from connect and disconnect
|
||||
* events: the board is re-sent on every bridge connect and every minute, so it
|
||||
* is right even after this module has missed something. Counting transitions
|
||||
* instead would drift, and drift in exactly the direction people notice —
|
||||
* players who never left.
|
||||
*/
|
||||
async function online(serverId) {
|
||||
const rows = await db.presenceFor(serverId)
|
||||
|
||||
return rows.map((r) => ({
|
||||
steamId: r.steamId,
|
||||
name: r.name || null,
|
||||
sleeping: Boolean(r.sleeping),
|
||||
connectedAt: r.connectedAt || null,
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, MAX_LIMIT }
|
||||
177
server/model/links/links.db.js
Normal file
177
server/model/links/links.db.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// ── SQL, and nothing else ─────────────────────────────────────────────────
|
||||
//
|
||||
// The `.db.js` half of the pair (see `servers.db.js` for why the split earns its
|
||||
// keep). Raw parameterised SQL through `core.query`, placeholders always.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const LINKS = 'rust_account_links'
|
||||
const PLAYERS = 'rust_players'
|
||||
const STATS = 'rust_player_wipe_stats'
|
||||
|
||||
/**
|
||||
* The link for one Steam id, or undefined.
|
||||
*
|
||||
* Joins core's `users` for the username, because every caller that asks "who
|
||||
* owns this?" wants a name rather than an integer — and the one caller that
|
||||
* refuses a re-link has to be able to say *whose* it is.
|
||||
*/
|
||||
async function getBySteamId(steamId) {
|
||||
const rows = await core.query(
|
||||
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId,
|
||||
l.linked_at AS linkedAt, u.username
|
||||
FROM ${LINKS} l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
WHERE l.steam_id = ?`,
|
||||
[steamId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Every Steam account one website user holds, newest first.
|
||||
*
|
||||
* **It joins `rust_players` for the name the game last saw**, and that is not a
|
||||
* convenience. The name on the LINK is what the player was called at the moment
|
||||
* they linked, which is a Rust name and changes on a whim — so a player who has
|
||||
* renamed since sees a name they no longer use, on the one page of the site that
|
||||
* is about who they are. The admin panel already preferred the newer one; this
|
||||
* is the same rule applied where the person themselves is reading.
|
||||
*
|
||||
* A LEFT JOIN, because a player can link an account and never play on it.
|
||||
*/
|
||||
async function listForUser(userId) {
|
||||
return core.query(
|
||||
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId,
|
||||
l.linked_at AS linkedAt, p.name AS playerName
|
||||
FROM ${LINKS} l
|
||||
LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id
|
||||
WHERE l.user_id = ?
|
||||
ORDER BY l.linked_at DESC`,
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a link.
|
||||
*
|
||||
* **A plain INSERT, never an upsert**, and that is the whole of D23 expressed in
|
||||
* SQL. `ON DUPLICATE KEY UPDATE` here would silently move a Steam id from one
|
||||
* website account to another — which, once phase 7 makes a link a privilege path
|
||||
* and phase 13 makes it an entitlement, is an account takeover performed by
|
||||
* typing a six-character code. The duplicate-key error is the refusal, and the
|
||||
* controller turns it into a sentence.
|
||||
*/
|
||||
async function insert({ steamId, userId, name, serverId }) {
|
||||
await core.query(
|
||||
`INSERT INTO ${LINKS} (steam_id, user_id, name, server_id)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[steamId, userId, name || null, serverId || null],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link the caller owns.
|
||||
*
|
||||
* Scoped by `user_id` in the statement rather than checked before it: a delete
|
||||
* that reads, decides, then writes has a gap between the read and the write, and
|
||||
* this way the ownership test and the deletion are the same operation. Answers
|
||||
* how many rows went, so a caller can tell "removed" from "was not yours".
|
||||
*/
|
||||
async function removeOwned(steamId, userId) {
|
||||
const result = await core.query(
|
||||
`DELETE FROM ${LINKS} WHERE steam_id = ? AND user_id = ?`,
|
||||
[steamId, userId],
|
||||
)
|
||||
return Number(result && result.affectedRows) || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link whoever holds it — the in-game `/unlink` path, and the staff
|
||||
* unlink on the `admin.users.detail` panel (D25).
|
||||
*
|
||||
* Unscoped by user on purpose: neither caller is the link's owner and both have
|
||||
* already established their authority another way. In game the authority is the
|
||||
* Steam account itself — whoever is connected as it is who it is; on the admin
|
||||
* panel it is the tier gate. Which is why the admin caller writes an
|
||||
* `activity.log` entry naming the operator and this does not: it cannot tell the
|
||||
* two apart, and a log line that guessed would be worse than none.
|
||||
*/
|
||||
async function removeBySteamId(steamId) {
|
||||
const result = await core.query(`DELETE FROM ${LINKS} WHERE steam_id = ?`, [steamId])
|
||||
return Number(result && result.affectedRows) || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link one user holds, enriched with what this module knows about that
|
||||
* player — for the `admin.users.detail` panel.
|
||||
*
|
||||
* A LEFT JOIN, because a player can link an account and never play on it. An
|
||||
* operator looking at that user should see the link, not an empty panel.
|
||||
*/
|
||||
async function listForUserWithPlayer(userId) {
|
||||
return core.query(
|
||||
`SELECT l.steam_id AS steamId, l.name, l.server_id AS serverId, l.linked_at AS linkedAt,
|
||||
p.name AS playerName, p.first_seen AS firstSeen, p.last_seen AS lastSeen
|
||||
FROM ${LINKS} l
|
||||
LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id
|
||||
WHERE l.user_id = ?
|
||||
ORDER BY l.linked_at DESC`,
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-server all-time totals for one Steam id.
|
||||
*
|
||||
* The same rows the public leaderboard sums, grouped by server instead of
|
||||
* filtered to one — so an operator sees a player across the fleet in one read.
|
||||
* All-time, deliberately: an admin looking at a user wants their history, not
|
||||
* this week's.
|
||||
*/
|
||||
async function statsForSteamId(steamId) {
|
||||
return core.query(
|
||||
`SELECT s.server_id AS serverId, srv.name AS serverName,
|
||||
SUM(s.kills) AS kills,
|
||||
SUM(s.deaths) AS deaths,
|
||||
SUM(s.npc_kills) AS npcKills,
|
||||
SUM(s.structures) AS structures,
|
||||
SUM(s.playtime_sec) AS playtimeSec,
|
||||
MAX(s.last_seen) AS lastSeen,
|
||||
COUNT(DISTINCT s.wipe_id) AS wipes
|
||||
FROM ${STATS} s
|
||||
LEFT JOIN rust_servers srv ON srv.id = s.server_id
|
||||
WHERE s.steam_id = ?
|
||||
GROUP BY s.server_id, srv.name
|
||||
ORDER BY SUM(s.playtime_sec) DESC`,
|
||||
[steamId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of these Steam ids are linked, and to whom.
|
||||
*
|
||||
* The one question every notification asks — "who on the website is this
|
||||
* player?" — asked for a set at once, because a raid names a cupboard's whole
|
||||
* authorisation list and a clan event a whole roster. An unlinked id is simply
|
||||
* absent from the answer: there is nobody to tell.
|
||||
*/
|
||||
async function userIdsForSteamIds(steamIds) {
|
||||
if (!steamIds.length) return []
|
||||
const marks = steamIds.map(() => '?').join(', ')
|
||||
return core.query(
|
||||
`SELECT steam_id AS steamId, user_id AS userId FROM ${LINKS} WHERE steam_id IN (${marks})`,
|
||||
steamIds,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBySteamId,
|
||||
listForUser,
|
||||
listForUserWithPlayer,
|
||||
insert,
|
||||
removeOwned,
|
||||
removeBySteamId,
|
||||
statsForSteamId,
|
||||
userIdsForSteamIds,
|
||||
}
|
||||
286
server/model/links/links.model.js
Normal file
286
server/model/links/links.model.js
Normal file
@@ -0,0 +1,286 @@
|
||||
// ── Who owns which Steam account ──────────────────────────────────────────
|
||||
//
|
||||
// R1's identity link, site-side. The flow it sits in the middle of:
|
||||
//
|
||||
// 1. In game, a player types `/link`. The plugin mints a one-time code, tells
|
||||
// them privately, and holds it in memory for five minutes.
|
||||
// 2. On the website, the player types that code. This module asks the sidecar,
|
||||
// which asks the plugin, which answers with the Steam id the code belongs
|
||||
// to and drops it.
|
||||
// 3. This file records the result.
|
||||
//
|
||||
// **The site is the author of record and the game holds nothing.** That is the
|
||||
// one real difference from the UO bridge, which writes a tag onto the game
|
||||
// account: there is no equivalent per-account store in Rust that survives a wipe,
|
||||
// and phase 7 needs the site to be authoritative anyway — it pushes permissions
|
||||
// INTO the game keyed by Steam id. A copy in the game would be a second thing to
|
||||
// reconcile every wipe, for no question it could answer better.
|
||||
|
||||
const core = require('../../core')
|
||||
const db = require('./links.db')
|
||||
const engagement = require('../../engagement/emit')
|
||||
const servers = require('../servers/servers.model')
|
||||
const sidecar = require('../../sidecarClient')
|
||||
|
||||
const log = core.logger('links')
|
||||
|
||||
/**
|
||||
* A link changed, so a clan member's website account changed (D57).
|
||||
*
|
||||
* Core resolves a Team member's `userId` from the provider's answer, and that
|
||||
* answer comes from this table. Without asking, a member who links today is not
|
||||
* a member of their clan's Team on the site until core's next scheduled sweep —
|
||||
* fifteen minutes by default — which is exactly when a player tries the clan
|
||||
* forum for the first time. A request, not a wait: it returns at once and never
|
||||
* throws into the link flow.
|
||||
*/
|
||||
function linksChanged(reason) {
|
||||
try {
|
||||
core.teams.reconcile({ reason })
|
||||
} catch (err) {
|
||||
log.warn('could not ask core to reconcile Teams after a link change', { reason, error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/** What a link looks like to any caller. Never carries a raw code. */
|
||||
function shape(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
steamId: row.steamId,
|
||||
name: row.name || null,
|
||||
serverId: row.serverId || null,
|
||||
linkedAt: row.linkedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Steam accounts one website user holds.
|
||||
*
|
||||
* The name is the one the GAME last saw, falling back to the one recorded when
|
||||
* they linked — the rule the admin panel already used, applied on the page the
|
||||
* player themselves reads. A browser walk found the two disagreeing: staff saw
|
||||
* `Wanderer` and the player saw `Wanderer-old`, for the same person on the same
|
||||
* site.
|
||||
*/
|
||||
async function listForUser(userId) {
|
||||
return (await db.listForUser(userId)).map((row) => ({
|
||||
...shape(row),
|
||||
name: row.playerName || row.name || null,
|
||||
}))
|
||||
}
|
||||
|
||||
/** True when this user holds this Steam id. The ownership gate every player read uses. */
|
||||
async function owns(steamId, userId) {
|
||||
const row = await db.getBySteamId(steamId)
|
||||
return Boolean(row && Number(row.userId) === Number(userId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a code against one server, and record the link.
|
||||
*
|
||||
* Answers a discriminated result rather than throwing, because every outcome
|
||||
* here is a sentence somebody has to read:
|
||||
*
|
||||
* `{ ok: true, link }` — linked
|
||||
* `{ ok: false, reason: 'rejected' }`— the game says that code is not good
|
||||
* `{ ok: false, reason: 'taken', username }` — someone else holds that Steam id
|
||||
* `{ ok: false, reason: 'offline' }` — the game or its sidecar did not answer
|
||||
*
|
||||
* **`rejected` deliberately collapses "unknown" and "expired".** The plugin
|
||||
* distinguishes them and an operator reading its log can too; a stranger typing
|
||||
* codes must not learn which of the two they hit, because that is the difference
|
||||
* between "keep guessing" and "guess faster".
|
||||
*/
|
||||
async function confirmOne({ server, code, userId }) {
|
||||
const result = await sidecar.confirmLink(server, code)
|
||||
|
||||
// The transport failed: the sidecar is unreachable, the game is not connected,
|
||||
// or the reply never came. None of those is a verdict on the code, so the
|
||||
// player is told to try again rather than that their code is wrong.
|
||||
if (!result.ok) {
|
||||
log.warn('link confirm did not reach the game', { server: server.id, status: result.status })
|
||||
return { ok: false, reason: 'offline' }
|
||||
}
|
||||
|
||||
const frame = result.data || {}
|
||||
|
||||
// The plugin's own refusal. `frame.reason` is `unknown`, `expired` or
|
||||
// `malformed`; it is logged and not surfaced (see the doc above).
|
||||
if (frame.kind !== 'link.ok' || !frame.steamId) {
|
||||
log.info('link code refused', { server: server.id, reason: frame.reason || frame.kind || 'unknown' })
|
||||
return { ok: false, reason: 'rejected' }
|
||||
}
|
||||
|
||||
const steamId = String(frame.steamId)
|
||||
const held = await db.getBySteamId(steamId)
|
||||
|
||||
// D23: refuse, and say whose it is. A move would transfer every permission and
|
||||
// entitlement phases 7 and 13 hang off this link, on a code anybody in game
|
||||
// could have run — and the player's way out is `/unlink` in game, which they
|
||||
// can reach from the machine they are sitting at.
|
||||
if (held) {
|
||||
if (Number(held.userId) === Number(userId)) {
|
||||
// Already theirs. Not an error: a player who pressed the button twice, or
|
||||
// one whose code was confirmed on a request that then timed out.
|
||||
return { ok: true, link: shape(held), already: true }
|
||||
}
|
||||
return { ok: false, reason: 'taken', username: held.username }
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert({
|
||||
steamId,
|
||||
userId,
|
||||
name: frame.name || null,
|
||||
serverId: server.id,
|
||||
})
|
||||
} catch (err) {
|
||||
// The race the PRIMARY KEY exists for: two confirmations of the same Steam
|
||||
// id, interleaved between the check above and this write. The key refuses the
|
||||
// second and it becomes the same refusal, rather than a 500.
|
||||
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
|
||||
const now = await db.getBySteamId(steamId)
|
||||
if (now && Number(now.userId) === Number(userId)) {
|
||||
return { ok: true, link: shape(now), already: true }
|
||||
}
|
||||
return { ok: false, reason: 'taken', username: now && now.username }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const link = shape(await db.getBySteamId(steamId))
|
||||
log.info('steam account linked', { steamId, userId, server: server.id })
|
||||
linksChanged('rust account linked')
|
||||
// Only a NEW link is news. The `already` path above is somebody pressing the
|
||||
// button twice, and telling them twice would make the notice meaningless for
|
||||
// the one case it exists for: a link they did not make.
|
||||
engagement.linked({ userId, steamId, name: frame.name })
|
||||
return { ok: true, link }
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a code against the fleet (D24).
|
||||
*
|
||||
* **A code is minted by ONE server and the player types six characters into a
|
||||
* browser**, so the site cannot know which server it came from — nothing in the
|
||||
* code says, and asking the player to pick would make a wrong guess
|
||||
* indistinguishable from a wrong code, which is the one refusal that must not be
|
||||
* ambiguous. So every enabled server is asked in turn and the first `link.ok`
|
||||
* wins. The others answer `unknown` and nothing happens there: a code is only
|
||||
* spent at the server that actually holds it.
|
||||
*
|
||||
* The loop stops early on `taken`, because that is a verdict about the Steam id
|
||||
* rather than about this server — asking the rest of the fleet would produce the
|
||||
* same answer more slowly.
|
||||
*
|
||||
* **"Every reachable server refused" is not the same answer as "a server was
|
||||
* unreachable"**, and collapsing them is how a player who linked on the one
|
||||
* server that is down gets told their code is wrong. `unsure` is that case, and
|
||||
* the sentence it earns says to try again rather than to run `/link` again.
|
||||
*/
|
||||
async function redeem({ code, userId }) {
|
||||
const fleet = await servers.listForPolling()
|
||||
|
||||
if (fleet.length === 0) return { ok: false, reason: 'no-servers' }
|
||||
|
||||
let refused = 0
|
||||
let unreachable = 0
|
||||
|
||||
for (const server of fleet) {
|
||||
// Sequential, deliberately. In parallel every server would be asked even
|
||||
// after one had already answered, and a code spent on the right server would
|
||||
// still be travelling to five others — for a fleet of six and a five-minute
|
||||
// TTL, there is nothing to win by racing them.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await confirmOne({ server, code, userId })
|
||||
|
||||
if (result.ok || result.reason === 'taken') return result
|
||||
|
||||
if (result.reason === 'offline') unreachable += 1
|
||||
else refused += 1
|
||||
}
|
||||
|
||||
if (refused === 0) return { ok: false, reason: 'offline' }
|
||||
if (unreachable > 0) return { ok: false, reason: 'unsure' }
|
||||
|
||||
return { ok: false, reason: 'rejected' }
|
||||
}
|
||||
|
||||
/** Remove a link the caller owns. False when they did not hold it. */
|
||||
async function unlinkOwned(steamId, userId) {
|
||||
const removed = (await db.removeOwned(steamId, userId)) > 0
|
||||
if (removed) linksChanged('rust account unlinked')
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link whoever holds it.
|
||||
*
|
||||
* Two callers, both of which have already established their authority and
|
||||
* neither of which is the link's owner: ingest applying an in-game `/unlink`
|
||||
* (the authority is the Steam account — whoever is connected as it is who it
|
||||
* is), and a staff unlink from the `admin.users.detail` panel (D25).
|
||||
*
|
||||
* It logs nothing about who asked, because the two callers record that
|
||||
* differently: the admin one writes an `activity.log` entry naming the operator,
|
||||
* and the game one has no operator to name.
|
||||
*/
|
||||
async function unlinkAnyOwner(steamId) {
|
||||
const removed = (await db.removeBySteamId(steamId)) > 0
|
||||
if (removed) linksChanged('rust account unlinked')
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link because the player asked in game.
|
||||
*
|
||||
* Called from ingest, off an `account.unlinked` event.
|
||||
*/
|
||||
async function unlinkFromGame(steamId) {
|
||||
const removed = await unlinkAnyOwner(steamId)
|
||||
if (removed) log.info('steam account unlinked in game', { steamId })
|
||||
return removed
|
||||
}
|
||||
|
||||
/** The admin panel's read: every link this user holds, with per-server totals. */
|
||||
async function forAdmin(userId) {
|
||||
const links = await db.listForUserWithPlayer(userId)
|
||||
|
||||
return Promise.all(
|
||||
links.map(async (row) => ({
|
||||
steamId: row.steamId,
|
||||
// The name on the LINK is what they were called when they linked; the one
|
||||
// on `rust_players` is what the game last saw. They differ the moment
|
||||
// somebody renames, and the newer one is the useful one to show.
|
||||
name: row.playerName || row.name || null,
|
||||
linkedName: row.name || null,
|
||||
serverId: row.serverId || null,
|
||||
linkedAt: row.linkedAt,
|
||||
firstSeen: row.firstSeen || null,
|
||||
lastSeen: row.lastSeen || null,
|
||||
servers: (await db.statsForSteamId(row.steamId)).map((s) => ({
|
||||
serverId: s.serverId,
|
||||
serverName: s.serverName || s.serverId,
|
||||
kills: Number(s.kills) || 0,
|
||||
deaths: Number(s.deaths) || 0,
|
||||
npcKills: Number(s.npcKills) || 0,
|
||||
structures: Number(s.structures) || 0,
|
||||
playtimeSec: Number(s.playtimeSec) || 0,
|
||||
wipes: Number(s.wipes) || 0,
|
||||
lastSeen: s.lastSeen || null,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
shape,
|
||||
listForUser,
|
||||
owns,
|
||||
confirmOne,
|
||||
redeem,
|
||||
unlinkOwned,
|
||||
unlinkAnyOwner,
|
||||
unlinkFromGame,
|
||||
forAdmin,
|
||||
}
|
||||
578
server/model/permissions/permissions.db.js
Normal file
578
server/model/permissions/permissions.db.js
Normal file
@@ -0,0 +1,578 @@
|
||||
// ── SQL for the permission mirror, and nothing else ───────────────────────
|
||||
//
|
||||
// The tables this file reads are described at length in `db/schema.sql`; what
|
||||
// matters here is which of them is authoritative for what, because four of the
|
||||
// eight look similar and answer completely different questions:
|
||||
//
|
||||
// AUTHORED `rust_perm_groups`, `..._group_permissions`, `..._group_members`,
|
||||
// `rust_perm_grants` — what an operator (and later an event) says
|
||||
// should be true. Keyed by WEBSITE USER (D28).
|
||||
// PUSHED `rust_perm_pushed` — what this site has confirmed into one game's
|
||||
// store. Keyed by STEAM ID, because it records what is in the game
|
||||
// and the game has never heard of a website account.
|
||||
// FOUND `rust_perm_drift` — what a sync found that the site did not
|
||||
// author. Replaced whole by each report: it is the current
|
||||
// difference, not a history of differences.
|
||||
// INSTRUCTED `rust_perm_revocations` — remove this, even though we never put
|
||||
// it there. The only way to act on drift, since a foreign grant
|
||||
// often names a Steam id no website account holds.
|
||||
//
|
||||
// Raw parameterised SQL through `core.query`, no ORM, like every other `.db.js`
|
||||
// here. Bulk writes are batched into one statement with a generated placeholder
|
||||
// list rather than looped, because a fleet-wide sync writes hundreds of rows and
|
||||
// a round trip each is how a boot tick becomes a second long.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const GROUPS = 'rust_perm_groups'
|
||||
const GROUP_PERMISSIONS = 'rust_perm_group_permissions'
|
||||
const GROUP_MEMBERS = 'rust_perm_group_members'
|
||||
const GRANTS = 'rust_perm_grants'
|
||||
const RUN_GRANTS = 'rust_perm_run_grants'
|
||||
const PUSHED = 'rust_perm_pushed'
|
||||
const DRIFT = 'rust_perm_drift'
|
||||
const REVOCATIONS = 'rust_perm_revocations'
|
||||
const SYNC = 'rust_perm_sync'
|
||||
const CATALOGUE = 'rust_perm_catalogue'
|
||||
const LINKS = 'rust_account_links'
|
||||
const SERVERS = 'rust_servers'
|
||||
|
||||
/** `(?,?,?),(?,?,?)` for `rows.length` rows of `width` columns. */
|
||||
function placeholders(rows, width) {
|
||||
return rows.map(() => `(${new Array(width).fill('?').join(',')})`).join(',')
|
||||
}
|
||||
|
||||
// ---- the authored set ----
|
||||
|
||||
async function listGroups() {
|
||||
return core.query(
|
||||
`SELECT name, title, \`rank\`, scope, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM ${GROUPS}
|
||||
ORDER BY \`rank\` DESC, name ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getGroup(name) {
|
||||
const rows = await core.query(
|
||||
`SELECT name, title, \`rank\`, scope FROM ${GROUPS} WHERE name = ?`,
|
||||
[name],
|
||||
)
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update one group.
|
||||
*
|
||||
* `ON DUPLICATE KEY UPDATE` rather than a check-then-write: two admins on the
|
||||
* same screen is not a race worth losing a title over, and the row's identity is
|
||||
* its name either way.
|
||||
*/
|
||||
async function upsertGroup({ name, title, rank, scope }) {
|
||||
await core.query(
|
||||
`INSERT INTO ${GROUPS} (name, title, \`rank\`, scope)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE title = VALUES(title), \`rank\` = VALUES(\`rank\`),
|
||||
scope = VALUES(scope), updated_at = CURRENT_TIMESTAMP`,
|
||||
[name, title, rank, scope],
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteGroup(name) {
|
||||
const result = await core.query(`DELETE FROM ${GROUPS} WHERE name = ?`, [name])
|
||||
return Number(result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
async function listGroupPermissions() {
|
||||
return core.query(
|
||||
`SELECT group_name AS groupName, permission FROM ${GROUP_PERMISSIONS} ORDER BY permission ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Replace a group's permission list whole. The form edits a list, so the write is a list. */
|
||||
async function setGroupPermissions(name, permissions) {
|
||||
await core.query(`DELETE FROM ${GROUP_PERMISSIONS} WHERE group_name = ?`, [name])
|
||||
|
||||
if (!permissions.length) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${GROUP_PERMISSIONS} (group_name, permission)
|
||||
VALUES ${placeholders(permissions, 2)}`,
|
||||
permissions.flatMap((permission) => [name, permission]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every membership, with the member's Steam accounts joined on.
|
||||
*
|
||||
* One query rather than a membership read plus a link read per member: the admin
|
||||
* screen renders both together and the push needs both together, and a fleet's
|
||||
* worth of members is one round trip either way.
|
||||
*/
|
||||
async function listGroupMembers() {
|
||||
return core.query(
|
||||
`SELECT m.group_name AS groupName, m.user_id AS userId, m.added_at AS addedAt,
|
||||
u.username, l.steam_id AS steamId, p.name AS playerName
|
||||
FROM ${GROUP_MEMBERS} m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
LEFT JOIN ${LINKS} l ON l.user_id = m.user_id
|
||||
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
|
||||
ORDER BY m.group_name ASC, u.username ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
async function addGroupMember(groupName, userId, addedBy) {
|
||||
await core.query(
|
||||
`INSERT IGNORE INTO ${GROUP_MEMBERS} (group_name, user_id, added_by) VALUES (?, ?, ?)`,
|
||||
[groupName, userId, addedBy],
|
||||
)
|
||||
}
|
||||
|
||||
async function removeGroupMember(groupName, userId) {
|
||||
const result = await core.query(
|
||||
`DELETE FROM ${GROUP_MEMBERS} WHERE group_name = ? AND user_id = ?`,
|
||||
[groupName, userId],
|
||||
)
|
||||
|
||||
return Number(result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Every direct grant, with the holder's accounts joined on.
|
||||
*
|
||||
* `username` is on the row because a grant with no linked Steam account still
|
||||
* has to be listable and nameable — that state is the one the admin screen most
|
||||
* needs to show, since it looks exactly like a working grant from every other
|
||||
* angle and reaches nobody.
|
||||
*/
|
||||
async function listGrants({ userId = null } = {}) {
|
||||
return core.query(
|
||||
`SELECT g.id, g.user_id AS userId, g.permission, g.scope, g.source, g.note,
|
||||
g.granted_at AS grantedAt, u.username,
|
||||
l.steam_id AS steamId, p.name AS playerName
|
||||
FROM ${GRANTS} g
|
||||
JOIN users u ON u.id = g.user_id
|
||||
LEFT JOIN ${LINKS} l ON l.user_id = g.user_id
|
||||
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
|
||||
${userId === null ? '' : 'WHERE g.user_id = ?'}
|
||||
ORDER BY u.username ASC, g.permission ASC`,
|
||||
userId === null ? [] : [userId],
|
||||
)
|
||||
}
|
||||
|
||||
async function getGrant(id) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, user_id AS userId, permission, scope, source FROM ${GRANTS} WHERE id = ?`,
|
||||
[id],
|
||||
)
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a grant, or leave the one that is already there alone.
|
||||
*
|
||||
* `INSERT IGNORE` against the unique key, and the return says which happened —
|
||||
* the controller needs to tell "granted" from "they already had it" to write an
|
||||
* honest activity row.
|
||||
*/
|
||||
async function insertGrant({ userId, permission, scope, source, note, grantedBy }) {
|
||||
const result = await core.query(
|
||||
`INSERT IGNORE INTO ${GRANTS} (user_id, permission, scope, source, note, granted_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[userId, permission, scope, source, note, grantedBy],
|
||||
)
|
||||
|
||||
return { inserted: Number(result.affectedRows || 0) > 0, id: result.insertId }
|
||||
}
|
||||
|
||||
async function deleteGrant(id) {
|
||||
const result = await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id])
|
||||
return Number(result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
// ---- what events granted (phase 13b) ----
|
||||
//
|
||||
// `rust_perm_run_grants` is authored by `rust.kit.entitle`, never by a person,
|
||||
// and it is read beside `rust_perm_grants` rather than merged into it (D84): the
|
||||
// push unions the two, and a revert deletes exactly one step's rows.
|
||||
|
||||
/** Every event grant, for the push. Small: one row per recipient per reward step still standing. */
|
||||
async function listRunGrants() {
|
||||
return core.query(
|
||||
`SELECT run_id AS runId, step_id AS stepId, user_id AS userId, server_id AS serverId,
|
||||
steam_id AS steamId, permission, kit, credit
|
||||
FROM ${RUN_GRANTS}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** One step's rows. A repeated key finds them here and writes nothing new. */
|
||||
async function listRunGrantsForStep(runId, stepId) {
|
||||
return core.query(
|
||||
`SELECT user_id AS userId, server_id AS serverId, steam_id AS steamId, permission, kit, credit
|
||||
FROM ${RUN_GRANTS}
|
||||
WHERE run_id = ? AND step_id = ?`,
|
||||
[String(runId), String(stepId)],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One step's recipients, in one statement. `INSERT IGNORE` against the unique
|
||||
* key, so a retry that races the first attempt writes each row once.
|
||||
*/
|
||||
async function insertRunGrants(rows) {
|
||||
if (!rows.length) return 0
|
||||
|
||||
const result = await core.query(
|
||||
`INSERT IGNORE INTO ${RUN_GRANTS} (run_id, step_id, idem_key, user_id, server_id, steam_id, permission, kit, credit)
|
||||
VALUES ${placeholders(rows, 9)}`,
|
||||
rows.flatMap((r) => [
|
||||
String(r.runId),
|
||||
String(r.stepId),
|
||||
String(r.idemKey || ''),
|
||||
r.userId,
|
||||
r.serverId,
|
||||
r.steamId,
|
||||
r.permission || '',
|
||||
r.kit,
|
||||
r.credit ? 1 : 0,
|
||||
]),
|
||||
)
|
||||
|
||||
return Number(result.affectedRows || 0)
|
||||
}
|
||||
|
||||
/** Withdraw one step's rows. Returns the servers they were on; none is a success. */
|
||||
async function deleteRunGrantsForStep(runId, stepId) {
|
||||
return deleteRunGrantsWhere('run_id = ? AND step_id = ?', [String(runId), String(stepId)])
|
||||
}
|
||||
|
||||
/** The same, found by core's idempotency key — the revert of an answer core lost. */
|
||||
async function deleteRunGrantsForKey(runId, idemKey) {
|
||||
if (!idemKey) return []
|
||||
return deleteRunGrantsWhere('run_id = ? AND idem_key = ?', [String(runId), String(idemKey)])
|
||||
}
|
||||
|
||||
async function deleteRunGrantsWhere(where, params) {
|
||||
const found = await core.query(`SELECT DISTINCT server_id AS serverId FROM ${RUN_GRANTS} WHERE ${where}`, params)
|
||||
if (!found.length) return []
|
||||
|
||||
await core.query(`DELETE FROM ${RUN_GRANTS} WHERE ${where}`, params)
|
||||
return found.map((row) => row.serverId)
|
||||
}
|
||||
|
||||
/**
|
||||
* One website account by name, for the authoring form.
|
||||
*
|
||||
* A form that made an operator type a numeric user id would be a form nobody
|
||||
* could use, and the alternative — calling core's own admin user search from the
|
||||
* client — would bind this module to the shape of a response the contract does
|
||||
* not cover. Reading the `users` table is already what every join in this file
|
||||
* does.
|
||||
*
|
||||
* Case-insensitive because the column's collation is: core stores usernames in a
|
||||
* `_ci` collation and an exact-case lookup would refuse a name the site itself
|
||||
* considers the same one.
|
||||
*/
|
||||
async function findUserByUsername(username) {
|
||||
const rows = await core.query(`SELECT id, username FROM users WHERE username = ? LIMIT 1`, [username])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** Which website user holds which Steam account. The join that turns an authored row into a push. */
|
||||
async function listLinks() {
|
||||
return core.query(`SELECT user_id AS userId, steam_id AS steamId FROM ${LINKS}`)
|
||||
}
|
||||
|
||||
// ---- one person's own half of all of it (the player tier) ----
|
||||
//
|
||||
// Every read below is scoped inside the statement rather than filtered after it.
|
||||
// The admin reads above answer "who holds what"; these answer "what do I hold",
|
||||
// and the difference between the two is a `WHERE` that must not be somebody
|
||||
// else's job to remember.
|
||||
|
||||
/** The groups one website user belongs to. Ordered the way the admin list is. */
|
||||
async function listGroupsForUser(userId) {
|
||||
return core.query(
|
||||
`SELECT g.name, g.title, g.\`rank\`, g.scope, m.added_at AS addedAt
|
||||
FROM ${GROUP_MEMBERS} m
|
||||
JOIN ${GROUPS} g ON g.name = m.group_name
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY g.\`rank\` DESC, g.name ASC`,
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every pushed row naming one of these Steam ids, across every server.
|
||||
*
|
||||
* The pushed ledger is keyed by Steam id because it records what is in a GAME
|
||||
* (D28's other half), so this is the one read in the file that starts from an
|
||||
* account rather than from a user. `kind` is carried through: a direct grant and
|
||||
* a group membership are different rows about the same person and only the
|
||||
* caller can say which of them it was looking for.
|
||||
*/
|
||||
async function listPushedForSteamIds(steamIds) {
|
||||
if (!steamIds.length) return []
|
||||
|
||||
return core.query(
|
||||
`SELECT server_id AS serverId, kind, subject, object
|
||||
FROM ${PUSHED}
|
||||
WHERE subject IN (${steamIds.map(() => '?').join(',')})
|
||||
AND kind IN ('grant', 'member')`,
|
||||
steamIds,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- what is actually out there ----
|
||||
|
||||
async function listPushed(serverId) {
|
||||
return core.query(
|
||||
`SELECT kind, subject, object FROM ${PUSHED} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
async function addPushed(serverId, rows) {
|
||||
if (!rows.length) return
|
||||
|
||||
await core.query(
|
||||
`INSERT IGNORE INTO ${PUSHED} (server_id, kind, subject, object)
|
||||
VALUES ${placeholders(rows, 4)}`,
|
||||
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
|
||||
)
|
||||
}
|
||||
|
||||
async function removePushed(serverId, rows) {
|
||||
for (const row of rows) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await core.query(
|
||||
`DELETE FROM ${PUSHED} WHERE server_id = ? AND kind = ? AND subject = ? AND object = ?`,
|
||||
[serverId, row.kind, row.subject, row.object],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one server's drift list with what the latest report found.
|
||||
*
|
||||
* Whole, rather than merged, and `first_seen` survives through the
|
||||
* `ON DUPLICATE KEY UPDATE` — so "this has been here since Tuesday" is still
|
||||
* answerable while "somebody has since undone it" removes the row.
|
||||
*/
|
||||
async function replaceDrift(serverId, rows) {
|
||||
if (!rows.length) {
|
||||
await core.query(`DELETE FROM ${DRIFT} WHERE server_id = ?`, [serverId])
|
||||
return
|
||||
}
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${DRIFT} (server_id, kind, subject, object)
|
||||
VALUES ${placeholders(rows, 4)}
|
||||
ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP`,
|
||||
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
|
||||
)
|
||||
|
||||
// Anything this report did NOT name is gone from the game, so it goes from
|
||||
// here. Named explicitly rather than swept by timestamp: two syncs a second
|
||||
// apart would make a timestamp window either delete live rows or keep dead
|
||||
// ones, depending on the clock.
|
||||
await core.query(
|
||||
`DELETE FROM ${DRIFT}
|
||||
WHERE server_id = ?
|
||||
AND (kind, subject, object) NOT IN (${placeholders(rows, 3)})`,
|
||||
[serverId, ...rows.flatMap((row) => [row.kind, row.subject, row.object])],
|
||||
)
|
||||
}
|
||||
|
||||
async function listDrift() {
|
||||
return core.query(
|
||||
`SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object,
|
||||
d.first_seen AS firstSeen, d.last_seen AS lastSeen,
|
||||
l.user_id AS userId, u.username, p.name AS playerName
|
||||
FROM ${DRIFT} d
|
||||
LEFT JOIN ${LINKS} l ON l.steam_id = d.subject
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
LEFT JOIN rust_players p ON p.steam_id = d.subject
|
||||
ORDER BY d.server_id ASC, d.kind ASC, d.subject ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getDrift(id) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, server_id AS serverId, kind, subject, object FROM ${DRIFT} WHERE id = ?`,
|
||||
[id],
|
||||
)
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function deleteDrift(id) {
|
||||
await core.query(`DELETE FROM ${DRIFT} WHERE id = ?`, [id])
|
||||
}
|
||||
|
||||
async function queueRevocation({ serverId, kind, subject, object, requestedBy }) {
|
||||
await core.query(
|
||||
`INSERT IGNORE INTO ${REVOCATIONS} (server_id, kind, subject, object, requested_by)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[serverId, kind, subject, object, requestedBy],
|
||||
)
|
||||
}
|
||||
|
||||
async function listRevocations(serverId) {
|
||||
return core.query(
|
||||
`SELECT id, kind, subject, object FROM ${REVOCATIONS} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteRevocations(ids) {
|
||||
if (!ids.length) return
|
||||
|
||||
await core.query(
|
||||
`DELETE FROM ${REVOCATIONS} WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the state of the mirror ----
|
||||
|
||||
/**
|
||||
* One sync row per configured server, created on demand.
|
||||
*
|
||||
* A server added today has no row and must not therefore be skipped for ever, so
|
||||
* the read inserts what is missing rather than the writer remembering to.
|
||||
*/
|
||||
async function ensureSyncRows() {
|
||||
await core.query(
|
||||
`INSERT IGNORE INTO ${SYNC} (server_id) SELECT id FROM ${SERVERS}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function listSync() {
|
||||
return core.query(
|
||||
`SELECT s.server_id AS serverId, s.state, s.dirty, s.desired_hash AS desiredHash,
|
||||
s.synced_hash AS syncedHash, s.boot_id AS bootId, s.wipe_id AS wipeId,
|
||||
s.last_attempt_at AS lastAttemptAt, s.last_ok_at AS lastOkAt,
|
||||
s.report, s.error
|
||||
FROM ${SYNC} s
|
||||
ORDER BY s.server_id ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark servers as needing a sync.
|
||||
*
|
||||
* `scope` is a server id or `*`; a fleet-wide change dirties every row, which is
|
||||
* right: the set each server should hold has changed even if only one of them
|
||||
* will notice a difference.
|
||||
*/
|
||||
async function markDirty(scope) {
|
||||
if (!scope || scope === '*') {
|
||||
await core.query(`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP`)
|
||||
return
|
||||
}
|
||||
|
||||
await core.query(
|
||||
`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP WHERE server_id = ?`,
|
||||
[scope],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the outcome of one attempt.
|
||||
*
|
||||
* **`dirty` is cleared unconditionally, and that is safe because it is an
|
||||
* optimisation rather than the truth.** Something may well have changed the
|
||||
* authored set while this sync was in flight, and clearing the flag would then
|
||||
* lose that change — except that the loop's real condition is
|
||||
* `desired_hash != synced_hash`, recomputed from the tables on every tick. The
|
||||
* flag only saves a hash comparison; the hash is what cannot be wrong.
|
||||
*
|
||||
* `last_ok_at` moves only on success, and it is passed rather than composed into
|
||||
* the SQL so the statement is the same string every time.
|
||||
*/
|
||||
async function putSyncResult(serverId, { state, syncedHash, desiredHash, bootId, wipeId, report, error }) {
|
||||
const okAt = state === 'ok' ? new Date() : null
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${SYNC} (server_id, state, dirty, desired_hash, synced_hash, boot_id, wipe_id,
|
||||
last_attempt_at, last_ok_at, report, error, updated_at)
|
||||
VALUES (?, ?, 0, ?, ?, ?, ?, NOW(), ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE state = VALUES(state), dirty = 0,
|
||||
desired_hash = VALUES(desired_hash),
|
||||
synced_hash = VALUES(synced_hash),
|
||||
boot_id = VALUES(boot_id), wipe_id = VALUES(wipe_id),
|
||||
last_attempt_at = NOW(),
|
||||
last_ok_at = COALESCE(VALUES(last_ok_at), last_ok_at),
|
||||
report = VALUES(report), error = VALUES(error),
|
||||
updated_at = NOW()`,
|
||||
[serverId, state, desiredHash, syncedHash, bootId, wipeId, okAt, report, error],
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the option source ----
|
||||
|
||||
async function putCatalogue(serverId, permissions) {
|
||||
await core.query(`DELETE FROM ${CATALOGUE} WHERE server_id = ?`, [serverId])
|
||||
|
||||
if (!permissions.length) return
|
||||
|
||||
await core.query(
|
||||
`INSERT IGNORE INTO ${CATALOGUE} (server_id, permission)
|
||||
VALUES ${placeholders(permissions, 2)}`,
|
||||
permissions.flatMap((permission) => [serverId, permission]),
|
||||
)
|
||||
}
|
||||
|
||||
async function listCatalogue() {
|
||||
return core.query(
|
||||
`SELECT server_id AS serverId, permission FROM ${CATALOGUE} ORDER BY permission ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GROUPS,
|
||||
GRANTS,
|
||||
RUN_GRANTS,
|
||||
PUSHED,
|
||||
DRIFT,
|
||||
listGroups,
|
||||
getGroup,
|
||||
upsertGroup,
|
||||
deleteGroup,
|
||||
listGroupPermissions,
|
||||
setGroupPermissions,
|
||||
listGroupMembers,
|
||||
addGroupMember,
|
||||
removeGroupMember,
|
||||
listGrants,
|
||||
getGrant,
|
||||
insertGrant,
|
||||
deleteGrant,
|
||||
listRunGrants,
|
||||
listRunGrantsForStep,
|
||||
insertRunGrants,
|
||||
deleteRunGrantsForStep,
|
||||
deleteRunGrantsForKey,
|
||||
findUserByUsername,
|
||||
listLinks,
|
||||
listGroupsForUser,
|
||||
listPushedForSteamIds,
|
||||
listPushed,
|
||||
addPushed,
|
||||
removePushed,
|
||||
replaceDrift,
|
||||
listDrift,
|
||||
getDrift,
|
||||
deleteDrift,
|
||||
queueRevocation,
|
||||
listRevocations,
|
||||
deleteRevocations,
|
||||
ensureSyncRows,
|
||||
listSync,
|
||||
markDirty,
|
||||
putSyncResult,
|
||||
putCatalogue,
|
||||
listCatalogue,
|
||||
}
|
||||
507
server/model/permissions/permissions.model.js
Normal file
507
server/model/permissions/permissions.model.js
Normal file
@@ -0,0 +1,507 @@
|
||||
// ── The authored set, and what it means for one server ────────────────────
|
||||
//
|
||||
// This file turns "what an operator wrote on the website" into "what one game
|
||||
// server's store should contain", which is where four of phase 7's decisions
|
||||
// actually live:
|
||||
//
|
||||
// D28 a grant is authored against a WEBSITE USER and resolved to every Steam
|
||||
// id they have linked, here, at the moment of the push.
|
||||
// D29 every authored row carries a scope — one server, or `*` for the fleet —
|
||||
// and a server sees only what names it.
|
||||
// D30 groups travel as groups. Membership is a separate wire fact from the
|
||||
// permissions the group carries, because the game stores them separately
|
||||
// and one of the two can fail on its own (§12.2 rule 4).
|
||||
// D31 the difference between the desired set and what this site has already
|
||||
// pushed is what gets retired. Anything else in the store is drift, and
|
||||
// drift is reported rather than undone.
|
||||
//
|
||||
// Nothing here talks to a sidecar — `permSync.js` does that. The split is the
|
||||
// usual one and earns its keep twice over here: the whole of the interesting
|
||||
// logic is a pure function of four tables, so it is tested without a game, a
|
||||
// sidecar, or a database.
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const db = require('./permissions.db')
|
||||
|
||||
/** A scope that means every server. Stored, rather than null, so the column never needs a coalesce. */
|
||||
const FLEET = '*'
|
||||
|
||||
/**
|
||||
* Permission and group names, as both frameworks store them.
|
||||
*
|
||||
* Lowercased on the way in, because the store lowers them and a site that did
|
||||
* not would author `Kits.VIP`, push it, read back `kits.vip`, and report its own
|
||||
* grant as drift for ever.
|
||||
*/
|
||||
function normaliseName(value) {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
/** Whether a scope reaches a server. */
|
||||
function inScope(scope, serverId) {
|
||||
return scope === FLEET || scope === serverId
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the authoring screen renders, in one read.
|
||||
*
|
||||
* Assembled here rather than in SQL because the shape is a tree — a group with
|
||||
* its permissions and its members — and the alternative is either four round
|
||||
* trips per group or one join that repeats every group row once per member.
|
||||
*/
|
||||
async function overview() {
|
||||
const [groups, groupPermissions, members, grants, sync, drift, catalogue] = await Promise.all([
|
||||
db.listGroups(),
|
||||
db.listGroupPermissions(),
|
||||
db.listGroupMembers(),
|
||||
db.listGrants(),
|
||||
db.listSync(),
|
||||
db.listDrift(),
|
||||
db.listCatalogue(),
|
||||
])
|
||||
|
||||
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [] }]))
|
||||
|
||||
for (const row of groupPermissions) {
|
||||
const group = byGroup.get(row.groupName)
|
||||
if (group) group.permissions.push(row.permission)
|
||||
}
|
||||
|
||||
// A member with two linked Steam accounts arrives as two rows from the join,
|
||||
// and is one person on the screen — holding BOTH accounts, not the first one
|
||||
// the join happened to return. The screen needs all of them: a membership is
|
||||
// pushed per account, and it can be waiting on one while it landed on another.
|
||||
const memberByKey = new Map()
|
||||
|
||||
for (const row of members) {
|
||||
const group = byGroup.get(row.groupName)
|
||||
if (!group) continue
|
||||
|
||||
const key = `${row.groupName}:${row.userId}`
|
||||
let member = memberByKey.get(key)
|
||||
|
||||
if (!member) {
|
||||
member = {
|
||||
userId: row.userId,
|
||||
username: row.username,
|
||||
accounts: [],
|
||||
addedAt: row.addedAt,
|
||||
}
|
||||
memberByKey.set(key, member)
|
||||
group.members.push(member)
|
||||
}
|
||||
|
||||
if (row.steamId) member.accounts.push({ steamId: row.steamId, name: row.playerName || null })
|
||||
}
|
||||
|
||||
return {
|
||||
groups: [...byGroup.values()],
|
||||
grants: collapseGrants(grants),
|
||||
servers: sync.map(shapeSync),
|
||||
drift,
|
||||
catalogue: catalogueByPermission(catalogue),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per grant, not one per linked account.
|
||||
*
|
||||
* The join in `listGrants` multiplies a grant by the holder's accounts, which is
|
||||
* what the push wants and the opposite of what a screen wants.
|
||||
*/
|
||||
function collapseGrants(rows) {
|
||||
const byId = new Map()
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = byId.get(row.id)
|
||||
|
||||
if (!existing) {
|
||||
byId.set(row.id, {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
username: row.username,
|
||||
permission: row.permission,
|
||||
scope: row.scope,
|
||||
source: row.source,
|
||||
note: row.note,
|
||||
grantedAt: row.grantedAt,
|
||||
accounts: row.steamId ? [{ steamId: row.steamId, name: row.playerName || null }] : [],
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (row.steamId) existing.accounts.push({ steamId: row.steamId, name: row.playerName || null })
|
||||
}
|
||||
|
||||
return [...byId.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* The sync row as a client reads it.
|
||||
*
|
||||
* `report` is stored as the JSON the game sent and parsed here rather than on the
|
||||
* way in, so a report this build cannot read is a rendering problem on one
|
||||
* screen instead of a write that failed.
|
||||
*/
|
||||
function shapeSync(row) {
|
||||
let report = null
|
||||
|
||||
if (row.report) {
|
||||
try {
|
||||
report = JSON.parse(row.report)
|
||||
} catch {
|
||||
report = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
serverId: row.serverId,
|
||||
state: row.state,
|
||||
dirty: Boolean(row.dirty),
|
||||
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
|
||||
lastAttemptAt: row.lastAttemptAt,
|
||||
lastOkAt: row.lastOkAt,
|
||||
error: row.error || null,
|
||||
report,
|
||||
}
|
||||
}
|
||||
|
||||
/** Which servers know each permission name — the form's option source, and its warning label. */
|
||||
function catalogueByPermission(rows) {
|
||||
const byPermission = new Map()
|
||||
|
||||
for (const row of rows) {
|
||||
if (!byPermission.has(row.permission)) byPermission.set(row.permission, [])
|
||||
byPermission.get(row.permission).push(row.serverId)
|
||||
}
|
||||
|
||||
return [...byPermission.entries()]
|
||||
.map(([permission, servers]) => ({ permission, servers }))
|
||||
.sort((a, b) => a.permission.localeCompare(b.permission))
|
||||
}
|
||||
|
||||
/**
|
||||
* ── What one person holds, as that person reads it ────────────────────────
|
||||
*
|
||||
* The admin overview answers *who holds what*; this answers *what do I hold*,
|
||||
* and it is a different shape rather than a filtered one. Three things make it
|
||||
* different:
|
||||
*
|
||||
* 1. **The scope arithmetic is answered here, not sent.** A client handed
|
||||
* `scope: '*'` would have to know what the fleet is and re-implement
|
||||
* `inScope` to say anything useful, and then there would be two of it. Each
|
||||
* entry carries the servers it actually reaches, already resolved.
|
||||
* 2. **`live` is per server and it is the pushed ledger, not the authored
|
||||
* row.** A grant made on the website is not a privilege in a game until a
|
||||
* sync confirmed it, and phase 7 is careful never to record a push that
|
||||
* silently did nothing (an unregistered permission, a store that has never
|
||||
* seen the player). So "waiting" here means waiting, and saying otherwise
|
||||
* would be the site claiming to have given something it has not.
|
||||
* 3. **Nothing says WHY it is waiting.** Which permission names a server's
|
||||
* loaded plugins registered is an operator's diagnosis and an inventory of
|
||||
* what is installed; a player gets the honest state, not the reason.
|
||||
*
|
||||
* Every read is scoped to the caller in SQL, and the pushed rows are looked up
|
||||
* by the caller's OWN Steam ids — so a person with no linked account correctly
|
||||
* sees entitlements that reach nobody yet, rather than nothing at all (the
|
||||
* mistake phase 7 shipped on the admin user page, §20.5).
|
||||
*/
|
||||
async function forPlayer(userId, steamIds, serverRows) {
|
||||
const [groups, groupPermissions, grants, pushed] = await Promise.all([
|
||||
db.listGroupsForUser(userId),
|
||||
db.listGroupPermissions(),
|
||||
db.listGrants({ userId }),
|
||||
db.listPushedForSteamIds(steamIds),
|
||||
])
|
||||
|
||||
const servers = serverRows.map((row) => ({ id: row.id, name: row.name || row.id }))
|
||||
|
||||
// `kind:object` -> the servers a row of ours landed on. The subject is one of
|
||||
// this caller's own Steam ids by construction, so it does not enter the key:
|
||||
// an entitlement is live for the person if it is live for any account they
|
||||
// hold, which is the same thing the game sees.
|
||||
const live = new Map()
|
||||
|
||||
for (const row of pushed) {
|
||||
const key = `${row.kind}:${normaliseName(row.object)}`
|
||||
if (!live.has(key)) live.set(key, new Set())
|
||||
live.get(key).add(row.serverId)
|
||||
}
|
||||
|
||||
/** The servers a scope reaches, each marked with whether it is there yet. */
|
||||
function reach(scope, key) {
|
||||
const landed = live.get(key) || new Set()
|
||||
|
||||
return servers
|
||||
.filter((server) => inScope(scope, server.id))
|
||||
.map((server) => ({ ...server, live: landed.has(server.id) }))
|
||||
}
|
||||
|
||||
const permissionsByGroup = new Map()
|
||||
|
||||
for (const row of groupPermissions) {
|
||||
if (!permissionsByGroup.has(row.groupName)) permissionsByGroup.set(row.groupName, [])
|
||||
permissionsByGroup.get(row.groupName).push(normaliseName(row.permission))
|
||||
}
|
||||
|
||||
return {
|
||||
groups: groups.map((group) => ({
|
||||
name: group.name,
|
||||
title: group.title || group.name,
|
||||
scope: group.scope,
|
||||
since: group.addedAt,
|
||||
permissions: (permissionsByGroup.get(group.name) || []).sort(),
|
||||
reach: reach(group.scope, `member:${normaliseName(group.name)}`),
|
||||
})),
|
||||
// `collapseGrants` first: the join multiplies a grant by the accounts its
|
||||
// holder has linked, and this caller may hold two.
|
||||
grants: collapseGrants(grants)
|
||||
.map((grant) => ({
|
||||
permission: grant.permission,
|
||||
scope: grant.scope,
|
||||
source: grant.source,
|
||||
note: grant.note,
|
||||
since: grant.grantedAt,
|
||||
reach: reach(grant.scope, `grant:${normaliseName(grant.permission)}`),
|
||||
}))
|
||||
.sort((a, b) => a.permission.localeCompare(b.permission)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole authored set, read once, in the shape the per-server build wants.
|
||||
*
|
||||
* Read once per sync tick rather than once per server: six servers is six
|
||||
* different answers derived from one set of tables, and re-reading them per
|
||||
* server is six times the queries for the same rows.
|
||||
*/
|
||||
async function readAuthored() {
|
||||
const [groups, groupPermissions, members, grants, links, runGrants] = await Promise.all([
|
||||
db.listGroups(),
|
||||
db.listGroupPermissions(),
|
||||
db.listGroupMembers(),
|
||||
db.listGrants(),
|
||||
db.listLinks(),
|
||||
db.listRunGrants(),
|
||||
])
|
||||
|
||||
const steamIdsByUser = new Map()
|
||||
|
||||
for (const link of links) {
|
||||
if (!steamIdsByUser.has(link.userId)) steamIdsByUser.set(link.userId, [])
|
||||
steamIdsByUser.get(link.userId).push(link.steamId)
|
||||
}
|
||||
|
||||
return { groups, groupPermissions, members, grants, runGrants, steamIdsByUser }
|
||||
}
|
||||
|
||||
/**
|
||||
* What one server's store should contain, and the rows that say so.
|
||||
*
|
||||
* Returns three things the caller needs together and must not compute twice:
|
||||
*
|
||||
* `payload` what goes on the wire
|
||||
* `rows` the same set in `rust_perm_pushed`'s shape, for the diff
|
||||
* `hash` a stable digest of `rows`, which is how the loop knows nothing
|
||||
* has changed without asking a game server
|
||||
*
|
||||
* **A user with no linked Steam account contributes nothing and is not an
|
||||
* error.** They are authored against perfectly well and reach nobody until they
|
||||
* link — which the admin screen says out loud, because a grant that reaches
|
||||
* nothing looks exactly like one that worked.
|
||||
*/
|
||||
function buildDesired(serverId, authored) {
|
||||
const { groups, groupPermissions, members, grants, steamIdsByUser } = authored
|
||||
const runGrants = authored.runGrants || []
|
||||
|
||||
const scopedGroups = groups.filter((group) => inScope(group.scope, serverId))
|
||||
const groupNames = new Set(scopedGroups.map((group) => group.name))
|
||||
|
||||
const permissionsByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
|
||||
const membersByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
|
||||
const managed = new Set()
|
||||
const rows = []
|
||||
|
||||
for (const group of scopedGroups)
|
||||
rows.push({ kind: 'group', subject: group.name, object: '' })
|
||||
|
||||
for (const row of groupPermissions) {
|
||||
if (!groupNames.has(row.groupName)) continue
|
||||
|
||||
const permission = normaliseName(row.permission)
|
||||
permissionsByGroup.get(row.groupName).push(permission)
|
||||
managed.add(permission)
|
||||
rows.push({ kind: 'group-permission', subject: row.groupName, object: permission })
|
||||
}
|
||||
|
||||
const seenMember = new Set()
|
||||
|
||||
for (const row of members) {
|
||||
if (!groupNames.has(row.groupName)) continue
|
||||
|
||||
for (const steamId of steamIdsByUser.get(row.userId) || []) {
|
||||
const key = `${row.groupName}:${steamId}`
|
||||
if (seenMember.has(key)) continue
|
||||
seenMember.add(key)
|
||||
|
||||
membersByGroup.get(row.groupName).push(steamId)
|
||||
rows.push({ kind: 'member', subject: steamId, object: row.groupName })
|
||||
}
|
||||
}
|
||||
|
||||
const permissionsBySteamId = new Map()
|
||||
const seenGrant = new Set()
|
||||
|
||||
for (const row of grants) {
|
||||
if (!inScope(row.scope, serverId)) continue
|
||||
|
||||
const permission = normaliseName(row.permission)
|
||||
|
||||
// Managed whether or not it reaches anybody: the namespace is what makes a
|
||||
// hand grant of this permission to somebody else show up as drift, and a
|
||||
// grant whose holder has linked nothing would otherwise silently narrow it.
|
||||
managed.add(permission)
|
||||
|
||||
// **Resolved from the link map, not from the row.** `listGrants` joins the
|
||||
// links and therefore repeats a grant once per linked account, which would
|
||||
// give the right answer here by accident — until somebody changes that query
|
||||
// and one of a person's two accounts quietly stops being granted. The map is
|
||||
// the same source the members above use, and it says what it means.
|
||||
for (const steamId of steamIdsByUser.get(row.userId) || []) {
|
||||
const key = `${steamId}:${permission}`
|
||||
if (seenGrant.has(key)) continue
|
||||
seenGrant.add(key)
|
||||
|
||||
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
|
||||
permissionsBySteamId.get(steamId).push(permission)
|
||||
rows.push({ kind: 'grant', subject: steamId, object: permission })
|
||||
}
|
||||
}
|
||||
|
||||
// ── What events granted (phase 13b, D84) ──────────────────────────────
|
||||
//
|
||||
// Unioned with the admin grants above through the same `seenGrant`, so a
|
||||
// permission held both ways is ONE row in the game — and withdrawing either
|
||||
// leaves the other standing, because the next build still finds it.
|
||||
//
|
||||
// An event grant reaches only the kit's server (D102), and like any grant it
|
||||
// reaches every account the user has linked (D28).
|
||||
//
|
||||
// The CREDIT is different: one win is one extra use, on the account that took
|
||||
// part, and only while that account is still linked to the user who won it.
|
||||
const credits = new Map()
|
||||
|
||||
for (const row of runGrants) {
|
||||
if (row.serverId !== serverId) continue
|
||||
|
||||
const linked = steamIdsByUser.get(row.userId) || []
|
||||
const permission = normaliseName(row.permission)
|
||||
|
||||
if (permission) {
|
||||
managed.add(permission)
|
||||
|
||||
for (const steamId of linked) {
|
||||
const key = `${steamId}:${permission}`
|
||||
if (seenGrant.has(key)) continue
|
||||
seenGrant.add(key)
|
||||
|
||||
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
|
||||
permissionsBySteamId.get(steamId).push(permission)
|
||||
rows.push({ kind: 'grant', subject: steamId, object: permission })
|
||||
}
|
||||
}
|
||||
|
||||
if (Number(row.credit) && linked.includes(row.steamId)) {
|
||||
// A Steam id is digits, so the first bar is always the split; a kit name
|
||||
// may contain one.
|
||||
const key = `${row.steamId}|${row.kit}`
|
||||
credits.set(key, (credits.get(key) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const creditRows = [...credits.entries()]
|
||||
.map(([key, count]) => {
|
||||
const bar = key.indexOf('|')
|
||||
return { steamId: key.slice(0, bar), kit: key.slice(bar + 1), count }
|
||||
})
|
||||
.sort((a, b) => (a.steamId + a.kit).localeCompare(b.steamId + b.kit))
|
||||
|
||||
const payload = {
|
||||
groups: scopedGroups.map((group) => ({
|
||||
name: group.name,
|
||||
title: group.title || group.name,
|
||||
rank: group.rank,
|
||||
permissions: permissionsByGroup.get(group.name),
|
||||
members: membersByGroup.get(group.name),
|
||||
})),
|
||||
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({
|
||||
steamId,
|
||||
permissions,
|
||||
})),
|
||||
managed: [...managed].sort(),
|
||||
// Always sent, even empty: to the plugin an absent field means "this site
|
||||
// says nothing about credits", and an empty one means "nobody has any" —
|
||||
// which is what a revert of the last reward must be able to say (D103).
|
||||
credits: creditRows,
|
||||
}
|
||||
|
||||
// Credits are in the digest, so a new reward or a revert pushes, but they are
|
||||
// NOT in `rows`: those are the pushed ledger's, and a use of a kit is not
|
||||
// something in the permission store to retire.
|
||||
const hashed = [
|
||||
...rows,
|
||||
...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
|
||||
]
|
||||
|
||||
return { payload, rows, hash: hashRows(hashed) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A digest of the desired set.
|
||||
*
|
||||
* Sorted before hashing, because the rows come out of several queries in an
|
||||
* order nothing guarantees — an unsorted digest would differ between two reads
|
||||
* of an unchanged set and push to every game server on every tick.
|
||||
*/
|
||||
function hashRows(rows) {
|
||||
const canonical = rows
|
||||
.map((row) => `${row.kind} | ||||