The module half of the read path. Seven tables, an ingest cursor, four public routes, and one file whose only job is deciding who may see what. **The record and the window are different things.** `rust_player_wipe_stats` and `rust_gather_totals` are permanent and per-wipe, so all-time is those rows SUMmed rather than a second set of counters that can disagree with them — that is R12's "per-wipe detail plus all-time rollups" in one table instead of two. `rust_events` is a bounded 30-day window of raw frames for the killfeed, and `rust_presence` is a board: replaced wholesale, never appended. **The feed is a cursor, not a socket, and the header says why.** Core runs Node 20, where a global WebSocket is still behind a flag, so a socket means taking `ws` — against a release that asserts it has no runtime dependencies (D5). The deciding argument is the other one though: a socket needs a cursor anyway, for whatever it missed while the module was restarting, and the catch-up path is the one that has to be right. A cursor alone is one mechanism exercised every five seconds rather than two where the second only runs after an outage. **The cursor advances after the batch, never before.** A crash between the two re-reads events already counted, which inflates a total; the other order loses them silently and for ever. One is visible and bounded, the other is invisible and permanent, so the code fails in the visible direction. A server with no cursor starts at the sidecar's current END rather than at zero — replaying a fortnight of deaths into stats for wipes the site never saw is not a catch-up. **`catalogue.js` is a security boundary, default-deny.** Protocol 2 carries IP addresses (login attempts, approvals, bans), one player's report about another, and the grid reference of somebody's base. They are stored, because an operator chasing ban evasion needs them; they are not served below the admin tier. The allowlist lives here rather than as a field on the wire, because a boundary declared by the sender is one a compromised or merely out-of-date game host can widen — the same reason core's own shard fan-out filters on the serving side. A kind this build has never heard of is not public, and a test holds the list against PROTOCOL.md §8.4 so that adding a kind to the protocol without classifying it fails a build. `PROTOCOL_VERSION` goes to 2 here in the same change as the emitters, though this module consumes none of the new frames yet: the sidecar refuses a mismatched client with a 409, so a module left on 1 would stop being able to read the board it has been reading all along. A constant that lags the deployment is an outage with a version number on it. 95 server tests, 20 client tests, every guard green, and `routes.manifest.json` regenerated against a real core at the pinned ref: 10 routes, all documented, none of core's moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
330 lines
11 KiB
JavaScript
330 lines
11 KiB
JavaScript
// ── The ingest ────────────────────────────────────────────────────────────
|
|
//
|
|
// Every test here is about one of three things, and all three are mistakes that
|
|
// look correct in review:
|
|
//
|
|
// • **who gets credited.** A suicide must not credit the victim with a kill.
|
|
// That single line would produce a leaderboard topped by whoever died most,
|
|
// and it would look plausible for a whole wipe.
|
|
// • **the cursor's ordering.** It advances AFTER the batch, never before, so a
|
|
// crash re-reads rather than skips. Skipping is silent and permanent.
|
|
// • **absent is not zero.** A session whose start was never seen contributes
|
|
// no playtime rather than zero playtime.
|
|
//
|
|
// The database is a recorder. Asserting the SQL exactly would be a test of the
|
|
// SQL's punctuation, so each case asserts the *statement shape* and the values —
|
|
// which table was written, and with what.
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert')
|
|
|
|
const { fakeCtx } = require('./_fakes')
|
|
|
|
/** Installs a core whose `db.query` records every statement. */
|
|
function withRecorder() {
|
|
const statements = []
|
|
|
|
const ctx = fakeCtx({
|
|
db: {
|
|
query: (sql, params = []) => {
|
|
statements.push({ sql, params })
|
|
return Promise.resolve([])
|
|
},
|
|
pool: {},
|
|
},
|
|
})
|
|
|
|
require('../core')._reset()
|
|
require('../core').init(ctx)
|
|
|
|
return {
|
|
statements,
|
|
/** Every statement that touched a table, with its parameters. */
|
|
touching(table) {
|
|
return statements.filter((s) => s.sql.includes(table))
|
|
},
|
|
}
|
|
}
|
|
|
|
const frame = (over = {}) => ({
|
|
type: 'event',
|
|
t: 1789560564452,
|
|
serverId: 'main',
|
|
wipeId: 'w-20260915T195817Z',
|
|
...over,
|
|
})
|
|
|
|
const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...over }) })
|
|
|
|
test('every frame is stored, whether or not this build understands it', async () => {
|
|
const rec = withRecorder()
|
|
const { apply } = require('../ingest')
|
|
|
|
await apply('main', item('player.death', { steamId: '76561198000000001' }))
|
|
await apply('main', item('something.from.protocol.9'))
|
|
|
|
const stored = rec.touching('rust_events')
|
|
assert.equal(stored.length, 2, 'an unrecognised kind must still be stored')
|
|
|
|
// The one copy of an event a later version will know how to read is the one
|
|
// this version chose not to throw away.
|
|
assert.ok(stored[1].params.includes('something.from.protocol.9'))
|
|
})
|
|
|
|
test('a wipe exists because a frame mentioned it', async () => {
|
|
const rec = withRecorder()
|
|
const { apply } = require('../ingest')
|
|
|
|
await apply('main', item('player.chat', { steamId: '1', message: 'hello' }))
|
|
|
|
const wipes = rec.touching('rust_wipes')
|
|
assert.equal(wipes.length, 1)
|
|
assert.deepEqual(wipes[0].params.slice(0, 2), ['main', 'w-20260915T195817Z'])
|
|
})
|
|
|
|
test('a kill credits the attacker and a death the victim', async () => {
|
|
const rec = withRecorder()
|
|
const { apply } = require('../ingest')
|
|
|
|
await apply(
|
|
'main',
|
|
item('player.death', {
|
|
steamId: 'victim',
|
|
attackerType: 'player',
|
|
attackerId: 'killer',
|
|
attackerName: 'Killer',
|
|
}),
|
|
)
|
|
|
|
const stats = rec.touching('rust_player_wipe_stats')
|
|
assert.equal(stats.length, 2, 'one row for the victim, one for the attacker')
|
|
|
|
// The parameter order is (server, wipe, steam, kills, deaths, suicides, ...).
|
|
const victim = stats.find((s) => s.params[2] === 'victim')
|
|
const killer = stats.find((s) => s.params[2] === 'killer')
|
|
|
|
assert.ok(victim && killer)
|
|
assert.equal(victim.params[3], 0, 'the victim scored no kill')
|
|
assert.equal(victim.params[4], 1, 'the victim died once')
|
|
assert.equal(killer.params[3], 1, 'the attacker scored one kill')
|
|
assert.equal(killer.params[4], 0, 'the attacker did not die')
|
|
})
|
|
|
|
test('a suicide is a death and a suicide, and credits nobody with a kill', async () => {
|
|
const rec = withRecorder()
|
|
const { apply } = require('../ingest')
|
|
|
|
await apply('main', item('player.death', { steamId: 'victim', attackerType: 'self' }))
|
|
|
|
const stats = rec.touching('rust_player_wipe_stats')
|
|
assert.equal(stats.length, 1, 'nobody is credited with the kill')
|
|
assert.equal(stats[0].params[4], 1, 'it is still a death')
|
|
assert.equal(stats[0].params[5], 1, 'and a suicide')
|
|
assert.equal(stats[0].params[3], 0)
|
|
})
|
|
|
|
test('an environment or NPC death credits no attacker', async () => {
|
|
for (const attackerType of ['environment', 'npc']) {
|
|
const rec = withRecorder()
|
|
const { apply } = require('../ingest')
|
|
|
|
await apply('main', item('player.death', { steamId: 'victim', attackerType }))
|
|
|
|
const stats = rec.touching('rust_player_wipe_stats')
|
|
assert.equal(stats.length, 1, `${attackerType} must credit nobody`)
|
|
assert.equal(stats[0].params[4], 1)
|
|
}
|
|
})
|
|
|
|
test('an absent session length adds no playtime and no session', async () => {
|
|
const rec = withRecorder()
|
|
const { apply } = require('../ingest')
|
|
|
|
// A player who was already on the server when the plugin loaded: the plugin
|
|
// omits `sessionSec` rather than sending 0, and the difference has to survive
|
|
// all the way to the column. Adding a zero would record a session of no
|
|
// length, which is a different claim from recording no session.
|
|
await apply('main', item('player.disconnected', { steamId: 'p1', reason: 'quit' }))
|
|
|
|
const stats = rec.touching('rust_player_wipe_stats')
|
|
assert.equal(stats[0].params[8], 0, 'no session counted')
|
|
assert.equal(stats[0].params[9], 0, 'no playtime added')
|
|
|
|
const rec2 = withRecorder()
|
|
await require('../ingest').apply(
|
|
'main',
|
|
item('player.disconnected', { steamId: 'p1', sessionSec: 600 }),
|
|
)
|
|
|
|
const counted = rec2.touching('rust_player_wipe_stats')
|
|
assert.equal(counted[0].params[8], 1)
|
|
assert.equal(counted[0].params[9], 600)
|
|
})
|
|
|
|
test('a tally is added per resource, as a delta', async () => {
|
|
const rec = withRecorder()
|
|
const { apply } = require('../ingest')
|
|
|
|
await apply(
|
|
'main',
|
|
item('player.tally', {
|
|
steamId: 'p1',
|
|
gathered: { wood: 1200, stones: 300 },
|
|
npcKills: 3,
|
|
structures: 2,
|
|
}),
|
|
)
|
|
|
|
const gathered = rec.touching('rust_gather_totals')
|
|
assert.equal(gathered.length, 2)
|
|
assert.deepEqual(
|
|
gathered.map((g) => [g.params[3], g.params[4]]),
|
|
[
|
|
['wood', 1200],
|
|
['stones', 300],
|
|
],
|
|
)
|
|
|
|
const stats = rec.touching('rust_player_wipe_stats')
|
|
assert.equal(stats[0].params[6], 3, 'npc kills')
|
|
assert.equal(stats[0].params[7], 2, 'structures')
|
|
|
|
// `amount = amount + VALUES(amount)` is what makes a delta correct. A running
|
|
// total on the wire would double every number here, slowly, looking right.
|
|
assert.match(gathered[0].sql, /amount = amount \+ VALUES\(amount\)/)
|
|
})
|
|
|
|
test('a new server starts at the feed tail, not at the beginning of history', async () => {
|
|
withRecorder()
|
|
|
|
const sidecar = require('../sidecarClient')
|
|
const db = require('../model/events/events.db')
|
|
const ingest = require('../ingest')
|
|
|
|
const originalTail = sidecar.feedTail
|
|
const originalCursor = db.getCursor
|
|
const originalSet = db.setCursor
|
|
const written = []
|
|
|
|
db.getCursor = async () => null
|
|
db.setCursor = async (...args) => written.push(args)
|
|
sidecar.feedTail = async () => ({ ok: true, status: 'ok', data: { lastId: 4021, items: [] } })
|
|
|
|
try {
|
|
const applied = await ingest.ingestServer({ id: 'main' })
|
|
|
|
assert.equal(applied, 0, 'nothing is replayed')
|
|
assert.deepEqual(written, [['main', 4021, 0]], 'the cursor starts at the end')
|
|
} finally {
|
|
sidecar.feedTail = originalTail
|
|
db.getCursor = originalCursor
|
|
db.setCursor = originalSet
|
|
}
|
|
})
|
|
|
|
test('an unreachable sidecar writes no cursor at all', async () => {
|
|
withRecorder()
|
|
|
|
const sidecar = require('../sidecarClient')
|
|
const db = require('../model/events/events.db')
|
|
const ingest = require('../ingest')
|
|
|
|
const originalTail = sidecar.feedTail
|
|
const originalCursor = db.getCursor
|
|
const originalSet = db.setCursor
|
|
const written = []
|
|
|
|
db.getCursor = async () => null
|
|
db.setCursor = async (...args) => written.push(args)
|
|
sidecar.feedTail = async () => ({ ok: false, status: 'transport-error', data: null })
|
|
|
|
try {
|
|
await ingest.ingestServer({ id: 'main' })
|
|
|
|
// A cursor of 0 written here would replay the sidecar's whole retained
|
|
// history the moment it came back — which is the failure that looks like a
|
|
// working catch-up until somebody reads the leaderboard.
|
|
assert.deepEqual(written, [])
|
|
} finally {
|
|
sidecar.feedTail = originalTail
|
|
db.getCursor = originalCursor
|
|
db.setCursor = originalSet
|
|
}
|
|
})
|
|
|
|
test('the cursor advances after the batch, and one bad event does not wedge it', async () => {
|
|
withRecorder()
|
|
|
|
const sidecar = require('../sidecarClient')
|
|
const db = require('../model/events/events.db')
|
|
const ingest = require('../ingest')
|
|
|
|
const originals = {
|
|
feed: sidecar.feed,
|
|
getCursor: db.getCursor,
|
|
setCursor: db.setCursor,
|
|
insertEvent: db.insertEvent,
|
|
}
|
|
|
|
const order = []
|
|
|
|
db.getCursor = async () => ({ lastEventId: 10 })
|
|
db.setCursor = async (_id, last) => order.push(`cursor:${last}`)
|
|
db.insertEvent = async (row) => {
|
|
order.push(`event:${row.kind}`)
|
|
if (row.kind === 'player.chat') throw new Error('malformed')
|
|
}
|
|
|
|
sidecar.feed = async (_server, since) =>
|
|
since === 10
|
|
? {
|
|
ok: true,
|
|
status: 'ok',
|
|
data: {
|
|
items: [item('player.chat'), item('player.connected', { steamId: 'p1' })],
|
|
lastId: 12,
|
|
more: false,
|
|
},
|
|
}
|
|
: { ok: true, status: 'ok', data: { items: [], lastId: since, more: false } }
|
|
|
|
try {
|
|
const applied = await ingest.ingestServer({ id: 'main' })
|
|
|
|
// The bad row is logged and skipped; the good one still counts.
|
|
assert.equal(applied, 1)
|
|
|
|
// And the ordering the whole design rests on: every event is written before
|
|
// the cursor moves past it.
|
|
assert.deepEqual(order, ['event:player.chat', 'event:player.connected', 'cursor:12'])
|
|
} finally {
|
|
Object.assign(db, {
|
|
getCursor: originals.getCursor,
|
|
setCursor: originals.setCursor,
|
|
insertEvent: originals.insertEvent,
|
|
})
|
|
sidecar.feed = originals.feed
|
|
}
|
|
})
|
|
|
|
test('a board replaces presence rather than appending to it', async () => {
|
|
const rec = withRecorder()
|
|
const ingest = require('../ingest')
|
|
|
|
await ingest.applyBoards('main', {
|
|
'players.online': {
|
|
kind: 'players.online',
|
|
type: 'snapshot',
|
|
count: 1,
|
|
players: [{ steamId: 'p1', name: 'One', sleeping: false }],
|
|
},
|
|
})
|
|
|
|
const presence = rec.touching('rust_presence')
|
|
|
|
// The DELETE is what makes it a board. Without it a player who left stays
|
|
// online for ever, which is the exact drift the board exists to correct.
|
|
assert.match(presence[0].sql, /^DELETE FROM rust_presence/)
|
|
assert.match(presence[1].sql, /INSERT INTO rust_presence/)
|
|
})
|