Files
Module-Rust/server/eventWorldCall.js
wtclaude 3aa142bf06
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 32s
PR Checks / frozen-manifest (pull_request) Successful in 44s
fix(rust): the last fixes' small ones (W3 W6 W7/W11 W8 W10 W13-W18)
Stage 2 of PLAN_LAST_FIXES (docs#366).

- W7/W11: an "is it loaded" refusal is confirmed live before it is given.
  New liveStatus.js asks the sidecar once (boot.refreshOne, as a poll tick
  would), at most once per server per 5 s and waiting at most 3 s, then
  reads the stored status again. Used by Economics (step and staff page),
  Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes,
  TruePVE and RunicNPC.
- W3: a world call answered `already-out` (the bridge half comes in stage
  3) succeeds as called 0, saying a Travelling Vendor is already out.
- W6: "A locked crate dropped by a Chinook is being hacked."
- W8: a RaidableBases profile's audit row names RaidableBases, not "@data".
- W10: the backpack sizes picker lists each size once, naming its servers.
- W13: a RaidableBases name both servers describe differently shows each
  server's own description.
- W14: each RaidableBases note after the first starts its own sentence.
- W15: a staff deposit refused at Economics' limit answers 409.
- W16: a payment's failedNote explains only the reasons that happened.
- W17: the sync state counts player grants that did not land
  (waitingToLand), and the Permissions page says "in sync · 1 waits for
  their player to join" instead of plain "in sync".
- W18: Guarded Crate's kept note drops the "30 minutes" it could not know.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-21 07:42:24 -05:00

192 lines
8.5 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 || {}
// The game runs one Travelling Vendor at a time and quietly starts nothing
// while one is out. The plugin looks first and says so (W3). Not a failure:
// what the step asked for is on the map.
if (data.reason === 'already-out') {
return {
ok: true,
detail: { event: event.value, called: 0, note: `a ${event.label} is already out on ${found.server.name || found.server.id}, and the game runs one at a time; nothing new was called` },
}
}
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 }