Stage 6 of the §14.3 integration plan, on protocol 14. Spec: docs rust-link/PROTOCOL.md §20.9. - gc.started and gc.ended public, gc.failed staff's; guardedCrate.js reads each row per viewer (the clearer's name for the presence audience, Steam id, position and key for staff, `won` for everyone). - Triggers rust.gc.started and rust.gc.ended (a crate a player cleared only). - The step rust.gc.place: an event from rust.options.gc_events and a world call's location; refused at verify for Guarded Crate absent, NpcSpawn missing (D364), console unavailable, or an unknown event. Kept (D373); the run log's success line says so (D380). Budget rust.gc.crates. - Warden (D375): guarded_won, title category guardedwon, Crates cleared column. - Map marker, server settings line, swagger, engagement manifest, bundle, README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
369 lines
18 KiB
JavaScript
369 lines
18 KiB
JavaScript
// ── Guarded Crate, with NpcSpawn (PLAN_REDESIGNS §14.3.5, protocol 14) ──────
|
||
//
|
||
// Each test is one way the feature could look right and be wrong:
|
||
//
|
||
// the clearer's name reaches a viewer below the presence audience (D376), or
|
||
// a Steam id, a position or a step's key reaches anybody but staff
|
||
// an ending with nobody clearing it reads as a win
|
||
// the step runs on a server that said it has no Guarded Crate, no NpcSpawn
|
||
// (D364), a framework that cannot run its command (D363), or no event of that
|
||
// name, or a server that has not said is refused
|
||
// the step's location is not a world call's, or the run log is not told the
|
||
// crate is kept (D373, D380)
|
||
// a refusal Guarded Crate would repeat is retried, or a crowded spot is not
|
||
// a retry of a key places a second crate
|
||
// the tally's crates cleared is dropped, or Warden's column and sort are missing
|
||
// a trigger declares a variable its emitter never fills, names a Steam id, or
|
||
// fires for a crate nobody cleared
|
||
|
||
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 gc = require('../guardedCrate')
|
||
const { gridOf } = require('../worldEvents')
|
||
const { TRIGGERS } = require('../engagement/triggers')
|
||
|
||
const A = '76561198000000001'
|
||
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: -604, z: -342, ...over })
|
||
const cleared = (over = {}) => frame('gc.ended', { steamId: A, name: 'Alice', ...over })
|
||
|
||
const REPORT = { loaded: true, version: '2.0.17', npcSpawn: { loaded: true, version: '3.4.8' }, events: ['Easy', 'Medium', 'Hard', 'Elite'] }
|
||
|
||
// ── Who may read what (D353, D376) ─────────────────────────────────────────
|
||
|
||
test('the public reads a guarded crate’s grid, never its position or the step’s key', () => {
|
||
const out = gc.shapeFor(frame('gc.started', { key: '7:1' }), { geometry: GEOMETRY })
|
||
assert.strictEqual(out.grid, gridOf(GEOMETRY, -604, -342))
|
||
assert.strictEqual(out.x, undefined)
|
||
assert.strictEqual(out.z, undefined)
|
||
assert.strictEqual(out.key, undefined)
|
||
assert.strictEqual(out.kind, 'gc.started')
|
||
})
|
||
|
||
test('a clearing names its clearer only to the presence audience, and the Steam id only to staff', () => {
|
||
const signedOut = gc.shapeFor(cleared(), { geometry: GEOMETRY })
|
||
assert.strictEqual(signedOut.won, true, 'that somebody cleared it names nobody')
|
||
assert.strictEqual(signedOut.name, undefined)
|
||
assert.strictEqual(signedOut.steamId, undefined)
|
||
|
||
const member = gc.shapeFor(cleared(), { presence: true, geometry: GEOMETRY })
|
||
assert.strictEqual(member.name, 'Alice')
|
||
assert.strictEqual(member.steamId, undefined)
|
||
|
||
const staff = gc.shapeFor(cleared({ key: '7:1' }), { staff: true, geometry: GEOMETRY })
|
||
assert.strictEqual(staff.name, 'Alice')
|
||
assert.strictEqual(staff.steamId, A)
|
||
assert.strictEqual(staff.x, -604)
|
||
assert.strictEqual(staff.key, '7:1')
|
||
})
|
||
|
||
test('an ending with nobody clearing it is not a win, for anybody', () => {
|
||
for (const view of [{}, { presence: true }, { staff: true }]) {
|
||
const out = gc.shapeFor(frame('gc.ended'), { ...view, geometry: GEOMETRY })
|
||
assert.strictEqual(out.won, false)
|
||
assert.strictEqual(out.name, undefined)
|
||
assert.strictEqual(out.steamId, undefined)
|
||
}
|
||
// A name without a Steam id is not a clearer either.
|
||
assert.strictEqual(gc.shapeFor(frame('gc.ended', { name: 'Alice' }), { staff: true }).name, undefined)
|
||
const other = { kind: 'player.death', x: 1 }
|
||
assert.strictEqual(gc.shapeFor(other, {}), other)
|
||
})
|
||
|
||
test('the feed reads a stored clearing for its viewer, the row’s own Steam id staff’s', async () => {
|
||
withRecorder((sql) => {
|
||
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'gc.ended', t: NOW, wipeId: 'w1', steamId: A, raw: JSON.stringify(cleared()) }]
|
||
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.strictEqual(signedOut.frame.won, true)
|
||
assert.ok(signedOut.frame.grid)
|
||
|
||
const [staff] = await events.recent({ serverId: 'main', admin: true })
|
||
assert.strictEqual(staff.steamId, A)
|
||
assert.strictEqual(staff.frame.name, 'Alice')
|
||
})
|
||
|
||
test('a server’s report is read leniently: what it never said is null', () => {
|
||
assert.deepStrictEqual(gc.reportOf(null), { guardedCrate: null, consolePlugins: null })
|
||
assert.deepStrictEqual(gc.reportOf({ guardedCrate: JSON.stringify(REPORT), console: '{"plugins":false}' }), {
|
||
guardedCrate: REPORT,
|
||
consolePlugins: false,
|
||
})
|
||
// A plugin that could not read the data file sends no events; NpcSpawn unsaid is null.
|
||
assert.deepStrictEqual(gc.reportOf({ guardedCrate: '{"loaded":true,"version":"2.0.17"}', console: null }).guardedCrate, {
|
||
loaded: true,
|
||
version: '2.0.17',
|
||
npcSpawn: null,
|
||
events: null,
|
||
})
|
||
assert.deepStrictEqual(gc.reportOf({ guardedCrate: 'not json', console: '{}' }), { guardedCrate: null, consolePlugins: null })
|
||
})
|
||
|
||
// ── The step ───────────────────────────────────────────────────────────────
|
||
|
||
const SERVERS = {
|
||
main: { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' },
|
||
pve: { id: 'pve', name: 'PvE', enabled: 1, sidecarBaseUrl: 'http://pve:1' },
|
||
}
|
||
|
||
/** The step against servers `main` and `pve`, whose last reports are `reports`, and a sidecar answering `reply`. */
|
||
function stubPlace(t, { reports = { main: { guardedCrate: REPORT, console: { plugins: true } } }, reply } = {}) {
|
||
const row = (id) => {
|
||
const r = reports[id]
|
||
return r ? { serverId: id, name: SERVERS[id].name, guardedCrate: JSON.stringify(r.guardedCrate ?? null), console: JSON.stringify(r.console ?? null) } : null
|
||
}
|
||
withRecorder((sql, params) => {
|
||
if (sql.includes('JOIN rust_server_state')) return Object.keys(reports).map(row)
|
||
if (sql.includes('FROM rust_server_state')) return row(params[0]) ? [row(params[0])] : []
|
||
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 = { gcPlace: client.gcPlace, getServer: serversDb.getServer }
|
||
const calls = []
|
||
serversDb.getServer = async (id) => SERVERS[id] || null
|
||
client.gcPlace = async (server, body) => {
|
||
calls.push({ server: server.id, body })
|
||
return typeof reply === 'function' ? reply(body) : reply
|
||
}
|
||
t.after(() => {
|
||
client.gcPlace = saved.gcPlace
|
||
serversDb.getServer = saved.getServer
|
||
})
|
||
return calls
|
||
}
|
||
|
||
const module_ = () => require('../eventGuardedCrate')
|
||
const placeStep = () => module_().ACTIONS[0]
|
||
const ok = (data) => ({ ok: true, status: 'ok', data })
|
||
const placed = (over = {}) => ok({ kind: 'gc.placed', key: '7:1', event: 'Elite', repeat: false, x: -604, z: -342, ...over })
|
||
|
||
test('the step is kept, priced one crate each, and has no revert (D373)', () => {
|
||
const step = placeStep()
|
||
assert.strictEqual(step.id, 'rust.gc.place')
|
||
assert.strictEqual(step.reversible, 'none')
|
||
assert.strictEqual(step.risk, 'change')
|
||
assert.strictEqual(step.revert, undefined)
|
||
assert.deepStrictEqual(step.cost({ event: 'Elite', monument: 'main/powerplant_1' }), { 'rust.gc.crates': 1 })
|
||
assert.match(step.description, /kept when the run ends/)
|
||
})
|
||
|
||
test('the event list is every server’s own, once each, and Guarded Crate’s defaults where none has said', async (t) => {
|
||
const [events] = module_().OPTION_SOURCES
|
||
stubPlace(t, {
|
||
reports: {
|
||
main: { guardedCrate: REPORT, console: { plugins: true } },
|
||
pve: { guardedCrate: { ...REPORT, events: ['easy', 'Boss Drop'] }, console: { plugins: true } },
|
||
},
|
||
})
|
||
assert.deepStrictEqual(await events.resolve(), [
|
||
{ value: 'Easy', label: 'Easy — Main, PvE' },
|
||
{ value: 'Medium', label: 'Medium — Main' },
|
||
{ value: 'Hard', label: 'Hard — Main' },
|
||
{ value: 'Elite', label: 'Elite — Main' },
|
||
{ value: 'Boss Drop', label: 'Boss Drop — PvE' },
|
||
])
|
||
|
||
stubPlace(t, { reports: { main: { guardedCrate: { loaded: false }, console: null } } })
|
||
assert.deepStrictEqual((await events.resolve()).map((e) => e.value), ['Easy', 'Medium', 'Hard', 'Elite'])
|
||
})
|
||
|
||
test('the dry run refuses what the server said it cannot do, and nothing else (D364)', async (t) => {
|
||
const run = (params) => placeStep().perform({ runId: 7, idempotencyKey: '7:1', params, verify: true })
|
||
const at = { event: 'Elite', monument: 'main/powerplant_1' }
|
||
|
||
stubPlace(t, { reports: { main: { guardedCrate: { loaded: false }, console: { plugins: true } } } })
|
||
const absent = await run(at)
|
||
assert.strictEqual(absent.retry, false)
|
||
assert.match(absent.error, /Guarded Crate is not installed on Main/)
|
||
|
||
stubPlace(t, { reports: { main: { guardedCrate: { ...REPORT, npcSpawn: { loaded: false } }, console: { plugins: true } } } })
|
||
const noGuards = await run(at)
|
||
assert.strictEqual(noGuards.retry, false)
|
||
assert.strictEqual(noGuards.error, 'Guarded Crate is loaded on Main but NpcSpawn is missing: its events cannot start')
|
||
|
||
stubPlace(t, { reports: { main: { guardedCrate: REPORT, console: { plugins: false } } } })
|
||
const carbon = await run(at)
|
||
assert.match(carbon.error, /cannot run Guarded Crate's console command/)
|
||
assert.match(carbon.error, /Rust-Plugins#34/)
|
||
|
||
const calls = stubPlace(t)
|
||
const unknown = await run({ ...at, event: 'Legendary' })
|
||
assert.strictEqual(unknown.retry, false)
|
||
assert.match(unknown.error, /"Legendary" is not one of Guarded Crate's events on Main \(Easy, Medium, Hard, Elite\)/)
|
||
assert.ok((await run({ ...at, event: 'elite' })).ok, 'a name is found ignoring case, as Guarded Crate finds it')
|
||
assert.match((await run({ monument: 'main/powerplant_1' })).error, /which of Guarded Crate's events/)
|
||
assert.match((await run({ event: 'Elite', x: 1, z: 2 })).error, /which server/)
|
||
assert.match((await run({ event: 'Elite', monument: 'main/powerplant_1', x: 1, z: 2 })).error, /exactly one of them/)
|
||
assert.strictEqual(calls.length, 0, 'the dry run places nothing')
|
||
|
||
// A plugin before protocol 14, or a server never reached, has not said: the plugin answers.
|
||
stubPlace(t, { reports: {} })
|
||
assert.ok((await run(at)).ok)
|
||
// Nor does a report that could not read the event names refuse a name.
|
||
stubPlace(t, { reports: { main: { guardedCrate: { ...REPORT, events: undefined }, console: { plugins: true } } } })
|
||
assert.ok((await run({ ...at, event: 'Boss Drop' })).ok)
|
||
})
|
||
|
||
test('a placement sends the key, the event and a world call’s location, and tells the run log the crate is kept', async (t) => {
|
||
const calls = stubPlace(t, { reply: placed() })
|
||
const result = await placeStep().perform({
|
||
runId: 7,
|
||
idempotencyKey: '7:1',
|
||
params: { event: 'Elite', monument: 'main/powerplant_1', offsetX: 10, offsetZ: -5, y: 40 },
|
||
})
|
||
assert.ok(result.ok)
|
||
assert.deepStrictEqual(calls, [{ server: 'main', body: { key: '7:1', event: 'Elite', monument: 'powerplant_1', offsetX: 10, offsetZ: -5 } }])
|
||
assert.strictEqual(result.detail.event, 'Elite')
|
||
assert.strictEqual(result.detail.grid, gridOf(GEOMETRY, -604, -342))
|
||
assert.strictEqual(result.detail.note, `The crate is ${module_().KEPT}`)
|
||
|
||
const byPoint = stubPlace(t, { reply: placed() })
|
||
await placeStep().perform({ runId: 7, idempotencyKey: '7:2', params: { event: 'Easy', server: 'main', x: -604, z: -342 } })
|
||
assert.deepStrictEqual(byPoint[0].body, { key: '7:2', event: 'Easy', x: -604, z: -342 })
|
||
})
|
||
|
||
test('a crowded spot and a game that is down are retried; a refusal Guarded Crate would repeat is not', async (t) => {
|
||
stubPlace(t, {
|
||
reply: (body) => ok(body.event === 'Hard'
|
||
? { kind: 'gc.error', reason: 'gc-too-close', message: 'another guarded crate is running within 80 m of that spot' }
|
||
: { kind: 'gc.error', reason: 'npcspawn-missing', message: 'Guarded Crate is loaded but NpcSpawn is missing: its events cannot start' }),
|
||
})
|
||
const step = placeStep()
|
||
const params = (event) => ({ event, monument: 'main/powerplant_1' })
|
||
|
||
const crowded = await step.perform({ runId: 7, idempotencyKey: '7:3', params: params('Hard') })
|
||
assert.strictEqual(crowded.ok, false)
|
||
assert.strictEqual(crowded.retry, undefined)
|
||
assert.match(crowded.error, /within 80 m/)
|
||
|
||
const noGuards = await step.perform({ runId: 7, idempotencyKey: '7:4', params: params('Easy') })
|
||
assert.strictEqual(noGuards.retry, false)
|
||
assert.match(noGuards.error, /NpcSpawn is missing/)
|
||
|
||
for (const reason of ['gc-missing', 'npcspawn-missing', 'console-unavailable', 'bad-event', 'bad-position', 'refused', 'no-monument', 'off-map', 'malformed', 'events-disabled']) {
|
||
assert.ok(module_().PERMANENT.has(reason), reason)
|
||
}
|
||
assert.ok(!module_().PERMANENT.has('gc-too-close'))
|
||
|
||
stubPlace(t, { reply: { ok: false, status: 'http-503' } })
|
||
const down = await step.perform({ runId: 7, idempotencyKey: '7:5', params: params('Elite') })
|
||
assert.strictEqual(down.retry, undefined)
|
||
assert.match(down.error, /has no game connected/)
|
||
})
|
||
|
||
test('a repeat of a key places nothing new, and says so beside the crate being kept', async (t) => {
|
||
stubPlace(t, { reply: placed({ repeat: true }) })
|
||
const again = await placeStep().perform({ runId: 7, idempotencyKey: '7:1', params: { event: 'Elite', monument: 'main/powerplant_1' } })
|
||
assert.ok(again.ok)
|
||
assert.strictEqual(again.detail.repeat, true)
|
||
assert.match(again.detail.note, /^answered from the first attempt; nothing new was placed\. The crate is kept/)
|
||
})
|
||
|
||
// ── Warden (D375) ──────────────────────────────────────────────────────────
|
||
|
||
test('the tally’s crates cleared 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', guardedWon: 2 } })
|
||
|
||
const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('guarded_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('guarded_won')], 2)
|
||
})
|
||
|
||
test('the leaderboard sorts by crates cleared 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, guardedWon: '3' }] : []))
|
||
const events = require('../model/events/events.model')
|
||
|
||
const [row] = await events.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'guardedWon' })
|
||
assert.strictEqual(row.guardedWon, 3)
|
||
const board = rec.touching('rust_player_wipe_stats').at(-1)
|
||
assert.match(board.sql, /SUM\(s\.guarded_won\) AS guardedWon/)
|
||
assert.match(board.sql, /ORDER BY[^]*guarded_won/)
|
||
})
|
||
|
||
// ── Triggers (D355, §14.3.9) ───────────────────────────────────────────────
|
||
|
||
test('a landing and a clearing are public triggers, neither declaring a Steam id', () => {
|
||
const found = TRIGGERS.filter((t) => t.id.startsWith('rust.gc.'))
|
||
assert.deepStrictEqual(found.map((t) => t.id).sort(), ['rust.gc.ended', 'rust.gc.started'])
|
||
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 landing and a clearing fire with what they declare; nobody clearing it and a timeout fire 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: 'gc.started', frame: frame('gc.started', { key: '7:1' }) })
|
||
await emit.onEvent(server, { id: 2, kind: 'gc.ended', frame: cleared() })
|
||
await emit.onEvent(server, { id: 3, kind: 'gc.ended', frame: frame('gc.ended') })
|
||
await emit.onEvent(server, { id: 4, kind: 'gc.failed', frame: frame('gc.failed') })
|
||
|
||
const calls = ctx.events.emit.calls
|
||
assert.strictEqual(calls.length, 2)
|
||
|
||
const [startedId, started] = calls[0]
|
||
assert.strictEqual(startedId, 'rust.gc.started')
|
||
assert.strictEqual(started.data.byEvent, true)
|
||
assert.strictEqual(started.data.subject, 'main:gc.started')
|
||
assert.match(started.data.intro, /A guarded crate landed at [A-Z]+\d+ on Main\./)
|
||
|
||
const [endedId, ended] = calls[1]
|
||
assert.strictEqual(endedId, 'rust.gc.ended')
|
||
assert.strictEqual(ended.data.player, 'Alice')
|
||
assert.match(ended.data.intro, /Alice cleared the guarded crate at [A-Z]+\d+ on Main\./)
|
||
|
||
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 crate tells nobody (D63)', async () => {
|
||
const { ctx } = withRecorder()
|
||
const emit = require('../engagement/emit')
|
||
emit.reset()
|
||
await emit.onEvent({ id: 'main', name: 'Main' }, { id: 1, kind: 'gc.ended', frame: cleared({ t: NOW - 3 * 3600 * 1000 }) })
|
||
assert.strictEqual(ctx.events.emit.calls.length, 0)
|
||
})
|