feat(rust): Guarded Crate — feed, map, Warden, Place a guarded crate (PLAN_REDESIGNS §14.3.5) #48

Merged
whitlocktech merged 1 commits from feat/guarded-crate into edge 2026-10-15 18:19:16 -05:00
28 changed files with 1230 additions and 24 deletions
Showing only changes of commit da1d74bdff - Show all commits

View File

@@ -135,6 +135,15 @@ one now, kept when the run ends: an airdrop or a patrol helicopter can be sent t
ten minutes after DT loads the step says "Dangerous Treasures is not ready yet" and tries again ten
minutes later. Refused where DT is absent, or where the framework cannot run DT's console command
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.4).
- **Guarded Crate** (Bazz3l, 2.0.17), with **NpcSpawn** (Codefling) for its guards. With both: a
guarded crate landing and being cleared are public feed lines and triggers (*"Alice cleared the
guarded crate · M4"*, the name only for the presence audience), a running crate is on the live map's
world layer, the **Crates cleared** leaderboard column and the *Warden* title count the crates a
player cleared by killing the last guard, and the step **Place a guarded crate** places one of its
events at a monument or a point. The crate is kept when the run ends — Guarded Crate cannot end one
alone — and the run log says so. Refused where Guarded Crate or NpcSpawn is absent, where the event
is not one the server has, or where the framework cannot run its console command
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.5).
News and event lines said in chat can wear one styled group's title and colours — the
**announcement voice**, chosen in Admin → Rust servers. The line is said by the bridge plugin with no

View File

@@ -37,6 +37,7 @@
"dynamicPvp.js",
"engagement",
"eventDangerousTreasures.js",
"eventGuardedCrate.js",
"eventLeases.js",
"eventPermRewards.js",
"eventProgress.js",
@@ -44,6 +45,7 @@
"eventTemplates.js",
"eventWorld.js",
"eventWorldCall.js",
"guardedCrate.js",
"index.js",
"ingest.js",
"mapImages.js",

View File

