Files
Module-Rust/server/model/servers/servers.db.js
wtclaude 481837e937
All checks were successful
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 49s
feat(rust): RaidableBases — feed, raids map layer, Warlord, Spawn a raid base, profiles (PLAN_REDESIGNS §11.4)
PLAN_REDESIGNS §11.4 (D202-D208, D386-D391), protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.12.

- raid.started, raid.completed, raid.purchased and raid.locked public,
  raid.ended staff's; raidableBases.js reads each row per viewer (raiders'
  and a buyer's names only for the presence audience, D376/D390; Steam ids,
  position and a run's key only for staff). Feed lines in the World filter.
- The step rust.raidbase.spawn, "Spawn a raid base" (ledgered, budget
  rust.raids): difficulty, profile and base from option sources listing each
  server's own names (D386), a spot as a world call's location with a height
  adjustment or an exact height, and PvP/NPCs/lock as on/off/blank. A base
  the raids helper made is a world resource, so eventWorld's revert and
  reconcile serve it and world.expired marks it expired; one it did not make
  records nothing and the run log says it despawns on its own timer. Refused
  at verify for a server that said it cannot; a dry run with a spot returns
  the spot check as detail.warnings (MODULE_API 1.16.0, D391). rb-not-ready
  retries in ten minutes.
- Warlord: raids_completed, title category raidscompleted, the leaderboard's
  Raid bases column (sort=raidsCompleted).
- The map's raids layer (D114), public by default with its own audience; the
  run's key is dropped before any viewer gets it.
- Server settings' line for RaidableBases; its profiles in Mod configuration
  under a virtual path (D205).
- Swagger fragment, bundle and README.

Tests: server 684/684 (raidableBases.test.js new, every name in it invented);
client 99/99, build and externals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 15:18:52 -05:00

356 lines
14 KiB
JavaScript

