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
This commit is contained in:
395
server/test/worldEvents.test.js
Normal file
395
server/test/worldEvents.test.js
Normal file
@@ -0,0 +1,395 @@
|
||||
// ── 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)
|
||||
})
|
||||
Reference in New Issue
Block a user