@@ -46,6 +46,10 @@ const COLUMNS = [
// rows already count a treasure, so the column is there exactly then.
const TREASURES = { key: 'treasuresWon', label: 'Treasures', sort: 'treasuresWon', value: (r) => count(r.treasuresWon) }
// Warden (PLAN_REDESIGNS §14.3.5, D375): guarded crates cleared, shown on the
// same rule from the server's `guardedCrate`.
const GUARDED = { key: 'guardedWon', label: 'Crates cleared', sort: 'guardedWon', value: (r) => count(r.guardedWon) }
const BALANCE = { key: 'balance', label: 'Coins', sort: 'balance', value: (r) => coins(r.balance) }
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
@@ -104,6 +108,7 @@ function Overall({ serverId, wipeId, sort, onSort }) {
const columns = [
...COLUMNS,
...(data && data.dangerousTreasures ? [TREASURES] : []),
...(data && data.guardedCrate ? [GUARDED] : []),
...(data && data.economics ? [BALANCE] : []),
]
// Present on every row or on none — the server decides per request.

View File

@@ -47,6 +47,7 @@ const COLOURS = {
supply: '#66bb6a',
crate: '#ffee58',
treasure: '#ffab00',
guarded: '#ff7043',
event: '#ce93d8',
boss: '#d500f9',
online: '#ffffff',
@@ -67,6 +68,8 @@ const WORLD_NAMES = {
crate: 'Locked crate',
// Dangerous Treasures (§14.3.4): its running chests.
treasure: 'Dangerous Treasures chest',
// Guarded Crate (§14.3.5): a crate whose guards still stand.
guarded: 'Guarded crate',
boss: 'Boss',
}

View File

@@ -55,6 +55,10 @@ export const FEED_KINDS = Object.freeze([
// ending (`dt.ended`) is staff's, and the map shows the chest go.
'dt.started',
'dt.won',
// Guarded Crate (§14.3.5): a crate landing, and one cleared, read per viewer
// like a treasure. A timeout (`gc.failed`) is staff's; the map shows it go.
'gc.started',
'gc.ended',
])
/** The filters the feed offers, and the kinds each one asks the API for. */
@@ -69,7 +73,7 @@ export const FILTERS = Object.freeze([
},
{ id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] },
{ id: 'bosses', label: 'Bosses', kinds: ['npc.boss.spawned', 'npc.boss.died'] },
{ id: 'world', label: 'World events', kinds: ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won'] },
{ id: 'world', label: 'World events', kinds: ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended'] },
])
export function kindsFor(filterId) {
@@ -168,6 +172,11 @@ export function describe(row) {
case 'dt.ended':
return treasureLine(row.kind, frame)
case 'gc.started':
case 'gc.ended':
case 'gc.failed':
return guardedLine(row.kind, frame)
default:
return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' }
}
@@ -377,4 +386,25 @@ function treasureLine(kind, frame) {
: { tone: 'kill', actor: null, verb: 'The treasure was won', subject: null, detail }
}
/**
* Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5). The server has read the
* frame for this viewer: `grid`, `won` (whether a player killed the last
* guard), and the clearer's `name` only for the presence audience (D376).
*/
function guardedLine(kind, frame) {
const detail = frame.grid || ''
if (kind === 'gc.started') {
return { tone: 'server', actor: null, verb: 'A guarded crate landed', subject: null, detail }
}
if (kind === 'gc.failed') {
return { tone: 'server', actor: null, verb: 'A guarded crate timed out with its guards standing', subject: null, detail }
}
if (frame.won === false) {
return { tone: 'server', actor: null, verb: 'A guarded crate ended with nobody clearing it', subject: null, detail }
}
return frame.name
? { tone: 'kill', actor: frame.name, verb: 'cleared the guarded crate', subject: null, detail }
: { tone: 'kill', actor: null, verb: 'The guarded crate was cleared', subject: null, detail }
}
export default { describe, FEED_KINDS, FILTERS, kindsFor }

View File

@@ -264,7 +264,29 @@ export function integrationsLine(r) {
const withBackpacks = r.integrations.backpacks ? `${withEconomics} ${backpacksLine(r.integrations.backpacks)}` : withEconomics
const withTruePve = r.integrations.truePve ? `${withBackpacks} ${truePveLine(r.integrations.truePve)}` : withBackpacks
const withDynamicPvp = r.integrations.dynamicPvp ? `${withTruePve} ${dynamicPvpLine(r.integrations.dynamicPvp)}` : withTruePve
return r.integrations.dangerousTreasures ? `${withDynamicPvp} ${dangerousTreasuresLine(r.integrations.dangerousTreasures, r.console)}` : withDynamicPvp
const withDt = r.integrations.dangerousTreasures ? `${withDynamicPvp} ${dangerousTreasuresLine(r.integrations.dangerousTreasures, r.console)}` : withDynamicPvp
return r.integrations.guardedCrate ? `${withDt} ${guardedCrateLine(r.integrations.guardedCrate, r.console)}` : withDt
}
/**
* Guarded Crate (PLAN_REDESIGNS §14.3.5), from protocol 14, with NpcSpawn beside it
* (D364): its crates on the feed and the map, and whether a step can place one,
* which needs NpcSpawn for the guards and the framework to run its console command.
*/
export function guardedCrateLine(gc, probe) {
const npc = gc.npcSpawn && gc.npcSpawn.loaded ? `NpcSpawn ${gc.npcSpawn.version || ''}`.trim() : null
if (!gc.loaded) {
return `Guarded Crate is not loaded — a step cannot place a guarded crate.${npc ? ` ${npc} is loaded.` : ''}`
}
const name = `Guarded Crate ${gc.version || ''}`.trim()
const events = Array.isArray(gc.events) && gc.events.length ? ` Its events: ${gc.events.join(', ')}.` : ''
if (!npc) {
return `${name} is loaded but NpcSpawn is missing: its events cannot start, and a step cannot place one.`
}
if (probe && probe.plugins === false) {
return `${name} is loaded with ${npc} — its crates show on the feed and the map, but this framework cannot run its console command, so a step cannot place one (Rust-Plugins#34).${events}`
}
return `${name} is loaded with ${npc} — its crates show on the feed and the map, and a step may place one.${events}`
}
/**

View File

@@ -203,9 +203,10 @@ test('every type the server knows has its own words, and an unknown one says its
assert.equal(describe(world({ event: 'meteor.landed' })).verb, 'meteor.landed')
})
test('the world filter asks for world events, Dynamic PVP zones opening and Dangerous Treasures, which the server serves publicly', () => {
assert.deepEqual(kindsFor('world'), ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won'])
test('the world filter asks for world events, Dynamic PVP zones opening, Dangerous Treasures and Guarded Crate, which the server serves publicly', () => {
assert.deepEqual(kindsFor('world'), ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended'])
assert.ok(!FEED_KINDS.includes('dt.ended'), 'a treasure event ending is a staff line')
assert.ok(!FEED_KINDS.includes('gc.failed'), 'a guarded crate timing out is a staff line')
assert.ok(FEED_KINDS.includes('pvp.zone.created'))
assert.ok(!FEED_KINDS.includes('pvp.zone.deleted'), 'a zone closing is a staff line')
})
@@ -271,3 +272,30 @@ test('a win names its winner only when the server sent the name, and says how ma
test('a treasure event ending, for a staff reader, is its own line', () => {
assert.equal(describe(treasure('dt.ended', { grid: 'K12' })).verb, 'A Dangerous Treasures event has ended')
})
// ── Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5) ───────────────────
const guarded = (kind, frame) => row(kind, { kind, ...frame })
test('a guarded crate landing says where, and names nobody', () => {
const line = describe(guarded('gc.started', { grid: 'M4' }))
assert.equal(line.verb, 'A guarded crate landed')
assert.equal(line.detail, 'M4')
assert.equal(line.actor, null)
})
test('a clearing names its clearer only when the server sent the name', () => {
const named = describe(guarded('gc.ended', { grid: 'M4', won: true, name: 'Alice' }))
assert.equal(named.actor, 'Alice')
assert.equal(named.verb, 'cleared the guarded crate')
assert.equal(named.detail, 'M4')
const unnamed = describe(guarded('gc.ended', { grid: 'M4', won: true }))
assert.equal(unnamed.actor, null)
assert.equal(unnamed.verb, 'The guarded crate was cleared')
})
test('an ending with nobody clearing it, and a timeout for a staff reader, are their own lines', () => {
assert.equal(describe(guarded('gc.ended', { grid: 'M4', won: false })).verb, 'A guarded crate ended with nobody clearing it')
assert.equal(describe(guarded('gc.failed', { grid: 'M4' })).verb, 'A guarded crate timed out with its guards standing')
})

View File

@@ -698,6 +698,142 @@
}
]
},
{
"id": "rust.gc.ended",
"label": "A guarded crate was cleared",
"description": "A player killed the last guard of a guarded crate on a server.",
"kind": "event",
"subjectKey": "subject",
"audience": "subscribers",
"ceiling": "everyone",
"version": 1,
"variables": [
{
"name": "serverId",
"type": "string",
"required": true,
"example": "main",
"description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
},
{
"name": "server",
"type": "string",
"required": true,
"example": "Runic Gateway | Main",
"description": "The server's display name."
},
{
"name": "serverUrl",
"type": "url",
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "subject",
"type": "string",
"required": true,
"example": "main:gc.ended",
"description": "The server and the event. The cooldown subject; not meant for display."
},
{
"name": "grid",
"type": "string",
"required": false,
"example": "G7",
"description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map."
},
{
"name": "player",
"type": "string",
"required": true,
"example": "Marisol",
"description": "Who killed the last guard, by in-game name."
}
]
},
{
"id": "rust.gc.started",
"label": "A guarded crate landed",
"description": "Guarded Crate dropped a locked crate ringed by guards on a server: on its own schedule, by an admin, or by an event.",
"kind": "event",
"subjectKey": "subject",
"audience": "subscribers",
"ceiling": "everyone",
"version": 1,
"variables": [
{
"name": "serverId",
"type": "string",
"required": true,
"example": "main",
"description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
},
{
"name": "server",
"type": "string",
"required": true,
"example": "Runic Gateway | Main",
"description": "The server's display name."
},
{
"name": "serverUrl",
"type": "url",
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "subject",
"type": "string",
"required": true,
"example": "main:gc.started",
"description": "The server and the event. The cooldown subject; not meant for display."
},
{
"name": "grid",
"type": "string",
"required": false,
"example": "G7",
"description": "The map grid square. Absent when the server has no map geometry yet, or the event is off the map."
},
{
"name": "byEvent",
"type": "boolean",
"required": true,
"example": false,
"description": "Whether one of this site's events placed it (a Place a guarded crate step)."
}
]
},
{
"id": "rust.kit.entitled",
"label": "An event rewarded you a kit",

View File

@@ -76,6 +76,12 @@ const PUBLIC_KINDS = Object.freeze([
// for the presence audience, Steam ids and the position only for staff.
'dt.started',
'dt.won',
// Protocol 14: a guarded crate landed, and was cleared (PLAN_REDESIGNS
// §14.3.5). Public as kinds, read per viewer by `guardedCrate.shapeFor` as a
// treasure is: the clearer's name only for the presence audience, the Steam id
// and the position only for staff.
'gc.started',
'gc.ended',
])
/**
@@ -144,6 +150,9 @@ const STAFF_KINDS = Object.freeze([
// Protocol 14 (§14.3.4): a Dangerous Treasures event ended, won or not. The
// plan keeps it staff's; the map shows the chest go.
'dt.ended',
// Protocol 14 (§14.3.5): a guarded crate timed out with guards still standing.
// The plan keeps it staff's; the map shows the crate go.
'gc.failed',
])
/**

View File

@@ -1390,3 +1390,7 @@ ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS credit_rule VARCHAR(8) NOT NUL
-- Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4, D375): the treasure
-- chests a player emptied, from the tally like the others (Treasure Hunter).
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS treasures_won INT UNSIGNED NOT NULL DEFAULT 0;
-- Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5, D375): the guarded crates
-- a player cleared by killing the last guard, from the tally like the others (Warden).
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS guarded_won INT UNSIGNED NOT NULL DEFAULT 0;

View File

@@ -187,6 +187,15 @@ const HEADLINES = Object.freeze({
intro: `${d.player || 'Somebody'} won the treasure${d.grid ? ` at ${d.grid}` : ''} on ${d.server}`
+ `${d.invaders > 1 ? `, with ${d.invaders} players fighting for it` : ''}.`,
}),
// Guarded Crate (protocol 14, §14.3.5). A missing grid drops out.
'rust.gc.started': (d) => ({
title: `A guarded crate landed on ${d.server}`,
intro: `A guarded crate landed${d.grid ? ` at ${d.grid}` : ''} on ${d.server}. Kill its guards to claim it.`,
}),
'rust.gc.ended': (d) => ({
title: `The guarded crate was cleared on ${d.server}`,
intro: `${d.player} cleared the guarded crate${d.grid ? ` at ${d.grid}` : ''} on ${d.server}.`,
}),
'rust.boss.killed': (d) => ({
title: `${d.boss} was killed on ${d.server}`,
intro: `${d.killer ? `${d.killer} landed the killing blow` : `${d.boss} fell`}`
@@ -605,9 +614,41 @@ async function onDangerousTreasures(server, item, frame) {
}) ? 1 : 0
}
/**
* Guarded Crate (protocol 14, §14.3.5): a crate landed, and one cleared. As a
* treasure's are: public news, a replay past a broadcast's window tells nobody
* (D63), the clearer by in-game name only. An ending with nobody named, and a
* timeout, raise nothing.
*/
async function onGuardedCrate(server, item, frame) {
const t = frameTime(item, frame)
if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
if (frame.kind === 'gc.ended' && !str(frame.name)) return 0
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
const data = { ...serverVars(server), subject: `${server.id}:${frame.kind}`, ...(grid ? { grid } : {}) }
if (frame.kind === 'gc.started') {
return fire(T['rust.gc.started'], {
data: { ...data, byEvent: Boolean(str(frame.key)) },
dedupeKey: dedupeKey('gc.started', server.id, t, frame.x, frame.z),
occurredAt: t,
}) ? 1 : 0
}
return fire(T['rust.gc.ended'], {
data: { ...data, player: str(frame.name) },
dedupeKey: dedupeKey('gc.ended', server.id, t, frame.steamId, frame.x, frame.z),
occurredAt: t,
}) ? 1 : 0
}
const HANDLERS = Object.freeze({
'dt.started': onDangerousTreasures,
'dt.won': onDangerousTreasures,
'gc.started': onGuardedCrate,
'gc.ended': onGuardedCrate,
'world.event': onWorld,
'npc.boss.spawned': onBoss,
'npc.boss.phase': onBoss,

View File

@@ -669,7 +669,50 @@ const DANGEROUS_TREASURES = [
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES, ...WORLD, ...DANGEROUS_TREASURES])
// ── Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5, §14.3.9) ────────────
//
// A guarded crate landing and being cleared are triggers, as Dangerous
// Treasures' are: public news, the server the cooldown subject. `player` is the
// clearer's in-game name only. A crate whose guards went with nobody killing
// the last (NpcSpawn reloaded under it) and one that timed out raise nothing:
// neither is news.
const GC_BY_EVENT = { name: 'byEvent', type: 'boolean', required: true, example: false,
description: 'Whether one of this site\'s events placed it (a Place a guarded crate step).' }
const GUARDED_CRATE = [
{
id: 'rust.gc.started',
label: 'A guarded crate landed',
description: 'Guarded Crate dropped a locked crate ringed by guards on a server: on its own schedule, by an admin, or by an event.',
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE, { ...DT_SUBJECT, example: 'main:gc.started' }, WORLD_GRID, GC_BY_EVENT],
},
{
id: 'rust.gc.ended',
label: 'A guarded crate was cleared',
description: 'A player killed the last guard of a guarded crate on a server.',
kind: 'event',
subjectKey: 'subject',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ ...DT_SUBJECT, example: 'main:gc.ended' },
WORLD_GRID,
{ name: 'player', type: 'string', required: true, example: 'Marisol',
description: 'Who killed the last guard, by in-game name.' },
],
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES, ...WORLD, ...DANGEROUS_TREASURES, ...GUARDED_CRATE])
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))