// ── SQL, and nothing else ─────────────────────────────────────────────────
//
// Core's own backend is layered `router → controller → model → db`, with models
// in pairs: a `.db.js` holding the SQL and a `.model.js` holding the logic that
// calls it. The split earns its keep here for the same reason it does in core —
// the file with the queries in it has no branching to test, and the file with the
// branching in it has no database to stand up.
//
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always.
const core = require('../../core')
const SERVERS = 'rust_servers'
const STATE = 'rust_server_state'
/**
* Every configured server, in the operator's own order.
*
* **The encrypted token comes back on this read and is never returned to a
* client.** Decryption happens in the model, one layer up; this file's job is to
* fetch a column, not to decide who may see it.
*/
async function listServers({ enabledOnly = false } = {}) {
return core.query(
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
protocol, enabled, sort_order AS sortOrder, announce_news AS announceNews,
news_delivery AS newsDelivery, credit_rule AS creditRule,
wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz,
DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt,
created_at AS createdAt, updated_at AS updatedAt
FROM ${SERVERS}
${enabledOnly ? 'WHERE enabled = 1' : ''}
ORDER BY sort_order ASC, id ASC`,
)
}
async function getServer(id) {
const rows = await core.query(
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
protocol, enabled, sort_order AS sortOrder, credit_rule AS creditRule,
wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz,
DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt,
created_at AS createdAt, updated_at AS updatedAt
FROM ${SERVERS}
WHERE id = ?`,
[id],
)
return rows[0] || null
}
/**
* Create or replace a server row.
*
* **`sidecar_token_enc` is only written when a value is supplied.** An admin form
* that shows a blank token field — which is the only thing it can show, since the
* token is write-only — posts an empty string on every save that did not intend
* to change it. Writing that through would erase the credential every time an
* operator renamed a server, and the failure would present as the bridge going
* down for no reason an hour after an unrelated edit.
*/
async function upsertServer({ id, name, sidecarBaseUrl, sidecarTokenEnc, protocol, enabled, sortOrder }) {
const setToken = sidecarTokenEnc !== null && sidecarTokenEnc !== undefined
await core.query(
`INSERT INTO ${SERVERS}
(id, name, sidecar_base_url, sidecar_token_enc, protocol, enabled, sort_order, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
sidecar_base_url = VALUES(sidecar_base_url),
${setToken ? 'sidecar_token_enc = VALUES(sidecar_token_enc),' : ''}
protocol = VALUES(protocol),
enabled = VALUES(enabled),
sort_order = VALUES(sort_order),
updated_at = CURRENT_TIMESTAMP`,
[id, name, sidecarBaseUrl, setToken ? sidecarTokenEnc : null, protocol, enabled ? 1 : 0, sortOrder],
)
}
/**
* Write one server's wipe schedule (phase 16, D130), all six columns at once.
*
* Its own statement rather than six more columns on `upsertServer`, because the
* schedule is written only when a save CARRIES one: a client that predates the
* schedule and posts the rest of the row must not reset it to `none`.
*
* `wipeOnceAt` is a Date or null. The pool negotiates the session's zone
* (`timezone: 'auto'` in core), so a Date written here reads back as the same
* instant.
*/
async function setSchedule(id, { wipeRule, wipeDay, wipeTime, wipeTz, wipeAnchor, wipeOnceAt }) {
await core.query(
`UPDATE ${SERVERS}
SET wipe_rule = ?, wipe_day = ?, wipe_time = ?, wipe_tz = ?, wipe_anchor = ?, wipe_once_at = ?
WHERE id = ?`,
[wipeRule, wipeDay, wipeTime, wipeTz, wipeAnchor, wipeOnceAt, id],
)
}
/**
* One server's credit rule (D372). Its own statement, like the schedule, and for
* the same reason: written only when a save carries one, so a client that
* predates the rule cannot reset it to the default.
*/
async function setCreditRule(id, rule) {
await core.query(`UPDATE ${SERVERS} SET credit_rule = ? WHERE id = ?`, [rule, id])
}
async function deleteServer(id) {
await core.query(`DELETE FROM ${SERVERS} WHERE id = ?`, [id])
}
/**
* `worldReady` from the hello the row keeps whole (`raw`): true, false, or null for
* a plugin that never says — which PLAN.md §28.6 reads as ready. The permission and
* title pushes hold while it is false (PLAN_FIXES F7): the plugin connects before the
* save loads, and a sync sent then waits on a main thread that is busy loading, times
* out, and the restart it was for is never shown as restored.
*/
function withWorldReady(row) {
if (!row) return row
const value = row.worldReady
const ready = value === null || value === undefined ? null : value === true || value === 1 || String(value) === 'true'
return { ...row, worldReady: ready, zoneHelper: helperOf(row.zoneHelper) }
}
/**
* The ZoneManager helper's state from the same hello (PLAN_FIXES D182): `{ state,
* version?, reason? }`, or null when the plugin reported none — no ZoneManager, or
* a plugin older than protocol 13. The driver hands JSON_EXTRACT back as text.
*/
function helperOf(value) {
if (value === null || value === undefined) return null
let parsed = value
if (typeof value === 'string') {
try {
parsed = JSON.parse(value)
} catch {
return null
}
}
if (!parsed || typeof parsed !== 'object' || typeof parsed.state !== 'string') return null
return {
state: parsed.state,
...(typeof parsed.version === 'string' ? { version: parsed.version } : {}),
...(typeof parsed.reason === 'string' ? { reason: parsed.reason } : {}),
}
}
/** The last thing each server said about itself, keyed by server id. */
async function listState() {
return (await core.query(
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
last_seen_at AS lastSeenAt, updated_at AS updatedAt,
JSON_EXTRACT(raw, '$.worldReady') AS worldReady,
JSON_EXTRACT(raw, '$.zoneHelper') AS zoneHelper
FROM ${STATE}`,
)).map(withWorldReady)
}
/** One server's observed state, or `null`. The single-row twin of `listState`. */
async function getState(serverId) {
const rows = await core.query(
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
last_seen_at AS lastSeenAt, updated_at AS updatedAt,
JSON_EXTRACT(raw, '$.worldReady') AS worldReady,
JSON_EXTRACT(raw, '$.zoneHelper') AS zoneHelper
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return withWorldReady(rows[0] || null)
}
/**
* Mark a server unreachable **without forgetting what it last said**.
*
* `putState` replaces the row whole, which is right when a sidecar answered: the
* frame it answered with is the complete truth about that server. It is wrong
* when nothing answered. A refresh that cannot reach a sidecar knows exactly one
* new fact — that it could not reach it — and writing the whole row from that
* one fact sets `hostname`, `level`, `seed`, `world_size` and `wipe_id` to NULL.
*
* The site's whole premise is that it renders the last thing each server said
* while every server is off. A row blanked the first time a game host reboots
* cannot do that: the page loses the map, the size, the seed and the wipe, and
* what it shows is not "offline, here is what we know" but "offline, and we have
* never heard of it". It is invisible in every test that stubs a reachable
* sidecar, and it shows up as a page that was complete an hour ago.
*
* So: three columns move, and the description stays where it is.
*/
async function markUnreachable(serverId, reachable = false) {
await core.query(
`INSERT INTO ${STATE} (server_id, reachable, online, players, updated_at)
VALUES (?, ?, 0, 0, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
reachable = VALUES(reachable),
online = 0,
players = 0,
updated_at = CURRENT_TIMESTAMP`,
[serverId, reachable ? 1 : 0],
)
}
/**
* Replace one server's observed state.
*
* **`updated_at` is set explicitly, and it has to be.** MariaDB's
* `ON UPDATE CURRENT_TIMESTAMP` fires only when an UPDATE actually CHANGES a
* value, so an update writing the same numbers back — exactly what a quiet
* server looks like — leaves the timestamp where it was. The row would then
* cross the freshness window and the page would report the server offline while
* it was up and reporting normally. That is invisible to every test and shows up
* as a page that was right when you looked at it and wrong an hour later.
*/
async function putState(state) {
await core.query(
`INSERT INTO ${STATE}
(server_id, reachable, online, players, max_players, hostname, level, seed,
world_size, boot_id, save_created_at, wipe_id, protocol, raw, last_seen_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, IF(?, CURRENT_TIMESTAMP, NULL), CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
reachable = VALUES(reachable), online = VALUES(online), players = VALUES(players),
max_players = VALUES(max_players), hostname = VALUES(hostname), level = VALUES(level),
seed = VALUES(seed), world_size = VALUES(world_size), boot_id = VALUES(boot_id),
save_created_at = VALUES(save_created_at), wipe_id = VALUES(wipe_id),
protocol = VALUES(protocol),
raw = VALUES(raw),
-- Only a CONNECTED game moves this; an unreachable write leaves it alone,
-- and so does a board the sidecar kept after the game went away (D68).
-- That is what lets a page say how long a server has been down rather
-- than how recently we failed to reach it.
last_seen_at = IF(?, CURRENT_TIMESTAMP, last_seen_at),
updated_at = CURRENT_TIMESTAMP`,
[
state.serverId,
state.reachable ? 1 : 0,
state.online ? 1 : 0,
state.players || 0,
state.maxPlayers || 0,
state.hostname || null,
state.level || null,
state.seed === undefined ? null : state.seed,
state.worldSize === undefined ? null : state.worldSize,
state.bootId || null,
state.saveCreatedAt || null,
state.wipeId || null,
state.protocol === undefined ? null : state.protocol,
state.raw ? JSON.stringify(state.raw) : null,
state.seen === false ? 0 : 1,
state.seen === false ? 0 : 1,
],
)
}
/**
* What one server's last status said about Dangerous Treasures and its console
* (protocol 14, PLAN_REDESIGNS §14.3.4): `{ dangerousTreasures, console }`, each
* as the driver hands JSON_EXTRACT back (text, or null for a plugin that never
* said). Null for a server with no state yet.
*/
async function dtReportFor(serverId) {
const rows = await core.query(
`SELECT JSON_EXTRACT(raw, '$.integrations.dangerousTreasures') AS dangerousTreasures,
JSON_EXTRACT(raw, '$.console') AS console
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/**
* What one server's last status said about Guarded Crate (with NpcSpawn and its
* event names) and its console (protocol 14, PLAN_REDESIGNS §14.3.5):
* `{ guardedCrate, console }`, as `dtReportFor`'s are. Null for a server with no
* state yet.
*/
async function gcReportFor(serverId) {
const rows = await core.query(
`SELECT JSON_EXTRACT(raw, '$.integrations.guardedCrate') AS guardedCrate,
JSON_EXTRACT(raw, '$.console') AS console
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/** `gcReportFor` for every enabled server, with its id and name: the event list a step offers. */
async function gcReports() {
return core.query(
`SELECT s.id AS serverId, s.name AS name,
JSON_EXTRACT(st.raw, '$.integrations.guardedCrate') AS guardedCrate,
JSON_EXTRACT(st.raw, '$.console') AS console
FROM ${SERVERS} s
JOIN ${STATE} st ON st.server_id = s.id
WHERE s.enabled = 1
ORDER BY s.sort_order ASC, s.id ASC`,
)
}
/**
* What one server's last status said about RaidableBases, its raids helper and
* its console (protocol 14, PLAN_REDESIGNS §11.4): `{ raidableBases, console }`,
* as `dtReportFor`'s are. Null for a server with no state yet.
*/
async function rbReportFor(serverId) {
const rows = await core.query(
`SELECT JSON_EXTRACT(raw, '$.integrations.raidableBases') AS raidableBases,
JSON_EXTRACT(raw, '$.console') AS console
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/** `rbReportFor` for every enabled server, with its id and name: the names a step offers. */
async function rbReports() {
return core.query(
`SELECT s.id AS serverId, s.name AS name,
JSON_EXTRACT(st.raw, '$.integrations.raidableBases') AS raidableBases,
JSON_EXTRACT(st.raw, '$.console') AS console
FROM ${SERVERS} s
JOIN ${STATE} st ON st.server_id = s.id
WHERE s.enabled = 1
ORDER BY s.sort_order ASC, s.id ASC`,
)
}
module.exports = {
SERVERS,
STATE,
dtReportFor,
rbReportFor,
rbReports,
gcReportFor,
gcReports,
listServers,
getServer,
upsertServer,
setSchedule,
setCreditRule,
deleteServer,
listState,
getState,
markUnreachable,
putState,
}