Files
Module-Rust/server/test/worldEvents.test.js
wtclaude e7d5c8ed1a
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 36s
PR Checks / frozen-manifest (pull_request) Successful in -1m32s
feat(rust): Rust's own world events — feed, credit rule, triggers, Call a world event (PLAN_REDESIGNS §14.3.1)
The site half of stage 1, on protocol 14:

- server/worldEvents.js: the 24 types, D372's four credit rules, the grid
  from the map's geometry (none off the map), and the per-viewer reading.
- The feed serves `world.event` (a public kind): an unknown type is staff's;
  the event and its grid are public; names only for the presence audience
  (D376); Steam ids and positions only for staff (D353). A downed one carries
  `credited`/`creditedCount` by the server's rule. Client lines for all 24
  types and a World events filter.
- rust_servers.credit_rule (blow, last, most, all; default blow), set on the
  server's settings. Ingest counts heli_kills (patrol helicopters and
  Chinooks, D379) and apc_kills from `world.event` by that rule, so the line
  and Rotorbane / Tankbreaker agree. A protocol-13 tally's heliKills/apcKills
  still add as before.
- The tally's cratesHacked / dropsLooted → crates_hacked / drops_looted;
  title categories Safecracker and Scavenger (D356); leaderboard columns
  Crates hacked and Drops looted, sortable (D377).
- 24 engagement triggers rust.world.<event> (D355): ceiling everyone,
  cooldown subject the server and event (crates add where), names only.
- server/eventWorldCall.js: rust.world.call, "Call a world event"
  (risk change, reversible none, budget rust.world.calls). A position for an
  airdrop or a patrol helicopter only; the Chinook takes none (D378).

Server 585/585 (22 new in test/worldEvents.test.js), client 84/84, every
check; booted against the pinned core (43 triggers, 12 actions, 10 budgets,
20 option sources), routes.manifest unchanged at 89. Engagement manifest and
swagger fragment regenerated.

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

396 lines
20 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.

