feat(rust): Dangerous Treasures — feed, map, Treasure Hunter, Start a Dangerous Treasures event (PLAN_REDESIGNS §14.3.4)
All checks were successful
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in 46s
PR Checks / server-tests (pull_request) Successful in 7m23s

Stage 5 of the §14.3 integration plan, on protocol 14 (docs
rust-link/PROTOCOL.md §20.8).

- dt.started and dt.won are public kinds, dt.ended a staff kind. The new
  dangerousTreasures.js reads each row per viewer: grid and invaderCount for
  everyone, the winner's name for the presence audience (D376), the position,
  the step's key, the winner's Steam id and the invaders for staff.
- Feed lines in the World events filter: "A Dangerous Treasures event has
  started · K12", "Alice won the treasure · K12 · 3 players fought for it".
- Triggers rust.dt.started (with byEvent) and rust.dt.won, public, cooldown
  subject the server, names only.
- eventDangerousTreasures.js: rust.dt.start, "Start a Dangerous Treasures
  event" (risk change, reversible none, budget rust.dt.events), server and
  difficulty. Refused at verify where the server said DT is not loaded or
  console.plugins is false (Rust-Plugins#34). dt-not-ready answers "Dangerous
  Treasures is not ready yet" with retryAfterMs 600000 (D365, D371);
  dt-no-spot retries on core's delay; DT's own refusals are permanent.
- Treasure Hunter (D375): treasuresWon -> treasures_won, the title category
  treasureswon, and a sortable Treasures column shown where the server
  reports DT or a row counts one.
- The map draws a running chest on the world layer; the server settings'
  plugin line names DT and whether a step can start one, from the
  integrations read's new `console`.
- coreApi ^1.15.0, ci/core-ref.json pinned to website main at #215's merge.
  ci/bundle.json ships the two new files; swagger fragment and engagement
  manifest regenerated; README lists DT.

Server 627/627 (16 new in test/dangerousTreasures.test.js) and every check;
client 92/92, build and externals. Booted against the pinned core: 45
triggers, 13 actions, 11 budgets, 21 option sources; 89 routes unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-15 06:45:30 -05:00
parent 6802dbf5f9
commit bfe9b5b18d
32 changed files with 1136 additions and 33 deletions

View File

@@ -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',
])
/**

View File

@@ -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 <level>` 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 }

View File

@@ -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;

View File

@@ -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,

View File

@@ -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])))

View File

@@ -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 <level>`, 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 }

View File

@@ -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,
})
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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 } },

View File

@@ -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 })

View File

@@ -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' } */

View File

@@ -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 })

View File

@@ -82,10 +82,10 @@ rustRouter.get(
'/servers/:id/leaderboard',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The leaderboard for one Rust server'
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336).'
// #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,

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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'])
})

View File

@@ -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)
})

View File

@@ -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.

View File

@@ -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')