feat(rust): Rust's own world events — feed, credit rule, triggers, Call a world event (PLAN_REDESIGNS §14.3.1)
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:
@@ -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,
|
||||
|
||||
@@ -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])))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user