feat(rust): Rust's own world events — feed, credit rule, triggers, Call a world event (PLAN_REDESIGNS §14.3.1)
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 36s
PR Checks / frozen-manifest (pull_request) Successful in -1m32s

The site half of stage 1, on protocol 14:

- server/worldEvents.js: the 24 types, D372's four credit rules, the grid
  from the map's geometry (none off the map), and the per-viewer reading.
- The feed serves `world.event` (a public kind): an unknown type is staff's;
  the event and its grid are public; names only for the presence audience
  (D376); Steam ids and positions only for staff (D353). A downed one carries
  `credited`/`creditedCount` by the server's rule. Client lines for all 24
  types and a World events filter.
- rust_servers.credit_rule (blow, last, most, all; default blow), set on the
  server's settings. Ingest counts heli_kills (patrol helicopters and
  Chinooks, D379) and apc_kills from `world.event` by that rule, so the line
  and Rotorbane / Tankbreaker agree. A protocol-13 tally's heliKills/apcKills
  still add as before.
- The tally's cratesHacked / dropsLooted → crates_hacked / drops_looted;
  title categories Safecracker and Scavenger (D356); leaderboard columns
  Crates hacked and Drops looted, sortable (D377).
- 24 engagement triggers rust.world.<event> (D355): ceiling everyone,
  cooldown subject the server and event (crates add where), names only.
- server/eventWorldCall.js: rust.world.call, "Call a world event"
  (risk change, reversible none, budget rust.world.calls). A position for an
  airdrop or a patrol helicopter only; the Chinook takes none (D378).

Server 585/585 (22 new in test/worldEvents.test.js), client 84/84, every
check; booted against the pinned core (43 triggers, 12 actions, 10 budgets,
20 option sources), routes.manifest unchanged at 89. Engagement manifest and
swagger fragment regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-15 00:39:00 -05:00
parent f65d96ebb6
commit e7d5c8ed1a
31 changed files with 3073 additions and 28 deletions

View File

