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:
@@ -59,6 +59,13 @@ const PUBLIC_KINDS = Object.freeze([
|
||||
// the players near it (D286), so `npc.boss.phase` is staff's, below.
|
||||
'npc.boss.spawned',
|
||||
'npc.boss.died',
|
||||
// Protocol 14: Rust's own world events (PLAN_REDESIGNS §14.3.1, D353, D376).
|
||||
// Public as a KIND, and filtered per `event` inside it by `worldEvents.js`:
|
||||
// an event type this build does not know is staff's alone, a player's name
|
||||
// shows only to the presence audience, a Steam id only to staff, and a
|
||||
// position to nobody. So it is not a presence kind — the event names nobody
|
||||
// once `worldEvents.shapeFor` has read it for a viewer below that audience.
|
||||
'world.event',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -152,7 +159,7 @@ const PRESENCE_KINDS = Object.freeze([
|
||||
'npc.boss.died',
|
||||
])
|
||||
|
||||
/** Every event kind the protocol defines, through protocol 13. */
|
||||
/** Every event kind the protocol defines, through protocol 14. */
|
||||
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
||||
|
||||
const PUBLIC = new Set(PUBLIC_KINDS)
|
||||
|
||||
@@ -1371,3 +1371,18 @@ ALTER TABLE rust_title_rules ADD COLUMN IF NOT EXISTS profile_id INT UNSIGNED NU
|
||||
-- richest column and the Magnate title rank only balances above the server's
|
||||
-- starting balance, so the untouched accounts of a fresh wipe rank nobody.
|
||||
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS balance DECIMAL(14,2) NULL;
|
||||
|
||||
-- Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1). Two title
|
||||
-- counts (D356): locked crates a player started hacking, once per crate
|
||||
-- (Safecracker), and airdrops they looted first (Scavenger). Sums, from the
|
||||
-- tally like the others. APC and helicopter kills keep their columns; under
|
||||
-- protocol 14 the site writes them from `world.event`'s credit block instead.
|
||||
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS crates_hacked INT UNSIGNED NOT NULL DEFAULT 0;
|
||||
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS drops_looted INT UNSIGNED NOT NULL DEFAULT 0;
|
||||
|
||||
-- Who a downed helicopter, Chinook or Bradley credits on this server (D372,
|
||||
-- D379): `blow` (the killing blow; for the patrol helicopter the crippling hit,
|
||||
-- else the last hit), `last`, `most` (damage) or `all` (everyone who hurt it,
|
||||
-- one each). The same rule writes the feed line and the count. A change applies
|
||||
-- from then on; counts already written are not recounted.
|
||||
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS credit_rule VARCHAR(8) NOT NULL DEFAULT 'blow';
|
||||
|
||||
@@ -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])))
|
||||
|
||||
|
||||
180
server/eventWorldCall.js
Normal file
180
server/eventWorldCall.js
Normal file
@@ -0,0 +1,180 @@
|
||||
// ── Calling one of Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ──
|
||||
//
|
||||
// `rust.world.call`, "Call a world event": a patrol helicopter, a cargo ship, a
|
||||
// Chinook, an airdrop, the Travelling Vendor, road Bradleys, the F-15 or a
|
||||
// satellite crash, now, the way the game's own schedule runs them (D354, D367).
|
||||
// The plugin runs the schedule's `TriggeredEvent` — no console command — so it
|
||||
// works the same on Carbon.
|
||||
//
|
||||
// **Kept (`reversible: 'none'`).** Nothing a call starts is removed when the run
|
||||
// ends: a cargo ship or a drop may already hold players or their loot (D354).
|
||||
// `risk: 'change'` all the same, so an operator switches it on deliberately.
|
||||
//
|
||||
// **A position only where the game has a route to one** (stage 1's spike): the
|
||||
// airdrop's drop point and the patrol helicopter's first destination. The
|
||||
// Chinook drops only at the map's drop zones, and the one route to a chosen
|
||||
// zone borrows the Airfield terminal's shared state, so it takes no position
|
||||
// (D378); the rest go where the game puts them.
|
||||
|
||||
const client = require('./sidecarClient')
|
||||
const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS, location } = require('./eventWorld')
|
||||
|
||||
/** The plugin's ceiling on calls per run (`WorldCallsHardMax`); an operator may set it lower. */
|
||||
const MAX_CALLS_PER_RUN = 10
|
||||
|
||||
/**
|
||||
* The events a call may run, mirroring the plugin's list. `max` is how many one
|
||||
* step may call at once: the game runs the cargo ship, the vendor and road
|
||||
* Bradleys (twelve at a time) one at a time.
|
||||
*/
|
||||
const EVENTS = Object.freeze([
|
||||
{ value: 'patrol_heli', label: 'Patrol helicopter', max: 5, placeable: true },
|
||||
{ value: 'airdrop', label: 'Airdrop', max: 5, placeable: true },
|
||||
{ value: 'chinook', label: 'Chinook (drops a locked crate where the game chooses)', max: 5 },
|
||||
{ value: 'cargo', label: 'Cargo ship', max: 1 },
|
||||
{ value: 'bradley_road', label: 'Road Bradleys (several at once)', max: 1 },
|
||||
{ value: 'travelling_vendor', label: 'Travelling Vendor', max: 1 },
|
||||
{ value: 'satellite', label: 'Satellite crash', max: 5 },
|
||||
{ value: 'f15', label: 'F-15 flyover (decoration only)', max: 5 },
|
||||
])
|
||||
|
||||
/** The plugin's refusals a second attempt would repeat. */
|
||||
const PERMANENT = new Set([
|
||||
'events-disabled',
|
||||
'malformed',
|
||||
'bad-event',
|
||||
'bad-count',
|
||||
'not-placeable',
|
||||
'too-many',
|
||||
'out-of-range',
|
||||
'no-monument',
|
||||
'off-map',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.world.calls',
|
||||
label: 'World events called',
|
||||
unit: 'events',
|
||||
description: "Rust's own world events an event calls — patrol helicopters, cargo ships, airdrops and the rest — each one counted. What a call starts is kept when the run ends.",
|
||||
},
|
||||
]
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
// The plugin's own list, mirrored, so the form fills with every server off.
|
||||
id: 'rust.options.world_events',
|
||||
label: 'World events',
|
||||
description: "Rust's own world events a step can call. Only the airdrop and the patrol helicopter take a position; the rest go where the game puts them.",
|
||||
async resolve() {
|
||||
return EVENTS.map((e) => ({ value: e.value, label: e.label }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** Whether a step gives a position at all: a monument, or x or z. */
|
||||
function hasPosition(params) {
|
||||
const blank = (v) => v === undefined || v === null || v === ''
|
||||
return !blank(params.monument) || !blank(params.x) || !blank(params.z)
|
||||
}
|
||||
|
||||
const worldCall = {
|
||||
id: 'rust.world.call',
|
||||
label: 'Call a world event',
|
||||
description:
|
||||
"Calls one of Rust's own world events now — a patrol helicopter, an airdrop, a Chinook, the cargo ship, road Bradleys, the Travelling Vendor, a satellite crash or an F-15 flyover — as the game's own schedule would. An airdrop or a patrol helicopter can be sent to a monument or a point; the rest go where the game puts them. What it starts is kept when the run ends.",
|
||||
risk: 'change',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'event', type: 'string', required: true, example: 'airdrop', source: 'rust.options.world_events',
|
||||
description: 'Which world event to call.' },
|
||||
{ name: 'count', type: 'int', required: false, example: 1,
|
||||
description: 'How many at once: 1 to 5, and 1 for the cargo ship, road Bradleys and the vendor. Left blank, 1.' },
|
||||
{ name: 'server', type: 'string', required: false, example: 'main', source: 'rust.options.servers',
|
||||
description: 'Which server. A monument already says; otherwise required.' },
|
||||
{ name: 'monument', type: 'string', required: false, example: 'main/powerplant_1', source: 'rust.options.monuments',
|
||||
description: 'Airdrop and patrol helicopter only: where to send it, by monument. Give this OR x and z, or neither for where the game chooses.' },
|
||||
{ name: 'offsetX', type: 'float', required: false, example: 0, description: "Metres east of the monument's centre (negative is west)." },
|
||||
{ name: 'offsetZ', type: 'float', required: false, example: 0, description: "Metres north of the monument's centre (negative is south)." },
|
||||
{ name: 'x', type: 'float', required: false, example: -604, description: 'Airdrop and patrol helicopter only: world x, instead of a monument.' },
|
||||
{ name: 'z', type: 'float', required: false, example: -342, description: 'Airdrop and patrol helicopter only: world z, instead of a monument.' },
|
||||
],
|
||||
|
||||
cost: (p) => ({ 'rust.world.calls': Math.max(1, Math.round(Number(p.count) || 1)) }),
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const event = EVENTS.find((e) => e.value === String(params.event || '').trim())
|
||||
if (!event) {
|
||||
return { ok: false, retry: false, error: `"${params.event}" is not one of the world events a Rust server calls` }
|
||||
}
|
||||
|
||||
const blank = params.count === undefined || params.count === null || params.count === ''
|
||||
const count = blank ? 1 : Number(params.count)
|
||||
if (!Number.isInteger(count) || count < 1 || count > event.max) {
|
||||
return { ok: false, retry: false, error: `call 1 to ${event.max} of the ${event.label.toLowerCase()} at a time, and "${params.count}" is not that` }
|
||||
}
|
||||
|
||||
let serverId = String(params.server || '').trim()
|
||||
let wire = {}
|
||||
|
||||
if (hasPosition(params)) {
|
||||
if (!event.placeable) {
|
||||
return { ok: false, retry: false, error: `the ${event.label.toLowerCase()} goes where the game puts it; only an airdrop and a patrol helicopter take a position` }
|
||||
}
|
||||
const where = location(params)
|
||||
if (!where.ok) return { ok: false, retry: false, error: where.error }
|
||||
serverId = where.serverId
|
||||
// A height means nothing to a call: the drop falls to the ground and the
|
||||
// helicopter flies at its own.
|
||||
const { y, ...flat } = where.wire
|
||||
wire = flat
|
||||
} else if (!serverId) {
|
||||
return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
|
||||
}
|
||||
|
||||
const found = await serverFor(serverId)
|
||||
if (!found.ok) return found
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await client.worldCall(found.server, {
|
||||
runId: String(runId),
|
||||
key: idempotencyKey,
|
||||
event: event.value,
|
||||
count,
|
||||
...wire,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
const permanent = result.status === 'not-configured' || result.status === 'no-token'
|
||||
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'world event call') }
|
||||
}
|
||||
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'world.called') {
|
||||
return {
|
||||
ok: false,
|
||||
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
|
||||
error: pluginError(data, `${found.server.name || found.server.id} refused the ${event.label.toLowerCase()}`),
|
||||
}
|
||||
}
|
||||
|
||||
const called = Number(data.called) || 0
|
||||
return {
|
||||
ok: true,
|
||||
detail: {
|
||||
event: event.value,
|
||||
called,
|
||||
...(called < count ? { note: `the game started ${called} of the ${count} asked for` } : {}),
|
||||
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing new was called' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const ACTIONS = [worldCall]
|
||||
|
||||
module.exports = { EVENTS, MAX_CALLS_PER_RUN, PERMANENT, BUDGETS, ACTIONS, OPTION_SOURCES, hasPosition }
|
||||
@@ -59,6 +59,7 @@ module.exports = function register(ctx, api) {
|
||||
const eventLeases = require('./eventLeases')
|
||||
const eventWorld = require('./eventWorld')
|
||||
const eventRewards = require('./eventRewards')
|
||||
const eventWorldCall = require('./eventWorldCall')
|
||||
const eventPermRewards = require('./eventPermRewards')
|
||||
const eventTemplates = require('./eventTemplates')
|
||||
const boot = require('./boot')
|
||||
@@ -173,8 +174,11 @@ module.exports = function register(ctx, api) {
|
||||
//
|
||||
// Permissions as rewards (PLAN_REDESIGNS.md §12) beside them: any registered
|
||||
// permission, lent until the run ends or given for good, each on its own budget.
|
||||
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS])
|
||||
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS])
|
||||
//
|
||||
// Calling one of Rust's own world events (PLAN_REDESIGNS.md §14.3.1, protocol
|
||||
// 14) beside them: kept when the run ends, on a budget of its own.
|
||||
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS])
|
||||
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS])
|
||||
|
||||
// The templates (PLAN_REDESIGNS.md §2.5, MODULE_API 1.13.0): a kit weekend
|
||||
// as a starting point on core's New event screen. Core keeps nothing about a
|
||||
@@ -193,6 +197,7 @@ module.exports = function register(ctx, api) {
|
||||
...eventWorld.OPTION_SOURCES,
|
||||
...eventRewards.OPTION_SOURCES,
|
||||
...eventPermRewards.OPTION_SOURCES,
|
||||
...eventWorldCall.OPTION_SOURCES,
|
||||
])
|
||||
|
||||
// The slash commands (phase 16, R11). The handlers run HERE, in the website
|
||||
@@ -211,10 +216,10 @@ module.exports = function register(ctx, api) {
|
||||
streams: STREAMS.length,
|
||||
audiences: AUDIENCES.length,
|
||||
leases: eventLeases.LEASES.length,
|
||||
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length,
|
||||
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length,
|
||||
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length,
|
||||
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length,
|
||||
announceLeg: eventRewards.LEG.leg,
|
||||
commands: commands.map((cmd) => cmd.name).join(' '),
|
||||
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length,
|
||||
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ const npcs = require('./model/npcs/npcs.model')
|
||||
const npcsDb = require('./model/npcs/npcs.db')
|
||||
const permissionsDb = require('./model/permissions/permissions.db')
|
||||
const sidecar = require('./sidecarClient')
|
||||
const worldEvents = require('./worldEvents')
|
||||
|
||||
const log = core.logger('ingest')
|
||||
|
||||
@@ -268,6 +269,23 @@ async function apply(serverId, item, server = null) {
|
||||
break
|
||||
}
|
||||
|
||||
// ── Protocol 14: Rust's own world events (§14.3.1) ──────────────────────
|
||||
//
|
||||
// Stored like everything else, and read per viewer by the feed. The one
|
||||
// thing a world event COUNTS is a downed patrol helicopter, Chinook or
|
||||
// Bradley: the plugin sends every candidate and the server's rule picks
|
||||
// (D372, D379), so Rotorbane and Tankbreaker always agree with the line.
|
||||
// A protocol-13 plugin still sends `heliKills` / `apcKills` on its tally
|
||||
// instead, and the tally path above adds those as it always has.
|
||||
case 'world.event': {
|
||||
const rule = worldEvents.creditRuleOf(server && server.creditRule)
|
||||
for (const kill of worldEvents.killsOf(frame, rule)) {
|
||||
await db.touchPlayer(kill.steamId, kill.name || null)
|
||||
await db.addTitleStats({ serverId, wipeId, steamId: kill.steamId }, { [kill.field]: 1 })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// ── Protocol 14: Economics wiped every balance (§11.2.2) ────────────────
|
||||
//
|
||||
// `ecowipe` puts everyone back at the start in the wipe in play, so none of
|
||||
|
||||
@@ -148,6 +148,9 @@ const TITLE_SUMS = {
|
||||
rockets: 'rockets',
|
||||
explosives: 'explosives',
|
||||
missions: 'missions',
|
||||
// Protocol 14 (D356): Safecracker and Scavenger.
|
||||
cratesHacked: 'crates_hacked',
|
||||
dropsLooted: 'drops_looted',
|
||||
}
|
||||
|
||||
/** The two *best* fields and their columns: the longest single kill, never a sum. */
|
||||
@@ -339,7 +342,15 @@ async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
|
||||
*/
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25, balanceFloor = null }) {
|
||||
const byBalance = sort === 'balance' && wipeId && Number.isFinite(balanceFloor)
|
||||
const column = { kills: 'kills', deaths: 'deaths', npcKills: 'npc_kills', playtime: 'playtime_sec' }[sort] || 'kills'
|
||||
const column = {
|
||||
kills: 'kills',
|
||||
deaths: 'deaths',
|
||||
npcKills: 'npc_kills',
|
||||
playtime: 'playtime_sec',
|
||||
// Protocol 14 (D356, D377): the columns Safecracker and Scavenger bring.
|
||||
cratesHacked: 'crates_hacked',
|
||||
dropsLooted: 'drops_looted',
|
||||
}[sort] || 'kills'
|
||||
|
||||
const params = [serverId]
|
||||
let where = 's.server_id = ?'
|
||||
@@ -363,6 +374,8 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
|
||||
SUM(s.npc_kills) AS npcKills,
|
||||
SUM(s.structures) AS structures,
|
||||
SUM(s.playtime_sec) AS playtimeSec,
|
||||
SUM(s.crates_hacked) AS cratesHacked,
|
||||
SUM(s.drops_looted) AS dropsLooted,
|
||||
MAX(s.balance) AS balance,
|
||||
MAX(s.last_seen) AS lastSeen
|
||||
FROM ${STATS} s
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
// type.
|
||||
|
||||
const catalogue = require('../../catalogue')
|
||||
const worldEvents = require('../../worldEvents')
|
||||
const db = require('./events.db')
|
||||
const mapDb = require('../map/map.db')
|
||||
const serversDb = require('../servers/servers.db')
|
||||
|
||||
/** Hard ceiling on a page, whatever a caller asks for. */
|
||||
const MAX_LIMIT = 200
|
||||
@@ -51,7 +54,7 @@ function parseKinds(raw) {
|
||||
* refused: naming it in an error would confirm the kind exists, which is a small
|
||||
* thing to leak and a free one to avoid.
|
||||
*/
|
||||
async function recent({ serverId, admin = false, presence = false, kind = null, wipeId = null, limit }) {
|
||||
async function recent({ serverId, admin = false, staff = false, presence = false, kind = null, wipeId = null, limit }) {
|
||||
const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
|
||||
|
||||
// Every requested kind was refused. Answering with an empty list is right —
|
||||
@@ -65,7 +68,38 @@ async function recent({ serverId, admin = false, presence = false, kind = null,
|
||||
limit: boundedLimit(limit),
|
||||
})
|
||||
|
||||
return rows.map(shape)
|
||||
const shaped = rows.map(shape)
|
||||
if (!shaped.some((r) => r.kind === 'world.event')) return shaped
|
||||
|
||||
// Protocol 14 (§14.3.1): each world event is read for THIS viewer — its type
|
||||
// allowed, its names to the presence audience, its Steam ids to staff, its
|
||||
// credit by the server's rule — and given a grid from the map's geometry.
|
||||
// Both reads only when the page holds a world event at all.
|
||||
const view = await worldView(serverId, { staff: staff || admin, presence })
|
||||
return shaped.map((row) => (row.kind === 'world.event' ? worldRow(row, view) : row)).filter(Boolean)
|
||||
}
|
||||
|
||||
/** What reading a world event for one viewer needs: the server's credit rule and map geometry. */
|
||||
async function worldView(serverId, { staff, presence }) {
|
||||
const [server, meta] = await Promise.all([
|
||||
serversDb.getServer(serverId).catch(() => null),
|
||||
mapDb.getMeta(serverId).catch(() => null),
|
||||
])
|
||||
return {
|
||||
staff,
|
||||
presence,
|
||||
rule: worldEvents.creditRuleOf(server && server.creditRule),
|
||||
geometry: meta
|
||||
? { worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** One world-event row for a viewer, or null when they may not see it. The row's own Steam id is staff's too. */
|
||||
function worldRow(row, view) {
|
||||
const frame = worldEvents.shapeFor(row.frame, view)
|
||||
if (!frame) return null
|
||||
return { ...row, steamId: view.staff ? row.steamId : null, frame }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,6 +157,9 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, pre
|
||||
npcKills: Number(r.npcKills) || 0,
|
||||
structures: Number(r.structures) || 0,
|
||||
playtimeSec: Number(r.playtimeSec) || 0,
|
||||
// Protocol 14 (D356): Safecracker's and Scavenger's counts.
|
||||
cratesHacked: Number(r.cratesHacked) || 0,
|
||||
dropsLooted: Number(r.dropsLooted) || 0,
|
||||
// Withheld below the presence audience. A tally refreshes it every minute a
|
||||
// player is on, so a `lastSeen` of forty seconds ago is the Online tab by
|
||||
// another name. The ORDER still uses it as a tie-break — that says who was
|
||||
|
||||
@@ -24,7 +24,7 @@ async function listServers({ enabledOnly = false } = {}) {
|
||||
return core.query(
|
||||
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
|
||||
protocol, enabled, sort_order AS sortOrder, announce_news AS announceNews,
|
||||
news_delivery AS newsDelivery,
|
||||
news_delivery AS newsDelivery, credit_rule AS creditRule,
|
||||
wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz,
|
||||
DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
@@ -37,7 +37,7 @@ async function listServers({ enabledOnly = false } = {}) {
|
||||
async function getServer(id) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
|
||||
protocol, enabled, sort_order AS sortOrder,
|
||||
protocol, enabled, sort_order AS sortOrder, credit_rule AS creditRule,
|
||||
wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz,
|
||||
DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
@@ -97,6 +97,15 @@ async function setSchedule(id, { wipeRule, wipeDay, wipeTime, wipeTz, wipeAnchor
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One server's credit rule (D372). Its own statement, like the schedule, and for
|
||||
* the same reason: written only when a save carries one, so a client that
|
||||
* predates the rule cannot reset it to the default.
|
||||
*/
|
||||
async function setCreditRule(id, rule) {
|
||||
await core.query(`UPDATE ${SERVERS} SET credit_rule = ? WHERE id = ?`, [rule, id])
|
||||
}
|
||||
|
||||
async function deleteServer(id) {
|
||||
await core.query(`DELETE FROM ${SERVERS} WHERE id = ?`, [id])
|
||||
}
|
||||
@@ -256,6 +265,7 @@ module.exports = {
|
||||
getServer,
|
||||
upsertServer,
|
||||
setSchedule,
|
||||
setCreditRule,
|
||||
deleteServer,
|
||||
listState,
|
||||
getState,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
const core = require('../../core')
|
||||
const db = require('./servers.db')
|
||||
const { nextWipe } = require('./nextWipe')
|
||||
const worldEvents = require('../../worldEvents')
|
||||
|
||||
const log = core.logger('servers')
|
||||
|
||||
@@ -59,6 +60,8 @@ function withToken(row) {
|
||||
announceNews: Boolean(Number(row.announceNews)),
|
||||
// D142: where that post goes — chat, or a popup.
|
||||
newsDelivery: row.newsDelivery === 'popup' ? 'popup' : 'chat',
|
||||
// D372: who a downed helicopter, Chinook or Bradley credits, for ingest.
|
||||
creditRule: worldEvents.creditRuleOf(row.creditRule),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +215,8 @@ async function listForAdmin(now = Date.now()) {
|
||||
// bridge makes. Anything but `patched` is said on the servers page.
|
||||
zoneHelper: (state && state.zoneHelper) || null,
|
||||
schedule: scheduleOf(row),
|
||||
// D372: who a downed helicopter, Chinook or Bradley credits on this server.
|
||||
creditRule: worldEvents.creditRuleOf(row.creditRule),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ const crypto = require('node:crypto')
|
||||
|
||||
/**
|
||||
* The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three
|
||||
* conditions and their shipped titles, the richest (§11.2.2, Magnate), plus
|
||||
* conditions and their shipped titles, the richest (§11.2.2, Magnate), the
|
||||
* two world-event counts (§14.3.1, Safecracker and Scavenger), plus
|
||||
* `playtime`, which predates them and has no default title, so a playtime rule
|
||||
* always carries its own text.
|
||||
*
|
||||
@@ -65,6 +66,9 @@ const STATS = {
|
||||
rockets: { label: 'Rockets fired', title: 'Artillerist', source: { sum: 'rockets' } },
|
||||
explosives: { label: 'Explosives thrown', title: 'Demolitionist', source: { sum: 'explosives' } },
|
||||
missions: { label: 'Missions completed', title: 'Wayfarer', source: { sum: 'missions' } },
|
||||
// Protocol 14, Rust's own world events (D356): a hack counts for its starter, once per crate.
|
||||
crateshacked: { label: 'Locked crates hacked', title: 'Safecracker', source: { sum: 'crates_hacked' } },
|
||||
dropslooted: { label: 'Airdrops looted first', title: 'Scavenger', source: { sum: 'drops_looted' } },
|
||||
richest: { label: 'Richest (Economics)', title: 'Magnate', source: { balance: 'balance' } },
|
||||
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
|
||||
profilekills: { label: 'Kills of an NPC profile', title: null, source: { npcProfile: true } },
|
||||
|
||||
@@ -113,6 +113,11 @@ async function putServer(req, res) {
|
||||
})
|
||||
if (schedule) await db.setSchedule(id, schedule)
|
||||
|
||||
// D372: written only when the save carries one, like the schedule. The router
|
||||
// has already refused a word this build does not know.
|
||||
const creditRule = req.body.creditRule === undefined ? null : req.body.creditRule
|
||||
if (creditRule) await db.setCreditRule(id, creditRule)
|
||||
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.server.save',
|
||||
@@ -125,6 +130,8 @@ async function putServer(req, res) {
|
||||
// The schedule as written, when the save carried one. Nothing in it is a
|
||||
// secret, and "who moved the wipe" is a question players will ask.
|
||||
...(schedule ? { schedule: { ...schedule, wipeOnceAt: schedule.wipeOnceAt ? schedule.wipeOnceAt.toISOString() : null } } : {}),
|
||||
// The credit rule as written, when the save carried one (D372).
|
||||
...(creditRule ? { creditRule } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const admin = require('./rust.controller')
|
||||
const worldEvents = require('../../worldEvents')
|
||||
const { requireRole, validate } = core.middleware
|
||||
const { body, param } = core.validator
|
||||
|
||||
@@ -63,7 +64,7 @@ adminRustRouter.put(
|
||||
'/servers/:id',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Create or update a Rust server'
|
||||
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. A 400 carries one sentence per problem in `errors`.'
|
||||
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. `creditRule` (protocol 14, D372) is who a downed patrol helicopter, Chinook or Bradley credits, for the feed line and for the Rotorbane and Tankbreaker counts alike: `blow` (the killing blow, the default; for the patrol helicopter the crippling hit, else the last hit), `last`, `most` (the most damage) or `all` (everyone who hurt it, one each). It is written only when present, and applies from then on. A 400 carries one sentence per problem in `errors`.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerSave" } } } } */
|
||||
/* #swagger.responses[204] = { description: 'Saved' } */
|
||||
/* #swagger.responses[400] = { description: 'Invalid body' } */
|
||||
@@ -91,6 +92,8 @@ adminRustRouter.put(
|
||||
body('wipeTz').optional({ values: 'null' }).isString().isLength({ max: 64 }),
|
||||
body('wipeAnchor').optional({ values: 'falsy' }).isISO8601({ strict: true }).isLength({ max: 10 }),
|
||||
body('wipeOnceAt').optional({ values: 'falsy' }).isISO8601({ strict: true }),
|
||||
// Protocol 14 (D372): who a downed helicopter, Chinook or Bradley credits.
|
||||
body('creditRule').optional().isIn(worldEvents.CREDIT_RULE_IDS),
|
||||
validate,
|
||||
admin.putServer,
|
||||
)
|
||||
|
||||
@@ -84,6 +84,9 @@ async function getServer(req, res) {
|
||||
* feed carries only what names nobody — a wipe, a start, a shutdown — and says
|
||||
* so with `presenceHidden`, so a page can explain a quiet feed instead of
|
||||
* implying a quiet server.
|
||||
*
|
||||
* `staff` is the same re-read rung, and reaches only what a world event carries
|
||||
* (protocol 14, D353): a Steam id and a position. It never widens the kinds.
|
||||
*/
|
||||
async function listEvents(req, res) {
|
||||
try {
|
||||
@@ -93,6 +96,7 @@ async function listEvents(req, res) {
|
||||
events: await events.recent({
|
||||
serverId: req.params.id,
|
||||
presence: presence.visible,
|
||||
staff: presence.level === 'staff',
|
||||
kind: req.query.kind,
|
||||
wipeId: req.query.wipe || null,
|
||||
limit: req.query.limit,
|
||||
|
||||
@@ -68,7 +68,7 @@ rustRouter.get(
|
||||
'/servers/:id/events',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'Recent events on one Rust server'
|
||||
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld.'
|
||||
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule`.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
@@ -85,7 +85,7 @@ rustRouter.get(
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336).'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, or balance (one wipe on a server with Economics)', schema: { type: 'string' } }
|
||||
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, or balance (one wipe on a server with Economics)', schema: { type: 'string' } }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'The leaderboard' } */
|
||||
siteMode,
|
||||
|
||||
@@ -428,6 +428,13 @@ const worldOwned = (server, { runId } = {}) =>
|
||||
const worldZone = (server, body) => request(server, '/world/zone', { method: 'POST', body })
|
||||
const worldPlace = (server, body) => request(server, '/world/place', { method: 'POST', body })
|
||||
|
||||
/**
|
||||
* Run one of the game's own world events for a run (protocol 14, §20.5). `data.kind`
|
||||
* is `world.called` (with `called`, and `repeat: true` for a key the plugin has
|
||||
* seen) or `world.error` with a reason. What it starts is kept: there is no revert.
|
||||
*/
|
||||
const worldCall = (server, body) => request(server, '/world/call', { method: 'POST', body })
|
||||
|
||||
/**
|
||||
* Give back what a run owns: named ids, else everything under a key, else the
|
||||
* whole run. `data` lists `removed`, `gone` (already not there — a success)
|
||||
@@ -564,6 +571,7 @@ module.exports = {
|
||||
worldOwned,
|
||||
worldZone,
|
||||
worldPlace,
|
||||
worldCall,
|
||||
worldRevert,
|
||||
tallyOpen,
|
||||
tallySnapshot,
|
||||
|
||||
@@ -109,6 +109,12 @@ module.exports = {
|
||||
wipeTz: { type: 'string', nullable: true, example: 'America/Chicago' },
|
||||
wipeAnchor: { type: 'string', format: 'date', nullable: true, example: null },
|
||||
wipeOnceAt: { type: 'string', format: 'date-time', nullable: true, example: null },
|
||||
creditRule: {
|
||||
type: 'string',
|
||||
enum: ['blow', 'last', 'most', 'all'],
|
||||
description: 'Who a downed patrol helicopter, Chinook or Bradley credits (D372): the killing blow, the last hit, the most damage, or everyone who hurt it. Written only when present.',
|
||||
example: 'blow',
|
||||
},
|
||||
},
|
||||
},
|
||||
RustAdminServerList: {
|
||||
@@ -139,6 +145,7 @@ module.exports = {
|
||||
},
|
||||
bootId: { type: 'string', nullable: true, example: 'boot-20260915T194502Z' },
|
||||
sidecarProtocol: { type: 'integer', nullable: true, example: 1 },
|
||||
creditRule: { type: 'string', enum: ['blow', 'last', 'most', 'all'], description: 'Who a downed patrol helicopter, Chinook or Bradley credits on this server (D372).', example: 'blow' },
|
||||
zoneHelper: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
|
||||
@@ -95,7 +95,7 @@ test('every kind is classified exactly once', () => {
|
||||
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
|
||||
})
|
||||
|
||||
test('the classification covers exactly the event kinds the protocol defines, through protocol 13', () => {
|
||||
test('the classification covers exactly the event kinds the protocol defines, through protocol 14', () => {
|
||||
// The spec lives in another repository, so the list is restated here rather
|
||||
// than parsed — and restating it is the point: adding a kind to the protocol
|
||||
// without deciding who may see it has to fail somewhere, and this is where.
|
||||
@@ -150,6 +150,9 @@ test('the classification covers exactly the event kinds the protocol defines, th
|
||||
'npc.boss.spawned',
|
||||
'npc.boss.phase',
|
||||
'npc.boss.died',
|
||||
// Protocol 14 (§20.5): Rust's own world events, one kind told apart by
|
||||
// `event`. Public as a kind; `worldEvents.js` filters per type and per viewer.
|
||||
'world.event',
|
||||
]
|
||||
|
||||
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
|
||||
@@ -165,6 +168,10 @@ test('the classification covers exactly the event kinds the protocol defines, th
|
||||
for (const kind of ['npc.boss.spawned', 'npc.boss.died']) {
|
||||
assert.equal(catalogue.isPublic(kind), true, `${kind} is said to the whole server, so it is public (D286)`)
|
||||
}
|
||||
// D353, D376: the world event is public and names nobody below the presence
|
||||
// audience — the names come off per viewer, so the kind is not a presence kind.
|
||||
assert.equal(catalogue.isPublic('world.event'), true)
|
||||
assert.equal(catalogue.isPresence('world.event'), false)
|
||||
})
|
||||
|
||||
test('every kind that names a player who was on is behind the presence setting', () => {
|
||||
@@ -187,6 +194,7 @@ test('every kind that names a player who was on is behind the presence setting',
|
||||
// A presence kind is a subset of the public ones, never a staff kind widened.
|
||||
for (const kind of catalogue.PRESENCE_KINDS) assert.ok(catalogue.PUBLIC_KINDS.includes(kind))
|
||||
|
||||
// And what is left names nobody.
|
||||
assert.deepEqual(catalogue.kindsFor({}).sort(), ['npc.boss.spawned', 'server.initialized', 'server.shutdown', 'server.wipe'])
|
||||
// And what is left names nobody — a world event included, once `worldEvents.shapeFor`
|
||||
// has taken its names off for a viewer below the presence audience (D376).
|
||||
assert.deepEqual(catalogue.kindsFor({}).sort(), ['npc.boss.spawned', 'server.initialized', 'server.shutdown', 'server.wipe', 'world.event'])
|
||||
})
|
||||
|
||||
@@ -165,6 +165,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
|
||||
'rust.participation.open',
|
||||
'rust.perm.give',
|
||||
'rust.perm.lend',
|
||||
'rust.world.call',
|
||||
'rust.zone.open',
|
||||
])
|
||||
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
|
||||
@@ -176,6 +177,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
|
||||
'rust.permissions',
|
||||
'rust.permissions.kept',
|
||||
'rust.prefabs',
|
||||
'rust.world.calls',
|
||||
'rust.zone.minutes',
|
||||
])
|
||||
|
||||
@@ -200,8 +202,9 @@ test('the event verbs are registered, and every budget has a verb that spends it
|
||||
|
||||
// What a teardown can give back is declared honestly: a line said, a
|
||||
// collect filed, coins paid (D335) and permissions given for good (D346) are
|
||||
// not undone.
|
||||
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give'])
|
||||
// not undone, and nor is a world event called (D354): a cargo ship or a drop
|
||||
// may already hold players or their loot.
|
||||
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give', 'rust.world.call'])
|
||||
for (const a of actions) {
|
||||
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
|
||||
// Every param source is one this module registers.
|
||||
|
||||
@@ -47,9 +47,11 @@ const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...
|
||||
|
||||
// ── The categories ────────────────────────────────────────────────────────
|
||||
|
||||
test('twenty-four categories carry a default title, and playtime carries none (D172, §11.2.2)', () => {
|
||||
assert.strictEqual(titles.CATEGORY_IDS.length, 24)
|
||||
test('twenty-six categories carry a default title, and playtime carries none (D172, §11.2.2, D356)', () => {
|
||||
assert.strictEqual(titles.CATEGORY_IDS.length, 26)
|
||||
assert.strictEqual(titles.STATS.richest.title, 'Magnate')
|
||||
assert.strictEqual(titles.STATS.crateshacked.title, 'Safecracker')
|
||||
assert.strictEqual(titles.STATS.dropslooted.title, 'Scavenger')
|
||||
assert.ok(!titles.CATEGORY_IDS.includes('playtime'))
|
||||
assert.strictEqual(titles.STATS.playtime.title, null)
|
||||
assert.strictEqual(titles.STATS.animalkills.title, 'Beastbane')
|
||||
|
||||
395
server/test/worldEvents.test.js
Normal file
395
server/test/worldEvents.test.js
Normal file
@@ -0,0 +1,395 @@
|
||||
// ── Rust's own world events (PLAN_REDESIGNS §14.3.1, protocol 14) ──────────
|
||||
//
|
||||
// Each test is one way the feature could look right and be wrong:
|
||||
//
|
||||
// the catalogue drifts from the protocol's 24 types, and a new one is public
|
||||
// the four credit rules (D372) disagree with their own table — or with the
|
||||
// count, so the line says Alice and Rotorbane counts Bob
|
||||
// a name reaches a viewer below the presence audience (D376), a Steam id or a
|
||||
// position reaches anybody but staff (D353)
|
||||
// a grid is clamped onto the edge of the map for something off it
|
||||
// the tally's two new counts are dropped on the floor
|
||||
// a trigger declares a variable its emitter never fills, or names a Steam id
|
||||
// the call step takes a position for an event that has no route to one (D378)
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
/** Installs a core whose `db.query` records every statement and answers `rows`. */
|
||||
function withRecorder(rows = []) {
|
||||
const statements = []
|
||||
const ctx = fakeCtx({
|
||||
db: {
|
||||
query: (sql, params = []) => {
|
||||
statements.push({ sql, params })
|
||||
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
})
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
return { ctx, statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) }
|
||||
}
|
||||
|
||||
withRecorder()
|
||||
|
||||
const worldEvents = require('../worldEvents')
|
||||
const catalogue = require('../catalogue')
|
||||
const { TRIGGERS } = require('../engagement/triggers')
|
||||
|
||||
const A = '76561198000000001'
|
||||
const B = '76561198000000002'
|
||||
const C = '76561198000000003'
|
||||
const NOW = Date.now()
|
||||
|
||||
/** A 6000 map of the game's own 41 cells of 146.3 m — the rigs' geometry. */
|
||||
const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 }
|
||||
|
||||
const world = (event, over = {}) => ({ kind: 'world.event', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', event, ...over })
|
||||
|
||||
/** Bob did 61 %, Alice the crippling hit — D372's own example. */
|
||||
const downed = (over = {}) => world('heli.downed', {
|
||||
x: 812,
|
||||
z: -340.5,
|
||||
credit: { blow: A, last: B, most: B, damagers: [{ steamId: B, damage: 6120 }, { steamId: A, damage: 3880 }] },
|
||||
names: { [A]: 'Alice', [B]: 'Bob' },
|
||||
...over,
|
||||
})
|
||||
|
||||
// ── The catalogue ──────────────────────────────────────────────────────────
|
||||
|
||||
test('the types are exactly the 24 the protocol defines, and an unknown one is staff only', () => {
|
||||
// Sourced from docs/rust-link/PROTOCOL.md §20.5 (PLAN_REDESIGNS §14.3.1, D379).
|
||||
const PROTOCOL_14 = [
|
||||
'heli.arrived', 'heli.left', 'heli.downed',
|
||||
'bradley.spawned', 'bradley.destroyed',
|
||||
'cargo.arrived', 'cargo.docking', 'cargo.docked', 'cargo.undocked', 'cargo.left',
|
||||
'chinook.arrived', 'chinook.dropped', 'chinook.downed',
|
||||
'airdrop.called', 'airdrop.landed', 'airdrop.looted',
|
||||
'crate.hack.started', 'crate.unlocked', 'crate.looted',
|
||||
'vendor.arrived', 'vendor.left',
|
||||
'satellite.crashed',
|
||||
'deepsea.opened', 'deepsea.closed',
|
||||
]
|
||||
assert.deepStrictEqual([...worldEvents.TYPE_IDS].sort(), [...PROTOCOL_14].sort())
|
||||
assert.ok(catalogue.isPublic('world.event'))
|
||||
|
||||
// Default deny, per type: a newer plugin's type is not served to the public.
|
||||
const future = world('meteor.landed', { x: 1, z: 1, steamId: A, name: 'Alice' })
|
||||
assert.strictEqual(worldEvents.shapeFor(future, { presence: true }), null)
|
||||
assert.strictEqual(worldEvents.shapeFor(future, { staff: true }).event, 'meteor.landed')
|
||||
})
|
||||
|
||||
// ── Credit (D372, D379) ────────────────────────────────────────────────────
|
||||
|
||||
test('each of the four credit rules picks what its table says', () => {
|
||||
assert.deepStrictEqual(worldEvents.credited(downed(), 'blow'), [A])
|
||||
assert.deepStrictEqual(worldEvents.credited(downed(), 'last'), [B])
|
||||
assert.deepStrictEqual(worldEvents.credited(downed(), 'most'), [B])
|
||||
assert.deepStrictEqual(worldEvents.credited(downed(), 'all'), [B, A])
|
||||
// An unknown rule is the default, the killing blow.
|
||||
assert.deepStrictEqual(worldEvents.credited(downed(), 'loudest'), [A])
|
||||
})
|
||||
|
||||
test('with no killing blow, the killing-blow rule falls back to the last hit', () => {
|
||||
const noBlow = downed({ credit: { last: B, most: A, damagers: [{ steamId: A, damage: 9 }, { steamId: B, damage: 1 }] } })
|
||||
assert.deepStrictEqual(worldEvents.credited(noBlow, 'blow'), [B])
|
||||
assert.deepStrictEqual(worldEvents.credited(noBlow, 'most'), [A])
|
||||
})
|
||||
|
||||
test('a death nobody touched credits nobody under any rule, and counts nothing', () => {
|
||||
const nobody = world('bradley.destroyed', { where: 'road', x: 0, z: 0, credit: { damagers: [] } })
|
||||
for (const rule of worldEvents.CREDIT_RULE_IDS) {
|
||||
assert.deepStrictEqual(worldEvents.credited(nobody, rule), [], rule)
|
||||
assert.deepStrictEqual(worldEvents.killsOf(nobody, rule), [], rule)
|
||||
}
|
||||
})
|
||||
|
||||
test('the count and the line credit the same players, under every rule', () => {
|
||||
for (const rule of worldEvents.CREDIT_RULE_IDS) {
|
||||
const line = worldEvents.shapeFor(downed(), { presence: true, rule }).credited.map((c) => c.name)
|
||||
const count = worldEvents.killsOf(downed(), rule).map((k) => k.name)
|
||||
assert.deepStrictEqual(line, count, rule)
|
||||
}
|
||||
// Each kill is the column the type counts toward: Rotorbane for both helicopters, Tankbreaker for Bradley.
|
||||
assert.strictEqual(worldEvents.killsOf(downed(), 'blow')[0].field, 'heliKills')
|
||||
assert.strictEqual(worldEvents.killsOf(world('chinook.downed', { credit: { blow: A } }), 'blow')[0].field, 'heliKills')
|
||||
assert.strictEqual(worldEvents.killsOf(world('bradley.destroyed', { where: 'launch_site', credit: { blow: A } }), 'blow')[0].field, 'apcKills')
|
||||
// Nothing else counts.
|
||||
assert.deepStrictEqual(worldEvents.killsOf(world('heli.arrived'), 'blow'), [])
|
||||
})
|
||||
|
||||
test('a junk id in a credit block is never credited', () => {
|
||||
const junk = downed({ credit: { blow: 'not-a-steam-id', last: '123', damagers: [{ steamId: '{}', damage: 1 }] } })
|
||||
for (const rule of worldEvents.CREDIT_RULE_IDS) assert.deepStrictEqual(worldEvents.credited(junk, rule), [], rule)
|
||||
})
|
||||
|
||||
// ── Who may read what (D353, D376) ─────────────────────────────────────────
|
||||
|
||||
test('below the presence audience a line names nobody, and carries no position', () => {
|
||||
const signedOut = worldEvents.shapeFor(downed(), { presence: false, rule: 'blow', geometry: GEOMETRY })
|
||||
assert.strictEqual(signedOut.event, 'heli.downed')
|
||||
assert.strictEqual(signedOut.grid, worldEvents.gridOf(GEOMETRY, 812, -340.5))
|
||||
assert.strictEqual(signedOut.creditedCount, 1)
|
||||
assert.strictEqual(signedOut.credited, undefined)
|
||||
for (const field of ['x', 'z', 'credit', 'names', 'steamId', 'name']) assert.strictEqual(signedOut[field], undefined, field)
|
||||
|
||||
const hack = worldEvents.shapeFor(world('crate.hack.started', { where: 'oilrig_large', steamId: A, name: 'Alice', x: 1, z: 1 }), { presence: false })
|
||||
assert.strictEqual(hack.name, undefined)
|
||||
assert.strictEqual(hack.steamId, undefined)
|
||||
assert.strictEqual(hack.where, 'oilrig_large')
|
||||
})
|
||||
|
||||
test('the presence audience reads names but no Steam id; staff read both, and the position', () => {
|
||||
const member = worldEvents.shapeFor(downed(), { presence: true, rule: 'all' })
|
||||
assert.deepStrictEqual(member.credited, [{ name: 'Bob' }, { name: 'Alice' }])
|
||||
assert.strictEqual(member.x, undefined)
|
||||
|
||||
const staff = worldEvents.shapeFor(downed(), { staff: true, presence: true, rule: 'all' })
|
||||
assert.deepStrictEqual(staff.credited, [{ name: 'Bob', steamId: B }, { name: 'Alice', steamId: A }])
|
||||
assert.strictEqual(staff.x, 812)
|
||||
assert.ok(staff.credit, 'staff see the whole credit block')
|
||||
|
||||
const loot = world('airdrop.looted', { steamId: A, name: 'Alice', x: 5, z: 5 })
|
||||
assert.deepStrictEqual(
|
||||
[worldEvents.shapeFor(loot, { presence: true }).name, worldEvents.shapeFor(loot, { presence: true }).steamId],
|
||||
['Alice', undefined],
|
||||
)
|
||||
assert.strictEqual(worldEvents.shapeFor(loot, { staff: true, presence: true }).steamId, A)
|
||||
})
|
||||
|
||||
test('a line with nobody in it reads the same for everybody', () => {
|
||||
const docking = world('cargo.docking', { harbour: 'harbor_1', harbourName: 'Harbor', x: 100, z: 100 })
|
||||
const a = worldEvents.shapeFor(docking, { presence: false, geometry: GEOMETRY })
|
||||
const b = worldEvents.shapeFor(docking, { presence: true, geometry: GEOMETRY })
|
||||
assert.deepStrictEqual(a, b)
|
||||
assert.strictEqual(a.harbourName, 'Harbor')
|
||||
})
|
||||
|
||||
// ── The grid ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('the grid is the game’s, and nothing off the map gets one', () => {
|
||||
// The north-west corner is A0; the 41st column is AO.
|
||||
assert.strictEqual(worldEvents.gridOf(GEOMETRY, -2999, 2999), 'A0')
|
||||
assert.strictEqual(worldEvents.gridOf(GEOMETRY, 2999, -2999), 'AO40')
|
||||
// The Deep Sea and its ghost ships sit thousands of metres off the map.
|
||||
assert.strictEqual(worldEvents.gridOf(GEOMETRY, -5200, 0), null)
|
||||
// No geometry yet, or no position: no grid, rather than a guess.
|
||||
assert.strictEqual(worldEvents.gridOf(null, 0, 0), null)
|
||||
assert.strictEqual(worldEvents.gridOf(GEOMETRY, undefined, 0), null)
|
||||
})
|
||||
|
||||
// ── Ingest ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a downed helicopter counts toward Rotorbane for whom the server’s rule credits', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
await apply('main', { id: 1, kind: 'world.event', frame: downed() }, { id: 'main', creditRule: 'all' })
|
||||
|
||||
const adds = rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('heli_kills'))
|
||||
assert.strictEqual(adds.length, 2)
|
||||
assert.deepStrictEqual(adds.map((s) => s.params[2]).sort(), [A, B].sort())
|
||||
|
||||
// Under the default rule, only the killing blow.
|
||||
const rec2 = withRecorder()
|
||||
await apply('main', { id: 2, kind: 'world.event', frame: downed() }, { id: 'main' })
|
||||
const once = rec2.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('heli_kills'))
|
||||
assert.strictEqual(once.length, 1)
|
||||
assert.strictEqual(once[0].params[2], A)
|
||||
})
|
||||
|
||||
test('a Bradley counts toward Tankbreaker, and an arrival counts nothing', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
await apply('main', { id: 1, kind: 'world.event', frame: world('bradley.destroyed', { where: 'launch_site', credit: { blow: C }, names: { [C]: 'Cleo' } }) }, { id: 'main' })
|
||||
const apc = rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('apc_kills'))
|
||||
assert.strictEqual(apc.length, 1)
|
||||
assert.strictEqual(apc[0].params[2], C)
|
||||
|
||||
const rec2 = withRecorder()
|
||||
await apply('main', { id: 2, kind: 'world.event', frame: world('heli.arrived', { x: 1, z: 1 }) }, { id: 'main' })
|
||||
assert.strictEqual(rec2.touching('rust_player_wipe_stats').length, 0)
|
||||
})
|
||||
|
||||
test('the tally’s crates hacked and drops looted are added up (D356)', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
await apply('main', { id: 1, kind: 'player.tally', frame: { kind: 'player.tally', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', steamId: A, name: 'Alice', cratesHacked: 2, dropsLooted: 1 } })
|
||||
|
||||
const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('crates_hacked'))
|
||||
assert.ok(titleAdd, 'the tally reached the title columns')
|
||||
assert.ok(titleAdd.sql.includes('drops_looted'))
|
||||
const cols = titleAdd.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+)\)/)[1].split(', ')
|
||||
assert.strictEqual(titleAdd.params[3 + cols.indexOf('crates_hacked')], 2)
|
||||
assert.strictEqual(titleAdd.params[3 + cols.indexOf('drops_looted')], 1)
|
||||
})
|
||||
|
||||
// ── The feed ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('the feed reads each world event for its viewer, with the server’s rule and grid', async () => {
|
||||
const stored = downed()
|
||||
withRecorder((sql) => {
|
||||
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'world.event', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stored) }]
|
||||
if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', creditRule: 'most', enabled: 1 }]
|
||||
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
|
||||
return []
|
||||
})
|
||||
const events = require('../model/events/events.model')
|
||||
|
||||
const [signedOut] = await events.recent({ serverId: 'main', presence: false })
|
||||
assert.strictEqual(signedOut.frame.creditRule, 'most')
|
||||
assert.strictEqual(signedOut.frame.credited, undefined)
|
||||
assert.strictEqual(signedOut.frame.grid, worldEvents.gridOf(GEOMETRY, 812, -340.5))
|
||||
|
||||
const [member] = await events.recent({ serverId: 'main', presence: true })
|
||||
assert.deepStrictEqual(member.frame.credited, [{ name: 'Bob' }])
|
||||
})
|
||||
|
||||
// ── Triggers (D355) ────────────────────────────────────────────────────────
|
||||
|
||||
test('one public trigger per type, none of them declaring a Steam id', () => {
|
||||
const world = TRIGGERS.filter((t) => t.id.startsWith('rust.world.'))
|
||||
assert.deepStrictEqual(world.map((t) => t.id.slice('rust.world.'.length)).sort(), [...worldEvents.TYPE_IDS].sort())
|
||||
for (const t of world) {
|
||||
assert.strictEqual(t.ceiling, 'everyone', t.id)
|
||||
assert.strictEqual(t.subjectKey, 'subject', t.id)
|
||||
assert.ok(t.variables.some((v) => v.name === 'subject'), t.id)
|
||||
assert.ok(!t.variables.some((v) => /steam/i.test(v.name)), `${t.id} declares a Steam id`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a world event fires its trigger with the variables it declares, and a headline', async (t) => {
|
||||
const { ctx } = withRecorder((sql) => (sql.includes('FROM rust_map_images') ? [{ serverId: 'main', ...GEOMETRY }] : []))
|
||||
const emit = require('../engagement/emit')
|
||||
emit.reset()
|
||||
|
||||
const server = { id: 'main', name: 'Main', creditRule: 'all' }
|
||||
await emit.onEvent(server, { id: 1, kind: 'world.event', frame: downed() })
|
||||
await emit.onEvent(server, { id: 2, kind: 'world.event', frame: world('crate.hack.started', { where: 'oilrig_small', steamId: A, name: 'Alice', x: 1, z: 1 }) })
|
||||
await emit.onEvent(server, { id: 3, kind: 'world.event', frame: world('meteor.landed') })
|
||||
|
||||
const calls = ctx.events.emit.calls
|
||||
assert.strictEqual(calls.length, 2, 'an unknown type fires nothing')
|
||||
|
||||
const [heliId, heli] = calls[0]
|
||||
assert.strictEqual(heliId, 'rust.world.heli.downed')
|
||||
assert.strictEqual(heli.data.player, 'Bob and Alice')
|
||||
assert.strictEqual(heli.data.credited, 2)
|
||||
assert.strictEqual(heli.data.subject, 'main:heli.downed')
|
||||
assert.ok(heli.data.grid)
|
||||
assert.match(heli.data.intro, /downed by Bob and Alice at /)
|
||||
|
||||
const [crateId, crate] = calls[1]
|
||||
assert.strictEqual(crateId, 'rust.world.crate.hack.started')
|
||||
assert.strictEqual(crate.data.subject, 'main:crate.hack.started:oilrig_small')
|
||||
assert.match(crate.data.intro, /Alice started hacking the locked crate on Small Oil Rig on Main/)
|
||||
|
||||
// Every variable a trigger fills is one it declares.
|
||||
for (const [id, envelope] of calls) {
|
||||
const declared = new Set(TRIGGERS.find((x) => x.id === id).variables.map((v) => v.name))
|
||||
for (const name of Object.keys(envelope.data)) assert.ok(declared.has(name), `${id} fills ${name}, which it does not declare`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an old world event tells nobody (D63)', async () => {
|
||||
const { ctx } = withRecorder()
|
||||
const emit = require('../engagement/emit')
|
||||
emit.reset()
|
||||
await emit.onEvent({ id: 'main', name: 'Main' }, { id: 1, kind: 'world.event', frame: world('heli.arrived', { t: NOW - 3 * 3600 * 1000 }) })
|
||||
assert.strictEqual(ctx.events.emit.calls.length, 0)
|
||||
})
|
||||
|
||||
// ── The call step (D354, D367, D378) ───────────────────────────────────────
|
||||
|
||||
function stubCall(t, reply) {
|
||||
const client = require('../sidecarClient')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const saved = { worldCall: client.worldCall, getServer: serversDb.getServer }
|
||||
const calls = []
|
||||
serversDb.getServer = async (id) => (id === 'main' ? { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' } : null)
|
||||
client.worldCall = async (server, body) => {
|
||||
calls.push({ server: server.id, body })
|
||||
return typeof reply === 'function' ? reply(body) : reply
|
||||
}
|
||||
t.after(() => {
|
||||
client.worldCall = saved.worldCall
|
||||
serversDb.getServer = saved.getServer
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
const callStep = () => require('../eventWorldCall').ACTIONS[0]
|
||||
const ok = (data) => ({ ok: true, status: 'ok', data })
|
||||
|
||||
test('the call step is kept, priced per event, and mirrors the plugin’s list', () => {
|
||||
const step = callStep()
|
||||
assert.strictEqual(step.id, 'rust.world.call')
|
||||
assert.strictEqual(step.reversible, 'none')
|
||||
assert.strictEqual(step.risk, 'change')
|
||||
assert.strictEqual(step.revert, undefined)
|
||||
assert.deepStrictEqual(step.cost({ event: 'airdrop', count: 3 }), { 'rust.world.calls': 3 })
|
||||
assert.deepStrictEqual(step.cost({ event: 'cargo' }), { 'rust.world.calls': 1 })
|
||||
|
||||
const { EVENTS } = require('../eventWorldCall')
|
||||
assert.deepStrictEqual(EVENTS.map((e) => e.value).sort(), ['airdrop', 'bradley_road', 'cargo', 'chinook', 'f15', 'patrol_heli', 'satellite', 'travelling_vendor'])
|
||||
assert.deepStrictEqual(EVENTS.filter((e) => e.placeable).map((e) => e.value).sort(), ['airdrop', 'patrol_heli'])
|
||||
})
|
||||
|
||||
test('a call is refused in the dry run for a bad event, a bad count, a position it cannot take, or no server', async (t) => {
|
||||
const calls = stubCall(t, ok({ kind: 'world.called', called: 1 }))
|
||||
const step = callStep()
|
||||
const run = (params) => step.perform({ runId: 7, idempotencyKey: '7:1', params, verify: true })
|
||||
|
||||
assert.match((await run({ server: 'main', event: 'meteor' })).error, /not one of the world events/)
|
||||
assert.match((await run({ server: 'main', event: 'cargo', count: 2 })).error, /call 1 to 1/)
|
||||
assert.match((await run({ server: 'main', event: 'airdrop', count: 6 })).error, /call 1 to 5/)
|
||||
// D378: the Chinook goes where the game chooses.
|
||||
assert.match((await run({ server: 'main', event: 'chinook', x: 10, z: 10 })).error, /only an airdrop and a patrol helicopter take a position/)
|
||||
assert.match((await run({ event: 'airdrop' })).error, /which server/)
|
||||
assert.ok((await run({ server: 'main', event: 'airdrop', x: 10, z: 10 })).ok)
|
||||
for (const r of [await run({ server: 'main', event: 'meteor' })]) assert.strictEqual(r.retry, false)
|
||||
assert.strictEqual(calls.length, 0, 'the dry run calls nothing')
|
||||
})
|
||||
|
||||
test('a call sends the key, the run and the position, and reads the plugin’s answer', async (t) => {
|
||||
const calls = stubCall(t, (body) => ok({ kind: 'world.called', key: body.key, event: body.event, called: body.count, repeat: false }))
|
||||
const step = callStep()
|
||||
|
||||
const result = await step.perform({ runId: 7, idempotencyKey: '7:2', params: { event: 'airdrop', count: 2, monument: 'main/powerplant_1', offsetX: 10, y: 99 } })
|
||||
assert.ok(result.ok)
|
||||
assert.deepStrictEqual(result.detail, { event: 'airdrop', called: 2 })
|
||||
assert.deepStrictEqual(calls[0], {
|
||||
server: 'main',
|
||||
body: { runId: '7', key: '7:2', event: 'airdrop', count: 2, monument: 'powerplant_1', offsetX: 10, offsetZ: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
test('a refusal the plugin would repeat is not retried; a game that is down is', async (t) => {
|
||||
stubCall(t, (body) => (body.event === 'cargo'
|
||||
? ok({ kind: 'world.error', reason: 'too-many', message: 'a run calls at most 10 world events on this server' })
|
||||
: { ok: false, status: 'http-503' }))
|
||||
const step = callStep()
|
||||
|
||||
const refused = await step.perform({ runId: 7, idempotencyKey: '7:3', params: { server: 'main', event: 'cargo' } })
|
||||
assert.strictEqual(refused.ok, false)
|
||||
assert.strictEqual(refused.retry, false)
|
||||
assert.match(refused.error, /at most 10/)
|
||||
|
||||
const down = await step.perform({ runId: 7, idempotencyKey: '7:4', params: { server: 'main', event: 'airdrop' } })
|
||||
assert.strictEqual(down.ok, false)
|
||||
assert.strictEqual(down.retry, undefined)
|
||||
assert.match(down.error, /has no game connected/)
|
||||
})
|
||||
|
||||
test('a repeat of a key says so and calls nothing new', async (t) => {
|
||||
stubCall(t, ok({ kind: 'world.called', called: 1, repeat: true }))
|
||||
const result = await callStep().perform({ runId: 7, idempotencyKey: '7:5', params: { server: 'main', event: 'patrol_heli' } })
|
||||
assert.ok(result.ok)
|
||||
assert.strictEqual(result.detail.repeat, true)
|
||||
})
|
||||
278
server/worldEvents.js
Normal file
278
server/worldEvents.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// ── 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
|
||||
* `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 = creditRuleOf(rule)
|
||||
const ids = credited(frame, rule)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
nameOf,
|
||||
gridOf,
|
||||
shapeFor,
|
||||
killsOf,
|
||||
namesPhrase,
|
||||
}
|
||||
Reference in New Issue
Block a user