diff --git a/README.md b/README.md index 42caf1a..903480f 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,14 @@ one now, kept when the run ends: an airdrop or a patrol helicopter can be sent t **PvP zones** layer (public by default, set in Admin → Rust visibility) and as a public feed line — *"A PvP zone opened around the downed patrol helicopter · G7 · for 10 min"* — that names nobody. Its zones are its own: no step and no trigger (`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.3). +- **Dangerous Treasures** (nivex, 3.0.0). With it: a treasure event starting and being won are public + feed lines and triggers (*"Alice won the treasure · K12 · 3 players fought for it"*, the name only + for the presence audience), a running chest is on the live map's world layer, the **Treasures** + leaderboard column and the *Treasure Hunter* title count the chests a player emptied, and the step + **Start a Dangerous Treasures event** opens one at a chosen difficulty where DT picks. In the first + 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). 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 74c4d95..eaa481f 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -32,9 +32,11 @@ "commands", "configEdit.js", "core.js", + "dangerousTreasures.js", "db", "dynamicPvp.js", "engagement", + "eventDangerousTreasures.js", "eventLeases.js", "eventPermRewards.js", "eventProgress.js", diff --git a/ci/core-ref.json b/ci/core-ref.json index 9b0808a..cbe8047 100644 --- a/ci/core-ref.json +++ b/ci/core-ref.json @@ -1,6 +1,6 @@ { - "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree).", + "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as.", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", - "ref": "a981b2b854cf0609cd55be9275613914503b87a2", - "refName": "main @ MODULE_API 1.14.0 (website#213 merged)" + "ref": "eb773693d32781f2ec717f9be07816b6afefd162", + "refName": "main @ MODULE_API 1.15.0 (website#215 merged)" } diff --git a/client/src/components/Leaderboard.jsx b/client/src/components/Leaderboard.jsx index 0d9a525..e8f7de9 100644 --- a/client/src/components/Leaderboard.jsx +++ b/client/src/components/Leaderboard.jsx @@ -41,6 +41,11 @@ const COLUMNS = [ // so with `economics` on the answer — so the column is there exactly when the // rows carry it. Sorting by it ranks only balances above the starting balance // (D336), which is why a fresh wipe's sort shows nobody. +// Treasure Hunter (PLAN_REDESIGNS §14.3.4, D375): Dangerous Treasures won. The +// server says `dangerousTreasures` where the server reports the plugin, or the +// 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) } + const BALANCE = { key: 'balance', label: 'Coins', sort: 'balance', value: (r) => coins(r.balance) } export default function Leaderboard({ serverId, wipeId, sort, onSort }) { @@ -96,7 +101,11 @@ function Overall({ serverId, wipeId, sort, onSort }) { ) const rows = data ? data.leaderboard : [] - const columns = data && data.economics ? [...COLUMNS, BALANCE] : COLUMNS + const columns = [ + ...COLUMNS, + ...(data && data.dangerousTreasures ? [TREASURES] : []), + ...(data && data.economics ? [BALANCE] : []), + ] // Present on every row or on none — the server decides per request. const showLastSeen = rows.some((row) => 'lastSeen' in row) diff --git a/client/src/components/MapView.jsx b/client/src/components/MapView.jsx index 8a91b66..a711ff4 100644 --- a/client/src/components/MapView.jsx +++ b/client/src/components/MapView.jsx @@ -46,6 +46,7 @@ const COLOURS = { bradley: '#a1887f', supply: '#66bb6a', crate: '#ffee58', + treasure: '#ffab00', event: '#ce93d8', boss: '#d500f9', online: '#ffffff', @@ -64,6 +65,8 @@ const WORLD_NAMES = { bradley: 'Bradley APC', supply: 'Supply drop', crate: 'Locked crate', + // Dangerous Treasures (§14.3.4): its running chests. + treasure: 'Dangerous Treasures chest', boss: 'Boss', } diff --git a/client/src/lib/feed.js b/client/src/lib/feed.js index 3323dad..1d16aea 100644 --- a/client/src/lib/feed.js +++ b/client/src/lib/feed.js @@ -50,6 +50,11 @@ export const FEED_KINDS = Object.freeze([ // Dynamic PVP's zones opening (§14.3.3). A closing (`pvp.zone.deleted`) is // staff's and is not asked for here; the map shows a zone go. 'pvp.zone.created', + // Dangerous Treasures (§14.3.4): a chest opening, and one won. The server reads + // each for the viewer, so the winner's name is here only where it may be; the + // ending (`dt.ended`) is staff's, and the map shows the chest go. + 'dt.started', + 'dt.won', ]) /** The filters the feed offers, and the kinds each one asks the API for. */ @@ -64,7 +69,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'] }, + { id: 'world', label: 'World events', kinds: ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won'] }, ]) export function kindsFor(filterId) { @@ -158,6 +163,11 @@ export function describe(row) { case 'pvp.zone.deleted': return pvpLine(row.kind, frame) + case 'dt.started': + case 'dt.won': + case 'dt.ended': + return treasureLine(row.kind, frame) + default: return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' } } @@ -346,4 +356,25 @@ function pvpLine(kind, frame) { return { tone: 'kill', actor: null, verb: `A PvP zone opened${around}`, subject: null, detail } } +/** + * Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4). The server has read + * the frame for this viewer: `grid`, `invaderCount`, and the winner's `name` only + * for the presence audience (D376). + */ +function treasureLine(kind, frame) { + const grid = frame.grid || '' + if (kind === 'dt.started') { + return { tone: 'server', actor: null, verb: 'A Dangerous Treasures event has started', subject: null, detail: grid } + } + if (kind === 'dt.ended') { + return { tone: 'server', actor: null, verb: 'A Dangerous Treasures event has ended', subject: null, detail: grid } + } + const n = Number(frame.invaderCount) + const fought = n > 1 ? `${n} players fought for it` : '' + const detail = [grid || null, fought || null].filter(Boolean).join(' · ') + return frame.name + ? { tone: 'kill', actor: frame.name, verb: 'won the treasure', subject: null, detail } + : { tone: 'kill', actor: null, verb: 'The treasure was won', 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 d0e03b1..251dedb 100644 --- a/client/src/routes/admin/ChatTitles.jsx +++ b/client/src/routes/admin/ChatTitles.jsx @@ -263,7 +263,22 @@ export function integrationsLine(r) { const withEconomics = r.integrations.economics ? `${line} ${economicsLine(r.integrations.economics)}` : line const withBackpacks = r.integrations.backpacks ? `${withEconomics} ${backpacksLine(r.integrations.backpacks)}` : withEconomics const withTruePve = r.integrations.truePve ? `${withBackpacks} ${truePveLine(r.integrations.truePve)}` : withBackpacks - return r.integrations.dynamicPvp ? `${withTruePve} ${dynamicPvpLine(r.integrations.dynamicPvp)}` : withTruePve + const withDynamicPvp = r.integrations.dynamicPvp ? `${withTruePve} ${dynamicPvpLine(r.integrations.dynamicPvp)}` : withTruePve + return r.integrations.dangerousTreasures ? `${withDynamicPvp} ${dangerousTreasuresLine(r.integrations.dangerousTreasures, r.console)}` : withDynamicPvp +} + +/** + * Dangerous Treasures (PLAN_REDESIGNS §14.3.4), from protocol 14: its events on the + * feed and the map, and whether a step can start one, which needs the framework + * to run its console command (hello's `console.plugins`, D363). + */ +export function dangerousTreasuresLine(dt, probe) { + if (!dt.loaded) return 'Dangerous Treasures is not loaded — a step cannot start a treasure event.' + const name = `Dangerous Treasures ${dt.version || ''}`.trim() + if (probe && probe.plugins === false) { + return `${name} is loaded — its events show on the feed and the map, but this framework cannot run its console command, so a step cannot start one (Rust-Plugins#34).` + } + return `${name} is loaded — its events show on the feed and the map, and a step may start one.` } /** Dynamic PVP (PLAN_REDESIGNS §14.3.3), from protocol 14: its zones on the feed and the map. */ diff --git a/client/test/feed.test.js b/client/test/feed.test.js index 0846439..e5773ee 100644 --- a/client/test/feed.test.js +++ b/client/test/feed.test.js @@ -203,8 +203,9 @@ 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 and Dynamic PVP zones opening, which the server serves publicly', () => { - assert.deepEqual(kindsFor('world'), ['world.event', 'pvp.zone.created']) +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']) + assert.ok(!FEED_KINDS.includes('dt.ended'), 'a treasure event ending 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') }) @@ -243,3 +244,30 @@ test('every one of Dynamic PVP’s own events has words, and the server and clie assert.ok(!describe(pvp('pvp.zone.created', { event })).verb.includes(`(${event})`), `${event} has no words of its own`) } }) + +// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4) ───────────── + +const treasure = (kind, frame) => row(kind, { kind, ...frame }) + +test('a treasure event starting says where, and names nobody', () => { + const line = describe(treasure('dt.started', { grid: 'K12' })) + assert.equal(line.verb, 'A Dangerous Treasures event has started') + assert.equal(line.detail, 'K12') + assert.equal(line.actor, null) +}) + +test('a win names its winner only when the server sent the name, and says how many fought', () => { + const named = describe(treasure('dt.won', { grid: 'K12', name: 'Alice', invaderCount: 3 })) + assert.equal(named.actor, 'Alice') + assert.equal(named.verb, 'won the treasure') + assert.equal(named.detail, 'K12 · 3 players fought for it') + + const unnamed = describe(treasure('dt.won', { grid: 'K12', invaderCount: 1 })) + assert.equal(unnamed.actor, null) + assert.equal(unnamed.verb, 'The treasure was won') + assert.equal(unnamed.detail, 'K12') +}) + +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') +}) diff --git a/engagement-triggers.json b/engagement-triggers.json index 37ce2fb..9c3d3ad 100644 --- a/engagement-triggers.json +++ b/engagement-triggers.json @@ -1,6 +1,6 @@ { "_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.", - "coreApi": "^1.14.0", + "coreApi": "^1.15.0", "triggers": [ { "id": "rust.base.destroyed", @@ -555,6 +555,149 @@ } ] }, + { + "id": "rust.dt.started", + "label": "A treasure event started", + "description": "Dangerous Treasures opened a guarded, burning treasure chest 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:dt.won", + "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 started it (a Start a Dangerous Treasures event step)." + } + ] + }, + { + "id": "rust.dt.won", + "label": "A treasure was won", + "description": "A player emptied a Dangerous Treasures chest 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:dt.won", + "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": false, + "example": "Marisol", + "description": "Who emptied the chest, by in-game name." + }, + { + "name": "invaders", + "type": "int", + "required": true, + "example": 3, + "description": "How many players fought for it, the winner included." + } + ] + }, { "id": "rust.kit.entitled", "label": "An event rewarded you a kit", diff --git a/module.json b/module.json index aa91c28..c403713 100644 --- a/module.json +++ b/module.json @@ -2,7 +2,7 @@ "id": "rust", "name": "Rust", "version": "0.1.0", - "coreApi": "^1.14.0", + "coreApi": "^1.15.0", "server": "server/index.js", "client": { "entry": "client/dist/entry.js" }, "schema": "server/db/schema.sql", diff --git a/server/catalogue.js b/server/catalogue.js index e63b1c5..1f17c38 100644 --- a/server/catalogue.js +++ b/server/catalogue.js @@ -70,6 +70,12 @@ const PUBLIC_KINDS = Object.freeze([ // names nobody, and `dynamicPvp.shapeFor` keeps its position for staff and an // operator's own event names off public pages. 'pvp.zone.created', + // Protocol 14: a Dangerous Treasures event opened, and was won + // (PLAN_REDESIGNS §14.3.4). Public as kinds, read per viewer by + // `dangerousTreasures.shapeFor` as a world event is: the winner's name only + // for the presence audience, Steam ids and the position only for staff. + 'dt.started', + 'dt.won', ]) /** @@ -135,6 +141,9 @@ const STAFF_KINDS = Object.freeze([ // Protocol 14 (§14.3.3): a Dynamic PVP zone closed. The plan keeps its close // a staff line: the public learns a zone opened, and the map shows it going. 'pvp.zone.deleted', + // 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', ]) /** diff --git a/server/dangerousTreasures.js b/server/dangerousTreasures.js new file mode 100644 index 0000000..352f31a --- /dev/null +++ b/server/dangerousTreasures.js @@ -0,0 +1,97 @@ +// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4) ────────────── +// +// nivex's Dangerous Treasures opens a guarded, burning treasure chest somewhere +// on the map. Its events reach this module as three kinds: `dt.started` +// (public), `dt.won` (public) and `dt.ended` (staff's). The chest emptied is +// *Treasure Hunter*'s count, from the tally's `treasuresWon` (D375). A run's step +// opens one (`eventDangerousTreasures.js`). +// +// ── Who may read what ────────────────────────────────────────────────────── +// +// As a world event is read (`worldEvents.js`, D353, D376): the event and its +// grid are public; the winner's name shows only to the operator's presence +// audience, so with presence left at staff a signed-out visitor reads "The +// treasure was won at K12"; Steam ids, the invaders' among them, and the +// position are staff's. How many fought for it is a count, and names nobody. +// The step's key, when a step opened it, is staff's too. + +const { gridOf } = require('./worldEvents') + +const KINDS = Object.freeze(['dt.started', 'dt.won', 'dt.ended']) + +/** DT's three shipped difficulties, as `dtevent ` takes them. */ +const LEVELS = Object.freeze([ + { value: 0, label: 'Easy' }, + { value: 1, label: 'Medium' }, + { value: 2, label: 'Hard' }, +]) + +const STEAM_ID = /^\d{17}$/ + +function isKind(kind) { + return KINDS.includes(kind) +} + +/** + * One `dt.*` frame for one viewer: `{ staff, presence, geometry }`. Passes the + * envelope, adds `grid`; a win adds `invaderCount`, and `name` for the presence + * audience. Staff also get `x`, `z`, the step's `key`, the winner's `steamId` and + * the `invaders`. 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 === 'dt.won') { + const invaders = Array.isArray(frame.invaders) ? frame.invaders : [] + const count = Number(frame.invaderCount) + out.invaderCount = Number.isInteger(count) && count >= 0 ? count : invaders.length + if ((presence || staff) && typeof frame.name === 'string' && frame.name) out.name = frame.name + if (staff) { + if (STEAM_ID.test(String(frame.steamId || ''))) out.steamId = String(frame.steamId) + out.invaders = invaders.map(String).filter((id) => STEAM_ID.test(id)) + } + } + + 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 Dangerous Treasures and its console, from + * `servers.db.dtReportFor`: `{ dangerousTreasures, consolePlugins }`, each null + * where the plugin never said (no state yet, or a plugin before protocol 14). + * `dangerousTreasures` is `{ loaded, version }`. + */ +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 dt = row ? parse(row.dangerousTreasures) : null + const probe = row ? parse(row.console) : null + return { + dangerousTreasures: + dt && typeof dt === 'object' ? { loaded: dt.loaded === true, version: typeof dt.version === 'string' ? dt.version : null } : null, + consolePlugins: probe && typeof probe === 'object' && typeof probe.plugins === 'boolean' ? probe.plugins : null, + } +} + +module.exports = { KINDS, LEVELS, isKind, shapeFor, reportOf } diff --git a/server/db/schema.sql b/server/db/schema.sql index 4431448..c51dd31 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1386,3 +1386,7 @@ ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS drops_looted INT UNS -- one each). The same rule writes the feed line and the count. A change applies -- from then on; counts already written are not recounted. ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS credit_rule VARCHAR(8) NOT NULL DEFAULT 'blow'; + +-- 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; diff --git a/server/engagement/emit.js b/server/engagement/emit.js index dec77e4..02a111d 100644 --- a/server/engagement/emit.js +++ b/server/engagement/emit.js @@ -177,6 +177,16 @@ const HEADLINES = Object.freeze({ title: `${d.boss} is down to ${d.percent}% on ${d.server}`, intro: `${d.boss} fell to ${d.percent}% of its health on ${d.server} and entered phase ${d.phase}.`, }), + // Dangerous Treasures (protocol 14, §14.3.4). A missing grid drops out. + 'rust.dt.started': (d) => ({ + title: `A treasure event has started on ${d.server}`, + intro: `A Dangerous Treasures chest opened${d.grid ? ` at ${d.grid}` : ''} on ${d.server}. It is guarded, and it is burning.`, + }), + 'rust.dt.won': (d) => ({ + title: `The treasure was won on ${d.server}`, + 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` : ''}.`, + }), 'rust.boss.killed': (d) => ({ title: `${d.boss} was killed on ${d.server}`, intro: `${d.killer ? `${d.killer} landed the killing blow` : `${d.boss} fell`}` @@ -561,7 +571,43 @@ async function onWorld(server, item, frame) { }) ? 1 : 0 } +/** + * Dangerous Treasures (protocol 14, §14.3.4): a chest opened, and one won. + * Public news, so a replay older than a broadcast's window tells nobody (D63). + * The grid comes from the map's geometry, as the feed's does; the winner by + * in-game name only, never a Steam id. An ending raises nothing. + */ +async function onDangerousTreasures(server, item, frame) { + const t = frameTime(item, frame) + if (!stillNews(t, BROADCAST_MAX_AGE_MS)) 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 === 'dt.started') { + return fire(T['rust.dt.started'], { + data: { ...data, byEvent: Boolean(str(frame.key)) }, + dedupeKey: dedupeKey('dt.started', server.id, t, frame.x, frame.z), + occurredAt: t, + }) ? 1 : 0 + } + + const invaders = Number(frame.invaderCount) + return fire(T['rust.dt.won'], { + data: { + ...data, + ...(str(frame.name) ? { player: str(frame.name) } : {}), + invaders: Number.isInteger(invaders) && invaders >= 0 ? invaders : Array.isArray(frame.invaders) ? frame.invaders.length : 0, + }, + dedupeKey: dedupeKey('dt.won', server.id, t, frame.steamId, frame.x, frame.z), + occurredAt: t, + }) ? 1 : 0 +} + const HANDLERS = Object.freeze({ + 'dt.started': onDangerousTreasures, + 'dt.won': onDangerousTreasures, 'world.event': onWorld, 'npc.boss.spawned': onBoss, 'npc.boss.phase': onBoss, diff --git a/server/engagement/triggers.js b/server/engagement/triggers.js index cc53746..2252f52 100644 --- a/server/engagement/triggers.js +++ b/server/engagement/triggers.js @@ -622,7 +622,54 @@ const WORLD = WORLD_EVENTS.map(([event, label, description, vars]) => ({ variables: [...SERVER, ...HEADLINE, WORLD_SUBJECT, ...vars], })) -const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES, ...WORLD]) +// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4, §14.3.9) ────── +// +// A treasure event opening and being won are triggers, as a world event is +// (D355): public news, `everyone` the ceiling and `subscribers` the default. +// The cooldown subject is the server, so a busy hour of treasures is one line +// per cooldown. `player` is the winner's in-game name only; no Steam id is +// declared. Its ending is staff's on the feed and raises nothing. + +const DT_SUBJECT = { name: 'subject', type: 'string', required: true, example: 'main:dt.won', + description: 'The server and the event. The cooldown subject; not meant for display.' } +const DT_BY_EVENT = { name: 'byEvent', type: 'boolean', required: true, example: false, + description: 'Whether one of this site\'s events started it (a Start a Dangerous Treasures event step).' } + +const DANGEROUS_TREASURES = [ + { + id: 'rust.dt.started', + label: 'A treasure event started', + description: 'Dangerous Treasures opened a guarded, burning treasure chest 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, WORLD_GRID, DT_BY_EVENT], + }, + { + id: 'rust.dt.won', + label: 'A treasure was won', + description: 'A player emptied a Dangerous Treasures chest on a server.', + kind: 'event', + subjectKey: 'subject', + audience: 'subscribers', + ceiling: 'everyone', + version: V1, + variables: [ + ...SERVER, + ...HEADLINE, + DT_SUBJECT, + WORLD_GRID, + { name: 'player', type: 'string', required: false, example: 'Marisol', + description: 'Who emptied the chest, by in-game name.' }, + { name: 'invaders', type: 'int', required: true, example: 3, + description: 'How many players fought for it, the winner included.' }, + ], + }, +] + +const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES, ...WORLD, ...DANGEROUS_TREASURES]) const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id]))) diff --git a/server/eventDangerousTreasures.js b/server/eventDangerousTreasures.js new file mode 100644 index 0000000..c455573 --- /dev/null +++ b/server/eventDangerousTreasures.js @@ -0,0 +1,172 @@ +// ── Starting a Dangerous Treasures event (protocol 14, PLAN_REDESIGNS §14.3.4) ── +// +// `rust.dt.start`, "Start a Dangerous Treasures event": one of nivex's treasure +// events, at the difficulty the step picks, where DT picks. The plugin runs DT's +// own console command, `dtevent `, and joins the start DT raises to the +// step's key, so a retry is answered with the first start and never opens a +// second chest. +// +// **Kept (`reversible: 'none'`).** DT guards, unlocks and despawns its own chest +// (900 s after it unlocks, if nobody empties it); a run ending takes nothing back. +// `risk: 'change'`, so an operator switches it on deliberately. +// +// **Refused at verify** where the server said DT is not loaded, or that its +// framework cannot run another plugin's console command (hello's +// `console.plugins`, D363, Rust-Plugins#34). A server that has not said (a plugin +// before protocol 14, or never reached) is left to the plugin, as a zone's +// TruePVE is (§14.3.16). +// +// ── D365: DT's warm-up ───────────────────────────────────────────────────── +// +// For about ten minutes after it loads DT is still finding where events may go, +// and every start fails. The plugin calls that `dt-not-ready`; the step reports +// "Dangerous Treasures is not ready yet" and asks core to try once more in ten +// minutes (`retryAfterMs`, MODULE_API 1.15.0, D371). `dt-no-spot` retries on +// core's ordinary delay. Past core's attempts the run pauses on its own +// `on_failure`, and staff press Retry on the paused step (EVENTS.md §I), which +// sends the same key. + +const client = require('./sidecarClient') +const { serverFor, transportError, pluginError } = require('./eventLeases') +const { BUDGET_MS } = require('./eventWorld') +const { gridOf } = require('./worldEvents') +const { LEVELS, reportOf } = require('./dangerousTreasures') +const serversDb = require('./model/servers/servers.db') +const mapDb = require('./model/map/map.db') + +/** How long core waits before trying a start DT was not ready for (D365). */ +const NOT_READY_RETRY_MS = 10 * 60 * 1000 + +/** What the step says DT is not ready, in the plan's words (D365). */ +const NOT_READY = 'Dangerous Treasures is not ready yet' + +/** The plugin's refusals a second attempt would repeat. */ +const PERMANENT = new Set([ + 'events-disabled', + 'malformed', + 'bad-level', + 'dt-missing', + 'console-unavailable', + // DT's own refusal in its own words: a difficulty its config no longer has, or + // its console command renamed. Neither is fixed by waiting. + 'refused', +]) + +const BUDGETS = [ + { + id: 'rust.dt.events', + label: 'Dangerous Treasures events', + unit: 'events', + description: 'Dangerous Treasures events an event starts, each one counted. Dangerous Treasures despawns its own chest; nothing is taken back when the run ends.', + }, +] + +const OPTION_SOURCES = [ + { + id: 'rust.options.dt_levels', + label: 'Dangerous Treasures difficulty', + description: "Dangerous Treasures' three difficulties, as it ships them.", + async resolve() { + return LEVELS.map((l) => ({ value: l.value, label: l.label })) + }, + }, +] + +/** A level param: 0, 1 or 2, or null for anything else. Blank is Easy. */ +function levelOf(raw) { + if (raw === undefined || raw === null || raw === '') return 0 + const n = Number(raw) + return LEVELS.some((l) => l.value === n) ? n : null +} + +/** + * 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) { + if (report.dangerousTreasures && !report.dangerousTreasures.loaded) { + return `Dangerous Treasures is not installed on ${name}` + } + if (report.consolePlugins === false) { + return `${name}'s modding framework cannot run Dangerous Treasures' console command from a plugin, so a treasure event cannot be started there yet (Rust-Plugins#34)` + } + return null +} + +/** The grid an opened chest 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 dtStart = { + id: 'rust.dt.start', + label: 'Start a Dangerous Treasures event', + description: + 'Starts one Dangerous Treasures event: a guarded, burning treasure chest that unlocks after a few minutes. Dangerous Treasures picks the spot, and despawns the chest itself if nobody empties it. Needs Dangerous Treasures on the server. In the first ten minutes after Dangerous Treasures loads it is not ready, and the step tries again ten minutes later.', + risk: 'change', + reversible: 'none', + version: 1, + budgetMs: BUDGET_MS, + params: [ + { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers', + description: 'Which server. It needs Dangerous Treasures installed.' }, + { name: 'level', type: 'int', required: false, example: 1, source: 'rust.options.dt_levels', + description: 'The difficulty: 0 Easy, 1 Medium, 2 Hard. Left blank, Easy.' }, + ], + + cost: () => ({ 'rust.dt.events': 1 }), + + async perform({ idempotencyKey, params, verify }) { + const level = levelOf(params.level) + if (level === null) { + return { ok: false, retry: false, error: `a Dangerous Treasures event is 0 (Easy), 1 (Medium) or 2 (Hard), not "${params.level}"` } + } + + const serverId = String(params.server || '').trim() + if (!serverId) return { ok: false, retry: false, error: 'the step does not say which server — pick one' } + + const found = await serverFor(serverId) + if (!found.ok) return found + const name = found.server.name || found.server.id + + const missing = missingOn(reportOf(await serversDb.dtReportFor(found.server.id)), name) + if (missing) return { ok: false, retry: false, error: missing } + + if (verify) return { ok: true } + + const result = await client.dtStart(found.server, { key: idempotencyKey, level }) + + if (!result.ok) { + const permanent = result.status === 'not-configured' || result.status === 'no-token' + return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'Dangerous Treasures start') } + } + + const data = result.data || {} + if (data.kind !== 'dt.opened') { + if (data.reason === 'dt-not-ready') { + return { ok: false, retry: true, retryAfterMs: NOT_READY_RETRY_MS, error: `${NOT_READY} on ${name}` } + } + return { + ok: false, + ...(PERMANENT.has(data.reason) ? { retry: false } : {}), + error: pluginError(data, `${name} did not start the Dangerous Treasures event`), + } + } + + const grid = await gridAt(found.server.id, data.x, data.z) + return { + ok: true, + detail: { + level: LEVELS.find((l) => l.value === Number(data.level))?.label || String(data.level), + ...(grid ? { grid } : {}), + ...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing new was started' } : {}), + }, + } + }, +} + +const ACTIONS = [dtStart] + +module.exports = { BUDGETS, ACTIONS, OPTION_SOURCES, PERMANENT, NOT_READY, NOT_READY_RETRY_MS, levelOf, missingOn } diff --git a/server/index.js b/server/index.js index 7210ba8..ff4d00e 100644 --- a/server/index.js +++ b/server/index.js @@ -60,6 +60,7 @@ module.exports = function register(ctx, api) { const eventWorld = require('./eventWorld') const eventRewards = require('./eventRewards') const eventWorldCall = require('./eventWorldCall') + const eventDangerousTreasures = require('./eventDangerousTreasures') const eventPermRewards = require('./eventPermRewards') const eventTemplates = require('./eventTemplates') const boot = require('./boot') @@ -176,9 +177,10 @@ module.exports = function register(ctx, api) { // permission, lent until the run ends or given for good, each on its own budget. // // 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. - api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS]) - api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS]) + // 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]) // 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 @@ -198,6 +200,7 @@ module.exports = function register(ctx, api) { ...eventRewards.OPTION_SOURCES, ...eventPermRewards.OPTION_SOURCES, ...eventWorldCall.OPTION_SOURCES, + ...eventDangerousTreasures.OPTION_SOURCES, ]) // The slash commands (phase 16, R11). The handlers run HERE, in the website @@ -216,10 +219,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, - budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.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, 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, + 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, }) } diff --git a/server/model/events/events.db.js b/server/model/events/events.db.js index e8de7d0..2d8b764 100644 --- a/server/model/events/events.db.js +++ b/server/model/events/events.db.js @@ -151,6 +151,8 @@ const TITLE_SUMS = { // Protocol 14 (D356): Safecracker and Scavenger. cratesHacked: 'crates_hacked', dropsLooted: 'drops_looted', + // Protocol 14 (D375): Treasure Hunter. + treasuresWon: 'treasures_won', } /** The two *best* fields and their columns: the longest single kill, never a sum. */ @@ -350,6 +352,8 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 // Protocol 14 (D356, D377): the columns Safecracker and Scavenger bring. cratesHacked: 'crates_hacked', dropsLooted: 'drops_looted', + // Protocol 14 (D375): Dangerous Treasures won, Treasure Hunter's column. + treasuresWon: 'treasures_won', }[sort] || 'kills' const params = [serverId] @@ -376,6 +380,7 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 SUM(s.playtime_sec) AS playtimeSec, SUM(s.crates_hacked) AS cratesHacked, SUM(s.drops_looted) AS dropsLooted, + SUM(s.treasures_won) AS treasuresWon, 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 5c146c6..ed3d1d3 100644 --- a/server/model/events/events.model.js +++ b/server/model/events/events.model.js @@ -15,6 +15,7 @@ const catalogue = require('../../catalogue') const worldEvents = require('../../worldEvents') const dynamicPvp = require('../../dynamicPvp') +const dangerousTreasures = require('../../dangerousTreasures') const db = require('./events.db') const mapDb = require('../map/map.db') const serversDb = require('../servers/servers.db') @@ -70,16 +71,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))) return shaped + if (!shaped.some((r) => r.kind === 'world.event' || dynamicPvp.KINDS.includes(r.kind) || dangerousTreasures.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. Both reads only when the page holds one of them at all. + // 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. const view = await worldView(serverId, { staff: staff || admin, presence }) return shaped - .map((row) => (row.kind === 'world.event' ? worldRow(row, view) : dynamicPvp.KINDS.includes(row.kind) ? pvpRow(row, view) : row)) + .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) + return row + }) .filter(Boolean) } @@ -104,6 +111,11 @@ function pvpRow(row, view) { return { ...row, frame: dynamicPvp.shapeFor(row.frame, view) } } +/** One Dangerous Treasures row for a viewer (§14.3.4). The row's own Steam id, the winner's, is staff's. */ +function dtRow(row, view) { + return { ...row, steamId: view.staff ? row.steamId : null, frame: dangerousTreasures.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) @@ -169,6 +181,8 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, pre // Protocol 14 (D356): Safecracker's and Scavenger's counts. cratesHacked: Number(r.cratesHacked) || 0, dropsLooted: Number(r.dropsLooted) || 0, + // Protocol 14 (D375): Treasure Hunter's count. + treasuresWon: Number(r.treasuresWon) || 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 5621948..236507d 100644 --- a/server/model/servers/servers.db.js +++ b/server/model/servers/servers.db.js @@ -258,9 +258,27 @@ async function putState(state) { ) } +/** + * What one server's last status said about Dangerous Treasures and its console + * (protocol 14, PLAN_REDESIGNS §14.3.4): `{ dangerousTreasures, console }`, each + * as the driver hands JSON_EXTRACT back (text, or null for a plugin that never + * said). Null for a server with no state yet. + */ +async function dtReportFor(serverId) { + const rows = await core.query( + `SELECT JSON_EXTRACT(raw, '$.integrations.dangerousTreasures') AS dangerousTreasures, + JSON_EXTRACT(raw, '$.console') AS console + FROM ${STATE} + WHERE server_id = ?`, + [serverId], + ) + return rows[0] || null +} + module.exports = { SERVERS, STATE, + dtReportFor, listServers, getServer, upsertServer, diff --git a/server/model/titles/titles.js b/server/model/titles/titles.js index e4d4332..c62635c 100644 --- a/server/model/titles/titles.js +++ b/server/model/titles/titles.js @@ -21,7 +21,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), plus + * two world-event counts (§14.3.1, Safecracker and Scavenger), Dangerous + * Treasures won (§14.3.4, Treasure Hunter), plus * `playtime`, which predates them and has no default title, so a playtime rule * always carries its own text. * @@ -69,6 +70,8 @@ const STATS = { // Protocol 14, Rust's own world events (D356): a hack counts for its starter, once per crate. crateshacked: { label: 'Locked crates hacked', title: 'Safecracker', source: { sum: 'crates_hacked' } }, 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' } }, 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/admin/rust.controller.js b/server/router/admin/rust.controller.js index 99c7dd7..c2ddc55 100644 --- a/server/router/admin/rust.controller.js +++ b/server/router/admin/rust.controller.js @@ -347,6 +347,9 @@ async function integrations(req, res) { ok: Boolean(data), status: result.status, integrations: data && data.integrations ? data.integrations : null, + // Protocol 14 (D363): whether the framework runs another plugin's console + // command from code, which a Dangerous Treasures step needs. + console: data && data.console ? data.console : null, }) } catch (err) { log.error('failed to read integrations', { server: id, error: err.message }) diff --git a/server/router/admin/rust.router.js b/server/router/admin/rust.router.js index 1345ad0..26d24d5 100644 --- a/server/router/admin/rust.router.js +++ b/server/router/admin/rust.router.js @@ -209,7 +209,7 @@ adminRustRouter.get( '/servers/:id/integrations', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Which optional mods a server has loaded' - // #swagger.description = 'Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why.' + // #swagger.description = 'Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why. `console` (protocol 14, D363) says whether the server’s framework runs another plugin’s console command from code, which a Start a Dangerous Treasures event step needs.' // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } } /* #swagger.responses[200] = { description: 'What the server has loaded', content: { "application/json": { schema: { $ref: "#/components/schemas/RustIntegrations" } } } } */ /* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */ diff --git a/server/router/public/rust.controller.js b/server/router/public/rust.controller.js index cba58f9..f333198 100644 --- a/server/router/public/rust.controller.js +++ b/server/router/public/rust.controller.js @@ -12,6 +12,7 @@ const core = require('../../core') const clans = require('../../model/clans/clans.model') +const dangerousTreasures = require('../../dangerousTreasures') const economics = require('../../model/economics/economics.model') const events = require('../../model/events/events.model') const map = require('../../model/map/map.model') @@ -119,6 +120,9 @@ async function listLeaderboard(req, res) { // reports Economics. `economics` is null otherwise, and the rows carry no // `balance`, so a page shows the column exactly when there is one. const econ = await economics.forServer(req.params.id) + // 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)) const withBalance = econ.loaded && wipeId ? { startingBalance: econ.startingBalance } : null const [rows, held] = await Promise.all([ @@ -140,6 +144,7 @@ async function listLeaderboard(req, res) { // past wipe was. 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)), }) } 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 97cd52e..c49b96e 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).' + // #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.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, 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, 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 54da4e5..b542b8b 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -435,6 +435,14 @@ const worldPlace = (server, body) => request(server, '/world/place', { method: ' */ const worldCall = (server, body) => request(server, '/world/call', { method: 'POST', body }) +/** + * Open one Dangerous Treasures event for a run's step (protocol 14, §20.8): + * `{ key, level }`. `data.kind` is `dt.opened` (with `x`, `z`, `level`, and + * `repeat: true` for a key the plugin has seen) or `dt.error` with a reason, + * `dt-not-ready` among them (D365). DT despawns its own chest: there is no revert. + */ +const dtStart = (server, body) => request(server, '/dt/start', { 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) @@ -572,6 +580,7 @@ module.exports = { worldZone, worldPlace, worldCall, + dtStart, worldRevert, tallyOpen, tallySnapshot, diff --git a/server/swagger/doc.js b/server/swagger/doc.js index eb02bea..3b276fb 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -270,8 +270,19 @@ module.exports = { description: 'Protocol 14 (PLAN_REDESIGNS §14.3.3): where loaded, its PvP zones reach the feed and the map’s pvp layer.', properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '5.2.0' } }, }, + dangerousTreasures: { + type: 'object', + 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' } }, + }, }, }, + console: { + type: 'object', + nullable: true, + description: 'Protocol 14 (D363): what the plugin’s console probe found at its load. Null from an older plugin, or when the game could not be asked.', + properties: { plugins: { type: 'boolean', description: 'Whether another plugin’s console command runs from code here.', example: true } }, + }, }, }, RustVoice: { diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js index b9d2661..eef015c 100644 --- a/server/test/catalogue.test.js +++ b/server/test/catalogue.test.js @@ -157,6 +157,12 @@ test('the classification covers exactly the event kinds the protocol defines, th // nobody; the closing is a staff line (PLAN_REDESIGNS §14.3.3). 'pvp.zone.created', 'pvp.zone.deleted', + // Protocol 14 (§20.8): Dangerous Treasures. The opening and the win are + // public, read per viewer by `dangerousTreasures.js`; the ending is staff's + // (PLAN_REDESIGNS §14.3.4). + 'dt.started', + 'dt.won', + 'dt.ended', ] assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort()) @@ -179,6 +185,13 @@ test('the classification covers exactly the event kinds the protocol defines, th assert.equal(catalogue.isPublic('pvp.zone.created'), true) assert.equal(catalogue.isPresence('pvp.zone.created'), false) assert.equal(catalogue.isPublic('pvp.zone.deleted'), false, 'a zone closing is a staff line') + // D376 for Dangerous Treasures: the win names its winner only to the presence + // audience, per viewer, so neither public kind is a presence kind. + for (const kind of ['dt.started', 'dt.won']) { + assert.equal(catalogue.isPublic(kind), true) + assert.equal(catalogue.isPresence(kind), false) + } + assert.equal(catalogue.isPublic('dt.ended'), false, 'a treasure event ending is a staff line') }) test('every kind that names a player who was on is behind the presence setting', () => { @@ -203,6 +216,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. - assert.deepEqual(catalogue.kindsFor({}).sort(), ['npc.boss.spawned', 'pvp.zone.created', 'server.initialized', 'server.shutdown', 'server.wipe', 'world.event']) + // 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']) }) diff --git a/server/test/dangerousTreasures.test.js b/server/test/dangerousTreasures.test.js new file mode 100644 index 0000000..fbd7399 --- /dev/null +++ b/server/test/dangerousTreasures.test.js @@ -0,0 +1,316 @@ +// ── Dangerous Treasures (PLAN_REDESIGNS §14.3.4, protocol 14) ────────────── +// +// Each test is one way the feature could look right and be wrong: +// +// the winner's name reaches a viewer below the presence audience (D376), or a +// Steam id, an invader, a position or a step's key reaches anybody but staff +// the step runs on a server that said it has no DT, or whose framework cannot +// run DT's console command (D363), or a server that has not said is refused +// DT's warm-up is not answered with core's ten-minute retry (D365, D371), or +// a refusal DT would repeat is retried +// a retry of a key opens a second chest +// the tally's treasures won is dropped on the floor, or Treasure Hunter's +// column and sort are missing +// a trigger declares a variable its emitter never fills, or names a Steam id + +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 dt = require('../dangerousTreasures') +const { gridOf } = require('../worldEvents') +const { TRIGGERS } = require('../engagement/triggers') + +const A = '76561198000000001' +const B = '76561198000000002' +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: 812, z: -340.5, ...over }) +const won = (over = {}) => frame('dt.won', { steamId: A, name: 'Alice', invaders: [A, B], invaderCount: 2, ...over }) + +// ── Who may read what (D353, D376) ───────────────────────────────────────── + +test('the public reads a treasure event’s grid, never its position or the step’s key', () => { + const out = dt.shapeFor(frame('dt.started', { key: '7:1' }), { geometry: GEOMETRY }) + assert.strictEqual(out.grid, gridOf(GEOMETRY, 812, -340.5)) + assert.strictEqual(out.x, undefined) + assert.strictEqual(out.z, undefined) + assert.strictEqual(out.key, undefined) + assert.strictEqual(out.kind, 'dt.started') +}) + +test('a win names its winner only to the presence audience, and its Steam ids only to staff', () => { + const signedOut = dt.shapeFor(won(), { geometry: GEOMETRY }) + assert.strictEqual(signedOut.name, undefined) + assert.strictEqual(signedOut.steamId, undefined) + assert.strictEqual(signedOut.invaders, undefined) + assert.strictEqual(signedOut.invaderCount, 2, 'a count names nobody') + + const member = dt.shapeFor(won(), { presence: true, geometry: GEOMETRY }) + assert.strictEqual(member.name, 'Alice') + assert.strictEqual(member.steamId, undefined) + assert.strictEqual(member.invaders, undefined) + + const staff = dt.shapeFor(won({ invaders: [A, B, 'not-an-id'] }), { staff: true, geometry: GEOMETRY }) + assert.strictEqual(staff.name, 'Alice') + assert.strictEqual(staff.steamId, A) + assert.deepStrictEqual(staff.invaders, [A, B]) + assert.strictEqual(staff.x, 812) +}) + +test('an older or partial win still counts its invaders, and something off the map has no grid', () => { + assert.strictEqual(dt.shapeFor(won({ invaderCount: undefined }), {}).invaderCount, 2) + assert.strictEqual(dt.shapeFor(frame('dt.started', { x: 9000 }), { geometry: GEOMETRY }).grid, undefined) + assert.strictEqual(dt.shapeFor(frame('dt.started'), {}).grid, undefined, 'no geometry, no grid') + const other = { kind: 'player.death', x: 1 } + assert.strictEqual(dt.shapeFor(other, {}), other) +}) + +test('the feed reads a stored win for its viewer, the row’s own Steam id staff’s', async () => { + withRecorder((sql) => { + if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'dt.won', t: NOW, wipeId: 'w1', steamId: A, raw: JSON.stringify(won()) }] + 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.ok(signedOut.frame.grid) + + const [staff] = await events.recent({ serverId: 'main', admin: true }) + assert.strictEqual(staff.steamId, A) + assert.deepStrictEqual(staff.frame.invaders, [A, B]) +}) + +test('a server’s report is read leniently: what it never said is null', () => { + assert.deepStrictEqual(dt.reportOf(null), { dangerousTreasures: null, consolePlugins: null }) + assert.deepStrictEqual(dt.reportOf({ dangerousTreasures: '{"loaded":true,"version":"3.0.0"}', console: '{"plugins":false}' }), { + dangerousTreasures: { loaded: true, version: '3.0.0' }, + consolePlugins: false, + }) + assert.deepStrictEqual(dt.reportOf({ dangerousTreasures: 'not json', console: '{}' }), { dangerousTreasures: null, consolePlugins: null }) +}) + +// ── The step ─────────────────────────────────────────────────────────────── + +/** The step against one server `main`, whose last report is `report`, and a sidecar answering `reply`. */ +function stubStart(t, { report = { dangerousTreasures: { loaded: true, version: '3.0.0' }, console: { plugins: true } }, reply } = {}) { + withRecorder((sql) => { + if (sql.includes('FROM rust_server_state')) { + return report === null ? [] : [{ dangerousTreasures: JSON.stringify(report.dangerousTreasures ?? null), console: JSON.stringify(report.console ?? null) }] + } + 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 = { dtStart: client.dtStart, getServer: serversDb.getServer } + const calls = [] + serversDb.getServer = async (id) => (id === 'main' ? { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' } : null) + client.dtStart = async (server, body) => { + calls.push({ server: server.id, body }) + return typeof reply === 'function' ? reply(body) : reply + } + t.after(() => { + client.dtStart = saved.dtStart + serversDb.getServer = saved.getServer + }) + return calls +} + +const startStep = () => require('../eventDangerousTreasures').ACTIONS[0] +const ok = (data) => ({ ok: true, status: 'ok', data }) +const opened = (over = {}) => ok({ kind: 'dt.opened', key: '7:1', level: 1, repeat: false, x: 812, z: -340.5, ...over }) + +test('the step is kept, priced one event each, and offers DT’s three difficulties', async () => { + const step = startStep() + assert.strictEqual(step.id, 'rust.dt.start') + assert.strictEqual(step.reversible, 'none') + assert.strictEqual(step.risk, 'change') + assert.strictEqual(step.revert, undefined) + assert.deepStrictEqual(step.cost({ server: 'main', level: 2 }), { 'rust.dt.events': 1 }) + + const [levels] = require('../eventDangerousTreasures').OPTION_SOURCES + assert.deepStrictEqual(await levels.resolve(), [ + { value: 0, label: 'Easy' }, + { value: 1, label: 'Medium' }, + { value: 2, label: 'Hard' }, + ]) +}) + +test('the dry run refuses a server without DT, or whose framework cannot run its command, and nothing else', async (t) => { + const run = (params) => startStep().perform({ runId: 7, idempotencyKey: '7:1', params, verify: true }) + + stubStart(t, { report: { dangerousTreasures: { loaded: false }, console: { plugins: true } } }) + const absent = await run({ server: 'main', level: 1 }) + assert.strictEqual(absent.retry, false) + assert.match(absent.error, /Dangerous Treasures is not installed on Main/) + + stubStart(t, { report: { dangerousTreasures: { loaded: true }, console: { plugins: false } } }) + const carbon = await run({ server: 'main', level: 1 }) + assert.strictEqual(carbon.retry, false) + assert.match(carbon.error, /cannot run Dangerous Treasures' console command/) + assert.match(carbon.error, /Rust-Plugins#34/) + + // A plugin before protocol 14, or a server never reached, has not said: the plugin answers. + stubStart(t, { report: null }) + assert.ok((await run({ server: 'main', level: 1 })).ok) + + const calls = stubStart(t) + assert.ok((await run({ server: 'main' })).ok, 'blank is Easy') + assert.match((await run({ server: 'main', level: 3 })).error, /0 \(Easy\), 1 \(Medium\) or 2 \(Hard\)/) + assert.match((await run({ server: 'main', level: 'hard' })).error, /not "hard"/) + assert.match((await run({ level: 1 })).error, /which server/) + assert.strictEqual(calls.length, 0, 'the dry run starts nothing') +}) + +test('a start sends the key and the level, and says where it opened', async (t) => { + const calls = stubStart(t, { reply: opened() }) + const result = await startStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', level: '1' } }) + assert.ok(result.ok) + assert.deepStrictEqual(calls, [{ server: 'main', body: { key: '7:1', level: 1 } }]) + assert.deepStrictEqual(result.detail, { level: 'Medium', grid: gridOf(GEOMETRY, 812, -340.5) }) +}) + +test('DT not ready is said in the plan’s words and retried once in ten minutes (D365, D371)', async (t) => { + stubStart(t, { reply: ok({ kind: 'dt.error', reason: 'dt-not-ready', message: 'Dangerous Treasures is not ready yet: …' }) }) + const result = await startStep().perform({ runId: 7, idempotencyKey: '7:2', params: { server: 'main', level: 0 } }) + assert.strictEqual(result.ok, false) + assert.strictEqual(result.retry, true) + assert.strictEqual(result.retryAfterMs, 600000) + assert.strictEqual(result.error, 'Dangerous Treasures is not ready yet on Main') +}) + +test('no spot retries on core’s ordinary delay; a refusal DT would repeat is not retried', async (t) => { + stubStart(t, { + reply: (body) => ok(body.level === 2 + ? { kind: 'dt.error', reason: 'dt-no-spot', message: 'Dangerous Treasures found no valid spot for an event' } + : { kind: 'dt.error', reason: 'refused', message: 'Dangerous Treasures did not start: Invalid difficulty' }), + }) + const step = startStep() + + const noSpot = await step.perform({ runId: 7, idempotencyKey: '7:3', params: { server: 'main', level: 2 } }) + assert.strictEqual(noSpot.ok, false) + assert.strictEqual(noSpot.retry, undefined) + assert.strictEqual(noSpot.retryAfterMs, undefined) + assert.match(noSpot.error, /no valid spot/) + + const refused = await step.perform({ runId: 7, idempotencyKey: '7:4', params: { server: 'main', level: 1 } }) + assert.strictEqual(refused.retry, false) + assert.match(refused.error, /Invalid difficulty/) + + for (const reason of ['dt-missing', 'console-unavailable', 'bad-level', 'malformed', 'events-disabled']) { + assert.ok(require('../eventDangerousTreasures').PERMANENT.has(reason), reason) + } +}) + +test('a game that is down is retried, and a repeat of a key opens nothing new', async (t) => { + stubStart(t, { reply: { ok: false, status: 'http-503' } }) + const down = await startStep().perform({ runId: 7, idempotencyKey: '7:5', params: { server: 'main', level: 1 } }) + assert.strictEqual(down.retry, undefined) + assert.match(down.error, /has no game connected/) + + stubStart(t, { reply: opened({ repeat: true }) }) + const again = await startStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', level: 1 } }) + assert.ok(again.ok) + assert.strictEqual(again.detail.repeat, true) +}) + +// ── Treasure Hunter (D375) ───────────────────────────────────────────────── + +test('the tally’s treasures won 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', treasuresWon: 3 } }) + + const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('treasures_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('treasures_won')], 3) +}) + +test('the leaderboard sorts by treasures won 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, treasuresWon: '4' }] : [])) + const events = require('../model/events/events.model') + + const [row] = await events.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'treasuresWon' }) + assert.strictEqual(row.treasuresWon, 4) + const board = rec.touching('rust_player_wipe_stats').at(-1) + assert.match(board.sql, /SUM\(s\.treasures_won\) AS treasuresWon/) + assert.match(board.sql, /ORDER BY[^]*treasures_won/) +}) + +// ── Triggers (D355, §14.3.9) ─────────────────────────────────────────────── + +test('a start and a win are public triggers, neither declaring a Steam id', () => { + const found = TRIGGERS.filter((t) => t.id.startsWith('rust.dt.')) + assert.deepStrictEqual(found.map((t) => t.id).sort(), ['rust.dt.started', 'rust.dt.won']) + 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 start and a win fire their triggers with what they declare; an ending fires 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: 'dt.started', frame: frame('dt.started', { key: '7:1' }) }) + await emit.onEvent(server, { id: 2, kind: 'dt.won', frame: won({ invaderCount: 3 }) }) + await emit.onEvent(server, { id: 3, kind: 'dt.ended', frame: frame('dt.ended') }) + + const calls = ctx.events.emit.calls + assert.strictEqual(calls.length, 2) + + const [startedId, started] = calls[0] + assert.strictEqual(startedId, 'rust.dt.started') + assert.strictEqual(started.data.byEvent, true) + assert.strictEqual(started.data.subject, 'main:dt.started') + assert.match(started.data.intro, /A Dangerous Treasures chest opened at [A-Z]+\d+ on Main/) + + const [wonId, winner] = calls[1] + assert.strictEqual(wonId, 'rust.dt.won') + assert.strictEqual(winner.data.player, 'Alice') + assert.strictEqual(winner.data.invaders, 3) + assert.match(winner.data.intro, /Alice won the treasure at [A-Z]+\d+ on Main, with 3 players fighting for it\./) + + 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 treasure tells nobody (D63)', async () => { + const { ctx } = withRecorder() + const emit = require('../engagement/emit') + emit.reset() + await emit.onEvent({ id: 'main', name: 'Main' }, { id: 1, kind: 'dt.won', frame: won({ t: NOW - 3 * 3600 * 1000 }) }) + assert.strictEqual(ctx.events.emit.calls.length, 0) +}) diff --git a/server/test/entry.test.js b/server/test/entry.test.js index 1a845f6..ede91de 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -159,6 +159,7 @@ test('the event verbs are registered, and every budget has a verb that spends it 'rust.backpack.size', 'rust.coins.pay', 'rust.crate.place', + 'rust.dt.start', 'rust.kit.entitle', 'rust.npc.place', 'rust.participation.collect', @@ -172,6 +173,7 @@ test('the event verbs are registered, and every budget has a verb that spends it 'rust.announcements', 'rust.backpacks', 'rust.coins', + 'rust.dt.events', 'rust.grants', 'rust.npcs', 'rust.permissions', @@ -203,8 +205,9 @@ test('the event verbs are registered, and every budget has a verb that spends it // What a teardown can give back is declared honestly: a line said, a // 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. - const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give', 'rust.world.call']) + // 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']) 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/titleConditions.test.js b/server/test/titleConditions.test.js index 7ed63fa..888d158 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-six categories carry a default title, and playtime carries none (D172, §11.2.2, D356)', () => { - assert.strictEqual(titles.CATEGORY_IDS.length, 26) +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) + assert.strictEqual(titles.STATS.treasureswon.title, 'Treasure Hunter') assert.strictEqual(titles.STATS.richest.title, 'Magnate') assert.strictEqual(titles.STATS.crateshacked.title, 'Safecracker') assert.strictEqual(titles.STATS.dropslooted.title, 'Scavenger') diff --git a/swagger-fragment.json b/swagger-fragment.json index aa4a673..31cae7a 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -1672,7 +1672,7 @@ "Admin · Rust" ], "summary": "Which optional mods a server has loaded", - "description": "Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why.", + "description": "Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why. `console` (protocol 14, D363) says whether the server’s framework runs another plugin’s console command from code, which a Start a Dangerous Treasures event step needs.", "parameters": [ { "name": "id", @@ -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).", + "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.", "parameters": [ { "name": "id", @@ -3282,7 +3282,7 @@ "name": "sort", "in": "query", "required": false, - "description": "kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, or balance (one wipe on a server with Economics)", + "description": "kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, or balance (one wipe on a server with Economics)", "schema": { "type": "string" } @@ -4920,6 +4920,7 @@ "missions", "crateshacked", "dropslooted", + "treasureswon", "richest", "playtime", "profilekills" @@ -5415,6 +5416,89 @@ } } } + }, + "dangerousTreasures": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "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": { + "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.0.0" + } + } + } + } + } + } + } + } + } + } + }, + "console": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Protocol 14 (D363): what the plugin’s console probe found at its load. Null from an older plugin, or when the game could not be asked." + }, + "properties": { + "type": "object", + "properties": { + "plugins": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Whether another plugin’s console command runs from code here." + }, + "example": { + "type": "boolean", + "example": true + } + } } } }