// ── 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 }