Files
Module-Rust/server/test/dangerousTreasures.test.js
wtclaude 3aa142bf06
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 32s
PR Checks / frozen-manifest (pull_request) Successful in 44s
fix(rust): the last fixes' small ones (W3 W6 W7/W11 W8 W10 W13-W18)
Stage 2 of PLAN_LAST_FIXES (docs#366).

- W7/W11: an "is it loaded" refusal is confirmed live before it is given.
  New liveStatus.js asks the sidecar once (boot.refreshOne, as a poll tick
  would), at most once per server per 5 s and waiting at most 3 s, then
  reads the stored status again. Used by Economics (step and staff page),
  Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes,
  TruePVE and RunicNPC.
- W3: a world call answered `already-out` (the bridge half comes in stage
  3) succeeds as called 0, saying a Travelling Vendor is already out.
- W6: "A locked crate dropped by a Chinook is being hacked."
- W8: a RaidableBases profile's audit row names RaidableBases, not "@data".
- W10: the backpack sizes picker lists each size once, naming its servers.
- W13: a RaidableBases name both servers describe differently shows each
  server's own description.
- W14: each RaidableBases note after the first starts its own sentence.
- W15: a staff deposit refused at Economics' limit answers 409.
- W16: a payment's failedNote explains only the reasons that happened.
- W17: the sync state counts player grants that did not land
  (waitingToLand), and the Permissions page says "in sync · 1 waits for
  their player to join" instead of plain "in sync".
- W18: Guarded Crate's kept note drops the "30 minutes" it could not know.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-21 07:42:24 -05:00

321 lines
16 KiB
JavaScript
Raw Permalink 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()
// The stored status is the whole answer in these tests: nothing asks a
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
require('../liveStatus').refresh = async () => false
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)
})