The bridge names a kill's weapon by the held entity's short prefab name (ak47u.entity), and the feed printed it with the dots turned to spaces: "with ak47u entity". Every event read now gives the frame a weaponName (server/model/titles/weaponNames.js): the game's own English name for the 53 ranged weapons (guns, bows, crossbows, launchers, flamethrowers), read off the Oxide rig's item list on Rust 26139947, and for anything else the prefab name as words with its .entity/.weapon ending dropped. The site prints weaponName and falls back to the same stripping for an older server. The prefab name stays in the frame: the title lists count by it. Added at read, so stored rows are renamed too. No protocol change; the events route's Swagger description names the new field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
259 lines
10 KiB
JavaScript
259 lines
10 KiB
JavaScript
// ── The read path's logic ─────────────────────────────────────────────────
|
|
//
|
|
// Everything that decides WHAT a caller gets, separated from the SQL that
|
|
// fetches it, so this file can be tested with no database and `events.db.js` has
|
|
// no branching to test.
|
|
//
|
|
// The decision that matters here is not a business rule, it is a boundary: what
|
|
// a signed-out visitor may see. Protocol 2 carries IP addresses and player
|
|
// reports, and the only thing standing between them and a public page is
|
|
// `catalogue.js`'s allowlist and the fact that **every read on this file takes an
|
|
// explicit viewer**. There is no default, because a default is what a caller
|
|
// gets when they forget — and the safe value is never the one that is easier to
|
|
// type.
|
|
|
|
const catalogue = require('../../catalogue')
|
|
const worldEvents = require('../../worldEvents')
|
|
const dynamicPvp = require('../../dynamicPvp')
|
|
const dangerousTreasures = require('../../dangerousTreasures')
|
|
const guardedCrate = require('../../guardedCrate')
|
|
const raidableBases = require('../../raidableBases')
|
|
const db = require('./events.db')
|
|
const mapDb = require('../map/map.db')
|
|
const serversDb = require('../servers/servers.db')
|
|
const weaponNames = require('../titles/weaponNames')
|
|
|
|
/** Hard ceiling on a page, whatever a caller asks for. */
|
|
const MAX_LIMIT = 200
|
|
|
|
function boundedLimit(requested, fallback = 50) {
|
|
const n = Number(requested)
|
|
if (!Number.isFinite(n) || n <= 0) return fallback
|
|
return Math.min(Math.trunc(n), MAX_LIMIT)
|
|
}
|
|
|
|
/**
|
|
* Parses a `kind` query parameter into a list.
|
|
*
|
|
* Accepts `?kind=player.death` and `?kind=player.death,player.chat`, and answers
|
|
* `null` for anything empty — which means "whatever this viewer may see" rather
|
|
* than "nothing", and is then narrowed by the catalogue.
|
|
*/
|
|
function parseKinds(raw) {
|
|
if (!raw) return null
|
|
|
|
const list = String(raw)
|
|
.split(',')
|
|
.map((k) => k.trim())
|
|
.filter(Boolean)
|
|
|
|
return list.length > 0 ? list : null
|
|
}
|
|
|
|
/**
|
|
* Recent events for one server, already narrowed to what this viewer may see.
|
|
*
|
|
* **`admin` is a parameter, not a default.** A route that forgets it gets the
|
|
* public list, which is the direction it is safe to be wrong in. And a kind the
|
|
* caller asked for that they may not see is dropped silently rather than
|
|
* refused: naming it in an error would confirm the kind exists, which is a small
|
|
* thing to leak and a free one to avoid.
|
|
*/
|
|
async function recent({ serverId, admin = false, staff = false, presence = false, kind = null, wipeId = null, limit }) {
|
|
const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
|
|
|
|
// Every requested kind was refused. Answering with an empty list is right —
|
|
// the events they asked for are, as far as they are concerned, not there.
|
|
if (kinds.length === 0) return []
|
|
|
|
const rows = await db.recentEvents({
|
|
serverId,
|
|
kinds,
|
|
wipeId,
|
|
limit: boundedLimit(limit),
|
|
})
|
|
|
|
const shaped = rows.map(shape)
|
|
if (!shaped.some((r) => r.kind === 'world.event' || dynamicPvp.KINDS.includes(r.kind) || dangerousTreasures.isKind(r.kind) || guardedCrate.isKind(r.kind) || raidableBases.isKind(r.kind))) return shaped
|
|
|
|
// Protocol 14 (§14.3.1): each world event is read for THIS viewer — its type
|
|
// allowed, its names to the presence audience, its Steam ids to staff, its
|
|
// credit by the server's rule — and given a grid from the map's geometry.
|
|
// Dynamic PVP's zone lines (§14.3.3) are read the same way for their grid and
|
|
// position, and Dangerous Treasures' (§14.3.4) for their grid, the winner's
|
|
// name and the Steam ids, and Guarded Crate's (§14.3.5) alike. Both reads only
|
|
// when the page holds one of them at all.
|
|
const view = await worldView(serverId, { staff: staff || admin, presence })
|
|
return shaped
|
|
.map((row) => {
|
|
if (row.kind === 'world.event') return worldRow(row, view)
|
|
if (dynamicPvp.KINDS.includes(row.kind)) return pvpRow(row, view)
|
|
if (dangerousTreasures.isKind(row.kind)) return dtRow(row, view)
|
|
if (guardedCrate.isKind(row.kind)) return gcRow(row, view)
|
|
if (raidableBases.isKind(row.kind)) return raidRow(row, view)
|
|
return row
|
|
})
|
|
.filter(Boolean)
|
|
}
|
|
|
|
/** What reading a world event for one viewer needs: the server's credit rule and map geometry. */
|
|
async function worldView(serverId, { staff, presence }) {
|
|
const [server, meta] = await Promise.all([
|
|
serversDb.getServer(serverId).catch(() => null),
|
|
mapDb.getMeta(serverId).catch(() => null),
|
|
])
|
|
return {
|
|
staff,
|
|
presence,
|
|
rule: worldEvents.creditRuleOf(server && server.creditRule),
|
|
geometry: meta
|
|
? { worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }
|
|
: null,
|
|
}
|
|
}
|
|
|
|
/** One Dynamic PVP row for a viewer (§14.3.3): its grid for everyone, its position for staff. */
|
|
function pvpRow(row, view) {
|
|
return { ...row, frame: dynamicPvp.shapeFor(row.frame, view) }
|
|
}
|
|
|
|
/** One Dangerous Treasures row for a viewer (§14.3.4). The row's own Steam id, the winner's, is staff's. */
|
|
function dtRow(row, view) {
|
|
return { ...row, steamId: view.staff ? row.steamId : null, frame: dangerousTreasures.shapeFor(row.frame, view) }
|
|
}
|
|
|
|
/** One Guarded Crate row for a viewer (§14.3.5). The row's own Steam id, the clearer's, is staff's. */
|
|
function gcRow(row, view) {
|
|
return { ...row, steamId: view.staff ? row.steamId : null, frame: guardedCrate.shapeFor(row.frame, view) }
|
|
}
|
|
|
|
/** One RaidableBases row for a viewer (§11.4). The row's own Steam id, a buyer's, is staff's. */
|
|
function raidRow(row, view) {
|
|
return { ...row, steamId: view.staff ? row.steamId : null, frame: raidableBases.shapeFor(row.frame, view) }
|
|
}
|
|
|
|
/** One world-event row for a viewer, or null when they may not see it. The row's own Steam id is staff's too. */
|
|
function worldRow(row, view) {
|
|
const frame = worldEvents.shapeFor(row.frame, view)
|
|
if (!frame) return null
|
|
return { ...row, steamId: view.staff ? row.steamId : null, frame }
|
|
}
|
|
|
|
/**
|
|
* One stored row as an API object.
|
|
*
|
|
* `raw` comes back from the database as text and is parsed here rather than in
|
|
* the db layer, because a row whose JSON will not parse is a reporting problem
|
|
* and not a query problem: it answers with the envelope it does know and an
|
|
* empty body, instead of failing a whole page over one bad row.
|
|
*/
|
|
function shape(row) {
|
|
let frame = {}
|
|
|
|
try {
|
|
frame = typeof row.raw === 'string' ? JSON.parse(row.raw) : row.raw || {}
|
|
} catch {
|
|
frame = {}
|
|
}
|
|
|
|
// The weapon as a page should print it (`model/titles/weaponNames.js`). Added
|
|
// at read, so rows stored before this existed read the same as new ones, and
|
|
// a later edit to the table renames every kill the feed still shows.
|
|
if (typeof frame.weapon === 'string' && frame.weaponName === undefined) {
|
|
frame.weaponName = weaponNames.nameOf(frame.weapon)
|
|
}
|
|
|
|
return {
|
|
id: Number(row.id),
|
|
kind: row.kind,
|
|
t: Number(row.t),
|
|
wipeId: row.wipeId || null,
|
|
steamId: row.steamId || null,
|
|
frame,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The leaderboard for a server, per wipe or all-time.
|
|
*
|
|
* All-time is the same rows summed differently rather than a second set of
|
|
* counters, so the two can never disagree — which is the whole reason R12's
|
|
* "per-wipe detail plus all-time rollups" is one table and not two.
|
|
*/
|
|
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false, economics = null }) {
|
|
// The richest column (§11.2.2): only for one wipe — a balance is a value, and
|
|
// there is no all-time one — and only where the server reports Economics.
|
|
const withBalance = Boolean(economics && wipeId)
|
|
const rows = await db.leaderboard({
|
|
serverId,
|
|
wipeId,
|
|
sort: sort === 'balance' && !withBalance ? 'kills' : sort,
|
|
limit: boundedLimit(limit, 25),
|
|
balanceFloor: withBalance && sort === 'balance' ? economics.startingBalance : null,
|
|
})
|
|
|
|
return rows.map((r) => ({
|
|
...(withBalance ? { balance: r.balance === null || r.balance === undefined ? null : Number(r.balance) } : {}),
|
|
steamId: r.steamId,
|
|
name: r.name || null,
|
|
kills: Number(r.kills) || 0,
|
|
deaths: Number(r.deaths) || 0,
|
|
npcKills: Number(r.npcKills) || 0,
|
|
structures: Number(r.structures) || 0,
|
|
playtimeSec: Number(r.playtimeSec) || 0,
|
|
// Protocol 14 (D356): Safecracker's and Scavenger's counts.
|
|
cratesHacked: Number(r.cratesHacked) || 0,
|
|
dropsLooted: Number(r.dropsLooted) || 0,
|
|
// Protocol 14 (D375): Treasure Hunter's count.
|
|
treasuresWon: Number(r.treasuresWon) || 0,
|
|
// Protocol 14 (D375): Warden's count.
|
|
guardedWon: Number(r.guardedWon) || 0,
|
|
// Protocol 14 (§11.4, D202): Warlord's count.
|
|
raidsCompleted: Number(r.raidsCompleted) || 0,
|
|
// Withheld below the presence audience. A tally refreshes it every minute a
|
|
// player is on, so a `lastSeen` of forty seconds ago is the Online tab by
|
|
// another name. The ORDER still uses it as a tie-break — that says who was
|
|
// on more recently, never whether anybody is on now.
|
|
...(presence ? { lastSeen: r.lastSeen || null } : {}),
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Every wipe this server has had, newest first.
|
|
*
|
|
* The list is what makes the per-wipe view navigable, and it is also the proof
|
|
* R12 asks for: a wipe that ended is still here, with its stats still attached.
|
|
*/
|
|
async function wipes(serverId) {
|
|
const rows = await db.listWipes(serverId)
|
|
|
|
return rows.map((r) => ({
|
|
wipeId: r.wipeId,
|
|
saveCreatedAt: r.saveCreatedAt || null,
|
|
firstSeen: r.firstSeen,
|
|
lastSeen: r.lastSeen,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Who is on the server right now.
|
|
*
|
|
* Read from the presence board rather than counted from connect and disconnect
|
|
* events: the board is re-sent on every bridge connect and every minute, so it
|
|
* is right even after this module has missed something. Counting transitions
|
|
* instead would drift, and drift in exactly the direction people notice —
|
|
* players who never left.
|
|
*/
|
|
async function online(serverId) {
|
|
const rows = await db.presenceFor(serverId)
|
|
|
|
return rows.map((r) => ({
|
|
steamId: r.steamId,
|
|
name: r.name || null,
|
|
sleeping: Boolean(r.sleeping),
|
|
connectedAt: r.connectedAt || null,
|
|
}))
|
|
}
|
|
|
|
module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, shape, MAX_LIMIT }
|