193
server/eventGuardedCrate.js Normal file
View File

@@ -0,0 +1,193 @@
// ── 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 }

97
server/guardedCrate.js Normal file
View File

@@ -0,0 +1,97 @@
// ── Guarded Crate, with NpcSpawn (protocol 14, PLAN_REDESIGNS §14.3.5) ─────
//
// Bazz3l's Guarded Crate drops a locked crate from a cargo plane and rings it
// with NpcSpawn's scientists; whoever kills the last guard has cleared it. Its
// events reach this module as three kinds: `gc.started` (public), `gc.ended`
// (public) and `gc.failed` (staff's, the timeout). The crate cleared is
// *Warden*'s count, from the tally's `guardedWon` (D375). A run's step places
// one (`eventGuardedCrate.js`).
//
// ── Who may read what ──────────────────────────────────────────────────────
//
// As Dangerous Treasures' are (`dangerousTreasures.js`, D376): the event and its
// grid are public; the clearer's name shows only to the operator's presence
// audience, so with presence left at staff a signed-out visitor reads "The
// guarded crate was cleared · M4"; the Steam id, the position and a step's key
// are staff's. Whether a player cleared it at all is public (`won`), since an
// ending with nobody named (NpcSpawn reloaded under it) names nobody either.
const { gridOf } = require('./worldEvents')
const KINDS = Object.freeze(['gc.started', 'gc.ended', 'gc.failed'])
const STEAM_ID = /^\d{17}$/
function isKind(kind) {
return KINDS.includes(kind)
}
/**
* One `gc.*` frame for one viewer: `{ staff, presence, geometry }`. Passes the
* envelope, adds `grid`; an ending adds `won`, and `name` for the presence
* audience. Staff also get `x`, `z`, the step's `key` and the clearer's
* `steamId`. Any other frame is returned as is.
*/
function shapeFor(frame, { staff = false, presence = false, geometry = null } = {}) {
if (!frame || !isKind(frame.kind)) return frame
const out = {}
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
const grid = gridOf(geometry, frame.x, frame.z)
if (grid) out.grid = grid
if (frame.kind === 'gc.ended') {
out.won = STEAM_ID.test(String(frame.steamId || ''))
if (out.won && (presence || staff) && typeof frame.name === 'string' && frame.name) out.name = frame.name
if (out.won && staff) out.steamId = String(frame.steamId)
}
if (staff) {
for (const key of ['x', 'z', 'key']) {
if (frame[key] !== undefined) out[key] = frame[key]
}
}
return out
}
/**
* A server's last report about Guarded Crate and its console, from
* `servers.db.gcReportFor`: `{ guardedCrate, consolePlugins }`, each null where
* the plugin never said (no state yet, or a plugin before protocol 14).
* `guardedCrate` is `{ loaded, version, npcSpawn: { loaded, version }, events }`;
* `npcSpawn` is null where it was not said, and `events` null where the
* plugin could not read Guarded Crate's data file.
*/
function reportOf(row) {
const parse = (value) => {
if (value === null || value === undefined) return null
if (typeof value !== 'string') return value
try {
return JSON.parse(value)
} catch {
return null
}
}
const integration = (body) =>
body && typeof body === 'object' ? { loaded: body.loaded === true, version: typeof body.version === 'string' ? body.version : null } : null
const gc = row ? parse(row.guardedCrate) : null
const probe = row ? parse(row.console) : null
return {
guardedCrate:
gc && typeof gc === 'object'
? {
...integration(gc),
npcSpawn: integration(gc.npcSpawn),
events: Array.isArray(gc.events) ? gc.events.filter((e) => typeof e === 'string' && e.trim()) : null,
}
: null,
consolePlugins: probe && typeof probe === 'object' && typeof probe.plugins === 'boolean' ? probe.plugins : null,
}
}
module.exports = { KINDS, isKind, shapeFor, reportOf }

View File

