Files
Module-Rust/server/eventGuardedCrate.js
wtclaude da1d74bdff
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 34s
PR Checks / frozen-manifest (pull_request) Successful in -1m34s
feat(rust): Guarded Crate — feed, map, Warden, Place a guarded crate (PLAN_REDESIGNS §14.3.5)
Stage 6 of the §14.3 integration plan, on protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.9.

- gc.started and gc.ended public, gc.failed staff's; guardedCrate.js reads
  each row per viewer (the clearer's name for the presence audience, Steam id,
  position and key for staff, `won` for everyone).
- Triggers rust.gc.started and rust.gc.ended (a crate a player cleared only).
- The step rust.gc.place: an event from rust.options.gc_events and a world
  call's location; refused at verify for Guarded Crate absent, NpcSpawn missing
  (D364), console unavailable, or an unknown event. Kept (D373); the run log's
  success line says so (D380). Budget rust.gc.crates.
- Warden (D375): guarded_won, title category guardedwon, Crates cleared column.
- Map marker, server settings line, swagger, engagement manifest, bundle, README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 12:26:50 -05:00

194 lines
8.9 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 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). */
const KEPT = 'kept when the run ends: it ends when its last guard dies, or when nobody has killed one for its time limit (30 minutes as Guarded Crate ships)'
/** 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
const missing = missingOn(reportOf(await serversDb.gcReportFor(found.server.id)), name, wanted)
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 }