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
317 lines
16 KiB
JavaScript
317 lines
16 KiB
JavaScript
// ── 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)
|
||
})
|