Files
Module-Rust/server/test/worldEvents.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

435 lines
23 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 Travelling Vendor already out is no failure: the step says so and counts nothing called (W3)', async (t) => {
stubCall(t, ok({ kind: 'world.error', reason: 'already-out', message: 'a Travelling Vendor is already out' }))
const result = await callStep().perform({ runId: 7, idempotencyKey: '7:6', params: { server: 'main', event: 'travelling_vendor' } })
assert.strictEqual(result.ok, true)
assert.deepStrictEqual(result.detail, {
event: 'travelling_vendor',
called: 0,
note: 'a Travelling Vendor is already out on Main, and the game runs one at a time; nothing new was called',
})
})
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)
})