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
167 lines
7.2 KiB
JavaScript
167 lines
7.2 KiB
JavaScript
// ── RaidableBases (protocol 14, PLAN_REDESIGNS §11.4) ───────────────────────
|
|
//
|
|
// nivex's RaidableBases pastes a stocked, guarded building for players to raid.
|
|
// Its bases reach this module as five kinds: `raid.started`, `raid.completed`,
|
|
// `raid.purchased` and `raid.locked` (public), and `raid.ended` (staff's). A base
|
|
// completed is *Warlord*'s count, from the tally's `raidsCompleted` (D202). A
|
|
// run's step spawns one (`eventRaidableBases.js`).
|
|
//
|
|
// **Every name is the plugin's (D386).** Difficulty names and levels, profile
|
|
// names and base names arrive as the installed plugin's own strings; nothing
|
|
// here lists them, and an operator's own difficulty is shown as written.
|
|
//
|
|
// ── Who may read what ──────────────────────────────────────────────────────
|
|
//
|
|
// As a world event is read (`worldEvents.js`, D353, D376, D390): the base, its
|
|
// difficulty, whether it is PvP, and its grid are public; the raiders' and a
|
|
// buyer's names show only to the operator's presence audience, so with presence
|
|
// left at staff a signed-out visitor reads "A Hard raid base was raided at G7";
|
|
// Steam ids, the position and a run's key are staff's. How
|
|
// many raided it is a count, and names nobody.
|
|
|
|
const { gridOf } = require('./worldEvents')
|
|
|
|
const KINDS = Object.freeze(['raid.started', 'raid.completed', 'raid.ended', 'raid.purchased', 'raid.locked'])
|
|
|
|
const STEAM_ID = /^\d{17}$/
|
|
|
|
function isKind(kind) {
|
|
return KINDS.includes(kind)
|
|
}
|
|
|
|
/**
|
|
* One `raid.*` frame for one viewer: `{ staff, presence, geometry }`. Passes
|
|
* the envelope, `level`, `difficulty`, `base`, `pvp` and `despawnAt`, adds `grid`; a
|
|
* completion adds `raiderCount`, and `names` (with the lock holder's
|
|
* `ownerName`) for the presence audience; a purchase or a lock adds `name` for
|
|
* the presence audience. Staff also get `x`, `z`, the run's `id`,
|
|
* `runId` and `key`, and every Steam id. Any other frame is returned as is.
|
|
*/
|
|
function shapeFor(frame, { staff = false, presence = false, geometry = null } = {}) {
|
|
if (!frame || !isKind(frame.kind)) return frame
|
|
|
|
const out = {}
|
|
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId']) {
|
|
if (frame[key] !== undefined) out[key] = frame[key]
|
|
}
|
|
|
|
const level = Number(frame.level)
|
|
if (Number.isInteger(level)) out.level = level
|
|
if (typeof frame.difficulty === 'string' && frame.difficulty) out.difficulty = frame.difficulty
|
|
if (typeof frame.pvp === 'boolean') out.pvp = frame.pvp
|
|
if (Number.isFinite(Number(frame.despawnAt)) && Number(frame.despawnAt) > 0) out.despawnAt = Number(frame.despawnAt)
|
|
// The base's file name is the operator's, as the difficulty is, and the map shows it too.
|
|
if (typeof frame.base === 'string' && frame.base) out.base = frame.base
|
|
|
|
const grid = gridOf(geometry, frame.x, frame.z)
|
|
if (grid) out.grid = grid
|
|
|
|
const named = presence || staff
|
|
|
|
if (frame.kind === 'raid.completed') {
|
|
const raiders = Array.isArray(frame.raiders) ? frame.raiders.filter((r) => r && typeof r === 'object') : []
|
|
const count = Number(frame.raiderCount)
|
|
out.raiderCount = Number.isInteger(count) && count >= 0 ? count : raiders.length
|
|
if (named) {
|
|
out.names = raiders.map((r) => (typeof r.name === 'string' ? r.name : '')).filter(Boolean)
|
|
if (typeof frame.ownerName === 'string' && frame.ownerName) out.ownerName = frame.ownerName
|
|
}
|
|
if (staff) {
|
|
out.raiders = raiders
|
|
.filter((r) => STEAM_ID.test(String(r.steamId || '')))
|
|
.map((r) => ({ steamId: String(r.steamId), ...(typeof r.name === 'string' ? { name: r.name } : {}) }))
|
|
if (STEAM_ID.test(String(frame.ownerId || ''))) out.ownerId = String(frame.ownerId)
|
|
}
|
|
}
|
|
|
|
if (frame.kind === 'raid.purchased' || frame.kind === 'raid.locked') {
|
|
if (named && typeof frame.name === 'string' && frame.name) out.name = frame.name
|
|
if (staff && STEAM_ID.test(String(frame.steamId || ''))) out.steamId = String(frame.steamId)
|
|
}
|
|
|
|
if (staff) {
|
|
for (const key of ['x', 'z', 'id', 'runId', 'key', 'radius']) {
|
|
if (frame[key] !== undefined) out[key] = frame[key]
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
/** A JSON column as an object, or null. */
|
|
function parse(value) {
|
|
if (value === null || value === undefined) return null
|
|
if (typeof value !== 'string') return value
|
|
try {
|
|
return JSON.parse(value)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** One profile as the helper reports it, or null when it is not one. */
|
|
function profileOf(p) {
|
|
if (!p || typeof p !== 'object' || typeof p.name !== 'string' || !p.name) return null
|
|
return {
|
|
name: p.name,
|
|
difficulty: typeof p.difficulty === 'string' ? p.difficulty : null,
|
|
level: Number.isInteger(Number(p.level)) ? Number(p.level) : null,
|
|
enabled: p.enabled === true,
|
|
pvp: p.pvp === true,
|
|
bases: Array.isArray(p.bases) ? p.bases.filter((b) => typeof b === 'string' && b) : [],
|
|
seabed: p.seabed === true,
|
|
float: p.float === true,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A server's last report about RaidableBases and its console, from
|
|
* `servers.db.rbReportFor`: `{ raidableBases, consolePlugins }`, each null
|
|
* where the plugin never said (no state yet, or a plugin before this build).
|
|
* `raidableBases` is `{ loaded, version, premium, spawnCall, copyPaste,
|
|
* difficulties, helper, gridReady, profiles }` — `helper.state` is `missing`,
|
|
* `patched`, `unsupported` or `no-raidablebases`; `profiles` is null without a
|
|
* patched helper, which is the only thing that can read them.
|
|
*/
|
|
function reportOf(row) {
|
|
const rb = row ? parse(row.raidableBases) : null
|
|
const probe = row ? parse(row.console) : null
|
|
let raidableBases = null
|
|
|
|
if (rb && typeof rb === 'object') {
|
|
const helper = rb.helper && typeof rb.helper === 'object' ? rb.helper : {}
|
|
raidableBases = {
|
|
loaded: rb.loaded === true,
|
|
version: typeof rb.version === 'string' ? rb.version : null,
|
|
premium: rb.premium === true,
|
|
spawnCall: rb.spawnCall === true,
|
|
copyPaste: rb.copyPaste && typeof rb.copyPaste === 'object' ? { loaded: rb.copyPaste.loaded === true } : null,
|
|
difficulties: Array.isArray(rb.difficulties)
|
|
? rb.difficulties
|
|
.filter((d) => d && typeof d.name === 'string' && d.name && Number.isInteger(Number(d.level)))
|
|
.map((d) => ({ name: d.name, level: Number(d.level) }))
|
|
: [],
|
|
helper: {
|
|
state: typeof helper.state === 'string' ? helper.state : 'missing',
|
|
version: typeof helper.version === 'string' ? helper.version : null,
|
|
reason: typeof helper.reason === 'string' ? helper.reason : null,
|
|
},
|
|
gridReady: typeof rb.gridReady === 'boolean' ? rb.gridReady : null,
|
|
profiles: Array.isArray(rb.profiles) ? rb.profiles.map(profileOf).filter(Boolean) : null,
|
|
}
|
|
}
|
|
|
|
return {
|
|
raidableBases,
|
|
consolePlugins: probe && typeof probe === 'object' && typeof probe.plugins === 'boolean' ? probe.plugins : null,
|
|
}
|
|
}
|
|
|
|
/** Whether the report says the raids helper is installed and patched. */
|
|
function helperPatched(report) {
|
|
const rb = report && report.raidableBases
|
|
return Boolean(rb && rb.loaded && rb.helper && rb.helper.state === 'patched')
|
|
}
|
|
|
|
module.exports = { KINDS, isKind, shapeFor, reportOf, profileOf, helperPatched }
|