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

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