// ── Rust's own world events (PLAN_REDESIGNS §14.3.1, protocol 14) ──────────
//
// Each test is one way the feature could look right and be wrong:
//
// the catalogue drifts from the protocol's 24 types, and a new one is public
// the four credit rules (D372) disagree with their own table — or with the
// count, so the line says Alice and Rotorbane counts Bob
// a name reaches a viewer below the presence audience (D376), a Steam id or a
// position reaches anybody but staff (D353)
// a grid is clamped onto the edge of the map for something off it
// the tally's two new counts are dropped on the floor
// a trigger declares a variable its emitter never fills, or names a Steam id
// the call step takes a position for an event that has no route to one (D378)
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 worldEvents = require('../worldEvents')
const catalogue = require('../catalogue')
const { TRIGGERS } = require('../engagement/triggers')
const A = '76561198000000001'
const B = '76561198000000002'
const C = '76561198000000003'
const NOW = Date.now()
/** A 6000 map of the game's own 41 cells of 146.3 m — the rigs' geometry. */
const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 }
const world = (event, over = {}) => ({ kind: 'world.event', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', event, ...over })
/** Bob did 61 %, Alice the crippling hit — D372's own example. */
const downed = (over = {}) => world('heli.downed', {
x: 812,
z: -340.5,
credit: { blow: A, last: B, most: B, damagers: [{ steamId: B, damage: 6120 }, { steamId: A, damage: 3880 }] },
names: { [A]: 'Alice', [B]: 'Bob' },
...over,
})
// ── The catalogue ──────────────────────────────────────────────────────────
test('the types are exactly the 24 the protocol defines, and an unknown one is staff only', () => {
// Sourced from docs/rust-link/PROTOCOL.md §20.5 (PLAN_REDESIGNS §14.3.1, D379).
const PROTOCOL_14 = [
'heli.arrived', 'heli.left', 'heli.downed',
'bradley.spawned', 'bradley.destroyed',
'cargo.arrived', 'cargo.docking', 'cargo.docked', 'cargo.undocked', 'cargo.left',
'chinook.arrived', 'chinook.dropped', 'chinook.downed',
'airdrop.called', 'airdrop.landed', 'airdrop.looted',
'crate.hack.started', 'crate.unlocked', 'crate.looted',
'vendor.arrived', 'vendor.left',
'satellite.crashed',
'deepsea.opened', 'deepsea.closed',
]
assert.deepStrictEqual([...worldEvents.TYPE_IDS].sort(), [...PROTOCOL_14].sort())
assert.ok(catalogue.isPublic('world.event'))
// Default deny, per type: a newer plugin's type is not served to the public.
const future = world('meteor.landed', { x: 1, z: 1, steamId: A, name: 'Alice' })
assert.strictEqual(worldEvents.shapeFor(future, { presence: true }), null)
assert.strictEqual(worldEvents.shapeFor(future, { staff: true }).event, 'meteor.landed')
})
// ── Credit (D372, D379) ────────────────────────────────────────────────────
test('each of the four credit rules picks what its table says', () => {
assert.deepStrictEqual(worldEvents.credited(downed(), 'blow'), [A])
assert.deepStrictEqual(worldEvents.credited(downed(), 'last'), [B])
assert.deepStrictEqual(worldEvents.credited(downed(), 'most'), [B])
assert.deepStrictEqual(worldEvents.credited(downed(), 'all'), [B, A])
// An unknown rule is the default, the killing blow.
assert.deepStrictEqual(worldEvents.credited(downed(), 'loudest'), [A])
})
test('with no killing blow, the killing-blow rule falls back to the last hit', () => {
const noBlow = downed({ credit: { last: B, most: A, damagers: [{ steamId: A, damage: 9 }, { steamId: B, damage: 1 }] } })
assert.deepStrictEqual(worldEvents.credited(noBlow, 'blow'), [B])
assert.deepStrictEqual(worldEvents.credited(noBlow, 'most'), [A])
})
test('a death nobody touched credits nobody under any rule, and counts nothing', () => {
const nobody = world('bradley.destroyed', { where: 'road', x: 0, z: 0, credit: { damagers: [] } })
for (const rule of worldEvents.CREDIT_RULE_IDS) {
assert.deepStrictEqual(worldEvents.credited(nobody, rule), [], rule)
assert.deepStrictEqual(worldEvents.killsOf(nobody, rule), [], rule)
}
})
test('the count and the line credit the same players, under every rule', () => {
for (const rule of worldEvents.CREDIT_RULE_IDS) {
const line = worldEvents.shapeFor(downed(), { presence: true, rule }).credited.map((c) => c.name)
const count = worldEvents.killsOf(downed(), rule).map((k) => k.name)
assert.deepStrictEqual(line, count, rule)
}
// Each kill is the column the type counts toward: Rotorbane for both helicopters, Tankbreaker for Bradley.
assert.strictEqual(worldEvents.killsOf(downed(), 'blow')[0].field, 'heliKills')
assert.strictEqual(worldEvents.killsOf(world('chinook.downed', { credit: { blow: A } }), 'blow')[0].field, 'heliKills')
assert.strictEqual(worldEvents.killsOf(world('bradley.destroyed', { where: 'launch_site', credit: { blow: A } }), 'blow')[0].field, 'apcKills')
// Nothing else counts.
assert.deepStrictEqual(worldEvents.killsOf(world('heli.arrived'), 'blow'), [])
})
test('a junk id in a credit block is never credited', () => {
const junk = downed({ credit: { blow: 'not-a-steam-id', last: '123', damagers: [{ steamId: '{}', damage: 1 }] } })
for (const rule of worldEvents.CREDIT_RULE_IDS) assert.deepStrictEqual(worldEvents.credited(junk, rule), [], rule)
})
// ── Who may read what (D353, D376) ─────────────────────────────────────────
test('below the presence audience a line names nobody, and carries no position', () => {
const signedOut = worldEvents.shapeFor(downed(), { presence: false, rule: 'blow', geometry: GEOMETRY })
assert.strictEqual(signedOut.event, 'heli.downed')
assert.strictEqual(signedOut.grid, worldEvents.gridOf(GEOMETRY, 812, -340.5))
assert.strictEqual(signedOut.creditedCount, 1)
assert.strictEqual(signedOut.credited, undefined)
for (const field of ['x', 'z', 'credit', 'names', 'steamId', 'name']) assert.strictEqual(signedOut[field], undefined, field)
const hack = worldEvents.shapeFor(world('crate.hack.started', { where: 'oilrig_large', steamId: A, name: 'Alice', x: 1, z: 1 }), { presence: false })
assert.strictEqual(hack.name, undefined)
assert.strictEqual(hack.steamId, undefined)
assert.strictEqual(hack.where, 'oilrig_large')
})
test('the presence audience reads names but no Steam id; staff read both, and the position', () => {
const member = worldEvents.shapeFor(downed(), { presence: true, rule: 'all' })
assert.deepStrictEqual(member.credited, [{ name: 'Bob' }, { name: 'Alice' }])
assert.strictEqual(member.x, undefined)
const staff = worldEvents.shapeFor(downed(), { staff: true, presence: true, rule: 'all' })
assert.deepStrictEqual(staff.credited, [{ name: 'Bob', steamId: B }, { name: 'Alice', steamId: A }])
assert.strictEqual(staff.x, 812)
assert.ok(staff.credit, 'staff see the whole credit block')
const loot = world('airdrop.looted', { steamId: A, name: 'Alice', x: 5, z: 5 })
assert.deepStrictEqual(
[worldEvents.shapeFor(loot, { presence: true }).name, worldEvents.shapeFor(loot, { presence: true }).steamId],
['Alice', undefined],
)
assert.strictEqual(worldEvents.shapeFor(loot, { staff: true, presence: true }).steamId, A)
})
test('a line with nobody in it reads the same for everybody', () => {
const docking = world('cargo.docking', { harbour: 'harbor_1', harbourName: 'Harbor', x: 100, z: 100 })
const a = worldEvents.shapeFor(docking, { presence: false, geometry: GEOMETRY })
const b = worldEvents.shapeFor(docking, { presence: true, geometry: GEOMETRY })
assert.deepStrictEqual(a, b)
assert.strictEqual(a.harbourName, 'Harbor')
})
// ── The grid ───────────────────────────────────────────────────────────────
test('the grid is the game’s, and nothing off the map gets one', () => {
// The north-west corner is A0; the 41st column is AO.
assert.strictEqual(worldEvents.gridOf(GEOMETRY, -2999, 2999), 'A0')
assert.strictEqual(worldEvents.gridOf(GEOMETRY, 2999, -2999), 'AO40')
// The Deep Sea and its ghost ships sit thousands of metres off the map.
assert.strictEqual(worldEvents.gridOf(GEOMETRY, -5200, 0), null)
// No geometry yet, or no position: no grid, rather than a guess.
assert.strictEqual(worldEvents.gridOf(null, 0, 0), null)
assert.strictEqual(worldEvents.gridOf(GEOMETRY, undefined, 0), null)
})
// ── Ingest ─────────────────────────────────────────────────────────────────
test('a downed helicopter counts toward Rotorbane for whom the server’s rule credits', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', { id: 1, kind: 'world.event', frame: downed() }, { id: 'main', creditRule: 'all' })
const adds = rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('heli_kills'))
assert.strictEqual(adds.length, 2)
assert.deepStrictEqual(adds.map((s) => s.params[2]).sort(), [A, B].sort())
// Under the default rule, only the killing blow.
const rec2 = withRecorder()
await apply('main', { id: 2, kind: 'world.event', frame: downed() }, { id: 'main' })
const once = rec2.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('heli_kills'))
assert.strictEqual(once.length, 1)
assert.strictEqual(once[0].params[2], A)
})
test('a Bradley counts toward Tankbreaker, and an arrival counts nothing', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
await apply('main', { id: 1, kind: 'world.event', frame: world('bradley.destroyed', { where: 'launch_site', credit: { blow: C }, names: { [C]: 'Cleo' } }) }, { id: 'main' })
const apc = rec.touching('rust_player_wipe_stats').filter((s) => s.sql.includes('apc_kills'))
assert.strictEqual(apc.length, 1)
assert.strictEqual(apc[0].params[2], C)
const rec2 = withRecorder()
await apply('main', { id: 2, kind: 'world.event', frame: world('heli.arrived', { x: 1, z: 1 }) }, { id: 'main' })
assert.strictEqual(rec2.touching('rust_player_wipe_stats').length, 0)
})
test('the tally’s crates hacked and drops looted are added up (D356)', 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', cratesHacked: 2, dropsLooted: 1 } })
const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('crates_hacked'))
assert.ok(titleAdd, 'the tally reached the title columns')
assert.ok(titleAdd.sql.includes('drops_looted'))
const cols = titleAdd.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+)\)/)[1].split(', ')
assert.strictEqual(titleAdd.params[3 + cols.indexOf('crates_hacked')], 2)
assert.strictEqual(titleAdd.params[3 + cols.indexOf('drops_looted')], 1)
})
// ── The feed ───────────────────────────────────────────────────────────────
test('the feed reads each world event for its viewer, with the server’s rule and grid', async () => {
const stored = downed()
withRecorder((sql) => {
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'world.event', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stored) }]
if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', creditRule: 'most', enabled: 1 }]
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.frame.creditRule, 'most')
assert.strictEqual(signedOut.frame.credited, undefined)
assert.strictEqual(signedOut.frame.grid, worldEvents.gridOf(GEOMETRY, 812, -340.5))
const [member] = await events.recent({ serverId: 'main', presence: true })
assert.deepStrictEqual(member.frame.credited, [{ name: 'Bob' }])
})
// ── Triggers (D355) ────────────────────────────────────────────────────────
test('one public trigger per type, none of them declaring a Steam id', () => {
const world = TRIGGERS.filter((t) => t.id.startsWith('rust.world.'))
assert.deepStrictEqual(world.map((t) => t.id.slice('rust.world.'.length)).sort(), [...worldEvents.TYPE_IDS].sort())
for (const t of world) {
assert.strictEqual(t.ceiling, 'everyone', t.id)
assert.strictEqual(t.subjectKey, 'subject', t.id)
assert.ok(t.variables.some((v) => v.name === 'subject'), t.id)
assert.ok(!t.variables.some((v) => /steam/i.test(v.name)), `${t.id} declares a Steam id`)
}
})
test('a world event fires its trigger with the variables it declares, and a headline', async (t) => {
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', creditRule: 'all' }
await emit.onEvent(server, { id: 1, kind: 'world.event', frame: downed() })
await emit.onEvent(server, { id: 2, kind: 'world.event', frame: world('crate.hack.started', { where: 'oilrig_small', steamId: A, name: 'Alice', x: 1, z: 1 }) })
await emit.onEvent(server, { id: 3, kind: 'world.event', frame: world('meteor.landed') })
const calls = ctx.events.emit.calls
assert.strictEqual(calls.length, 2, 'an unknown type fires nothing')
const [heliId, heli] = calls[0]
assert.strictEqual(heliId, 'rust.world.heli.downed')
assert.strictEqual(heli.data.player, 'Bob and Alice')
assert.strictEqual(heli.data.credited, 2)
assert.strictEqual(heli.data.subject, 'main:heli.downed')
assert.ok(heli.data.grid)
assert.match(heli.data.intro, /downed by Bob and Alice at /)
const [crateId, crate] = calls[1]
assert.strictEqual(crateId, 'rust.world.crate.hack.started')
assert.strictEqual(crate.data.subject, 'main:crate.hack.started:oilrig_small')
assert.match(crate.data.intro, /Alice started hacking the locked crate on Small Oil Rig on Main/)
// Every variable a trigger fills is one it declares.
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 world event tells nobody (D63)', async () => {
const { ctx } = withRecorder()
const emit = require('../engagement/emit')
emit.reset()
await emit.onEvent({ id: 'main', name: 'Main' }, { id: 1, kind: 'world.event', frame: world('heli.arrived', { t: NOW - 3 * 3600 * 1000 }) })
assert.strictEqual(ctx.events.emit.calls.length, 0)
})
// ── The call step (D354, D367, D378) ───────────────────────────────────────
function stubCall(t, reply) {
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const saved = { worldCall: client.worldCall, getServer: serversDb.getServer }
const calls = []
serversDb.getServer = async (id) => (id === 'main' ? { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' } : null)
client.worldCall = async (server, body) => {
calls.push({ server: server.id, body })
return typeof reply === 'function' ? reply(body) : reply
}
t.after(() => {
client.worldCall = saved.worldCall
serversDb.getServer = saved.getServer
})
return calls
}
const callStep = () => require('../eventWorldCall').ACTIONS[0]
const ok = (data) => ({ ok: true, status: 'ok', data })
test('the call step is kept, priced per event, and mirrors the plugin’s list', () => {
const step = callStep()
assert.strictEqual(step.id, 'rust.world.call')
assert.strictEqual(step.reversible, 'none')
assert.strictEqual(step.risk, 'change')
assert.strictEqual(step.revert, undefined)
assert.deepStrictEqual(step.cost({ event: 'airdrop', count: 3 }), { 'rust.world.calls': 3 })
assert.deepStrictEqual(step.cost({ event: 'cargo' }), { 'rust.world.calls': 1 })
const { EVENTS } = require('../eventWorldCall')
assert.deepStrictEqual(EVENTS.map((e) => e.value).sort(), ['airdrop', 'bradley_road', 'cargo', 'chinook', 'f15', 'patrol_heli', 'satellite', 'travelling_vendor'])
assert.deepStrictEqual(EVENTS.filter((e) => e.placeable).map((e) => e.value).sort(), ['airdrop', 'patrol_heli'])
})
test('a call is refused in the dry run for a bad event, a bad count, a position it cannot take, or no server', async (t) => {
const calls = stubCall(t, ok({ kind: 'world.called', called: 1 }))
const step = callStep()
const run = (params) => step.perform({ runId: 7, idempotencyKey: '7:1', params, verify: true })
assert.match((await run({ server: 'main', event: 'meteor' })).error, /not one of the world events/)
assert.match((await run({ server: 'main', event: 'cargo', count: 2 })).error, /call 1 to 1/)
assert.match((await run({ server: 'main', event: 'airdrop', count: 6 })).error, /call 1 to 5/)
// D378: the Chinook goes where the game chooses.
assert.match((await run({ server: 'main', event: 'chinook', x: 10, z: 10 })).error, /only an airdrop and a patrol helicopter take a position/)
assert.match((await run({ event: 'airdrop' })).error, /which server/)
assert.ok((await run({ server: 'main', event: 'airdrop', x: 10, z: 10 })).ok)
for (const r of [await run({ server: 'main', event: 'meteor' })]) assert.strictEqual(r.retry, false)
assert.strictEqual(calls.length, 0, 'the dry run calls nothing')
})
test('a call sends the key, the run and the position, and reads the plugin’s answer', async (t) => {
const calls = stubCall(t, (body) => ok({ kind: 'world.called', key: body.key, event: body.event, called: body.count, repeat: false }))
const step = callStep()
const result = await step.perform({ runId: 7, idempotencyKey: '7:2', params: { event: 'airdrop', count: 2, monument: 'main/powerplant_1', offsetX: 10, y: 99 } })
assert.ok(result.ok)
assert.deepStrictEqual(result.detail, { event: 'airdrop', called: 2 })
assert.deepStrictEqual(calls[0], {
server: 'main',
body: { runId: '7', key: '7:2', event: 'airdrop', count: 2, monument: 'powerplant_1', offsetX: 10, offsetZ: 0 },
})
})
test('a refusal the plugin would repeat is not retried; a game that is down is', async (t) => {
stubCall(t, (body) => (body.event === 'cargo'
? ok({ kind: 'world.error', reason: 'too-many', message: 'a run calls at most 10 world events on this server' })
: { ok: false, status: 'http-503' }))
const step = callStep()
const refused = await step.perform({ runId: 7, idempotencyKey: '7:3', params: { server: 'main', event: 'cargo' } })
assert.strictEqual(refused.ok, false)
assert.strictEqual(refused.retry, false)
assert.match(refused.error, /at most 10/)
const down = await step.perform({ runId: 7, idempotencyKey: '7:4', params: { server: 'main', event: 'airdrop' } })
assert.strictEqual(down.ok, false)
assert.strictEqual(down.retry, undefined)
assert.match(down.error, /has no game connected/)
})
test('a repeat of a key says so and calls nothing new', async (t) => {
stubCall(t, ok({ kind: 'world.called', called: 1, repeat: true }))
const result = await callStep().perform({ runId: 7, idempotencyKey: '7:5', params: { server: 'main', event: 'patrol_heli' } })
assert.ok(result.ok)
assert.strictEqual(result.detail.repeat, true)
})