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
192 lines
8.5 KiB
JavaScript
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 }
|