Files
Module-Rust/client/src/lib/feed.js
wtclaude f2ca314d10
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 46s
feat(rust): bosses and passive NPCs on the profile form, and public boss triggers (runicnpc stage 6)
The profile form's boss box (D273-D277): the bar's distance, where its
lines are said, the spawn and death lines, and phases at health
percentages, each with optional damage, aim, speed, ranges, a kit swap,
adds of another profile and a line. The passive role (D278, D279,
D288): press E answers with a chat line or a window, it moves as its
profile says (now including `stand`), and it starts unhurtable.

The site checks both as RunicNPC does, in its words; a phase's kit is
checked on each server like the profile's own, and its adds must name a
site profile that is not a boss.

Public triggers rust.boss.spawned, rust.boss.phase and rust.boss.killed
(D277) from the bridge's new frames. In the feed, a boss appearing is
public; its death names the killer, so it sits behind the presence
setting; its phases are staff's, as the game tells them only to players
near it (D286). engagement-triggers.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 10:54:28 -05:00

197 lines
7.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',
// 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',
])
/** 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'] },
])
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 }
}
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',
// 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 }
}
}
export default { describe, FEED_KINDS, FILTERS, kindsFor }