Registers the engagement set R7 put in v1: thirteen triggers, four push streams, three audiences, four bodies (two triggers, email and in-app) and thirteen disabled rules in seven groups (PLAN.md §25, D59-D68). The raid alert goes to everyone authorised on the tool cupboard, one emit per linked person with ownerUserId, so the owner ceiling holds per emit. It covers doors and walls (protocol 7), never names the raider, alerts nobody when there is no cupboard, and carries ownerOnline so "offline only" is the seeded rule's condition rather than code. The fan-out runs off ingest before a frame is applied, since applying a disband deletes the roster the notice is sent to. A replayed event is told only while it is news: 15 minutes for broadcasts, 24 hours for personal and staff events. Dedupe keys come from the event, not the sidecar's row id. Server online/offline and a new kills leader are in-memory transitions, never on first sight, and a tie is not a lead. A login with no approval within a minute becomes a staff notice via a query, so a restart loses nothing. Also fixes a phase-4 gap (D68): the refresh now asks /health, so a game that hung, or whose bridge was unloaded, while the sidecar stayed up no longer reads as online. It stops naming players as online, and a stale board no longer moves "last seen". engagement-triggers.json is the committed freeze of all of it, checked in CI with line endings normalised. The check was verified by breaking it both ways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
476 lines
20 KiB
JavaScript
476 lines
20 KiB
JavaScript
// ── Notifications and engagement (phase 10, PLAN.md §25) ───────────────────
|
|
//
|
|
// The properties this suite holds, each with a failure behind it:
|
|
//
|
|
// • every declaration is one core will accept — a ceiling, a subjectKey that
|
|
// names a declared variable, an example on every variable, a closed type —
|
|
// because core refuses the WHOLE module at boot over one bad declaration;
|
|
// • no trigger declares an address, or the raider (D66);
|
|
// • the raid alert reaches exactly the authorised, linked people, one emit
|
|
// each with `ownerUserId`, and nobody when there is no cupboard (D59, D67);
|
|
// • a replayed event is told only while it is still news (D63);
|
|
// • a clan notice goes to the clan and never to whoever caused it;
|
|
// • a transition is announced once, and a first sighting never;
|
|
// • a tie at the top of the leaderboard is not a new leader;
|
|
// • every seed body interpolates only variables its trigger declares.
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert')
|
|
|
|
const { fakeCtx, spy } = require('./_fakes')
|
|
|
|
// Core's own check on a `url` variable (utils/engagementEmit.js RELATIVE_URL),
|
|
// copied so a path this module builds is held to the rule it will meet.
|
|
const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
|
|
|
|
const CEILINGS = ['everyone', 'authenticated', 'subscribers', 'members', 'staff', 'admin', 'owner']
|
|
const TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url']
|
|
|
|
const SERVER = { id: 'main', name: 'Main' }
|
|
const NOW = Date.now()
|
|
|
|
/** A fresh ctx, a recording emit, and a link table the tests control. */
|
|
function setup({ links = {}, members = {}, state = null, board = [] } = {}) {
|
|
require('../core')._reset()
|
|
const ctx = fakeCtx()
|
|
require('../core').init(ctx)
|
|
|
|
const emit = require('../engagement/emit')
|
|
emit.reset()
|
|
|
|
const linksDb = require('../model/links/links.db')
|
|
const clansDb = require('../model/clans/clans.db')
|
|
const clans = require('../model/clans/clans.model')
|
|
const serversDb = require('../model/servers/servers.db')
|
|
const eventsDb = require('../model/events/events.db')
|
|
|
|
const originals = [
|
|
[linksDb, { ...linksDb }], [clansDb, { ...clansDb }], [clans, { ...clans }],
|
|
[serversDb, { ...serversDb }], [eventsDb, { ...eventsDb }],
|
|
]
|
|
|
|
linksDb.userIdsForSteamIds = async (ids) =>
|
|
ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
|
|
clans.resolveExternalId = async (serverId, frame) => (frame.clanId ? `${serverId}:${frame.clanId}:1` : null)
|
|
clansDb.listMembers = async (externalId) => (members[externalId] || []).map((steamId) => ({ steamId }))
|
|
serversDb.getState = async () => state
|
|
eventsDb.leaderboard = async () => board.shift() || []
|
|
|
|
const restore = () => {
|
|
for (const [mod, copy] of originals) Object.assign(mod, copy)
|
|
}
|
|
|
|
return { emit, calls: ctx.events.emit.calls, restore, eventsDb }
|
|
}
|
|
|
|
const raidFrame = (extra = {}) => ({
|
|
kind: 'entity.destroyed',
|
|
t: NOW,
|
|
ownerId: '100',
|
|
prefab: 'door.hinged.metal',
|
|
structure: 'door',
|
|
attackerId: '900',
|
|
attackerName: 'Raider',
|
|
grid: 'H7',
|
|
buildingId: '8113',
|
|
authorized: [
|
|
{ steamId: '101', online: false },
|
|
{ steamId: '102', online: true },
|
|
{ steamId: '103', online: false },
|
|
],
|
|
...extra,
|
|
})
|
|
|
|
// ── The declarations ───────────────────────────────────────────────────────
|
|
|
|
test('every trigger is a declaration core will accept', () => {
|
|
const { TRIGGERS } = require('../engagement/triggers')
|
|
const ids = new Set()
|
|
|
|
for (const t of TRIGGERS) {
|
|
assert.ok(t.id.startsWith('rust.'), `${t.id} is namespaced`)
|
|
assert.ok(!ids.has(t.id), `${t.id} is declared once`)
|
|
ids.add(t.id)
|
|
assert.ok(CEILINGS.includes(t.ceiling), `${t.id} has a real ceiling (there is no "self")`)
|
|
assert.ok(CEILINGS.includes(t.audience), `${t.id} has a real default audience`)
|
|
assert.strictEqual(t.kind, 'event')
|
|
|
|
const names = new Set(t.variables.map((v) => v.name))
|
|
assert.ok(names.has(t.subjectKey), `${t.id}'s subjectKey names a declared variable`)
|
|
|
|
for (const v of t.variables) {
|
|
assert.ok(TYPES.includes(v.type), `${t.id}.${v.name} has a closed type`)
|
|
assert.ok(v.example !== undefined, `${t.id}.${v.name} has an example`)
|
|
if (v.type === 'url') assert.match(v.example, RELATIVE_URL, `${t.id}.${v.name}'s example is site-relative`)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('the default audience is never wider than the ceiling', () => {
|
|
const { TRIGGERS } = require('../engagement/triggers')
|
|
// The pairs this catalogue uses, each one core's `permits` accepts. A new
|
|
// pairing is a new line here — decided, not assumed.
|
|
const ALLOWED = new Set(['everyone>subscribers', 'owner>owner', 'members>members', 'staff>staff'])
|
|
for (const t of TRIGGERS) {
|
|
assert.ok(ALLOWED.has(`${t.ceiling}>${t.audience}`), `${t.id}: ${t.audience} under ${t.ceiling}`)
|
|
}
|
|
})
|
|
|
|
test('no trigger declares an address, or who raided whom (D66)', () => {
|
|
const { TRIGGERS } = require('../engagement/triggers')
|
|
for (const t of TRIGGERS) {
|
|
for (const v of t.variables) {
|
|
// By camelCase WORD: `wipeId` contains the letters "ip" and is not one.
|
|
const words = v.name.split(/(?=[A-Z])/).map((w) => w.toLowerCase())
|
|
for (const banned of ['ip', 'address', 'attacker', 'raider']) {
|
|
assert.ok(!words.includes(banned), `${t.id}.${v.name}`)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
test('the ceilings are the ones §25.2 decided', () => {
|
|
const { TRIGGERS } = require('../engagement/triggers')
|
|
const by = Object.fromEntries(TRIGGERS.map((t) => [t.id, t.ceiling]))
|
|
assert.strictEqual(by['rust.base.destroyed'], 'owner')
|
|
assert.strictEqual(by['rust.player.linked'], 'owner')
|
|
for (const id of ['rust.player.reported', 'rust.player.banned', 'rust.player.unbanned', 'rust.login.denied']) {
|
|
assert.strictEqual(by[id], 'staff', id)
|
|
}
|
|
for (const id of ['rust.clan.member.left', 'rust.clan.member.kicked', 'rust.clan.disbanded']) {
|
|
assert.strictEqual(by[id], 'members', id)
|
|
}
|
|
// D64: core's team.member.joined already covers it, and kits wait for phase 13.
|
|
assert.strictEqual(by['rust.clan.member.added'], undefined)
|
|
assert.strictEqual(by['rust.kit.entitled'], undefined)
|
|
})
|
|
|
|
test('a clan path survives core\'s url check, colons and all', () => {
|
|
const { clanPath, serverPath, leaderboardPath } = require('../engagement/triggers')
|
|
assert.match(clanPath('main:12:1790142840000'), RELATIVE_URL)
|
|
assert.match(serverPath('eu 2'), RELATIVE_URL)
|
|
assert.match(leaderboardPath('main'), RELATIVE_URL)
|
|
})
|
|
|
|
// ── The seeds ──────────────────────────────────────────────────────────────
|
|
|
|
test('every seeded rule is ours, off, and names bodies that exist', () => {
|
|
const { TRIGGERS } = require('../engagement/triggers')
|
|
const { TEMPLATES, RULE_GROUPS } = require('../engagement/seeds')
|
|
const triggerIds = new Set(TRIGGERS.map((t) => t.id))
|
|
const own = new Set(TEMPLATES.map((t) => t.key))
|
|
const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest'])
|
|
const groupKeys = new Set()
|
|
|
|
for (const group of RULE_GROUPS) {
|
|
assert.ok(!groupKeys.has(group.key), `group ${group.key} is unique`)
|
|
groupKeys.add(group.key)
|
|
for (const r of group.rules) {
|
|
assert.ok(triggerIds.has(r.trigger_id), `${r.trigger_id} is one of ours`)
|
|
assert.strictEqual(r.enabled, undefined, 'enabled is never a seed parameter')
|
|
assert.ok(Number.isInteger(r.max_sends_per_hour) && r.max_sends_per_hour >= 1)
|
|
for (const [slot, key] of Object.entries(r.template_keys)) {
|
|
assert.ok(own.has(key) || coreKeys.has(key), `${r.trigger_id}: ${key}`)
|
|
if (slot !== 'digest') assert.ok(r.channels.includes(slot), `${r.trigger_id}: ${slot} is a channel`)
|
|
}
|
|
for (const channel of r.channels) {
|
|
if (channel !== 'push') assert.ok(r.template_keys[channel], `${r.trigger_id}: a body for ${channel}`)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
test('push appears only on the rules whose trigger is also a stream (D65)', () => {
|
|
const { STREAMS } = require('../engagement/streams')
|
|
const { RULE_GROUPS } = require('../engagement/seeds')
|
|
const pushable = new Set(STREAMS.map((s) => s.id))
|
|
for (const group of RULE_GROUPS) {
|
|
for (const r of group.rules) {
|
|
if (r.channels.includes('push')) assert.ok(pushable.has(r.trigger_id), r.trigger_id)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('every body interpolates only what its trigger declares', () => {
|
|
const { TRIGGERS } = require('../engagement/triggers')
|
|
const { TEMPLATES } = require('../engagement/seeds')
|
|
const declared = new Map(TRIGGERS.map((t) => [t.id, new Set(t.variables.map((v) => v.name))]))
|
|
// Core's per-delivery and ambient variables — supplied by the renderer.
|
|
const ambient = new Set(['unsubscribeUrl', 'siteName', 'siteUrl', 'logoUrl', 'year'])
|
|
|
|
for (const t of TEMPLATES) {
|
|
const vars = declared.get(t.triggerId)
|
|
assert.ok(vars, `${t.key}'s trigger exists`)
|
|
const text = JSON.stringify([t.subject, t.blocks])
|
|
for (const [, name] of text.matchAll(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g)) {
|
|
assert.ok(vars.has(name) || ambient.has(name), `${t.key} uses {{${name}}}`)
|
|
}
|
|
assert.ok(t.key.startsWith('rust.'))
|
|
if (t.channel === 'email') assert.ok(t.subject)
|
|
else assert.strictEqual(t.subject, null)
|
|
}
|
|
})
|
|
|
|
test('the seeded raid rule is the OFFLINE raid alert, as a condition (D61)', () => {
|
|
const { RULE_GROUPS } = require('../engagement/seeds')
|
|
const raid = RULE_GROUPS.find((g) => g.key === 'raid-v1').rules[0]
|
|
assert.deepStrictEqual(raid.conditions, { variable: 'ownerOnline', cmp: 'eq', value: false })
|
|
assert.strictEqual(raid.audience, 'owner')
|
|
})
|
|
|
|
// ── The raid alert ─────────────────────────────────────────────────────────
|
|
|
|
test('the raid alert reaches each authorised, linked person, and nobody else', async () => {
|
|
const { emit, calls, restore } = setup({ links: { 101: 11, 102: 12 } })
|
|
try {
|
|
const sent = await emit.onEvent(SERVER, { id: 1, kind: 'entity.destroyed', frame: raidFrame() })
|
|
assert.strictEqual(sent, 2)
|
|
assert.deepStrictEqual(calls.map((c) => c[1].ownerUserId).sort(), [11, 12])
|
|
|
|
for (const [trigger, env] of calls) {
|
|
assert.strictEqual(trigger, 'rust.base.destroyed')
|
|
assert.strictEqual(env.recipientUserIds, undefined, 'owner-shaped, never a recipient list')
|
|
assert.strictEqual(env.data.building, '8113')
|
|
assert.strictEqual(env.data.structure, 'door')
|
|
assert.strictEqual(env.data.atGrid, ' in H7')
|
|
assert.ok(!JSON.stringify(env.data).includes('Raider'), 'the raider is never named')
|
|
assert.ok(!JSON.stringify(env.data).includes('900'))
|
|
}
|
|
const online = Object.fromEntries(calls.map((c) => [c[1].ownerUserId, c[1].data.ownerOnline]))
|
|
assert.deepStrictEqual(online, { 11: false, 12: true })
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('two Steam accounts held by one person are one alert, online if either is', async () => {
|
|
const { emit, calls, restore } = setup({ links: { 101: 11, 102: 11 } })
|
|
try {
|
|
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame() })
|
|
assert.strictEqual(calls.length, 1)
|
|
assert.strictEqual(calls[0][1].data.ownerOnline, true)
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('an authorised attacker is demolishing their own base: no alert', async () => {
|
|
const { emit, calls, restore } = setup({ links: { 101: 11, 102: 12 } })
|
|
try {
|
|
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ attackerId: '102' }) })
|
|
assert.strictEqual(calls.length, 0)
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('no cupboard, nobody to tell — and a protocol-6 frame is the same (D67)', async () => {
|
|
const { emit, calls, restore } = setup({ links: { 100: 10, 101: 11 } })
|
|
try {
|
|
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ buildingId: undefined, authorized: undefined }) })
|
|
// A protocol-6 frame: a BuildingBlock with an owner and no `authorized`.
|
|
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: { kind: 'entity.destroyed', t: NOW, ownerId: '100', prefab: 'wall' } })
|
|
assert.strictEqual(calls.length, 0, 'the placer is never a fallback')
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('a replayed raid is still told for a day, and not after (D63)', async () => {
|
|
const { emit, calls, restore } = setup({ links: { 101: 11 } })
|
|
try {
|
|
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ t: NOW - 3 * 3600 * 1000 }) })
|
|
assert.strictEqual(calls.length, 1)
|
|
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ t: NOW - 25 * 3600 * 1000 }) })
|
|
assert.strictEqual(calls.length, 1)
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('the same frame replayed carries the same dedupe key, within core\'s bound', async () => {
|
|
const { emit, calls, restore } = setup({ links: { 101: 11 } })
|
|
try {
|
|
const item = { id: 7, kind: 'entity.destroyed', frame: raidFrame() }
|
|
await emit.onEvent(SERVER, item)
|
|
await emit.onEvent(SERVER, { ...item, id: 99 })
|
|
assert.strictEqual(calls[0][1].dedupeKey, calls[1][1].dedupeKey, 'keyed on the event, not the row id')
|
|
assert.ok(calls[0][1].dedupeKey.length <= 190)
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
// ── Broadcasts ─────────────────────────────────────────────────────────────
|
|
|
|
test('a wipe is news for fifteen minutes (D63)', async () => {
|
|
const { emit, calls, restore } = setup()
|
|
try {
|
|
await emit.onEvent(SERVER, { kind: 'server.wipe', frame: { kind: 'server.wipe', t: NOW - 60 * 1000, wipeId: 'w2' } })
|
|
await emit.onEvent(SERVER, { kind: 'server.wipe', frame: { kind: 'server.wipe', t: NOW - 20 * 60 * 1000, wipeId: 'w3' } })
|
|
assert.strictEqual(calls.length, 1)
|
|
assert.strictEqual(calls[0][0], 'rust.wipe.started')
|
|
assert.strictEqual(calls[0][1].data.wipeId, 'w2')
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('online and offline are transitions, and a first sighting is not one', () => {
|
|
const { emit, calls, restore } = setup()
|
|
try {
|
|
emit.serverObserved(SERVER, true)
|
|
emit.serverObserved(SERVER, true)
|
|
assert.strictEqual(calls.length, 0, 'a restart announces nothing')
|
|
emit.serverObserved(SERVER, false)
|
|
emit.serverObserved(SERVER, false)
|
|
emit.serverObserved(SERVER, true)
|
|
assert.deepStrictEqual(calls.map((c) => c[0]), ['rust.server.offline', 'rust.server.online'])
|
|
assert.strictEqual(calls[0][1].data.serverId, 'main')
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('a new leader is announced once; a tie is not a new leader', async () => {
|
|
const row = (steamId, kills) => ({ steamId, name: `P${steamId}`, kills })
|
|
const { emit, calls, restore } = setup({
|
|
state: { wipeId: 'w1' },
|
|
board: [
|
|
[row('1', 5), row('2', 3)], // first sight: remembered, not announced
|
|
[row('2', 5), row('1', 5)], // level on kills: not a change
|
|
[row('2', 6), row('1', 5)], // strictly ahead: announced
|
|
[row('2', 7), row('1', 5)], // the same leader: nothing
|
|
],
|
|
})
|
|
try {
|
|
for (let i = 0; i < 4; i += 1) await emit.checkLeader(SERVER)
|
|
assert.strictEqual(calls.length, 1)
|
|
assert.strictEqual(calls[0][0], 'rust.leaderboard.topped')
|
|
assert.strictEqual(calls[0][1].data.leader, 'P2')
|
|
assert.strictEqual(calls[0][1].data.kills, 6)
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
// ── Clans ──────────────────────────────────────────────────────────────────
|
|
|
|
test('a disband goes to the roster on the frame, not to the one who did it', async () => {
|
|
const { emit, calls, restore } = setup({ links: { 201: 21, 202: 22, 203: 23 } })
|
|
try {
|
|
await emit.onEvent(SERVER, {
|
|
kind: 'clan.disbanded',
|
|
frame: { kind: 'clan.disbanded', t: NOW, clanId: 4, clanName: 'Belt', steamId: '201', name: 'Boss', members: ['201', '202', '203'] },
|
|
})
|
|
assert.strictEqual(calls.length, 1)
|
|
assert.strictEqual(calls[0][0], 'rust.clan.disbanded')
|
|
assert.deepStrictEqual(calls[0][1].recipientUserIds.sort(), [22, 23])
|
|
assert.strictEqual(calls[0][1].data.by, 'Boss')
|
|
assert.match(calls[0][1].data.clanUrl, RELATIVE_URL)
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('the one kicked is told; the one who kicked is not', async () => {
|
|
const { emit, calls, restore } = setup({
|
|
links: { 301: 31, 302: 32, 303: 33 },
|
|
members: { 'main:5:1': ['301', '302'] }, // the board already dropped 303
|
|
})
|
|
try {
|
|
await emit.onEvent(SERVER, {
|
|
kind: 'clan.member.kicked',
|
|
frame: { kind: 'clan.member.kicked', t: NOW, clanId: 5, steamId: '303', name: 'Out', bySteamId: '301', byName: 'Boss' },
|
|
})
|
|
assert.deepStrictEqual(calls[0][1].recipientUserIds.sort(), [32, 33])
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('a leaver is not told they left, and a lone leaver tells nobody', async () => {
|
|
const { emit, calls, restore } = setup({ links: { 401: 41, 402: 42 }, members: { 'main:6:1': ['401', '402'] } })
|
|
try {
|
|
await emit.onEvent(SERVER, { kind: 'clan.member.left', frame: { kind: 'clan.member.left', t: NOW, clanId: 6, steamId: '402' } })
|
|
assert.deepStrictEqual(calls[0][1].recipientUserIds, [41])
|
|
await emit.onEvent(SERVER, { kind: 'clan.member.left', frame: { kind: 'clan.member.left', t: NOW, clanId: 7, steamId: '402' } })
|
|
assert.strictEqual(calls.length, 1)
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
// ── Moderation ─────────────────────────────────────────────────────────────
|
|
|
|
test('a ban never carries the address the frame does', async () => {
|
|
const { emit, calls, restore } = setup()
|
|
try {
|
|
await emit.onEvent(SERVER, {
|
|
kind: 'player.banned',
|
|
frame: { kind: 'player.banned', t: NOW, steamId: '555', name: 'Cheater', ip: '203.0.113.9', reason: 'aimbot' },
|
|
})
|
|
assert.strictEqual(calls.length, 1)
|
|
assert.ok(!JSON.stringify(calls[0][1]).includes('203.0.113.9'))
|
|
assert.strictEqual(calls[0][1].data.reason, 'aimbot')
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('an unapproved login becomes a staff notice, keyed on the attempt', async () => {
|
|
const { emit, calls, restore, eventsDb } = setup()
|
|
try {
|
|
const asked = []
|
|
eventsDb.unapprovedLogins = async (q) => {
|
|
asked.push(q)
|
|
return [{ steamId: '777', t: NOW - 120000, name: 'Knocker' }]
|
|
}
|
|
const sent = await emit.sweepLoginDenied([SERVER], NOW)
|
|
await emit.sweepLoginDenied([SERVER], NOW)
|
|
assert.strictEqual(sent, 1)
|
|
assert.strictEqual(asked[0].to, NOW - emit.LOGIN_APPROVAL_WINDOW_MS, 'an attempt waits its minute first')
|
|
assert.strictEqual(calls[0][0], 'rust.login.denied')
|
|
assert.strictEqual(calls[0][1].dedupeKey, calls[1][1].dedupeKey, 'a second sweep is a no-op in core')
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
// ── The rest ───────────────────────────────────────────────────────────────
|
|
|
|
test('a new link tells its owner, and only its owner', () => {
|
|
const { emit, calls, restore } = setup()
|
|
try {
|
|
emit.linked({ userId: 5, steamId: '76561198000000001', name: 'Me' })
|
|
assert.strictEqual(calls[0][0], 'rust.player.linked')
|
|
assert.strictEqual(calls[0][1].ownerUserId, 5)
|
|
assert.strictEqual(emit.linked({ userId: 0, steamId: 'x' }), 0)
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('a frame the fan-out cannot handle costs one notice, never the caller', async () => {
|
|
const { emit, restore } = setup()
|
|
try {
|
|
const linksDb = require('../model/links/links.db')
|
|
linksDb.userIdsForSteamIds = async () => { throw new Error('database gone') }
|
|
const sent = await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame() })
|
|
assert.strictEqual(sent, 0)
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('an audience that fails answers nobody, never everybody', async () => {
|
|
require('../core')._reset()
|
|
const ctx = fakeCtx({ db: { query: spy(() => Promise.reject(new Error('down'))), pool: {} } })
|
|
require('../core').init(ctx)
|
|
const { AUDIENCES } = require('../engagement/audiences')
|
|
for (const a of AUDIENCES) {
|
|
assert.deepStrictEqual(await a.resolve({ clan: 'main:1:1', serverId: 'main' }), [], a.id)
|
|
assert.deepStrictEqual(await a.resolve({}), [], `${a.id} with no param`)
|
|
}
|
|
})
|