Files
Module-Rust/server/eventGuardedCrate.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

200 lines
9.2 KiB
JavaScript

// ── Placing a guarded crate (protocol 14, PLAN_REDESIGNS §14.3.5) ────────────
//
// `rust.gc.place`, "Place a guarded crate": one of Guarded Crate's events (Easy,
// Medium, Hard and Elite as it ships; an operator may add or rename them), at a
// monument or a point. The plugin runs Guarded Crate's own console command,
// `gcrate position <event> x,y,z`, and joins the start Guarded Crate raises to
// the step's key, so a retry is answered with the first placement and never
// places a second crate.
//
// **Kept (`reversible: 'none'`, D373).** Guarded Crate has no way to end one
// event: `gcrate stop all` ends every one on the server, other people's
// included, and raises no hook. So a crate a run placed stays until its last
// guard dies or its own timer runs out, and the step's line in the run log says
// so when it is placed (D380): core gives a module nothing at a run's end for a
// step that is kept. `risk: 'change'`, so an operator switches it on deliberately.
//
// **Refused at verify (D364)** where the server said Guarded Crate is not
// loaded, that NpcSpawn is missing (Guarded Crate's `position` does not check,
// and the crate would land unguarded), that its framework cannot run another
// plugin's console command (hello's `console.plugins`, D363, Rust-Plugins#34),
// or that it has no event of the name asked for. A server that has not said is
// left to the plugin, as a Dangerous Treasures step is (§14.3.18).
const client = require('./sidecarClient')
const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS, location } = require('./eventWorld')
const { gridOf } = require('./worldEvents')
const { reportOf } = require('./guardedCrate')
const live = require('./liveStatus')
const serversDb = require('./model/servers/servers.db')
const mapDb = require('./model/map/map.db')
/** Guarded Crate's events as it ships them, offered when no server has said its own. */
const DEFAULT_EVENTS = Object.freeze(['Easy', 'Medium', 'Hard', 'Elite'])
/**
* What the run log says of a crate the run leaves behind (D373, D380). No
* number: each of Guarded Crate's events has its own time limit, and the
* server's report names the events but not their limits (W18).
*/
const KEPT = "kept when the run ends: it ends when its last guard dies, or when nobody has killed one within its event's time limit in Guarded Crate"
/** The plugin's refusals a second attempt would repeat. `gc-too-close` is not one: the other crate may be cleared. */
const PERMANENT = new Set([
'events-disabled',
'malformed',
'bad-event',
'bad-position',
'gc-missing',
'npcspawn-missing',
'console-unavailable',
'no-monument',
'out-of-range',
'off-map',
// Guarded Crate's own refusal in its own words, or its command switched off.
'refused',
])
const BUDGETS = [
{
id: 'rust.gc.crates',
label: 'Guarded crates',
unit: 'crates',
description: 'Guarded crates an event places, each one counted. A guarded crate is kept when the run ends: it ends when its guards are cleared or after its own time.',
},
]
/** One event name as `events` lists it, ignoring case as Guarded Crate does, or null. */
function eventIn(events, wanted) {
const lower = String(wanted || '').trim().toLowerCase()
return (events || []).find((e) => e.toLowerCase() === lower) || null
}
const OPTION_SOURCES = [
{
id: 'rust.options.gc_events',
label: 'Guarded Crate events',
description: "The events each server's Guarded Crate has, by name; Guarded Crate's four defaults where no server has said.",
async resolve() {
const rows = await serversDb.gcReports().catch(() => [])
const byName = new Map()
for (const row of rows) {
const report = reportOf(row).guardedCrate
if (!report || !report.loaded || !report.events) continue
for (const name of report.events) {
const key = name.toLowerCase()
if (!byName.has(key)) byName.set(key, { value: name, servers: [] })
byName.get(key).servers.push(row.name || row.serverId)
}
}
if (byName.size === 0) {
return DEFAULT_EVENTS.map((name) => ({ value: name, label: `${name} (as Guarded Crate ships)` }))
}
return [...byName.values()].map((e) => ({ value: e.value, label: `${e.value} — ${e.servers.join(', ')}` }))
},
},
]
/**
* Why a server cannot take the step, from what it last said, or null. Only a
* report that SAYS no refuses; one that said nothing is the plugin's to answer.
*/
function missingOn(report, name, wanted) {
const gc = report.guardedCrate
if (gc && !gc.loaded) return `Guarded Crate is not installed on ${name}`
if (gc && gc.npcSpawn && !gc.npcSpawn.loaded) {
return `Guarded Crate is loaded on ${name} but NpcSpawn is missing: its events cannot start`
}
if (report.consolePlugins === false) {
return `${name}'s modding framework cannot run Guarded Crate's console command from a plugin, so a guarded crate cannot be placed there yet (Rust-Plugins#34)`
}
if (gc && gc.events && !eventIn(gc.events, wanted)) {
return `"${wanted}" is not one of Guarded Crate's events on ${name} (${gc.events.join(', ')})`
}
return null
}
/** The grid a placed crate is in, or null without the map's geometry. */
async function gridAt(serverId, x, z) {
const meta = await mapDb.getMeta(serverId).catch(() => null)
if (!meta) return null
return gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, x, z)
}
const gcPlace = {
id: 'rust.gc.place',
label: 'Place a guarded crate',
description:
"Places one Guarded Crate event at a monument or a point: a cargo plane drops a locked crate there, ringed by guards, and whoever kills the last guard has cleared it. Needs Guarded Crate and NpcSpawn on the server. The crate is kept when the run ends — Guarded Crate cannot end one alone — and ends when its guards are cleared or after its own time.",
risk: 'change',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'event', type: 'string', required: true, example: 'Elite', source: 'rust.options.gc_events',
description: "Which of Guarded Crate's events: how many guards, and how hard. Easy, Medium, Hard or Elite as it ships." },
{ name: 'server', type: 'string', required: false, example: 'main', source: 'rust.options.servers',
description: 'Which server. A monument already says; for x and z, required.' },
{ name: 'monument', type: 'string', required: false, example: 'main/powerplant_1', source: 'rust.options.monuments',
description: 'Where, by monument. Give this OR x and z.' },
{ 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: 'World x, instead of a monument.' },
{ name: 'z', type: 'float', required: false, example: -342, description: 'World z, instead of a monument.' },
],
cost: () => ({ 'rust.gc.crates': 1 }),
async perform({ idempotencyKey, params, verify }) {
const wanted = String(params.event || '').trim()
if (!wanted) return { ok: false, retry: false, error: "the step does not say which of Guarded Crate's events — pick one" }
const where = location(params)
if (!where.ok) return { ok: false, retry: false, error: where.error }
// A height means nothing here: the crate falls to the ground, and its guards stand on it.
const { y, ...wire } = where.wire
const found = await serverFor(where.serverId)
if (!found.ok) return found
const name = found.server.name || found.server.id
// Confirmed live before it refuses: the stored status lags a restart (W7).
const missing = await live.confirmed(found.server, async () => missingOn(reportOf(await serversDb.gcReportFor(found.server.id)), name, wanted), Boolean)
if (missing) return { ok: false, retry: false, error: missing }
if (verify) return { ok: true }
const result = await client.gcPlace(found.server, { key: idempotencyKey, event: wanted, ...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, 'guarded crate placement') }
}
const data = result.data || {}
if (data.kind !== 'gc.placed') {
return {
ok: false,
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
error: pluginError(data, `${name} did not place the guarded crate`),
}
}
const grid = await gridAt(found.server.id, data.x, data.z)
return {
ok: true,
detail: {
event: String(data.event || wanted),
...(grid ? { grid } : {}),
note: data.repeat ? `answered from the first attempt; nothing new was placed. The crate is ${KEPT}` : `The crate is ${KEPT}`,
...(data.repeat ? { repeat: true } : {}),
},
}
},
}
const ACTIONS = [gcPlace]
module.exports = { BUDGETS, ACTIONS, OPTION_SOURCES, PERMANENT, DEFAULT_EVENTS, KEPT, eventIn, missingOn }