Files
Module-Rust/server/test/worldEvents.test.js
wtclaude 8aff9b33a6
All checks were successful
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 48s
PR Checks / server-tests (pull_request) Successful in 7m16s
fix(rust): send reward notices, and keep a downed line's credit rule (W21, W4)
W21: the permission push named its fields one by one and left out
`notices`, so no kit reward, lend or give ever told its player
anything (D333). The body is now built by `pushBody`, which carries
every field the payload has; a test fails if a payload field is
dropped again.

W4: a downed patrol helicopter, Chinook or Bradley is stored with the
credit rule in force when it arrived, and the feed reads that stamp.
Before, the line re-credited past rows with the server's current rule
while the Rotorbane/Tankbreaker counts kept the old one. Rows stored
before this change keep today's reading.

PLAN_LAST_FIXES.md stage 1.

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

424 lines
22 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('a downed one is stored with the rule that credited it; an arrival is stored as sent (W4)', async () => {
const { apply } = require('../ingest')
const storedRaw = async (frame) => {
const rec = withRecorder()
await apply('main', { id: 1, kind: 'world.event', frame }, { id: 'main', creditRule: 'all' })
const insert = rec.touching('rust_events').find((s) => /INSERT/i.test(s.sql))
return JSON.parse(insert.params.find((p) => typeof p === 'string' && p.startsWith('{')))
}
assert.strictEqual((await storedRaw(downed())).creditRule, 'all')
assert.strictEqual((await storedRaw(world('heli.arrived', { x: 1, z: 1 }))).creditRule, undefined)
})
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' }])
})
test('a line keeps the rule it was stored with after the server’s rule changes (W4)', async () => {
const stamped = downed({ creditRule: 'all' })
withRecorder((sql) => {
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'world.event', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stamped) }]
if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', creditRule: 'most', enabled: 1 }]
return []
})
const events = require('../model/events/events.model')
const [member] = await events.recent({ serverId: 'main', presence: true })
assert.strictEqual(member.frame.creditRule, 'all')
assert.strictEqual(member.frame.creditedCount, 2)
assert.deepStrictEqual(member.frame.credited.map((c) => c.name).sort(), ['Alice', '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)
})