Files
Module-Rust/server/eventWorldCall.js
wtclaude e7d5c8ed1a
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 36s
PR Checks / frozen-manifest (pull_request) Successful in -1m32s
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
2026-10-15 00:39:00 -05:00

181 lines
8.0 KiB
JavaScript

// ── 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 }