@@ -61,6 +61,7 @@ module.exports = function register(ctx, api) {
const eventRewards = require('./eventRewards')
const eventWorldCall = require('./eventWorldCall')
const eventDangerousTreasures = require('./eventDangerousTreasures')
const eventGuardedCrate = require('./eventGuardedCrate')
const eventPermRewards = require('./eventPermRewards')
const eventTemplates = require('./eventTemplates')
const boot = require('./boot')
@@ -178,9 +179,10 @@ module.exports = function register(ctx, api) {
//
// Calling one of Rust's own world events (PLAN_REDESIGNS.md §14.3.1, protocol
// 14) beside them: kept when the run ends, on a budget of its own. Starting a
// Dangerous Treasures event (§14.3.4) likewise, on its own budget.
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS, ...eventDangerousTreasures.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS, ...eventDangerousTreasures.ACTIONS])
// Dangerous Treasures event (§14.3.4) and placing a guarded crate (§14.3.5)
// likewise, each on its own budget.
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS, ...eventDangerousTreasures.BUDGETS, ...eventGuardedCrate.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS, ...eventDangerousTreasures.ACTIONS, ...eventGuardedCrate.ACTIONS])
// The templates (PLAN_REDESIGNS.md §2.5, MODULE_API 1.13.0): a kit weekend
// as a starting point on core's New event screen. Core keeps nothing about a
@@ -201,6 +203,7 @@ module.exports = function register(ctx, api) {
...eventPermRewards.OPTION_SOURCES,
...eventWorldCall.OPTION_SOURCES,
...eventDangerousTreasures.OPTION_SOURCES,
...eventGuardedCrate.OPTION_SOURCES,
])
// The slash commands (phase 16, R11). The handlers run HERE, in the website
@@ -219,10 +222,10 @@ module.exports = function register(ctx, api) {
streams: STREAMS.length,
audiences: AUDIENCES.length,
leases: eventLeases.LEASES.length,
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length + eventDangerousTreasures.ACTIONS.length,
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length,
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length + eventDangerousTreasures.ACTIONS.length + eventGuardedCrate.ACTIONS.length,
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length + eventGuardedCrate.BUDGETS.length,
announceLeg: eventRewards.LEG.leg,
commands: commands.map((cmd) => cmd.name).join(' '),
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length + eventDangerousTreasures.OPTION_SOURCES.length,
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length + eventDangerousTreasures.OPTION_SOURCES.length + eventGuardedCrate.OPTION_SOURCES.length,
})
}

View File

@@ -153,6 +153,8 @@ const TITLE_SUMS = {
dropsLooted: 'drops_looted',
// Protocol 14 (D375): Treasure Hunter.
treasuresWon: 'treasures_won',
// Protocol 14 (D375): Warden.
guardedWon: 'guarded_won',
}
/** The two *best* fields and their columns: the longest single kill, never a sum. */
@@ -354,6 +356,8 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
dropsLooted: 'drops_looted',
// Protocol 14 (D375): Dangerous Treasures won, Treasure Hunter's column.
treasuresWon: 'treasures_won',
// Protocol 14 (D375): guarded crates cleared, Warden's column.
guardedWon: 'guarded_won',
}[sort] || 'kills'
const params = [serverId]
@@ -381,6 +385,7 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
SUM(s.crates_hacked) AS cratesHacked,
SUM(s.drops_looted) AS dropsLooted,
SUM(s.treasures_won) AS treasuresWon,
SUM(s.guarded_won) AS guardedWon,
MAX(s.balance) AS balance,
MAX(s.last_seen) AS lastSeen
FROM ${STATS} s

View File

