The bridge names a kill's weapon by the held entity's short prefab name (ak47u.entity), and the feed printed it with the dots turned to spaces: "with ak47u entity". Every event read now gives the frame a weaponName (server/model/titles/weaponNames.js): the game's own English name for the 53 ranged weapons (guns, bows, crossbows, launchers, flamethrowers), read off the Oxide rig's item list on Rust 26139947, and for anything else the prefab name as words with its .entity/.weapon ending dropped. The site prints weaponName and falls back to the same stripping for an older server. The prefab name stays in the frame: the title lists count by it. Added at read, so stored rows are renamed too. No protocol change; the events route's Swagger description names the new field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
466 lines
20 KiB
JavaScript
466 lines
20 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, 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 }
|