From da1d74bdff155d5438b524836e3be36281632884 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 12:26:50 -0500 Subject: [PATCH] =?UTF-8?q?feat(rust):=20Guarded=20Crate=20=E2=80=94=20fee?= =?UTF-8?q?d,=20map,=20Warden,=20Place=20a=20guarded=20crate=20(PLAN=5FRED?= =?UTF-8?q?ESIGNS=20=C2=A714.3.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 9 + ci/bundle.json | 2 + client/src/components/Leaderboard.jsx | 5 + client/src/components/MapView.jsx | 3 + client/src/lib/feed.js | 32 ++- client/src/routes/admin/ChatTitles.jsx | 24 +- client/test/feed.test.js | 32 ++- engagement-triggers.json | 136 +++++++++ server/catalogue.js | 9 + server/db/schema.sql | 4 + server/engagement/emit.js | 41 +++ server/engagement/triggers.js | 45 ++- server/eventGuardedCrate.js | 193 +++++++++++++ server/guardedCrate.js | 97 +++++++ server/index.js | 15 +- server/model/events/events.db.js | 5 + server/model/events/events.model.js | 14 +- server/model/servers/servers.db.js | 32 +++ server/model/titles/titles.js | 5 +- server/router/public/rust.controller.js | 4 + server/router/public/rust.router.js | 4 +- server/sidecarClient.js | 10 + server/swagger/doc.js | 14 + server/test/catalogue.test.js | 16 +- server/test/entry.test.js | 7 +- server/test/guardedCrate.test.js | 368 ++++++++++++++++++++++++ server/test/titleConditions.test.js | 5 +- swagger-fragment.json | 123 +++++++- 28 files changed, 1230 insertions(+), 24 deletions(-) create mode 100644 server/eventGuardedCrate.js create mode 100644 server/guardedCrate.js create mode 100644 server/test/guardedCrate.test.js diff --git a/README.md b/README.md index 903480f..80f29cf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ci/bundle.json b/ci/bundle.json index eaa481f..2242708 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -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", diff --git a/client/src/components/Leaderboard.jsx b/client/src/components/Leaderboard.jsx index e8f7de9..3e921c5 100644 --- a/client/src/components/Leaderboard.jsx +++ b/client/src/components/Leaderboard.jsx @@ -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. diff --git a/client/src/components/MapView.jsx b/client/src/components/MapView.jsx index a711ff4..fe647f9 100644 --- a/client/src/components/MapView.jsx +++ b/client/src/components/MapView.jsx @@ -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', } diff --git a/client/src/lib/feed.js b/client/src/lib/feed.js index 1d16aea..1dff67d 100644 --- a/client/src/lib/feed.js +++ b/client/src/lib/feed.js @@ -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 } diff --git a/client/src/routes/admin/ChatTitles.jsx b/client/src/routes/admin/ChatTitles.jsx index 251dedb..e40622e 100644 --- a/client/src/routes/admin/ChatTitles.jsx +++ b/client/src/routes/admin/ChatTitles.jsx @@ -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}` } /** diff --git a/client/test/feed.test.js b/client/test/feed.test.js index e5773ee..3f96847 100644 --- a/client/test/feed.test.js +++ b/client/test/feed.test.js @@ -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') +}) diff --git a/engagement-triggers.json b/engagement-triggers.json index 9c3d3ad..b27617d 100644 --- a/engagement-triggers.json +++ b/engagement-triggers.json @@ -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", diff --git a/server/catalogue.js b/server/catalogue.js index 1f17c38..1fde088 100644 --- a/server/catalogue.js +++ b/server/catalogue.js @@ -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', ]) /** diff --git a/server/db/schema.sql b/server/db/schema.sql index c51dd31..a36efd8 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -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; diff --git a/server/engagement/emit.js b/server/engagement/emit.js index 02a111d..3d408c2 100644 --- a/server/engagement/emit.js +++ b/server/engagement/emit.js @@ -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, diff --git a/server/engagement/triggers.js b/server/engagement/triggers.js index 2252f52..4cca178 100644 --- a/server/engagement/triggers.js +++ b/server/engagement/triggers.js @@ -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]))) diff --git a/server/eventGuardedCrate.js b/server/eventGuardedCrate.js new file mode 100644 index 0000000..1e2ef5a --- /dev/null +++ b/server/eventGuardedCrate.js @@ -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 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 } diff --git a/server/guardedCrate.js b/server/guardedCrate.js new file mode 100644 index 0000000..237cad8 --- /dev/null +++ b/server/guardedCrate.js @@ -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 } diff --git a/server/index.js b/server/index.js index ff4d00e..1e10927 100644 --- a/server/index.js +++ b/server/index.js @@ -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, }) } diff --git a/server/model/events/events.db.js b/server/model/events/events.db.js index 2d8b764..737d4d1 100644 --- a/server/model/events/events.db.js +++ b/server/model/events/events.db.js @@ -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 diff --git a/server/model/events/events.model.js b/server/model/events/events.model.js index ed3d1d3..7c8fd6f 100644 --- a/server/model/events/events.model.js +++ b/server/model/events/events.model.js @@ -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 diff --git a/server/model/servers/servers.db.js b/server/model/servers/servers.db.js index 236507d..c7c9d96 100644 --- a/server/model/servers/servers.db.js +++ b/server/model/servers/servers.db.js @@ -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, diff --git a/server/model/titles/titles.js b/server/model/titles/titles.js index c62635c..daf8d23 100644 --- a/server/model/titles/titles.js +++ b/server/model/titles/titles.js @@ -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 } }, diff --git a/server/router/public/rust.controller.js b/server/router/public/rust.controller.js index f333198..43b32b3 100644 --- a/server/router/public/rust.controller.js +++ b/server/router/public/rust.controller.js @@ -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 }) diff --git a/server/router/public/rust.router.js b/server/router/public/rust.router.js index c49b96e..23433a4 100644 --- a/server/router/public/rust.router.js +++ b/server/router/public/rust.router.js @@ -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, diff --git a/server/sidecarClient.js b/server/sidecarClient.js index b542b8b..3ca1257 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -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, diff --git a/server/swagger/doc.js b/server/swagger/doc.js index 3b276fb..4f10d45 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -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: { diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js index eef015c..8b647dd 100644 --- a/server/test/catalogue.test.js +++ b/server/test/catalogue.test.js @@ -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']) }) diff --git a/server/test/entry.test.js b/server/test/entry.test.js index ede91de..bb421e6 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -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. diff --git a/server/test/guardedCrate.test.js b/server/test/guardedCrate.test.js new file mode 100644 index 0000000..3a728bb --- /dev/null +++ b/server/test/guardedCrate.test.js @@ -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) +}) diff --git a/server/test/titleConditions.test.js b/server/test/titleConditions.test.js index 888d158..4c75d12 100644 --- a/server/test/titleConditions.test.js +++ b/server/test/titleConditions.test.js @@ -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') diff --git a/swagger-fragment.json b/swagger-fragment.json index 31cae7a..13c922a 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -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" + } + } + } + } + } + } + } } } } -- 2.49.1