@@ -16,6 +16,7 @@ const catalogue = require('../../catalogue')
const worldEvents = require('../../worldEvents')
const dynamicPvp = require('../../dynamicPvp')
const dangerousTreasures = require('../../dangerousTreasures')
const guardedCrate = require('../../guardedCrate')
const db = require('./events.db')
const mapDb = require('../map/map.db')
const serversDb = require('../servers/servers.db')
@@ -71,20 +72,22 @@ async function recent({ serverId, admin = false, staff = false, presence = false
})
const shaped = rows.map(shape)
if (!shaped.some((r) => r.kind === 'world.event' || dynamicPvp.KINDS.includes(r.kind) || dangerousTreasures.isKind(r.kind))) return shaped
if (!shaped.some((r) => r.kind === 'world.event' || dynamicPvp.KINDS.includes(r.kind) || dangerousTreasures.isKind(r.kind) || guardedCrate.isKind(r.kind))) return shaped
// Protocol 14 (§14.3.1): each world event is read for THIS viewer — its type
// allowed, its names to the presence audience, its Steam ids to staff, its
// credit by the server's rule — and given a grid from the map's geometry.
// Dynamic PVP's zone lines (§14.3.3) are read the same way for their grid and
// position, and Dangerous Treasures' (§14.3.4) for their grid, the winner's
// name and the Steam ids. Both reads only when the page holds one of them at all.
// name and the Steam ids, and Guarded Crate's (§14.3.5) alike. Both reads only
// when the page holds one of them at all.
const view = await worldView(serverId, { staff: staff || admin, presence })
return shaped
.map((row) => {
if (row.kind === 'world.event') return worldRow(row, view)
if (dynamicPvp.KINDS.includes(row.kind)) return pvpRow(row, view)
if (dangerousTreasures.isKind(row.kind)) return dtRow(row, view)
if (guardedCrate.isKind(row.kind)) return gcRow(row, view)
return row
})
.filter(Boolean)
@@ -116,6 +119,11 @@ function dtRow(row, view) {
return { ...row, steamId: view.staff ? row.steamId : null, frame: dangerousTreasures.shapeFor(row.frame, view) }
}
/** One Guarded Crate row for a viewer (§14.3.5). The row's own Steam id, the clearer's, is staff's. */
function gcRow(row, view) {
return { ...row, steamId: view.staff ? row.steamId : null, frame: guardedCrate.shapeFor(row.frame, view) }
}
/** One world-event row for a viewer, or null when they may not see it. The row's own Steam id is staff's too. */
function worldRow(row, view) {
const frame = worldEvents.shapeFor(row.frame, view)
@@ -183,6 +191,8 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, pre
dropsLooted: Number(r.dropsLooted) || 0,
// Protocol 14 (D375): Treasure Hunter's count.
treasuresWon: Number(r.treasuresWon) || 0,
// Protocol 14 (D375): Warden's count.
guardedWon: Number(r.guardedWon) || 0,
// Withheld below the presence audience. A tally refreshes it every minute a
// player is on, so a `lastSeen` of forty seconds ago is the Online tab by
// another name. The ORDER still uses it as a tie-break — that says who was

View File

@@ -275,10 +275,42 @@ async function dtReportFor(serverId) {
return rows[0] || null
}
/**
* What one server's last status said about Guarded Crate (with NpcSpawn and its
* event names) and its console (protocol 14, PLAN_REDESIGNS §14.3.5):
* `{ guardedCrate, console }`, as `dtReportFor`'s are. Null for a server with no
* state yet.
*/
async function gcReportFor(serverId) {
const rows = await core.query(
`SELECT JSON_EXTRACT(raw, '$.integrations.guardedCrate') AS guardedCrate,
JSON_EXTRACT(raw, '$.console') AS console
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/** `gcReportFor` for every enabled server, with its id and name: the event list a step offers. */
async function gcReports() {
return core.query(
`SELECT s.id AS serverId, s.name AS name,
JSON_EXTRACT(st.raw, '$.integrations.guardedCrate') AS guardedCrate,
JSON_EXTRACT(st.raw, '$.console') AS console
FROM ${SERVERS} s
JOIN ${STATE} st ON st.server_id = s.id
WHERE s.enabled = 1
ORDER BY s.sort_order ASC, s.id ASC`,
)
}
module.exports = {
SERVERS,
STATE,
dtReportFor,
gcReportFor,
gcReports,
listServers,
getServer,
upsertServer,

View File

@@ -22,7 +22,8 @@ const crypto = require('node:crypto')
* The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three
* conditions and their shipped titles, the richest (§11.2.2, Magnate), the
* two world-event counts (§14.3.1, Safecracker and Scavenger), Dangerous
* Treasures won (§14.3.4, Treasure Hunter), plus
* Treasures won (§14.3.4, Treasure Hunter), guarded crates cleared (§14.3.5,
* Warden), plus
* `playtime`, which predates them and has no default title, so a playtime rule
* always carries its own text.
*
@@ -72,6 +73,8 @@ const STATS = {
dropslooted: { label: 'Airdrops looted first', title: 'Scavenger', source: { sum: 'drops_looted' } },
// Protocol 14, Dangerous Treasures (D375): a chest counts for the player who emptied it.
treasureswon: { label: 'Dangerous Treasures won', title: 'Treasure Hunter', source: { sum: 'treasures_won' } },
// Protocol 14, Guarded Crate (D375): a crate counts for the player who killed its last guard.
guardedwon: { label: 'Guarded crates cleared', title: 'Warden', source: { sum: 'guarded_won' } },
richest: { label: 'Richest (Economics)', title: 'Magnate', source: { balance: 'balance' } },
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
profilekills: { label: 'Kills of an NPC profile', title: null, source: { npcProfile: true } },

View File

@@ -13,6 +13,7 @@ const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const dangerousTreasures = require('../../dangerousTreasures')
const guardedCrate = require('../../guardedCrate')
const economics = require('../../model/economics/economics.model')
const events = require('../../model/events/events.model')
const map = require('../../model/map/map.model')
@@ -123,6 +124,8 @@ async function listLeaderboard(req, res) {
// Treasure Hunter's column (D375): on a server that reports Dangerous
// Treasures, and on any page whose rows already count a treasure won.
const dt = dangerousTreasures.reportOf(await serversDb.dtReportFor(req.params.id).catch(() => null))
// Warden's column (D375), likewise for Guarded Crate.
const gc = guardedCrate.reportOf(await serversDb.gcReportFor(req.params.id).catch(() => null))
const withBalance = econ.loaded && wipeId ? { startingBalance: econ.startingBalance } : null
const [rows, held] = await Promise.all([
@@ -145,6 +148,7 @@ async function listLeaderboard(req, res) {
leaderboard: rows.map((row) => ({ ...row, titles: held.get(row.steamId) || [] })),
economics: withBalance,
dangerousTreasures: Boolean((dt.dangerousTreasures && dt.dangerousTreasures.loaded) || rows.some((r) => r.treasuresWon > 0)),
guardedCrate: Boolean((gc.guardedCrate && gc.guardedCrate.loaded) || rows.some((r) => r.guardedWon > 0)),
})
} catch (err) {
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })

View File

@@ -82,10 +82,10 @@ rustRouter.get(
'/servers/:id/leaderboard',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The leaderboard for one Rust server'
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336). Each row carries `treasuresWon` (Dangerous Treasures chests emptied, Treasure Hunter’s count, PLAN_REDESIGNS §14.3.4), and the answer carries `dangerousTreasures: true` where the server reports Dangerous Treasures or a row already counts one, which is when a page shows that column.'
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336). Each row carries `treasuresWon` (Dangerous Treasures chests emptied, Treasure Hunter’s count, PLAN_REDESIGNS §14.3.4), and the answer carries `dangerousTreasures: true` where the server reports Dangerous Treasures or a row already counts one, which is when a page shows that column. Likewise `guardedWon` (guarded crates cleared, Warden’s count, PLAN_REDESIGNS §14.3.5) and `guardedCrate: true` where the server reports Guarded Crate or a row already counts one.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, or balance (one wipe on a server with Economics)', schema: { type: 'string' } }
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, guardedWon, or balance (one wipe on a server with Economics)', schema: { type: 'string' } }
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'The leaderboard' } */
siteMode,

View File

@@ -443,6 +443,15 @@ const worldCall = (server, body) => request(server, '/world/call', { method: 'PO
*/
const dtStart = (server, body) => request(server, '/dt/start', { method: 'POST', body })
/**
* Place one guarded crate for a run's step (protocol 14, §20.9): `{ key, event }`
* and a location as a world call takes one (`monument` and offsets, or `x` and
* `z`). `data.kind` is `gc.placed` (with `event`, `x`, `z`, and `repeat: true`
* for a key the plugin has seen) or `gc.error` with a reason, `npcspawn-missing`
* among them (D364). The crate is kept when the run ends (D373): there is no revert.
*/
const gcPlace = (server, body) => request(server, '/gc/place', { method: 'POST', body })
/**
* Give back what a run owns: named ids, else everything under a key, else the
* whole run. `data` lists `removed`, `gone` (already not there — a success)
@@ -581,6 +590,7 @@ module.exports = {
worldPlace,
worldCall,
dtStart,
gcPlace,
worldRevert,
tallyOpen,
tallySnapshot,

View File

@@ -275,6 +275,20 @@ module.exports = {
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.4): where loaded, its treasure events reach the feed, the map and Treasure Hunter, and a step may start one (where `console.plugins` is true).',
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '3.0.0' } },
},
guardedCrate: {
type: 'object',
description: 'Protocol 14 (PLAN_REDESIGNS §14.3.5): where loaded, its guarded crates reach the feed, the map and Warden, and a step may place one (where NpcSpawn is loaded and `console.plugins` is true). `events` are the event names in its data file, absent when the file could not be read.',
properties: {
loaded: { type: 'boolean', example: true },
version: { type: 'string', example: '2.0.17' },
npcSpawn: {
type: 'object',
description: 'NpcSpawn, which its guards need (D364).',
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '3.4.8' } },
},
events: { type: 'array', items: { type: 'string' }, example: ['Easy', 'Medium', 'Hard', 'Elite'] },
},
},
},
},
console: {

View File

@@ -163,6 +163,12 @@ test('the classification covers exactly the event kinds the protocol defines, th
'dt.started',
'dt.won',
'dt.ended',
// Protocol 14 (§20.9): Guarded Crate. The landing and the clearing are
// public, read per viewer by `guardedCrate.js`; the timeout is staff's
// (PLAN_REDESIGNS §14.3.5).
'gc.started',
'gc.ended',
'gc.failed',
]
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
@@ -192,6 +198,11 @@ test('the classification covers exactly the event kinds the protocol defines, th
assert.equal(catalogue.isPresence(kind), false)
}
assert.equal(catalogue.isPublic('dt.ended'), false, 'a treasure event ending is a staff line')
for (const kind of ['gc.started', 'gc.ended']) {
assert.equal(catalogue.isPublic(kind), true)
assert.equal(catalogue.isPresence(kind), false)
}
assert.equal(catalogue.isPublic('gc.failed'), false, 'a guarded crate timing out is a staff line')
})
test('every kind that names a player who was on is behind the presence setting', () => {
@@ -217,6 +228,7 @@ test('every kind that names a player who was on is behind the presence setting',
// And what is left names nobody — a world event included, once `worldEvents.shapeFor`
// has taken its names off for a viewer below the presence audience (D376).
// Dynamic PVP's opening names nobody at all, and a Dangerous Treasures win names
// nobody once `dangerousTreasures.shapeFor` has read it for such a viewer.
assert.deepEqual(catalogue.kindsFor({}).sort(), ['dt.started', 'dt.won', 'npc.boss.spawned', 'pvp.zone.created', 'server.initialized', 'server.shutdown', 'server.wipe', 'world.event'])
// nobody once `dangerousTreasures.shapeFor` has read it for such a viewer, nor
// a guarded crate cleared once `guardedCrate.shapeFor` has.
assert.deepEqual(catalogue.kindsFor({}).sort(), ['dt.started', 'dt.won', 'gc.ended', 'gc.started', 'npc.boss.spawned', 'pvp.zone.created', 'server.initialized', 'server.shutdown', 'server.wipe', 'world.event'])
})

View File

@@ -160,6 +160,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
'rust.coins.pay',
'rust.crate.place',
'rust.dt.start',
'rust.gc.place',
'rust.kit.entitle',
'rust.npc.place',
'rust.participation.collect',
@@ -174,6 +175,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
'rust.backpacks',
'rust.coins',
'rust.dt.events',
'rust.gc.crates',
'rust.grants',
'rust.npcs',
'rust.permissions',
@@ -206,8 +208,9 @@ test('the event verbs are registered, and every budget has a verb that spends it
// collect filed, coins paid (D335) and permissions given for good (D346) are
// not undone, and nor is a world event called (D354): a cargo ship or a drop
// may already hold players or their loot; nor a Dangerous Treasures event
// started, which DT despawns itself (§14.3.4).
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give', 'rust.world.call', 'rust.dt.start'])
// started, which DT despawns itself (§14.3.4); nor a guarded crate placed,
// which Guarded Crate cannot end alone (§14.3.5, D373).
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give', 'rust.world.call', 'rust.dt.start', 'rust.gc.place'])
for (const a of actions) {
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
// Every param source is one this module registers.

View File

@@ -0,0 +1,368 @@
// ── Guarded Crate, with NpcSpawn (PLAN_REDESIGNS §14.3.5, protocol 14) ──────
//
// Each test is one way the feature could look right and be wrong:
//
// the clearer's name reaches a viewer below the presence audience (D376), or
// a Steam id, a position or a step's key reaches anybody but staff
// an ending with nobody clearing it reads as a win
// the step runs on a server that said it has no Guarded Crate, no NpcSpawn
// (D364), a framework that cannot run its command (D363), or no event of that
// name, or a server that has not said is refused
// the step's location is not a world call's, or the run log is not told the
// crate is kept (D373, D380)
// a refusal Guarded Crate would repeat is retried, or a crowded spot is not
// a retry of a key places a second crate
// the tally's crates cleared is dropped, or Warden's column and sort are missing
// a trigger declares a variable its emitter never fills, names a Steam id, or
// fires for a crate nobody cleared
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/** Installs a core whose `db.query` records every statement and answers `rows`. */
function withRecorder(rows = []) {
const statements = []
const ctx = fakeCtx({
db: {
query: (sql, params = []) => {
statements.push({ sql, params })
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return { ctx, statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) }
}
withRecorder()
const gc = require('../guardedCrate')
const { gridOf } = require('../worldEvents')
const { TRIGGERS } = require('../engagement/triggers')
const A = '76561198000000001'
const NOW = Date.now()
const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 }
const frame = (kind, over = {}) => ({ kind, type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', x: -604, z: -342, ...over })
const cleared = (over = {}) => frame('gc.ended', { steamId: A, name: 'Alice', ...over })
const REPORT = { loaded: true, version: '2.0.17', npcSpawn: { loaded: true, version: '3.4.8' }, events: ['Easy', 'Medium', 'Hard', 'Elite'] }
// ── Who may read what (D353, D376) ─────────────────────────────────────────
test('the public reads a guarded crate’s grid, never its position or the step’s key', () => {
const out = gc.shapeFor(frame('gc.started', { key: '7:1' }), { geometry: GEOMETRY })
assert.strictEqual(out.grid, gridOf(GEOMETRY, -604, -342))
assert.strictEqual(out.x, undefined)
assert.strictEqual(out.z, undefined)
assert.strictEqual(out.key, undefined)
assert.strictEqual(out.kind, 'gc.started')
})
test('a clearing names its clearer only to the presence audience, and the Steam id only to staff', () => {
const signedOut = gc.shapeFor(cleared(), { geometry: GEOMETRY })
assert.strictEqual(signedOut.won, true, 'that somebody cleared it names nobody')
assert.strictEqual(signedOut.name, undefined)
assert.strictEqual(signedOut.steamId, undefined)
const member = gc.shapeFor(cleared(), { presence: true, geometry: GEOMETRY })
assert.strictEqual(member.name, 'Alice')
assert.strictEqual(member.steamId, undefined)
const staff = gc.shapeFor(cleared({ key: '7:1' }), { staff: true, geometry: GEOMETRY })
assert.strictEqual(staff.name, 'Alice')
assert.strictEqual(staff.steamId, A)
assert.strictEqual(staff.x, -604)
assert.strictEqual(staff.key, '7:1')
})
test('an ending with nobody clearing it is not a win, for anybody', () => {
for (const view of [{}, { presence: true }, { staff: true }]) {
const out = gc.shapeFor(frame('gc.ended'), { ...view, geometry: GEOMETRY })
assert.strictEqual(out.won, false)
assert.strictEqual(out.name, undefined)
assert.strictEqual(out.steamId, undefined)
}
// A name without a Steam id is not a clearer either.
assert.strictEqual(gc.shapeFor(frame('gc.ended', { name: 'Alice' }), { staff: true }).name, undefined)
const other = { kind: 'player.death', x: 1 }
assert.strictEqual(gc.shapeFor(other, {}), other)
})
test('the feed reads a stored clearing for its viewer, the row’s own Steam id staff’s', async () => {
withRecorder((sql) => {
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'gc.ended', t: NOW, wipeId: 'w1', steamId: A, raw: JSON.stringify(cleared()) }]
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
return []
})
const events = require('../model/events/events.model')
const [signedOut] = await events.recent({ serverId: 'main', presence: false })
assert.strictEqual(signedOut.steamId, null)
assert.strictEqual(signedOut.frame.name, undefined)
assert.strictEqual(signedOut.frame.won, true)
assert.ok(signedOut.frame.grid)
const [staff] = await events.recent({ serverId: 'main', admin: true })
assert.strictEqual(staff.steamId, A)
assert.strictEqual(staff.frame.name, 'Alice')
})
test('a server’s report is read leniently: what it never said is null', () => {
assert.deepStrictEqual(gc.reportOf(null), { guardedCrate: null, consolePlugins: null })
assert.deepStrictEqual(gc.reportOf({ guardedCrate: JSON.stringify(REPORT), console: '{"plugins":false}' }), {
guardedCrate: REPORT,
consolePlugins: false,
})
// A plugin that could not read the data file sends no events; NpcSpawn unsaid is null.
assert.deepStrictEqual(gc.reportOf({ guardedCrate: '{"loaded":true,"version":"2.0.17"}', console: null }).guardedCrate, {
loaded: true,
version: '2.0.17',
npcSpawn: null,
events: null,
})
assert.deepStrictEqual(gc.reportOf({ guardedCrate: 'not json', console: '{}' }), { guardedCrate: null, consolePlugins: null })
})
// ── The step ───────────────────────────────────────────────────────────────
const SERVERS = {
main: { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' },
pve: { id: 'pve', name: 'PvE', enabled: 1, sidecarBaseUrl: 'http://pve:1' },
}
/** The step against servers `main` and `pve`, whose last reports are `reports`, and a sidecar answering `reply`. */
function stubPlace(t, { reports = { main: { guardedCrate: REPORT, console: { plugins: true } } }, reply } = {}) {
const row = (id) => {
const r = reports[id]
return r ? { serverId: id, name: SERVERS[id].name, guardedCrate: JSON.stringify(r.guardedCrate ?? null), console: JSON.stringify(r.console ?? null) } : null
}
withRecorder((sql, params) => {
if (sql.includes('JOIN rust_server_state')) return Object.keys(reports).map(row)
if (sql.includes('FROM rust_server_state')) return row(params[0]) ? [row(params[0])] : []
if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }]
return []
})
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const saved = { gcPlace: client.gcPlace, getServer: serversDb.getServer }
const calls = []
serversDb.getServer = async (id) => SERVERS[id] || null
client.gcPlace = async (server, body) => {
calls.push({ server: server.id, body })
return typeof reply === 'function' ? reply(body) : reply
}
t.after(() => {
client.gcPlace = saved.gcPlace
serversDb.getServer = saved.getServer
})
return calls
}
const module_ = () => require('../eventGuardedCrate')
const placeStep = () => module_().ACTIONS[0]
const ok = (data) => ({ ok: true, status: 'ok', data })
const placed = (over = {}) => ok({ kind: 'gc.placed', key: '7:1', event: 'Elite', repeat: false, x: -604, z: -342, ...over })
test('the step is kept, priced one crate each, and has no revert (D373)', () => {
const step = placeStep()
assert.strictEqual(step.id, 'rust.gc.place')
assert.strictEqual(step.reversible, 'none')
assert.strictEqual(step.risk, 'change')
assert.strictEqual(step.revert, undefined)
assert.deepStrictEqual(step.cost({ event: 'Elite', monument: 'main/powerplant_1' }), { 'rust.gc.crates': 1 })
assert.match(step.description, /kept when the run ends/)
})
test('the event list is every server’s own, once each, and Guarded Crate’s defaults where none has said', async (t) => {
const [events] = module_().OPTION_SOURCES
stubPlace(t, {
reports: {
main: { guardedCrate: REPORT, console: { plugins: true } },
pve: { guardedCrate: { ...REPORT, events: ['easy', 'Boss Drop'] }, console: { plugins: true } },
},
})
assert.deepStrictEqual(await events.resolve(), [
{ value: 'Easy', label: 'Easy — Main, PvE' },
{ value: 'Medium', label: 'Medium — Main' },
{ value: 'Hard', label: 'Hard — Main' },
{ value: 'Elite', label: 'Elite — Main' },
{ value: 'Boss Drop', label: 'Boss Drop — PvE' },
])
stubPlace(t, { reports: { main: { guardedCrate: { loaded: false }, console: null } } })
assert.deepStrictEqual((await events.resolve()).map((e) => e.value), ['Easy', 'Medium', 'Hard', 'Elite'])
})
test('the dry run refuses what the server said it cannot do, and nothing else (D364)', async (t) => {
const run = (params) => placeStep().perform({ runId: 7, idempotencyKey: '7:1', params, verify: true })
const at = { event: 'Elite', monument: 'main/powerplant_1' }
stubPlace(t, { reports: { main: { guardedCrate: { loaded: false }, console: { plugins: true } } } })
const absent = await run(at)
assert.strictEqual(absent.retry, false)
assert.match(absent.error, /Guarded Crate is not installed on Main/)
stubPlace(t, { reports: { main: { guardedCrate: { ...REPORT, npcSpawn: { loaded: false } }, console: { plugins: true } } } })
const noGuards = await run(at)
assert.strictEqual(noGuards.retry, false)
assert.strictEqual(noGuards.error, 'Guarded Crate is loaded on Main but NpcSpawn is missing: its events cannot start')
stubPlace(t, { reports: { main: { guardedCrate: REPORT, console: { plugins: false } } } })
const carbon = await run(at)
assert.match(carbon.error, /cannot run Guarded Crate's console command/)
assert.match(carbon.error, /Rust-Plugins#34/)
const calls = stubPlace(t)
const unknown = await run({ ...at, event: 'Legendary' })
assert.strictEqual(unknown.retry, false)
assert.match(unknown.error, /"Legendary" is not one of Guarded Crate's events on Main \(Easy, Medium, Hard, Elite\)/)
assert.ok((await run({ ...at, event: 'elite' })).ok, 'a name is found ignoring case, as Guarded Crate finds it')
assert.match((await run({ monument: 'main/powerplant_1' })).error, /which of Guarded Crate's events/)
assert.match((await run({ event: 'Elite', x: 1, z: 2 })).error, /which server/)
assert.match((await run({ event: 'Elite', monument: 'main/powerplant_1', x: 1, z: 2 })).error, /exactly one of them/)
assert.strictEqual(calls.length, 0, 'the dry run places nothing')
// A plugin before protocol 14, or a server never reached, has not said: the plugin answers.
stubPlace(t, { reports: {} })
assert.ok((await run(at)).ok)
// Nor does a report that could not read the event names refuse a name.
stubPlace(t, { reports: { main: { guardedCrate: { ...REPORT, events: undefined }, console: { plugins: true } } } })
assert.ok((await run({ ...at, event: 'Boss Drop' })).ok)
})
test('a placement sends the key, the event and a world call’s location, and tells the run log the crate is kept', async (t) => {
const calls = stubPlace(t, { reply: placed() })
const result = await placeStep().perform({
runId: 7,
idempotencyKey: '7:1',
params: { event: 'Elite', monument: 'main/powerplant_1', offsetX: 10, offsetZ: -5, y: 40 },
})
assert.ok(result.ok)
assert.deepStrictEqual(calls, [{ server: 'main', body: { key: '7:1', event: 'Elite', monument: 'powerplant_1', offsetX: 10, offsetZ: -5 } }])
assert.strictEqual(result.detail.event, 'Elite')
assert.strictEqual(result.detail.grid, gridOf(GEOMETRY, -604, -342))
assert.strictEqual(result.detail.note, `The crate is ${module_().KEPT}`)
const byPoint = stubPlace(t, { reply: placed() })
await placeStep().perform({ runId: 7, idempotencyKey: '7:2', params: { event: 'Easy', server: 'main', x: -604, z: -342 } })
assert.deepStrictEqual(byPoint[0].body, { key: '7:2', event: 'Easy', x: -604, z: -342 })
})
test('a crowded spot and a game that is down are retried; a refusal Guarded Crate would repeat is not', async (t) => {
stubPlace(t, {
reply: (body) => ok(body.event === 'Hard'
? { kind: 'gc.error', reason: 'gc-too-close', message: 'another guarded crate is running within 80 m of that spot' }
: { kind: 'gc.error', reason: 'npcspawn-missing', message: 'Guarded Crate is loaded but NpcSpawn is missing: its events cannot start' }),
})
const step = placeStep()
const params = (event) => ({ event, monument: 'main/powerplant_1' })
const crowded = await step.perform({ runId: 7, idempotencyKey: '7:3', params: params('Hard') })
assert.strictEqual(crowded.ok, false)
assert.strictEqual(crowded.retry, undefined)
assert.match(crowded.error, /within 80 m/)
const noGuards = await step.perform({ runId: 7, idempotencyKey: '7:4', params: params('Easy') })
assert.strictEqual(noGuards.retry, false)
assert.match(noGuards.error, /NpcSpawn is missing/)
for (const reason of ['gc-missing', 'npcspawn-missing', 'console-unavailable', 'bad-event', 'bad-position', 'refused', 'no-monument', 'off-map', 'malformed', 'events-disabled']) {
assert.ok(module_().PERMANENT.has(reason), reason)
}
assert.ok(!module_().PERMANENT.has('gc-too-close'))
stubPlace(t, { reply: { ok: false, status: 'http-503' } })
const down = await step.perform({ runId: 7, idempotencyKey: '7:5', params: params('Elite') })
assert.strictEqual(down.retry, undefined)
assert.match(down.error, /has no game connected/)
})
test('a repeat of a key places nothing new, and says so beside the crate being kept', async (t) => {
stubPlace(t, { reply: placed({ repeat: true }) })
const again = await placeStep().perform({ runId: 7, idempotencyKey: '7:1', params: { event: 'Elite', monument: 'main/powerplant_1' } })
assert.ok(again.ok)
assert.strictEqual(again.detail.repeat, true)
assert.match(again.detail.note, /^answered from the first attempt; nothing new was placed\. The crate is kept/)
})
// ── Warden (D375) ──────────────────────────────────────────────────────────
test('the tally’s crates cleared is added up', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', { id: 1, kind: 'player.tally', frame: { kind: 'player.tally', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', steamId: A, name: 'Alice', guardedWon: 2 } })
const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('guarded_won'))
assert.ok(titleAdd, 'the tally reached the title columns')
const cols = titleAdd.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+)\)/)[1].split(', ')
assert.strictEqual(titleAdd.params[3 + cols.indexOf('guarded_won')], 2)
})
test('the leaderboard sorts by crates cleared and carries the count on every row', async () => {
const rec = withRecorder((sql) => (sql.includes('FROM rust_player_wipe_stats') ? [{ steamId: A, name: 'Alice', kills: 1, guardedWon: '3' }] : []))
const events = require('../model/events/events.model')
const [row] = await events.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'guardedWon' })
assert.strictEqual(row.guardedWon, 3)
const board = rec.touching('rust_player_wipe_stats').at(-1)
assert.match(board.sql, /SUM\(s\.guarded_won\) AS guardedWon/)
assert.match(board.sql, /ORDER BY[^]*guarded_won/)
})
// ── Triggers (D355, §14.3.9) ───────────────────────────────────────────────
test('a landing and a clearing are public triggers, neither declaring a Steam id', () => {
const found = TRIGGERS.filter((t) => t.id.startsWith('rust.gc.'))
assert.deepStrictEqual(found.map((t) => t.id).sort(), ['rust.gc.ended', 'rust.gc.started'])
for (const t of found) {
assert.strictEqual(t.ceiling, 'everyone', t.id)
assert.strictEqual(t.subjectKey, 'subject', t.id)
assert.ok(!t.variables.some((v) => /steam/i.test(v.name)), `${t.id} declares a Steam id`)
}
})
test('a landing and a clearing fire with what they declare; nobody clearing it and a timeout fire nothing', async () => {
const { ctx } = withRecorder((sql) => (sql.includes('FROM rust_map_images') ? [{ serverId: 'main', ...GEOMETRY }] : []))
const emit = require('../engagement/emit')
emit.reset()
const server = { id: 'main', name: 'Main' }
await emit.onEvent(server, { id: 1, kind: 'gc.started', frame: frame('gc.started', { key: '7:1' }) })
await emit.onEvent(server, { id: 2, kind: 'gc.ended', frame: cleared() })
await emit.onEvent(server, { id: 3, kind: 'gc.ended', frame: frame('gc.ended') })
await emit.onEvent(server, { id: 4, kind: 'gc.failed', frame: frame('gc.failed') })
const calls = ctx.events.emit.calls
assert.strictEqual(calls.length, 2)
const [startedId, started] = calls[0]
assert.strictEqual(startedId, 'rust.gc.started')
assert.strictEqual(started.data.byEvent, true)
assert.strictEqual(started.data.subject, 'main:gc.started')
assert.match(started.data.intro, /A guarded crate landed at [A-Z]+\d+ on Main\./)
const [endedId, ended] = calls[1]
assert.strictEqual(endedId, 'rust.gc.ended')
assert.strictEqual(ended.data.player, 'Alice')
assert.match(ended.data.intro, /Alice cleared the guarded crate at [A-Z]+\d+ on Main\./)
for (const [id, envelope] of calls) {
const declared = new Set(TRIGGERS.find((x) => x.id === id).variables.map((v) => v.name))
for (const name of Object.keys(envelope.data)) assert.ok(declared.has(name), `${id} fills ${name}, which it does not declare`)
}
})
test('an old crate tells nobody (D63)', async () => {
const { ctx } = withRecorder()
const emit = require('../engagement/emit')
emit.reset()
await emit.onEvent({ id: 'main', name: 'Main' }, { id: 1, kind: 'gc.ended', frame: cleared({ t: NOW - 3 * 3600 * 1000 }) })
assert.strictEqual(ctx.events.emit.calls.length, 0)
})

View File

@@ -47,8 +47,9 @@ const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...
// ── The categories ────────────────────────────────────────────────────────
test('twenty-seven categories carry a default title, and playtime carries none (D172, §11.2.2, D356, D375)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 27)
test('twenty-eight categories carry a default title, and playtime carries none (D172, §11.2.2, D356, D375)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 28)
assert.strictEqual(titles.STATS.guardedwon.title, 'Warden')
assert.strictEqual(titles.STATS.treasureswon.title, 'Treasure Hunter')
assert.strictEqual(titles.STATS.richest.title, 'Magnate')
assert.strictEqual(titles.STATS.crateshacked.title, 'Safecracker')

View File

@@ -3258,7 +3258,7 @@
"Public · Rust"
],
"summary": "The leaderboard for one Rust server",
"description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336). Each row carries `treasuresWon` (Dangerous Treasures chests emptied, Treasure Hunter’s count, PLAN_REDESIGNS §14.3.4), and the answer carries `dangerousTreasures: true` where the server reports Dangerous Treasures or a row already counts one, which is when a page shows that column.",
"description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336). Each row carries `treasuresWon` (Dangerous Treasures chests emptied, Treasure Hunter’s count, PLAN_REDESIGNS §14.3.4), and the answer carries `dangerousTreasures: true` where the server reports Dangerous Treasures or a row already counts one, which is when a page shows that column. Likewise `guardedWon` (guarded crates cleared, Warden’s count, PLAN_REDESIGNS §14.3.5) and `guardedCrate: true` where the server reports Guarded Crate or a row already counts one.",
"parameters": [
{
"name": "id",
@@ -3282,7 +3282,7 @@
"name": "sort",
"in": "query",
"required": false,
"description": "kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, or balance (one wipe on a server with Economics)",
"description": "kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, guardedWon, or balance (one wipe on a server with Economics)",
"schema": {
"type": "string"
}
@@ -4921,6 +4921,7 @@
"crateshacked",
"dropslooted",
"treasureswon",
"guardedwon",
"richest",
"playtime",
"profilekills"
@@ -5460,6 +5461,124 @@
}
}
}
},
"guardedCrate": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Protocol 14 (PLAN_REDESIGNS §14.3.5): where loaded, its guarded crates reach the feed, the map and Warden, and a step may place one (where NpcSpawn is loaded and `console.plugins` is true). `events` are the event names in its data file, absent when the file could not be read."
},
"properties": {
"type": "object",
"properties": {
"loaded": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"version": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "2.0.17"
}
}
},
"npcSpawn": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "NpcSpawn, which its guards need (D364)."
},
"properties": {
"type": "object",
"properties": {
"loaded": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"version": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "3.4.8"
}
}
}
}
}
}
},
"events": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"example": {
"type": "array",
"example": [
"Easy",
"Medium",
"Hard",
"Elite"
],
"items": {
"type": "string"
}
}
}
}
}
}
}
}
}
}