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:
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 }
|
||||
Reference in New Issue
Block a user