The module's half of PLAN_FIXES §6 step 2 (decisions D181-D185, docs#288). - F13/F14 (D170, D183): `world.expired`, recognisable from protocol 13 by its `what`, is handed to core as the resource the zone step ledgered (`world`, `<serverId>:<id>`) through ctx.events.expired, which records it `expired`. coreApi moves to ^1.11.0 (website#209). - F8 (D184): `plugin.loaded` / `plugin.unloaded` mark the permission sync dirty when the plugin added or removed permissions, so an unresolved grant lands on the next tick instead of the fifteen-minute audit. - Catalogue: plugin.loaded/unloaded, world.expired and lease.expired are staff kinds. The last two were never classified (default deny kept them off public pages); the test now covers every event kind through protocol 13. - F7: permission and title pushes hold while the stored hello says `worldReady: false` (a human's "sync now" does not); a failed or refused permission sync now logs at warn. - F2 (D185): the killfeed names an NPC attacker — a family (Scientist, Bandit guard, Bradley APC…) or the prefab without its variant digits (wolf2 → Wolf). - F5/F6: a link code is asked of the servers that minted one in the last six minutes first, then of the rest, each group in parallel; "unsure" only when one of the minting servers is unreachable. - D182: the admin server list carries the ZoneManager helper's state from the hello, and the servers page says what a missing or failed helper costs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
179 lines
6.8 KiB
JavaScript
179 lines
6.8 KiB
JavaScript
// ── 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 { attacker, 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: attacker(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 }
|