// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ────────── // // The patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked // crates, the Travelling Vendor, the satellite and the Deep Sea reach this // module as ONE frame kind, `world.event`, whose `event` says which (D349, // D353). This file is the one place that knows the 24 of them, and the three // readers — the feed, the ingest and the engagement triggers — all ask it. // // ── Why `event` and not `type` ───────────────────────────────────────────── // // The plan's example put the world event's type in `type`, which is the // envelope's field: the sidecar files a frame on it (`event`, `snapshot`, // `reply`), and it dropped every world event as untyped on the rig. Protocol // 13's `world.expired` hit the same collision and settled on its own field; so // does this one (stage 1, 2026-10-15). // // ── Who gets the credit (D372, D379) ─────────────────────────────────────── // // For a patrol helicopter, a Chinook and a Bradley the plugin sends every // candidate — the killing blow, the last hit, the most damage, everyone who // hurt it — and decides nothing. The server's rule, chosen by its operator, // picks from them HERE, for the feed line and for Rotorbane / Tankbreaker // alike, so the two can never disagree. A rule changed applies from then on: // the counts already written stay as they were. // // ── Who may read what (D353, D376) ───────────────────────────────────────── // // The event and its grid are public. A player's name shows only to the // operator's presence audience — with presence left at staff, a signed-out // visitor reads "Patrol helicopter downed at G8" — and a Steam id only to staff. // A position never leaves this module: the map has its own audience for where // things are (D111), and the feed must not be a way round it. An event type // this build does not know is staff's alone (default deny, as `catalogue.js`). /** * Every type the protocol defines (PROTOCOL.md §20.5), and what each carries. * * `player` names one player (`steamId`, `name`) * `credit` carries a credit block, and which tally column it counts toward */ const TYPES = Object.freeze({ 'heli.arrived': {}, 'heli.left': {}, 'heli.downed': { credit: 'heliKills' }, 'bradley.spawned': {}, 'bradley.destroyed': { credit: 'apcKills' }, 'cargo.arrived': {}, 'cargo.docking': {}, 'cargo.docked': {}, 'cargo.undocked': {}, 'cargo.left': {}, 'chinook.arrived': {}, 'chinook.dropped': {}, // D379: a Chinook shot down counted toward Rotorbane under D174, and still does. 'chinook.downed': { credit: 'heliKills' }, 'airdrop.called': { player: true }, 'airdrop.landed': { player: true }, 'airdrop.looted': { player: true }, 'crate.hack.started': { player: true }, 'crate.unlocked': { player: true }, 'crate.looted': { player: true }, 'vendor.arrived': {}, 'vendor.left': {}, 'satellite.crashed': {}, 'deepsea.opened': {}, 'deepsea.closed': {}, }) const TYPE_IDS = Object.freeze(Object.keys(TYPES)) /** The operator's credit rules (D372), in the order the form offers them. `blow` is the default. */ const CREDIT_RULES = Object.freeze([ { id: 'blow', label: 'Killing blow', description: 'The player whose hit destroyed it; for the patrol helicopter, the crippling hit, else the last hit.' }, { id: 'last', label: 'Last hit', description: 'The last player to hurt it.' }, { id: 'most', label: 'Most damage', description: 'The player who dealt it the most damage.' }, { id: 'all', label: 'Everyone who hurt it', description: 'Every player who damaged it, one each.' }, ]) const CREDIT_RULE_IDS = Object.freeze(CREDIT_RULES.map((r) => r.id)) const DEFAULT_CREDIT_RULE = 'blow' /** A stored or posted rule, narrowed to one this build knows. */ function creditRuleOf(value) { return CREDIT_RULE_IDS.includes(value) ? value : DEFAULT_CREDIT_RULE } const STEAM_ID = /^\d{17}$/ function steamId(value) { const text = value === undefined || value === null ? '' : String(value) return STEAM_ID.test(text) ? text : null } function isKnownType(event) { return Object.prototype.hasOwnProperty.call(TYPES, event) } /** * Who a downed helicopter, Chinook or Bradley credits under `rule`: a list of * Steam ids, empty when nobody qualifies. D372's table, exactly: * * blow `blow`, else `last` * last `last` * most `most` * all every `damagers` entry, one each (and the blow, if it dealt nothing) */ function credited(frame, rule) { const credit = (frame && frame.credit) || {} const one = (id) => (steamId(id) ? [steamId(id)] : []) switch (creditRuleOf(rule)) { case 'last': return one(credit.last) case 'most': return one(credit.most) case 'all': { const ids = [] for (const row of Array.isArray(credit.damagers) ? credit.damagers : []) { const id = steamId(row && row.steamId) if (id && !ids.includes(id)) ids.push(id) } const blow = steamId(credit.blow) if (blow && !ids.includes(blow)) ids.push(blow) return ids } default: return one(credit.blow).length ? one(credit.blow) : one(credit.last) } } /** The name a frame carries for a credited Steam id, or null. */ function nameOf(frame, id) { const names = (frame && frame.names) || {} const name = names[id] return typeof name === 'string' && name ? name : null } /** * The grid label for a world position, as the in-game map writes it (D119): the * game's own cell count and size, which the map stores from the plugin's * `map.info`. Null without a geometry, and null OFF the map — the Deep Sea and * its ghost ships are thousands of metres outside it, and a clamped edge cell * there would be a confident wrong answer. The client's `mapGeometry.js` holds * the same arithmetic for the map; this copy is for the lines the server writes. */ function gridOf(geometry, x, z) { const g = geometry || {} const xs = Number(x) const zs = Number(z) if (!(g.gridCells > 0) || !(g.gridCellSize > 0) || !(g.worldSize > 0)) return null if (!Number.isFinite(xs) || !Number.isFinite(zs)) return null const half = g.worldSize / 2 if (Math.abs(xs) > half || Math.abs(zs) > half) return null const clamp = (v) => Math.max(0, Math.min(g.gridCells - 1, v)) const col = clamp(Math.floor((xs + half) / g.gridCellSize)) const row = clamp(Math.floor((half - zs) / g.gridCellSize)) return `${columnName(col)}${row}` } /** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */ function columnName(index) { let name = '' let n = index + 1 while (n > 0) { const r = (n - 1) % 26 name = String.fromCharCode(65 + r) + name n = Math.floor((n - 1) / 26) } return name } /** * One stored `world.event` frame as a viewer may read it, or null when the * viewer may not read it at all. **Pure.** * * `staff` the viewer is an admin or a moderator: Steam ids, and a type this * build does not know * `presence` the viewer is in the server's presence audience: names * `rule` the server's credit rule, for a row stored before the frame * carried its own (`stampRule`) * `geometry` the server's map geometry, for the grid * * What comes back keeps the plugin's own fields for what it describes (`event`, * `where`, `count`, `harbour`/`harbourName`, `monument`/`monumentName`) and * adds `grid` and, for a downed one, `credited` — `[{ name?, steamId? }]` by * the rule — with `creditRule` beside it. Positions, the raw credit block and * the name table are not passed on: every one of them is either a position or * a list of who was there. */ function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDIT_RULE, geometry = null } = {}) { if (!frame || frame.kind !== 'world.event') return frame const event = frame.event const known = isKnownType(event) if (!known && !staff) return null const out = {} for (const key of ['kind', 'type', 't', 'serverId', 'wipeId', 'event', 'where', 'count', 'harbour', 'harbourName', 'monument', 'monumentName']) { if (frame[key] !== undefined) out[key] = frame[key] } const grid = gridOf(geometry, frame.x, frame.z) if (grid) out.grid = grid if (staff) { if (frame.x !== undefined) out.x = frame.x if (frame.z !== undefined) out.z = frame.z } // One player, named. if (known && TYPES[event].player && steamId(frame.steamId)) { if (presence && frame.name) out.name = frame.name if (staff) out.steamId = frame.steamId } // Credit, by the server's rule. if (known && TYPES[event].credit) { out.creditRule = ruleOf(frame, rule) const ids = credited(frame, out.creditRule) out.creditedCount = ids.length if (presence || staff) { out.credited = ids.map((id) => ({ ...(nameOf(frame, id) ? { name: nameOf(frame, id) } : {}), ...(staff ? { steamId: id } : {}), })) } if (staff && frame.credit) out.credit = frame.credit } if (!known) { // Staff only, and everything, so a newer plugin's type is at least readable. return { ...frame, ...out } } return out } /** * The frame as it is stored: a downed helicopter, Chinook or Bradley carries * the rule that credited it when it arrived (`creditRule`), so the feed line * keeps naming who the counts were given to after the server's rule changes * (W4). Every other frame is stored as sent. */ function stampRule(frame, rule) { if (!frame || frame.kind !== 'world.event' || !isKnownType(frame.event) || !TYPES[frame.event].credit) return frame return { ...frame, creditRule: creditRuleOf(rule) } } /** * The rule a stored line is read by: its own stamp, or — for a row from before * the stamp — the server's rule now, which is how those rows always read. */ function ruleOf(frame, rule) { return frame && CREDIT_RULE_IDS.includes(frame.creditRule) ? frame.creditRule : creditRuleOf(rule) } /** * What one `world.event` adds to the counts: a credited kill of a helicopter, * a Chinook or a Bradley, for each player the server's rule names. `[{ steamId, * name, field }]`, where `field` is the tally field the kill would have been * (`heliKills`, `apcKills`), so ingest adds it the way it adds a tally's. */ function killsOf(frame, rule) { if (!frame || frame.kind !== 'world.event' || !isKnownType(frame.event)) return [] const field = TYPES[frame.event].credit if (!field) return [] return credited(frame, rule).map((id) => ({ steamId: id, name: nameOf(frame, id), field })) } /** * The names a line credits, as a phrase: "Alice", "Alice and Bob", "Alice, Bob * and 2 others". Used by the notifications' headlines; the client writes its own * from the same list. */ function namesPhrase(names, total = names.length) { const shown = names.filter(Boolean).slice(0, 2) if (shown.length === 0) return null const more = Math.max(0, 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] } module.exports = { TYPES, TYPE_IDS, CREDIT_RULES, CREDIT_RULE_IDS, DEFAULT_CREDIT_RULE, creditRuleOf, isKnownType, credited, stampRule, ruleOf, nameOf, gridOf, shapeFor, killsOf, namesPhrase, }