R1's identity link, site-side, and R13's first extension slot. A player types /link in game, the plugin hands them a six-character code privately, and they enter it here; the site records who owns which Steam account, and an operator sees that on core's own `/admin/users/:id` page. **The site is the author of record and the game holds nothing.** There is no per-account store in Rust that survives a wipe, and phase 7 needs the site authoritative anyway — it pushes permissions INTO the game keyed by Steam id. A copy in the game would be a second thing to reconcile every wipe, for no question it could answer better. ## D24 — a code is minted by ONE server, so every server is asked Nothing in six characters says where it came from. The fleet is asked in turn and the first `link.ok` wins; the others answer `unknown` and nothing happens there, because a code is only spent at the server that actually holds it. Asking the player to pick was rejected: a wrong pick would come back indistinguishable from a wrong code, and that is the one refusal which must not be ambiguous. **"Every reachable server refused" is not the same answer as "a server was unreachable."** Collapsing them tells a player whose server is down that their code is wrong — so they run /link again on that same server and are told the same thing for as long as it stays down. `unsure` is that case, and it says to try again rather than to fetch a new code. ## D23 — a Steam id another account holds is refused, never moved The primary key is `steam_id`, and it is load-bearing rather than tidy: phase 7 grants permissions against a link and phase 13 hangs entitlements off it, so a silent move is an account takeover performed by typing six characters. The refusal names the holder, because the advice is unusable without it. The INSERT is a plain INSERT for the same reason — `ON DUPLICATE KEY UPDATE` here would BE that move — and the duplicate-key error is the refusal for the race the check above cannot close. The way out is `/unlink` in game, which reaches the site off the ingest feed rather than through a route (the plugin has no link to delete). D25 adds the other way out: staff can sever a link from the admin panel, for a player who cannot reach that Steam account in game. ## The slot, and the hole it found in this repo's own generator `admin.users.detail` is declared in `module.json` AND registered in `index.js` AND filled by the chunk — three places, because the server half and the client half are different registrations that share one name. `swaggerFragment.js` knew only about tier routers, so the two routes under `/admin/users/:id` were generated by nothing: a fragment that was internally consistent and described two routes fewer than the module serves. A slot's mount is core's and cannot be derived here, so it is a fourth constant beside `TIER_BASE` — held to account by the frozen-manifest job, which was verified to catch exactly this by removing the two paths and watching it fail. ## Smaller things worth knowing - **Core's `useAsync` has no `refresh`.** A counter in the deps is how a page re-reads after its own write; it blanks while it re-reads, which is right here and is exactly what made it wrong for a poll. - **Every player-portal nav row needs an `icon`** — core draws one on every row, and the client suite says so. This module had no icons file until now, because the public header is text buttons. - The two new frame kinds are STAFF-only. Neither carries a code, but both name a Steam id beside a website account's activity, and that join is not a public fact about what happened on a server. - The link code route carries its own rate limiter rather than core's `accountChangeLimiter`: this is guessing somebody else's secret, not changing your own password, and a shared counter would let one policy set the other. Protocol 3 on all three declaration sites; 17 new tests, 136 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
362 lines
12 KiB
JavaScript
362 lines
12 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/)
|
|
})
|
|
|
|
// ── Protocol 3: the frame that changes something other than a counter ─────
|
|
|
|
test('an in-game /unlink severs the site link, scoped by Steam id alone', async () => {
|
|
const rec = withRecorder()
|
|
const ingest = require('../ingest')
|
|
|
|
await ingest.apply('main', item('account.unlinked', { steamId: '7656', name: 'Wanderer', origin: 'in-game' }))
|
|
|
|
const del = rec.statements.find((st) => st.sql.trim().toUpperCase().startsWith('DELETE'))
|
|
|
|
// It arrives on the FEED rather than through a route because the plugin has no
|
|
// link to delete — the site is the author of record. And it is the only way out
|
|
// of a link on the wrong account, because the site refuses to move a Steam id
|
|
// another account already holds (D23).
|
|
assert.ok(del, 'an unlink frame must delete the link')
|
|
assert.ok(del.sql.includes('rust_account_links'))
|
|
assert.deepEqual(del.params, ['7656'])
|
|
})
|
|
|
|
test('asking for a code links nothing — the code does not travel on the wire', async () => {
|
|
const rec = withRecorder()
|
|
const ingest = require('../ingest')
|
|
|
|
await ingest.apply('main', item('account.link.requested', { steamId: '7656', name: 'Wanderer', ttlSec: 300 }))
|
|
|
|
// The frame exists so an operator can see linking being used. Nothing about it
|
|
// is redeemable: the code travels through the player, which is what makes
|
|
// typing it proof that they are the one who asked.
|
|
assert.equal(rec.touching('rust_account_links').length, 0)
|
|
assert.equal(rec.touching('rust_players').length, 1)
|
|
})
|