Files
Module-Rust/server/test/dangerousTreasures.test.js
wtclaude bfe9b5b18d
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
feat(rust): Dangerous Treasures — feed, map, Treasure Hunter, Start a Dangerous Treasures event (PLAN_REDESIGNS §14.3.4)
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
2026-10-15 06:45:30 -05:00

317 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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)
})