// ── 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, weapon } 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', // RunicNPC bosses (runicnpc stage 6): the game says both to the whole server. // The death names its killer, so the server shows it only where presence is on. 'npc.boss.spawned', 'npc.boss.died', // Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1): the server // reads each one for the viewer, so a name is here only where it may be. 'world.event', // Dynamic PVP's zones opening (§14.3.3). A closing (`pvp.zone.deleted`) is // staff's and is not asked for here; the map shows a zone go. 'pvp.zone.created', // Dangerous Treasures (§14.3.4): a chest opening, and one won. The server reads // each for the viewer, so the winner's name is here only where it may be; the // ending (`dt.ended`) is staff's, and the map shows the chest go. 'dt.started', 'dt.won', // Guarded Crate (§14.3.5): a crate landing, and one cleared, read per viewer // like a treasure. A timeout (`gc.failed`) is staff's; the map shows it go. 'gc.started', 'gc.ended', // RaidableBases (§11.4): a base appearing, raided, bought or locked to a // player, read per viewer; its despawn (`raid.ended`) is staff's, and the map shows it go. 'raid.started', 'raid.completed', 'raid.purchased', 'raid.locked', ]) /** 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'] }, { id: 'bosses', label: 'Bosses', kinds: ['npc.boss.spawned', 'npc.boss.died'] }, { id: 'world', label: 'World events', kinds: ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended', 'raid.started', 'raid.completed', 'raid.purchased', 'raid.locked'] }, ]) 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: '' } // A boss's name is the profile's, as typed (`name`), never a prefab's. case 'npc.boss.spawned': return { tone: 'server', actor: name, verb: 'has appeared', subject: null, detail: 'a boss' } case 'npc.boss.died': { const fought = Number(frame.damagers) > 1 ? `${frame.damagers} players fought it` : '' return frame.killerName ? { tone: 'kill', actor: frame.killerName, verb: 'killed', subject: name, detail: fought } : { tone: 'death', actor: name, verb: 'was killed', subject: null, detail: fought } } case 'world.event': return worldLine(frame) case 'pvp.zone.created': case 'pvp.zone.deleted': return pvpLine(row.kind, frame) case 'dt.started': case 'dt.won': case 'dt.ended': return treasureLine(row.kind, frame) case 'gc.started': case 'gc.ended': case 'gc.failed': return guardedLine(row.kind, frame) case 'raid.started': case 'raid.completed': case 'raid.ended': case 'raid.purchased': case 'raid.locked': return raidLine(row.kind, frame) 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 ${weapon(frame)}` : 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', // One of RunicNPC's (runicnpc stage 4): its own name, as typed on its profile, // rather than the prefab it is built from. Older frames carry only the prefab. actor: frame.attackerNpc || 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 } } } /** Where a locked crate is, as the end of a sentence. */ const CRATE_PLACES = Object.freeze({ oilrig_large: 'on Large Oil Rig', oilrig_small: 'on Small Oil Rig', cargo: 'on the cargo ship', ghostship: 'on a ghost ship', chinook: 'dropped by a Chinook', }) /** * Who a downed helicopter, Chinook or Bradley credits, as a phrase: "Alice", * "Alice and Bob", "Alice, Bob and 2 others". Null when the line names nobody * — below the presence audience the server sends `creditedCount` and no names. */ export function creditedPhrase(frame) { const names = (Array.isArray(frame.credited) ? frame.credited : []).map((c) => c && c.name).filter(Boolean) if (names.length === 0) return null const total = Math.max(names.length, Number(frame.creditedCount) || 0) const shown = names.slice(0, 2) const more = total - shown.length if (more > 0) return `${shown.join(', ')} and ${more} other${more === 1 ? '' : 's'}` return shown.length === 2 ? `${shown[0]} and ${shown[1]}` : shown[0] } /** * Rust's own world events (protocol 14), one sentence per type. The server has * already read the frame for this viewer: it carries `grid`, a `name` only for * the presence audience, and `credited` by the server's rule (D372, D376). A * line therefore never decides who may read a name; it only says what it has. */ function worldLine(frame) { const grid = frame.grid || '' const who = frame.name || null const place = CRATE_PLACES[frame.where] || 'somewhere on the map' const by = creditedPhrase(frame) const downed = (what) => (by ? { tone: 'kill', actor: null, verb: `${what} by`, subject: by, detail: grid } : { tone: 'kill', actor: null, verb: what, subject: null, detail: grid }) const say = (verb, detail = grid) => ({ tone: 'server', actor: null, verb, subject: null, detail }) const harbour = frame.harbourName || 'the harbour' switch (frame.event) { case 'heli.arrived': return say('A patrol helicopter has arrived') case 'heli.left': return say('The patrol helicopter has left', '') case 'heli.downed': return downed('Patrol helicopter downed') case 'chinook.downed': return downed('Chinook shot down') case 'bradley.destroyed': return downed(frame.where === 'launch_site' ? "Launch Site's Bradley destroyed" : frame.where === 'road' ? 'Road Bradley destroyed' : 'Bradley destroyed') case 'bradley.spawned': { const n = Number(frame.count) || 1 if (frame.where === 'road') return say(n > 1 ? `${n} road Bradleys are out` : 'A road Bradley is out', '') return say('Bradley is patrolling Launch Site', '') } case 'cargo.arrived': return say('The cargo ship has arrived') case 'cargo.docking': return say(`The cargo ship is docking at ${harbour}`) case 'cargo.docked': return say(`The cargo ship has docked at ${harbour}`) case 'cargo.undocked': return say(`The cargo ship has cast off from ${harbour}`) case 'cargo.left': return say('The cargo ship is leaving', '') case 'chinook.arrived': return say('A Chinook is inbound with a locked crate') case 'chinook.dropped': return say(frame.monumentName ? `A Chinook dropped a locked crate at ${frame.monumentName}` : 'A Chinook dropped a locked crate') case 'airdrop.called': return who ? { tone: 'server', actor: who, verb: 'called an airdrop', subject: null, detail: grid } : say('An airdrop is on its way') case 'airdrop.landed': return say('An airdrop has landed', [who ? `called by ${who}` : null, grid].filter(Boolean).join(' · ')) case 'airdrop.looted': return who ? { tone: 'server', actor: who, verb: 'looted an airdrop', subject: null, detail: grid } : say('An airdrop was looted') case 'crate.hack.started': return who ? { tone: 'server', actor: who, verb: `started hacking the locked crate ${place}`, subject: null, detail: grid } : say(`A locked crate ${place} is being hacked`) case 'crate.unlocked': return say(`The locked crate ${place} is open`) case 'crate.looted': return who ? { tone: 'server', actor: who, verb: `looted the locked crate ${place}`, subject: null, detail: grid } : say(`The locked crate ${place} was looted`) case 'vendor.arrived': return say('The Travelling Vendor has arrived') case 'vendor.left': return say('The Travelling Vendor has left', '') case 'satellite.crashed': return say('A satellite has crashed') case 'deepsea.opened': return say('The Deep Sea is open', '') case 'deepsea.closed': return say('The Deep Sea has closed', '') default: // A type this build has no words for, which only staff are sent: its own name. return { tone: 'other', actor: who, verb: String(frame.event || 'world event'), subject: null, detail: grid } } } /** * What a Dynamic PVP zone is around, by its event (Dynamic PVP 5.2.0's own * names). The server sends `event` to the public only for these; a monument's or * an operator's own event reaches staff as written. */ export const PVP_AROUND = Object.freeze({ Helicopter: 'the downed patrol helicopter', Bradley: 'the destroyed Bradley', Satellite: 'the crashed satellite', TimedSupply: 'an airdrop', SupplySignal: 'a signalled airdrop', CargoShip: 'the cargo ship', HackableCrate: 'a locked crate', ExcavatorIgnition: 'the Giant Excavator', GhostShip: 'a ghost ship', DeepSeaIsland: 'a Deep Sea island', IslandCannon: 'an island cannon', }) /** How long a zone was given, as a detail: "for 10 min", or cargo's whole visit. */ function pvpLasts(frame) { const sec = Number(frame.durationSec) if (!(sec > 0)) return frame.event === 'CargoShip' ? 'while the ship is here' : '' return sec < 120 ? `for ${Math.round(sec)} s` : `for ${Math.round(sec / 60)} min` } /** Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3). The line names nobody. */ function pvpLine(kind, frame) { const around = PVP_AROUND[frame.event] ? ` around ${PVP_AROUND[frame.event]}` : frame.event ? ` (${frame.event})` : '' if (kind === 'pvp.zone.deleted') { return { tone: 'server', actor: null, verb: `The PvP zone${around} has closed`, subject: null, detail: '' } } const detail = [frame.grid || null, pvpLasts(frame) || null].filter(Boolean).join(' · ') return { tone: 'kill', actor: null, verb: `A PvP zone opened${around}`, subject: null, detail } } /** * Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4). The server has read * the frame for this viewer: `grid`, `invaderCount`, and the winner's `name` only * for the presence audience (D376). */ function treasureLine(kind, frame) { const grid = frame.grid || '' if (kind === 'dt.started') { return { tone: 'server', actor: null, verb: 'A Dangerous Treasures event has started', subject: null, detail: grid } } if (kind === 'dt.ended') { return { tone: 'server', actor: null, verb: 'A Dangerous Treasures event has ended', subject: null, detail: grid } } const n = Number(frame.invaderCount) const fought = n > 1 ? `${n} players fought for it` : '' const detail = [grid || null, fought || null].filter(Boolean).join(' · ') return frame.name ? { tone: 'kill', actor: frame.name, verb: 'won the treasure', subject: null, detail } : { tone: 'kill', actor: null, verb: 'The treasure was won', subject: null, detail } } /** * Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5). The server has read the * frame for this viewer: `grid`, `won` (whether a player killed the last * guard), and the clearer's `name` only for the presence audience (D376). */ function guardedLine(kind, frame) { const detail = frame.grid || '' if (kind === 'gc.started') { return { tone: 'server', actor: null, verb: 'A guarded crate landed', subject: null, detail } } if (kind === 'gc.failed') { return { tone: 'server', actor: null, verb: 'A guarded crate timed out with its guards standing', subject: null, detail } } if (frame.won === false) { return { tone: 'server', actor: null, verb: 'A guarded crate ended with nobody clearing it', subject: null, detail } } return frame.name ? { tone: 'kill', actor: frame.name, verb: 'cleared the guarded crate', subject: null, detail } : { tone: 'kill', actor: null, verb: 'The guarded crate was cleared', subject: null, detail } } /** "Alice", "Alice and Bob", "Alice, Bob and 3 others". */ export function raiderNames(names, count) { const shown = (names || []).slice(0, 2) const rest = Math.max(0, (Number(count) || shown.length) - shown.length) if (shown.length === 0) return null if (rest === 0) return shown.length === 2 ? `${shown[0]} and ${shown[1]}` : shown[0] return `${shown.join(', ')} and ${rest} other${rest === 1 ? '' : 's'}` } /** * RaidableBases (protocol 14, PLAN_REDESIGNS §11.4). The server has read the * frame for this viewer: `grid`, the plugin's own `difficulty` name (D386), `pvp`, * and the raiders' `names` or a buyer's `name` only for the presence audience * (D376, D390); `raiderCount` for everyone. */ function raidLine(kind, frame) { const base = frame.difficulty ? `${frame.difficulty} raid base` : 'raid base' const a = /^[aeiou]/i.test(base) ? 'An' : 'A' const detail = [frame.grid || null, frame.pvp === true ? 'PvP' : null].filter(Boolean).join(' · ') if (kind === 'raid.started') return { tone: 'server', actor: null, verb: `${a} ${base} appeared`, subject: null, detail } if (kind === 'raid.ended') return { tone: 'server', actor: null, verb: `${a} ${base} despawned`, subject: null, detail } if (kind === 'raid.completed') { const who = raiderNames(frame.names, frame.raiderCount) if (who) return { tone: 'kill', actor: who, verb: `raided ${a.toLowerCase()} ${base}`, subject: null, detail } const count = Number(frame.raiderCount) || 0 const by = count > 0 ? `by ${count} player${count === 1 ? '' : 's'}` : null return { tone: 'kill', actor: null, verb: `${a} ${base} was raided`, subject: null, detail: [detail, by].filter(Boolean).join(' · ') } } if (kind === 'raid.purchased') { return frame.name ? { tone: 'server', actor: frame.name, verb: `bought ${a.toLowerCase()} ${base}`, subject: null, detail } : { tone: 'server', actor: null, verb: `${a} ${base} was bought`, subject: null, detail } } return frame.name ? { tone: 'server', actor: null, verb: `${a} ${base} was locked to ${frame.name}`, subject: null, detail } : { tone: 'server', actor: null, verb: `${a} ${base} was locked to a player`, subject: null, detail } } export default { describe, FEED_KINDS, FILTERS, kindsFor }