@@ -42,6 +42,8 @@ const clansDb = require('../model/clans/clans.db')
const eventsDb = require('../model/events/events.db')
const linksDb = require('../model/links/links.db')
const serversDb = require('../model/servers/servers.db')
const mapDb = require('../model/map/map.db')
const worldEvents = require('../worldEvents')
const { TRIGGER_IDS: T, PATHS, serverPath, leaderboardPath, clanPath } = require('./triggers')
const log = core.logger('engagement')
@@ -186,9 +188,65 @@ const HEADLINES = Object.freeze({
}),
})
/** Where a crate is, as a sentence ends: "on Large Oil Rig", "dropped by a Chinook". */
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',
})
/**
* The headline of a world event (protocol 14), one sentence per type. Plain
* register like the rest; a part that is missing drops out rather than leaving
* a hole, so "at G7" disappears when the server has no map geometry yet.
*/
function worldHeadline(event, d) {
const at = d.grid ? ` at ${d.grid}` : ''
const on = ` on ${d.server}`
const by = d.player ? ` by ${d.player}` : ''
const place = CRATE_PLACES[d.where] || 'somewhere on the map'
const bradley = d.where === 'launch_site' ? "Launch Site's Bradley" : d.where === 'road' ? 'A road Bradley' : 'A Bradley'
const lines = {
'heli.arrived': ['A patrol helicopter is overhead', `A patrol helicopter flew onto the map${at}${on}.`],
'heli.left': ['The patrol helicopter has left', `The patrol helicopter left the map${on}.`],
'heli.downed': ['Patrol helicopter downed', `The patrol helicopter was downed${by}${at}${on}.`],
'bradley.spawned': [
d.where === 'road' ? (d.count > 1 ? `${d.count} road Bradleys are out` : 'A road Bradley is out') : 'Bradley is patrolling Launch Site',
d.where === 'road' ? `Road Bradleys took to the roads${on}.` : `Launch Site's Bradley is back${on}.`,
],
'bradley.destroyed': [`${bradley} was destroyed`, `${bradley} was destroyed${by}${at}${on}.`],
'cargo.arrived': ['The cargo ship has arrived', `The cargo ship sailed onto the map${at}${on}.`],
'cargo.docking': ['The cargo ship is docking', `The cargo ship is coming in to dock${d.harbour ? ` at ${d.harbour}` : ''}${on}.`],
'cargo.docked': ['The cargo ship has docked', `The cargo ship docked${d.harbour ? ` at ${d.harbour}` : ''}${on}, with fresh crates.`],
'cargo.undocked': ['The cargo ship cast off', `The cargo ship left${d.harbour ? ` ${d.harbour}` : ' the harbour'}${on}.`],
'cargo.left': ['The cargo ship is leaving', `The cargo ship turned for the edge of the map${on}.`],
'chinook.arrived': ['A Chinook is inbound', `A Chinook flew onto the map${at}${on} with a locked crate.`],
'chinook.dropped': ['A Chinook dropped a locked crate', `A Chinook dropped a locked crate${d.monument ? ` at ${d.monument}` : at}${on}.`],
'chinook.downed': ['A Chinook was shot down', `A Chinook was shot down${by}${at}${on}.`],
'airdrop.called': ['An airdrop is on its way', `An airdrop is on its way${at}${on}${d.player ? `, called by ${d.player}` : ''}.`],
'airdrop.landed': ['An airdrop has landed', `An airdrop landed${at}${on}.`],
'airdrop.looted': ['An airdrop was looted', `${d.player || 'Somebody'} looted an airdrop${at}${on}.`],
'crate.hack.started': ['A locked crate is being hacked', `${d.player || 'Somebody'} started hacking the locked crate ${place}${on}.`],
'crate.unlocked': ['A locked crate is open', `The locked crate ${place} finished unlocking${on}.`],
'crate.looted': ['A locked crate was looted', `${d.player || 'Somebody'} looted the locked crate ${place}${on}.`],
'vendor.arrived': ['The Travelling Vendor is here', `The Travelling Vendor drove onto the map${at}${on}.`],
'vendor.left': ['The Travelling Vendor has left', `The Travelling Vendor left the map${on}.`],
'satellite.crashed': ['A satellite has crashed', `A satellite came down${at}${on}. Its crates are burning.`],
'deepsea.opened': ['The Deep Sea is open', `The Deep Sea opened${on}.`],
'deepsea.closed': ['The Deep Sea has closed', `The Deep Sea closed${on}.`],
}
const line = lines[event]
return line ? { title: `${line[0]}${on}`, intro: line[1] } : {}
}
const WORLD_PREFIX = 'rust.world.'
function headline(triggerId, data) {
const make = HEADLINES[triggerId]
return make ? make(data || {}) : {}
if (make) return make(data || {})
return String(triggerId).startsWith(WORLD_PREFIX) ? worldHeadline(triggerId.slice(WORLD_PREFIX.length), data || {}) : {}
}
/**
@@ -460,7 +518,51 @@ async function onBoss(server, item, frame) {
}) ? 1 : 0
}
/**
* Rust's own world events (protocol 14, D355): one trigger per type. Public
* news, so a replay older than a broadcast's window tells nobody (D63). The
* grid comes from the map's geometry, as the feed's does; a downed helicopter,
* Chinook or Bradley names whom the server's credit rule names, as its feed
* line and its count do (D372). Names only, never a Steam id.
*/
async function onWorld(server, item, frame) {
const t = frameTime(item, frame)
const event = str(frame.event)
if (!event || !worldEvents.isKnownType(event) || !stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
const type = worldEvents.TYPES[event]
const crate = event.startsWith('crate.')
const data = {
...serverVars(server),
subject: crate ? `${server.id}:${event}:${frame.where || ''}` : `${server.id}:${event}`,
...(grid ? { grid } : {}),
...(frame.where !== undefined ? { where: str(frame.where) } : {}),
...(event === 'bradley.spawned' ? { count: Math.max(1, Math.floor(Number(frame.count) || 1)) } : {}),
...(frame.harbourName || frame.harbour ? { harbour: str(frame.harbourName) || str(frame.harbour) } : {}),
...(frame.monumentName || frame.monument ? { monument: str(frame.monumentName) || str(frame.monument) } : {}),
}
if (type.player && str(frame.name)) data.player = str(frame.name)
if (type.credit) {
const ids = worldEvents.credited(frame, worldEvents.creditRuleOf(server.creditRule))
const phrase = worldEvents.namesPhrase(ids.map((id) => worldEvents.nameOf(frame, id)), ids.length)
data.credited = ids.length
if (phrase) data.player = phrase
}
return fire(`${WORLD_PREFIX}${event}`, {
data,
dedupeKey: dedupeKey('world', server.id, event, t, frame.x, frame.z),
occurredAt: t,
}) ? 1 : 0
}
const HANDLERS = Object.freeze({
'world.event': onWorld,
'npc.boss.spawned': onBoss,
'npc.boss.phase': onBoss,
'npc.boss.died': onBoss,

View File

@@ -548,7 +548,81 @@ const BOSSES = [
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES])
// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1, D355) ─────
//
// One trigger per `world.event` type, `rust.world.<type>`. Public news, as a
// boss is: `everyone` is the ceiling and `subscribers` the default. A phase
// gate reads the same catalogue, so "advance when Bradley is destroyed" is
// `{ on: 'rust.world.bradley.destroyed' }` with no further code.
//
// The cooldown subject is the server and the type, and the crate types add
// where the crate is — one line per oil rig per cooldown, not one per hack on
// the map. `player` is the in-game name only, as a boss's killer is; no Steam
// id is declared.
const WORLD_SUBJECT = { name: 'subject', type: 'string', required: true, example: 'main:heli.downed',
description: 'The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display.' }
const WORLD_GRID = { name: 'grid', type: 'string', required: false, example: 'G7',
description: 'The map grid square. Absent when the server has no map geometry yet, or the event is off the map.' }
const WORLD_PLAYER = { name: 'player', type: 'string', required: false, example: 'Marisol',
description: 'The player it names, by in-game name. Absent when nobody is named.' }
const WORLD_CREDITED = [
{ name: 'player', type: 'string', required: false, example: 'Marisol and Darrow',
description: 'Who is credited, by in-game name and by the server\'s credit rule: "Alice", "Alice and Bob", "Alice, Bob and 2 others". Absent when nobody is.' },
{ name: 'credited', type: 'int', required: true, example: 2,
description: 'How many players the credit rule names.' },
]
const WORLD_WHERE = (example, list) => ({ name: 'where', type: 'string', required: true, example,
description: `Where it is: ${list}.` })
const CRATE_WHERE = WORLD_WHERE('oilrig_large', 'oilrig_large, oilrig_small, cargo, ghostship or chinook (a crate a Chinook dropped)')
const WORLD_HARBOUR = { name: 'harbour', type: 'string', required: false, example: 'Harbor',
description: 'The harbour, by the game\'s own name for it.' }
const WORLD_MONUMENT = { name: 'monument', type: 'string', required: false, example: 'Water Treatment Plant',
description: 'The monument it dropped at, by the game\'s own name for it.' }
const WORLD_COUNT = { name: 'count', type: 'int', required: true, example: 12,
description: 'How many: road Bradleys come several at once.' }
/** Each world event's label, description and the variables it carries beyond the server's. */
const WORLD_EVENTS = [
['heli.arrived', 'A patrol helicopter arrived', 'A patrol helicopter flew onto the map.', [WORLD_GRID]],
['heli.left', 'The patrol helicopter left', 'A patrol helicopter left the map without being downed.', [WORLD_GRID]],
['heli.downed', 'A patrol helicopter was downed', 'Players brought down a patrol helicopter.', [WORLD_GRID, ...WORLD_CREDITED]],
['bradley.spawned', 'Bradley is out', 'Launch Site\'s Bradley spawned, or road Bradleys took to the roads.', [WORLD_WHERE('road', 'launch_site or road'), WORLD_COUNT]],
['bradley.destroyed', 'A Bradley was destroyed', 'Players destroyed a Bradley APC: Launch Site\'s, a road one, or another.', [WORLD_WHERE('launch_site', 'launch_site, road or other'), WORLD_GRID, ...WORLD_CREDITED]],
['cargo.arrived', 'The cargo ship arrived', 'The cargo ship sailed onto the map.', [WORLD_GRID]],
['cargo.docking', 'The cargo ship is docking', 'The cargo ship turned in to dock at a harbour.', [WORLD_GRID, WORLD_HARBOUR]],
['cargo.docked', 'The cargo ship docked', 'The cargo ship docked at a harbour, with fresh crates.', [WORLD_GRID, WORLD_HARBOUR]],
['cargo.undocked', 'The cargo ship cast off', 'The cargo ship left the harbour it was docked at.', [WORLD_GRID, WORLD_HARBOUR]],
['cargo.left', 'The cargo ship is leaving', 'The cargo ship turned for the edge of the map.', [WORLD_GRID]],
['chinook.arrived', 'A Chinook arrived', 'A Chinook flew onto the map with a locked crate.', [WORLD_GRID]],
['chinook.dropped', 'A Chinook dropped its crate', 'A Chinook dropped a locked crate at a monument.', [WORLD_GRID, WORLD_MONUMENT]],
['chinook.downed', 'A Chinook was shot down', 'Players shot down a Chinook.', [WORLD_GRID, ...WORLD_CREDITED]],
['airdrop.called', 'An airdrop is on its way', 'A cargo plane is bringing an airdrop: called by a supply signal, or by the schedule.', [WORLD_GRID, WORLD_PLAYER]],
['airdrop.landed', 'An airdrop landed', 'An airdrop reached the ground.', [WORLD_GRID, WORLD_PLAYER]],
['airdrop.looted', 'An airdrop was looted', 'Somebody opened an airdrop first.', [WORLD_GRID, WORLD_PLAYER]],
['crate.hack.started', 'A locked crate is being hacked', 'Somebody started hacking a locked crate. On an oil rig this also calls its scientists.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
['crate.unlocked', 'A locked crate unlocked', 'A hacked locked crate finished unlocking.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
['crate.looted', 'A locked crate was looted', 'Somebody opened an unlocked locked crate first.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
['vendor.arrived', 'The Travelling Vendor arrived', 'The Travelling Vendor drove onto the map.', [WORLD_GRID]],
['vendor.left', 'The Travelling Vendor left', 'The Travelling Vendor left the map.', [WORLD_GRID]],
['satellite.crashed', 'A satellite crashed', 'A satellite came down, and its crates are burning.', [WORLD_GRID]],
['deepsea.opened', 'The Deep Sea opened', 'The Deep Sea is open: its ghost ships and islands can be reached.', []],
['deepsea.closed', 'The Deep Sea closed', 'The Deep Sea closed.', []],
]
const WORLD = WORLD_EVENTS.map(([event, label, description, vars]) => ({
id: `rust.world.${event}`,
label,
description,
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE, WORLD_SUBJECT, ...vars],
}))
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES, ...WORLD])
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))