PLAN_REDESIGNS §11.4 (D202-D208, D386-D391), protocol 14. Spec: docs rust-link/PROTOCOL.md §20.12. - raid.started, raid.completed, raid.purchased and raid.locked public, raid.ended staff's; raidableBases.js reads each row per viewer (raiders' and a buyer's names only for the presence audience, D376/D390; Steam ids, position and a run's key only for staff). Feed lines in the World filter. - The step rust.raidbase.spawn, "Spawn a raid base" (ledgered, budget rust.raids): difficulty, profile and base from option sources listing each server's own names (D386), a spot as a world call's location with a height adjustment or an exact height, and PvP/NPCs/lock as on/off/blank. A base the raids helper made is a world resource, so eventWorld's revert and reconcile serve it and world.expired marks it expired; one it did not make records nothing and the run log says it despawns on its own timer. Refused at verify for a server that said it cannot; a dry run with a spot returns the spot check as detail.warnings (MODULE_API 1.16.0, D391). rb-not-ready retries in ten minutes. - Warlord: raids_completed, title category raidscompleted, the leaderboard's Raid bases column (sort=raidsCompleted). - The map's raids layer (D114), public by default with its own audience; the run's key is dropped before any viewer gets it. - Server settings' line for RaidableBases; its profiles in Mod configuration under a virtual path (D205). - Swagger fragment, bundle and README. Tests: server 684/684 (raidableBases.test.js new, every name in it invented); client 99/99, build and externals. 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, 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',
|
|
// 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 ${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 }
|
|
}
|
|
}
|
|
|
|
/** 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 is being hacked ${place}`)
|
|
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 }
|