From 481837e9374ceb13763f76302c4df2d32066b616 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 16 Oct 2026 15:18:52 -0500 Subject: [PATCH] =?UTF-8?q?feat(rust):=20RaidableBases=20=E2=80=94=20feed,?= =?UTF-8?q?=20raids=20map=20layer,=20Warlord,=20Spawn=20a=20raid=20base,?= =?UTF-8?q?=20profiles=20(PLAN=5FREDESIGNS=20=C2=A711.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 13 + ci/bundle.json | 2 + client/src/components/Leaderboard.jsx | 5 + client/src/components/MapView.jsx | 21 +- client/src/lib/feed.js | 57 ++- client/src/lib/mapEvents.js | 18 + client/src/routes/admin/ChatTitles.jsx | 37 +- client/src/routes/admin/ModConfig.jsx | 3 +- client/src/routes/admin/Visibility.jsx | 1 + client/test/feed.test.js | 43 +- client/test/mapEvents.test.js | 9 +- server/catalogue.js | 12 + server/db/schema.sql | 4 + server/eventRaidableBases.js | 453 +++++++++++++++++++ server/index.js | 15 +- server/model/config/config.model.js | 11 +- server/model/events/events.db.js | 5 + server/model/events/events.model.js | 11 +- server/model/map/map.model.js | 18 +- server/model/servers/servers.db.js | 31 ++ server/model/titles/titles.js | 4 +- server/raidableBases.js | 166 +++++++ server/router/admin/visibility.router.js | 4 +- server/router/public/rust.controller.js | 4 + server/router/public/rust.router.js | 4 +- server/sidecarClient.js | 20 + server/swagger/doc.js | 61 ++- server/test/catalogue.test.js | 13 +- server/test/config.test.js | 12 + server/test/entry.test.js | 2 + server/test/map.test.js | 2 +- server/test/raidableBases.test.js | 428 ++++++++++++++++++ server/test/titleConditions.test.js | 5 +- swagger-fragment.json | 540 ++++++++++++++++++++++- 34 files changed, 1996 insertions(+), 38 deletions(-) create mode 100644 server/eventRaidableBases.js create mode 100644 server/raidableBases.js create mode 100644 server/test/raidableBases.test.js diff --git a/README.md b/README.md index 00a0c56..17668bd 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,19 @@ one now, kept when the run ends: an airdrop or a patrol helicopter can be sent t alone — and the run log says so. Refused where Guarded Crate or NpcSpawn is absent, where the event is not one the server has, or where the framework cannot run its console command (`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.5). +- **RaidableBases** (nivex, 3.2.2, free or paid), with **CopyPaste** to paste its bases, and the + **raids helper** `RunicGatewayRaids.cs` the plugin ships beside the bridge. With them: a base + appearing, raided, bought or locked to a player are public feed lines (*"Alice and Bob raided a Hard + raid base · G7"*, the names only for the presence audience), every live base is on the live map's own + **Raid bases** layer (public by default, set in Admin → Rust visibility), the **Raid bases** + leaderboard column and the *Warlord* title count the bases a player raided, and the step **Spawn a + raid base** spawns one: at a difficulty, a profile or a named base, where RaidableBases picks or at a + spot, with its own PvP, NPCs, despawn time and lock. That base is the event's, despawned when the run + ends. A dry run checks a spot and says what is in the way, as advice. Every difficulty, profile and + base name is the server's own. Without the helper the step can still ask RaidableBases for a base + where it picks, but it despawns on its own timer, and the step says so. RaidableBases' profiles are + editable from **Mod configuration**, reloaded and rolled back like any config + (`docs/modules/rust/PLAN_REDESIGNS.md` §11.4). - **ZLevels Remastered** (nivex, 3.3.1). The servers page says whether it is loaded, and that is all: its levels are never read, and the leaderboard has no column for them. Its XP boosts are its own permissions (`zlevelsremastered.vip1`–`vip3`), so a *Double XP weekend* is a **Permissions until the event ends** diff --git a/ci/bundle.json b/ci/bundle.json index bb3dc3b..bf9dfba 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -41,6 +41,7 @@ "eventGuardedCrate.js", "eventLeases.js", "eventPermRewards.js", + "eventRaidableBases.js", "eventProgress.js", "eventRewards.js", "eventTemplates.js", @@ -55,6 +56,7 @@ "npcSync.js", "package.json", "permSync.js", + "raidableBases.js", "router", "sidecarClient.js", "titleSync.js", diff --git a/client/src/components/Leaderboard.jsx b/client/src/components/Leaderboard.jsx index 3e921c5..48588b0 100644 --- a/client/src/components/Leaderboard.jsx +++ b/client/src/components/Leaderboard.jsx @@ -50,6 +50,10 @@ const TREASURES = { key: 'treasuresWon', label: 'Treasures', sort: 'treasuresWon // same rule from the server's `guardedCrate`. const GUARDED = { key: 'guardedWon', label: 'Crates cleared', sort: 'guardedWon', value: (r) => count(r.guardedWon) } +// Warlord (PLAN_REDESIGNS §11.4, D202): RaidableBases bases completed, shown on +// the same rule from the server's `raidableBases`. +const RAIDS = { key: 'raidsCompleted', label: 'Raid bases', sort: 'raidsCompleted', value: (r) => count(r.raidsCompleted) } + const BALANCE = { key: 'balance', label: 'Coins', sort: 'balance', value: (r) => coins(r.balance) } export default function Leaderboard({ serverId, wipeId, sort, onSort }) { @@ -109,6 +113,7 @@ function Overall({ serverId, wipeId, sort, onSort }) { ...COLUMNS, ...(data && data.dangerousTreasures ? [TREASURES] : []), ...(data && data.guardedCrate ? [GUARDED] : []), + ...(data && data.raidableBases ? [RAIDS] : []), ...(data && data.economics ? [BALANCE] : []), ] // Present on every row or on none — the server decides per request. diff --git a/client/src/components/MapView.jsx b/client/src/components/MapView.jsx index fe647f9..dde7ec0 100644 --- a/client/src/components/MapView.jsx +++ b/client/src/components/MapView.jsx @@ -21,7 +21,7 @@ import Empty from './Empty.jsx' import usePolled from '../hooks/usePolled.js' import { ago } from '../lib/format.js' import { boundsOf, countdown, fromLatLng, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js' -import { eventTip, isBoss, pvpTip, titlesFrom, unknownRuns } from '../lib/mapEvents.js' +import { eventTip, isBoss, pvpTip, raidTip, titlesFrom, unknownRuns } from '../lib/mapEvents.js' import api, { BASE } from '../api.js' const STYLE_ID = 'rust-leaflet-css' @@ -36,6 +36,7 @@ const LAYERS = [ { id: 'players', label: 'Players' }, { id: 'bases', label: 'Bases' }, { id: 'pvp', label: 'PvP zones' }, + { id: 'raids', label: 'Raid bases' }, ] const COLOURS = { @@ -57,6 +58,8 @@ const COLOURS = { self: '#00e5ff', mate: '#7cffb2', pvp: '#ff1744', + raid: '#ff9100', + raidOurs: '#ffd600', } const WORLD_NAMES = { @@ -87,7 +90,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null } const [leaflet, setLeaflet] = useState(null) const [leafletError, setLeafletError] = useState(null) - const [shown, setShown] = useState({ grid: true, world: true, events: true, players: true, bases: true, pvp: true, mates: true }) + const [shown, setShown] = useState({ grid: true, world: true, events: true, players: true, bases: true, pvp: true, raids: true, mates: true }) useEffect(() => { let alive = true @@ -168,7 +171,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null } map.fitBounds(bounds) const made = {} - for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'pvp', 'mates', 'pins']) made[id] = L.layerGroup().addTo(map) + for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'pvp', 'raids', 'mates', 'pins']) made[id] = L.layerGroup().addTo(map) // The labels are a layer of their own inside the grid's, shown only when a cell // is wide enough on screen to hold one: at the fitted zoom a 20-cell map's // labels overlap into a wall of text (found on the phase 14 walk). @@ -246,7 +249,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null } const { L } = leaflet const answer = live.data || {} const at = (p) => toLatLng(geometry, p.x, p.z) - for (const id of ['world', 'events', 'players', 'bases', 'pvp', 'mates']) made[id].clearLayers() + for (const id of ['world', 'events', 'players', 'bases', 'pvp', 'raids', 'mates']) made[id].clearLayers() for (const w of answer.world || []) { let tip = WORLD_NAMES[w.kind] || w.kind @@ -289,6 +292,13 @@ export default function MapView({ serverId, online, onPick = null, pins = null } .addTo(made.pvp) } + // RaidableBases' bases (§11.4): orange, and yellow for one this site's event spawned. + for (const r of answer.raids || []) { + dot(L, at(r), r.runId ? COLOURS.raidOurs : COLOURS.raid, 7, 2) + .bindTooltip(escape(raidTip(r, Date.now()))) + .addTo(made.raids) + } + for (const m of answer.mates || []) { dot(L, at(m), m.self ? COLOURS.self : COLOURS.mate, m.self ? 8 : 6, 2) .bindTooltip(escape(m.self ? `You${m.online ? '' : ' (asleep, offline)'}` : m.name || 'Clan mate')) @@ -314,7 +324,7 @@ export default function MapView({ serverId, online, onPick = null, pins = null } const map = mapRef.current const made = groups.current if (!map || !made) return - const want = { grid: shown.grid, monuments: shown.world, world: shown.world, events: shown.events, players: shown.players, bases: shown.bases, pvp: shown.pvp, mates: shown.mates } + const want = { grid: shown.grid, monuments: shown.world, world: shown.world, events: shown.events, players: shown.players, bases: shown.bases, pvp: shown.pvp, raids: shown.raids, mates: shown.mates } for (const [id, on] of Object.entries(want)) { if (on && !map.hasLayer(made[id])) made[id].addTo(map) if (!on && map.hasLayer(made[id])) map.removeLayer(made[id]) @@ -397,6 +407,7 @@ function Legend({ meta, live, shown, onToggle }) { players: [COLOURS.online, COLOURS.sleeping], bases: [COLOURS.tc, COLOURS.vending], pvp: [COLOURS.pvp], + raids: [COLOURS.raid, COLOURS.raidOurs], } return ( diff --git a/client/src/lib/feed.js b/client/src/lib/feed.js index 1dff67d..5f5f12c 100644 --- a/client/src/lib/feed.js +++ b/client/src/lib/feed.js @@ -59,6 +59,12 @@ export const FEED_KINDS = Object.freeze([ // like a treasure. A timeout (`gc.failed`) is staff's; the map shows it go. 'gc.started', 'gc.ended', + // RaidableBases (§11.4): a base appearing, raided, bought or locked to a + // player, read per viewer; its despawn (`raid.ended`) is staff's, and the map shows it go. + 'raid.started', + 'raid.completed', + 'raid.purchased', + 'raid.locked', ]) /** The filters the feed offers, and the kinds each one asks the API for. */ @@ -73,7 +79,7 @@ export const FILTERS = Object.freeze([ }, { id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] }, { id: 'bosses', label: 'Bosses', kinds: ['npc.boss.spawned', 'npc.boss.died'] }, - { id: 'world', label: 'World events', kinds: ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended'] }, + { id: 'world', label: 'World events', kinds: ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended', 'raid.started', 'raid.completed', 'raid.purchased', 'raid.locked'] }, ]) export function kindsFor(filterId) { @@ -177,6 +183,13 @@ export function describe(row) { case 'gc.failed': return guardedLine(row.kind, frame) + case 'raid.started': + case 'raid.completed': + case 'raid.ended': + case 'raid.purchased': + case 'raid.locked': + return raidLine(row.kind, frame) + default: return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' } } @@ -407,4 +420,46 @@ function guardedLine(kind, frame) { : { tone: 'kill', actor: null, verb: 'The guarded crate was cleared', subject: null, detail } } +/** "Alice", "Alice and Bob", "Alice, Bob and 3 others". */ +export function raiderNames(names, count) { + const shown = (names || []).slice(0, 2) + const rest = Math.max(0, (Number(count) || shown.length) - shown.length) + if (shown.length === 0) return null + if (rest === 0) return shown.length === 2 ? `${shown[0]} and ${shown[1]}` : shown[0] + return `${shown.join(', ')} and ${rest} other${rest === 1 ? '' : 's'}` +} + +/** + * RaidableBases (protocol 14, PLAN_REDESIGNS §11.4). The server has read the + * frame for this viewer: `grid`, the plugin's own `difficulty` name (D386), `pvp`, + * and the raiders' `names` or a buyer's `name` only for the presence audience + * (D376, D390); `raiderCount` for everyone. + */ +function raidLine(kind, frame) { + const base = frame.difficulty ? `${frame.difficulty} raid base` : 'raid base' + const a = /^[aeiou]/i.test(base) ? 'An' : 'A' + const detail = [frame.grid || null, frame.pvp === true ? 'PvP' : null].filter(Boolean).join(' · ') + + if (kind === 'raid.started') return { tone: 'server', actor: null, verb: `${a} ${base} appeared`, subject: null, detail } + if (kind === 'raid.ended') return { tone: 'server', actor: null, verb: `${a} ${base} despawned`, subject: null, detail } + + if (kind === 'raid.completed') { + const who = raiderNames(frame.names, frame.raiderCount) + if (who) return { tone: 'kill', actor: who, verb: `raided ${a.toLowerCase()} ${base}`, subject: null, detail } + const count = Number(frame.raiderCount) || 0 + const by = count > 0 ? `by ${count} player${count === 1 ? '' : 's'}` : null + return { tone: 'kill', actor: null, verb: `${a} ${base} was raided`, subject: null, detail: [detail, by].filter(Boolean).join(' · ') } + } + + if (kind === 'raid.purchased') { + return frame.name + ? { tone: 'server', actor: frame.name, verb: `bought ${a.toLowerCase()} ${base}`, subject: null, detail } + : { tone: 'server', actor: null, verb: `${a} ${base} was bought`, subject: null, detail } + } + + return frame.name + ? { tone: 'server', actor: null, verb: `${a} ${base} was locked to ${frame.name}`, subject: null, detail } + : { tone: 'server', actor: null, verb: `${a} ${base} was locked to a player`, subject: null, detail } +} + export default { describe, FEED_KINDS, FILTERS, kindsFor } diff --git a/client/src/lib/mapEvents.js b/client/src/lib/mapEvents.js index ccc8a7b..31c8378 100644 --- a/client/src/lib/mapEvents.js +++ b/client/src/lib/mapEvents.js @@ -65,3 +65,21 @@ export function pvpTip(z) { const name = PVP_NAMES[z.event] || z.event return name ? `PvP zone · ${name}` : 'PvP zone' } + +// ── RaidableBases' bases (protocol 14, PLAN_REDESIGNS §11.4) ─────────────── + +/** + * A raid base's tooltip: "Raid base · Hard · raidhard2 · PvP · despawns in 42 min", + * and "· this site's event" for one a run spawned. The difficulty and base names are + * the plugin's own (D386). + */ +export function raidTip(r, now = Date.now()) { + const parts = ['Raid base'] + if (r.difficulty) parts.push(r.difficulty) + if (r.base) parts.push(r.base) + if (r.pvp === true) parts.push('PvP') + const left = Number(r.despawnAt) - now + if (Number.isFinite(left) && left > 0) parts.push(`despawns in ${Math.max(1, Math.round(left / 60000))} min`) + if (r.runId) parts.push('this site’s event') + return parts.join(' · ') +} diff --git a/client/src/routes/admin/ChatTitles.jsx b/client/src/routes/admin/ChatTitles.jsx index 8ca53f3..6cc3661 100644 --- a/client/src/routes/admin/ChatTitles.jsx +++ b/client/src/routes/admin/ChatTitles.jsx @@ -266,7 +266,42 @@ export function integrationsLine(r) { const withDynamicPvp = r.integrations.dynamicPvp ? `${withTruePve} ${dynamicPvpLine(r.integrations.dynamicPvp)}` : withTruePve const withDt = r.integrations.dangerousTreasures ? `${withDynamicPvp} ${dangerousTreasuresLine(r.integrations.dangerousTreasures, r.console)}` : withDynamicPvp const withGc = r.integrations.guardedCrate ? `${withDt} ${guardedCrateLine(r.integrations.guardedCrate, r.console)}` : withDt - return r.integrations.zLevels ? `${withGc} ${zLevelsLine(r.integrations.zLevels)}` : withGc + const withZ = r.integrations.zLevels ? `${withGc} ${zLevelsLine(r.integrations.zLevels)}` : withGc + return r.integrations.raidableBases ? `${withZ} ${raidableBasesLine(r.integrations.raidableBases, r.console)}` : withZ +} + +/** + * RaidableBases (PLAN_REDESIGNS §11.4), from protocol 14: its edition, its own + * difficulties (D386), CopyPaste, and the raids helper that makes a step's base the + * run's (D204) — and what a step may do without it (D348, D387). + */ +export function raidableBasesLine(rb, probe) { + if (!rb.loaded) { + return `RaidableBases is not loaded — no raid bases on the feed or the map, and a step cannot spawn one.${rb.helper && rb.helper.state !== 'missing' ? ' RunicGatewayRaids.cs is installed and waits for it.' : ''}` + } + const name = `RaidableBases ${rb.version || ''} (${rb.premium ? 'paid' : 'free'} edition)`.replace(' ()', '').trim() + const difficulties = Array.isArray(rb.difficulties) && rb.difficulties.length + ? ` Its difficulties: ${rb.difficulties.map((d) => d.name).join(', ')}.` + : '' + if (rb.copyPaste && !rb.copyPaste.loaded) { + return `${name} is loaded but CopyPaste is missing: it cannot paste a base, and a step cannot spawn one.` + } + const state = rb.helper ? rb.helper.state : 'missing' + if (state === 'patched') { + const grid = rb.gridReady === false ? ' It is still building its spawn grid (up to ten minutes after it loads); a step waits for it.' : '' + const profiles = Array.isArray(rb.profiles) ? ` ${rb.profiles.length} profile${rb.profiles.length === 1 ? '' : 's'}.` : '' + return `${name} is loaded with RunicGatewayRaids.cs — its bases show on the feed and the map, and a step may spawn one that is the event’s: at a spot, with its own settings and lock, despawned when the event ends.${difficulties}${profiles}${grid}` + } + const helper = state === 'missing' + ? 'RunicGatewayRaids.cs is not installed' + : `RunicGatewayRaids.cs could not patch it${rb.helper && rb.helper.reason ? ` (${rb.helper.reason})` : ''}` + if (rb.spawnCall) { + return `${name} is loaded — its bases show on the feed and the map. ${helper}, so a step may spawn one only where RaidableBases picks, at a difficulty, and it despawns on its own timer.${difficulties}` + } + if (probe && probe.plugins === false) { + return `${name} is loaded — its bases show on the feed and the map. ${helper}, and this framework cannot run its console command, so a step cannot spawn one (Rust-Plugins#34).` + } + return `${name} is loaded — its bases show on the feed and the map. ${helper}, so a step may spawn one only where RaidableBases picks, and it despawns on its own timer.${difficulties}` } /** diff --git a/client/src/routes/admin/ModConfig.jsx b/client/src/routes/admin/ModConfig.jsx index 1dd9fa2..87451c6 100644 --- a/client/src/routes/admin/ModConfig.jsx +++ b/client/src/routes/admin/ModConfig.jsx @@ -444,7 +444,8 @@ export default function ModConfig() { setTier('form') }} > - {entry.path} + {/* A RaidableBases profile (D205) travels under a virtual path; show its name. */} + {entry.profile ? `Profile · ${entry.path.split('/').pop()}` : entry.path} {Math.round(entry.bytes / 102.4) / 10} KB diff --git a/client/src/routes/admin/Visibility.jsx b/client/src/routes/admin/Visibility.jsx index 27f3122..f9505a7 100644 --- a/client/src/routes/admin/Visibility.jsx +++ b/client/src/routes/admin/Visibility.jsx @@ -385,6 +385,7 @@ const MAP_LAYERS = [ { id: 'players', label: 'Players', hint: 'Where everybody is, with names, and the sleepers.' }, { id: 'bases', label: 'Bases', hint: 'Tool cupboards and vending machines, as positions only.' }, { id: 'pvp', label: 'PvP zones', hint: 'Dynamic PVP’s own PvP zones around a downed helicopter, a Bradley, cargo and the rest. Needs Dynamic PVP on the server.' }, + { id: 'raids', label: 'Raid bases', hint: 'RaidableBases’ live bases, their difficulty and when they despawn, and which an event of this site spawned. Needs RaidableBases on the server.' }, ] const RANK = { public: 0, signed_in: 1, staff: 2 } diff --git a/client/test/feed.test.js b/client/test/feed.test.js index 3f96847..ea76202 100644 --- a/client/test/feed.test.js +++ b/client/test/feed.test.js @@ -203,8 +203,9 @@ test('every type the server knows has its own words, and an unknown one says its assert.equal(describe(world({ event: 'meteor.landed' })).verb, 'meteor.landed') }) -test('the world filter asks for world events, Dynamic PVP zones opening, Dangerous Treasures and Guarded Crate, which the server serves publicly', () => { - assert.deepEqual(kindsFor('world'), ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended']) +test('the world filter asks for world events, Dynamic PVP zones opening, Dangerous Treasures, Guarded Crate and RaidableBases, which the server serves publicly', () => { + assert.deepEqual(kindsFor('world'), ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended', 'raid.started', 'raid.completed', 'raid.purchased', 'raid.locked']) + assert.ok(!FEED_KINDS.includes('raid.ended'), 'a raid base despawning is a staff line') assert.ok(!FEED_KINDS.includes('dt.ended'), 'a treasure event ending is a staff line') assert.ok(!FEED_KINDS.includes('gc.failed'), 'a guarded crate timing out is a staff line') assert.ok(FEED_KINDS.includes('pvp.zone.created')) @@ -299,3 +300,41 @@ test('an ending with nobody clearing it, and a timeout for a staff reader, are t assert.equal(describe(guarded('gc.ended', { grid: 'M4', won: false })).verb, 'A guarded crate ended with nobody clearing it') assert.equal(describe(guarded('gc.failed', { grid: 'M4' })).verb, 'A guarded crate timed out with its guards standing') }) + +// ── RaidableBases (protocol 14, PLAN_REDESIGNS §11.4) ───────────────────── +// +// Every difficulty here is invented (D386): the line says the plugin's own name. + +const raid = (kind, frame) => row(kind, { kind, ...frame }) + +test('a raid base appearing says its difficulty as the plugin named it, where, and whether it is PvP', () => { + const line = describe(raid('raid.started', { difficulty: 'Wobbly', grid: 'G7', pvp: true })) + assert.equal(line.verb, 'A Wobbly raid base appeared') + assert.equal(line.detail, 'G7 · PvP') + assert.equal(line.actor, null) + assert.equal(describe(raid('raid.started', { difficulty: 'Eerie', grid: 'G7' })).verb, 'An Eerie raid base appeared') + assert.equal(describe(raid('raid.started', { grid: 'G7' })).verb, 'A raid base appeared') +}) + +test('a raid names its raiders only when the server sent the names, and otherwise counts them', () => { + const named = describe(raid('raid.completed', { difficulty: 'Wobbly', grid: 'G7', names: ['Alice', 'Bob', 'Cy'], raiderCount: 4 })) + assert.equal(named.actor, 'Alice, Bob and 2 others') + assert.equal(named.verb, 'raided a Wobbly raid base') + + const two = describe(raid('raid.completed', { difficulty: 'Wobbly', names: ['Alice', 'Bob'], raiderCount: 2 })) + assert.equal(two.actor, 'Alice and Bob') + + const unnamed = describe(raid('raid.completed', { difficulty: 'Wobbly', grid: 'G7', raiderCount: 3 })) + assert.equal(unnamed.actor, null) + assert.equal(unnamed.verb, 'A Wobbly raid base was raided') + assert.equal(unnamed.detail, 'G7 · by 3 players') +}) + +test('a purchase and an admin lock name the player only when the server sent the name (D390), and a despawn is its own line', () => { + assert.equal(describe(raid('raid.purchased', { difficulty: 'Wobbly', name: 'Alice' })).actor, 'Alice') + assert.equal(describe(raid('raid.purchased', { difficulty: 'Wobbly', name: 'Alice' })).verb, 'bought a Wobbly raid base') + assert.equal(describe(raid('raid.purchased', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base was bought') + assert.equal(describe(raid('raid.locked', { difficulty: 'Wobbly', name: 'Alice' })).verb, 'A Wobbly raid base was locked to Alice') + assert.equal(describe(raid('raid.locked', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base was locked to a player') + assert.equal(describe(raid('raid.ended', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base despawned') +}) diff --git a/client/test/mapEvents.test.js b/client/test/mapEvents.test.js index d19c9a6..0ecae2e 100644 --- a/client/test/mapEvents.test.js +++ b/client/test/mapEvents.test.js @@ -8,7 +8,7 @@ import assert from 'node:assert/strict' import { createRequire } from 'node:module' -import { eventTip, isBoss, pvpTip, PVP_NAMES, titlesFrom, unknownRuns } from '../src/lib/mapEvents.js' +import { eventTip, isBoss, pvpTip, PVP_NAMES, raidTip, titlesFrom, unknownRuns } from '../src/lib/mapEvents.js' const CALENDAR = { entries: [ @@ -59,3 +59,10 @@ test('a Dynamic PVP ring says what it is around', () => { const { GENERAL_EVENTS } = createRequire(import.meta.url)('../../server/dynamicPvp.js') assert.deepEqual(Object.keys(PVP_NAMES).sort(), [...GENERAL_EVENTS].sort()) }) + +test('a raid base’s tooltip says its own difficulty and base, PvP, when it despawns, and whether an event of this site spawned it', () => { + const now = 1_000_000 + assert.equal(raidTip({ difficulty: 'Wobbly', base: 'wobble3', pvp: true, despawnAt: now + 42 * 60000 }, now), 'Raid base · Wobbly · wobble3 · PvP · despawns in 42 min') + assert.equal(raidTip({ difficulty: 'Wobbly', runId: '7' }, now), 'Raid base · Wobbly · this site’s event') + assert.equal(raidTip({ despawnAt: now - 1 }, now), 'Raid base') +}) diff --git a/server/catalogue.js b/server/catalogue.js index 1fde088..1bf3bbe 100644 --- a/server/catalogue.js +++ b/server/catalogue.js @@ -82,6 +82,15 @@ const PUBLIC_KINDS = Object.freeze([ // and the position only for staff. 'gc.started', 'gc.ended', + // Protocol 14: a RaidableBases base appeared, was raided, was bought, or was + // locked to a player by an admin (PLAN_REDESIGNS §11.4, D390). Public as + // kinds, read per viewer by `raidableBases.shapeFor`: the raiders' and a + // buyer's names only for the presence audience, Steam ids, the position and a + // run's key only for staff. + 'raid.started', + 'raid.completed', + 'raid.purchased', + 'raid.locked', ]) /** @@ -153,6 +162,9 @@ const STAFF_KINDS = Object.freeze([ // Protocol 14 (§14.3.5): a guarded crate timed out with guards still standing. // The plan keeps it staff's; the map shows the crate go. 'gc.failed', + // Protocol 14 (§11.4): a RaidableBases base despawned. Staff's; the map shows + // the base go. + 'raid.ended', ]) /** diff --git a/server/db/schema.sql b/server/db/schema.sql index a36efd8..ebd0fb0 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1394,3 +1394,7 @@ ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS treasures_won INT UN -- Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5, D375): the guarded crates -- a player cleared by killing the last guard, from the tally like the others (Warden). ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS guarded_won INT UNSIGNED NOT NULL DEFAULT 0; + +-- RaidableBases (protocol 14, PLAN_REDESIGNS §11.4, D202): the bases a player +-- completed as one of the plugin's own raiders, from the tally like the others (Warlord). +ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS raids_completed INT UNSIGNED NOT NULL DEFAULT 0; diff --git a/server/eventRaidableBases.js b/server/eventRaidableBases.js new file mode 100644 index 0000000..4787cdc --- /dev/null +++ b/server/eventRaidableBases.js @@ -0,0 +1,453 @@ +// ── Spawning a RaidableBases base (protocol 14, PLAN_REDESIGNS §11.4) ─────── +// +// `rust.raidbase.spawn`, "Spawn a raid base": one of RaidableBases' bases for a +// run, at a difficulty, a profile or a named base, where RaidableBases picks +// (D203) or at a spot the admin gave (D207), with settings for this one spawn +// (D205) and the lock to the first attacker set for it (D206). +// +// ── Two routes, by what the server has (D348, D387) ───────────────────────── +// +// * **With the raids helper** (`RunicGatewayRaids.cs`, D204) the base is the +// RUN's: the plugin answers with an id, the base is a ledger resource like a +// zone (`reversible: 'ledger'`), teardown despawns that one base and no +// other, and if RaidableBases ends it first (its own timer, or raided) the +// row is marked `expired` (D170). A spot, a profile, a base and the settings +// all need it. +// * **Without it** the plugin can still be asked for a base at a difficulty +// (the paid edition's own call), or for any base (the free edition's console +// command, which says nothing back). That base is NOT the run's: it records +// no resource, despawns on RaidableBases' own timer, and the run log says so. +// +// ── Names are the plugin's (D386) ────────────────────────────────────────── +// +// Difficulty, profile and base pickers list what each server's installed copy +// reported at hello. Nothing here knows "Easy" or "Nightmare": an operator's +// own difficulty is offered as written, and a server that has said nothing +// offers nothing. +// +// ── The spot check (D208, D388, D389, D391) ───────────────────────────────── +// +// At run time the plugin refuses a spot with a player, a building, a deployable +// or a raid base on it, or water under it unless the profile is built for +// water; rocks and cliffs are warnings, carried into the run log. The dry run +// asks the same check and returns its findings as WARNINGS (MODULE_API 1.16.0): +// advice, because the world can change before the event runs. +// +// **RaidableBases' grid.** For up to ten minutes after it loads (666 s on the +// rigs' 6000 map, §11.4.2) the plugin refuses every spawn; the plugin calls that +// `rb-not-ready`, and the step asks core to try again in ten minutes +// (`retryAfterMs`, D371), as a Dangerous Treasures start does (D365). + +const client = require('./sidecarClient') +const { serverFor, transportError, pluginError } = require('./eventLeases') +const { BUDGET_MS, location, revert, reconcile } = require('./eventWorld') +const { gridOf } = require('./worldEvents') +const { reportOf, helperPatched } = require('./raidableBases') +const serversDb = require('./model/servers/servers.db') +const mapDb = require('./model/map/map.db') + +/** The resource kind a run's base is ledgered under: the world registry's, so `eventWorld`'s revert and reconcile serve it. */ +const OWNED_KIND = 'world' + +/** How long core waits before trying a spawn RaidableBases was not ready for (its grid, D365's pattern). */ +const NOT_READY_RETRY_MS = 10 * 60 * 1000 + +/** What the step says while the grid builds. */ +const NOT_READY = 'RaidableBases is not ready yet: it is still building its spawn grid' + +/** What the run log says of a base that is not the run's. */ +const OWN_TIMER = 'not the run’s: RaidableBases despawns it on its own timer, and teardown leaves it' + +/** The plugin's refusals a second attempt would repeat. `rb-busy` and `rb-not-ready` are not: they pass. */ +const PERMANENT = new Set([ + 'events-disabled', + 'malformed', + 'rb-missing', + 'copypaste-missing', + 'helper-needed', + 'helper-unsupported', + 'bad-difficulty', + 'bad-profile', + 'bad-base', + 'console-unavailable', + 'no-monument', + 'out-of-range', + 'off-map', + 'in-water', + // The spot is taken. A player may walk off, a deployable stays: a refusal on a + // spot is reported, and the run pauses on its own `on_failure` rather than + // spawning the base somewhere the admin did not choose (D208). + 'spot-blocked', + 'refused', +]) + +/** The on/off switches a step may set for one spawn; blank keeps the profile's own (or, for the lock, the config's). */ +const SWITCHES = Object.freeze([ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, +]) + +const BUDGETS = [ + { + id: 'rust.raids', + label: 'RaidableBases bases', + unit: 'bases', + description: 'RaidableBases bases an event spawns, each one counted. With the raids helper a base is the run’s and despawns at teardown; without it RaidableBases despawns it on its own timer.', + }, +] + +/** Every enabled server's report, with its name. */ +async function reports() { + const rows = await serversDb.rbReports().catch(() => []) + return rows.map((row) => ({ serverId: row.serverId, name: row.name || row.serverId, ...reportOf(row) })) +} + +/** Each value once, with the servers that have it, as `{ value, label }`. */ +function byValue(entries) { + const out = new Map() + for (const { value, label, server } of entries) { + const key = value.toLowerCase() + if (!out.has(key)) out.set(key, { value, label, servers: [] }) + if (!out.get(key).servers.includes(server)) out.get(key).servers.push(server) + } + return [...out.values()].map((e) => ({ value: e.value, label: `${e.label} — ${e.servers.join(', ')}` })) +} + +const OPTION_SOURCES = [ + { + id: 'rust.options.raid_difficulties', + label: 'RaidableBases difficulties', + description: "The difficulties each server's RaidableBases lists, by its own names and levels. Nothing is offered for a server that has not said.", + async resolve() { + const entries = [] + for (const r of await reports()) { + const rb = r.raidableBases + if (!rb || !rb.loaded) continue + for (const d of rb.difficulties) entries.push({ value: d.name, label: `${d.name} (level ${d.level})`, server: r.name }) + } + return byValue(entries) + }, + }, + { + id: 'rust.options.raid_profiles', + label: 'RaidableBases profiles', + description: "The profiles each server's RaidableBases loaded, with their difficulty. Needs the raids helper, which is what reads them.", + async resolve() { + const entries = [] + for (const r of await reports()) { + const profiles = r.raidableBases && r.raidableBases.loaded ? r.raidableBases.profiles : null + for (const p of profiles || []) { + entries.push({ value: p.name, label: `${p.name}${p.difficulty ? ` · ${p.difficulty}` : ''}${p.enabled ? '' : ' (disabled)'}`, server: r.name }) + } + } + return byValue(entries) + }, + }, + { + id: 'rust.options.raid_bases', + label: 'RaidableBases bases', + searchable: true, + description: "The bases each server's RaidableBases can paste (those with a CopyPaste file), by profile. Needs the raids helper.", + async resolve({ q } = {}) { + const term = String(q || '').trim().toLowerCase() + const entries = [] + for (const r of await reports()) { + const profiles = r.raidableBases && r.raidableBases.loaded ? r.raidableBases.profiles : null + for (const p of profiles || []) { + for (const b of p.bases) { + if (term && !b.toLowerCase().includes(term) && !p.name.toLowerCase().includes(term)) continue + entries.push({ value: b, label: `${b} · ${p.name}${p.difficulty ? ` (${p.difficulty})` : ''}`, server: r.name }) + } + } + } + return byValue(entries) + }, + }, + { + id: 'rust.options.raid_switch', + label: 'On or off', + description: 'On or off for this one base. Left blank, the profile’s own setting stands (the lock: RaidableBases’ config).', + async resolve() { + return SWITCHES.map((s) => ({ ...s })) + }, + }, +] + +/** A switch param: true, false, or undefined for blank. Null for anything else. */ +function switchOf(raw) { + if (raw === undefined || raw === null || raw === '') return undefined + const text = String(raw).trim().toLowerCase() + if (text === 'on' || text === 'true') return true + if (text === 'off' || text === 'false') return false + return null +} + +/** A whole-number param in a range, undefined for blank, null when it is not one. */ +function intOf(raw, min, max) { + if (raw === undefined || raw === null || raw === '') return undefined + const n = Number(raw) + return Number.isInteger(n) && n >= min && n <= max ? n : null +} + +/** A number param, undefined for blank, null when it is not one. */ +function floatOf(raw) { + if (raw === undefined || raw === null || raw === '') return undefined + const n = Number(raw) + return Number.isFinite(n) ? n : null +} + +/** + * The step's params as the wire's, or `{ error }`. Validates shapes only; the + * names are checked against the server's report by `missingOn`. + */ +function wireOf(params) { + const wire = {} + const text = (v) => String(v === undefined || v === null ? '' : v).trim() + + for (const field of ['difficulty', 'profile', 'base']) { + if (text(params[field])) wire[field] = text(params[field]) + } + + const spot = Boolean(text(params.monument)) || params.x !== undefined && params.x !== '' || params.z !== undefined && params.z !== '' + let serverId = text(params.server) + + if (spot) { + const where = location(params) + if (!where.ok) return { error: where.error } + const { y, ...at } = where.wire + Object.assign(wire, at) + serverId = where.serverId + + const adjust = floatOf(params.heightAdjust) + const height = floatOf(params.height) + if (adjust === null || height === null) return { error: 'a height is a number of metres' } + if (adjust !== undefined && height !== undefined) return { error: 'a spot takes a height adjustment or an exact height, not both' } + if (adjust !== undefined) wire.heightAdjust = adjust + if (height !== undefined) wire.height = height + } else if (floatOf(params.heightAdjust) !== undefined || floatOf(params.height) !== undefined) { + return { error: 'a height needs a spot: a monument, or x and z' } + } + + if (!serverId) return { error: 'the step does not say which server — pick one' } + + for (const flag of ['pvp', 'npcs', 'lock']) { + const value = switchOf(params[flag]) + if (value === null) return { error: `${flag} is on, off or blank, not "${params[flag]}"` } + if (value !== undefined) wire[flag] = value + } + + const npcCount = intOf(params.npcCount, 0, 20) + if (npcCount === null) return { error: `the NPC count is a whole number from 0 to 20, not "${params.npcCount}"` } + if (npcCount !== undefined) wire.npcCount = npcCount + + const despawn = intOf(params.despawnMinutes, 1, 24 * 60) + if (despawn === null) return { error: `despawn minutes are a whole number from 1 to 1440, not "${params.despawnMinutes}"` } + if (despawn !== undefined) wire.despawnMinutes = despawn + + return { serverId, wire, spot } +} + +/** Whether the wire asks for anything only the raids helper can do. */ +function needsHelper(wire, spot) { + return spot || ['profile', 'base', 'pvp', 'npcs', 'npcCount', 'despawnMinutes', 'lock'].some((k) => wire[k] !== undefined) +} + +const eq = (a, b) => String(a).toLowerCase() === String(b).toLowerCase() + +/** + * Why a server cannot take the step, from what it last said, or null. Only a + * report that SAYS no refuses; one that said nothing is the plugin's to answer. + */ +function missingOn(report, name, wire, spot) { + const rb = report.raidableBases + if (!rb) return null + if (!rb.loaded) return `RaidableBases is not installed on ${name}` + if (rb.copyPaste && !rb.copyPaste.loaded) return `RaidableBases is loaded on ${name} but CopyPaste is missing: it pastes every base` + + const patched = helperPatched(report) + if (needsHelper(wire, spot) && !patched) { + const state = rb.helper.state + const why = state === 'missing' + ? `RunicGatewayRaids.cs is not installed on ${name}` + : `RunicGatewayRaids.cs could not patch ${name}'s RaidableBases (${rb.helper.reason || state})` + return `${why}, so a spot, a profile, a base and settings for one spawn cannot be used there; without them RaidableBases picks the spot and despawns the base on its own timer` + } + + if (!patched && !rb.spawnCall && report.consolePlugins === false) { + return `${name}'s modding framework cannot run RaidableBases' console command from a plugin, and this edition has no spawn call another plugin can use; install RunicGatewayRaids.cs to spawn a base there (Rust-Plugins#34)` + } + + if (wire.difficulty && rb.difficulties.length > 0 && !rb.difficulties.some((d) => eq(d.name, wire.difficulty))) { + return `"${wire.difficulty}" is not one of RaidableBases' difficulties on ${name} (${rb.difficulties.map((d) => d.name).join(', ')})` + } + + if (rb.profiles) { + const profile = wire.profile ? rb.profiles.find((p) => eq(p.name, wire.profile)) : null + if (wire.profile && !profile) return `RaidableBases on ${name} has no profile "${wire.profile}"` + if (profile && wire.difficulty && profile.difficulty && !eq(profile.difficulty, wire.difficulty)) { + return `profile "${profile.name}" is ${profile.difficulty}, not ${wire.difficulty}` + } + if (wire.base) { + const owner = rb.profiles.find((p) => p.bases.some((b) => eq(b, wire.base))) + if (!owner) return `RaidableBases on ${name} has no base "${wire.base}" with a CopyPaste file` + if (profile && owner !== profile) return `base "${wire.base}" belongs to profile "${owner.name}", not "${profile.name}"` + if (wire.difficulty && owner.difficulty && !eq(owner.difficulty, wire.difficulty)) { + return `base "${wire.base}" is ${owner.difficulty}, not ${wire.difficulty}` + } + } + } + + return null +} + +/** The grid at a point, or null without the map's geometry. */ +async function gridAt(serverId, x, z) { + const meta = await mapDb.getMeta(serverId).catch(() => null) + if (!meta) return null + return gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, x, z) +} + +/** The dry run's advice on a spot (D208, D391): warnings, never a refusal. */ +async function spotAdvice(server, name, wire) { + const { pvp, npcs, npcCount, despawnMinutes, lock, heightAdjust, height, ...ask } = wire + const result = await client.raidCheck(server, ask) + if (!result.ok) return [`the spot could not be checked now (${transportError(server, result, 'spot check')})`] + const data = result.data || {} + if (data.kind !== 'raid.checked') return [`the spot could not be checked now: ${pluginError(data, `${name} did not check the spot`)}`] + const advice = (Array.isArray(data.warnings) ? data.warnings : []).map((w) => `at the spot now: ${w}`) + if (!data.ok) { + advice.unshift(`the spot is refused now (${data.reason || 'refused'}): ${data.message || 'no reason given'}. It is checked again when the event runs`) + } + return advice +} + +const raidSpawn = { + id: 'rust.raidbase.spawn', + label: 'Spawn a raid base', + description: + 'Spawns one RaidableBases base: where RaidableBases picks, or at a spot you give. Needs RaidableBases and CopyPaste on the server. With RunicGatewayRaids.cs (installed with the plugin) the base is the event’s — it despawns when the event ends, and may have its own PvP, NPCs, despawn time and lock — and a spot, a profile or a named base may be chosen; without it RaidableBases picks everything and despawns the base on its own timer. For up to ten minutes after RaidableBases loads it is not ready, and the step tries again ten minutes later.', + risk: 'change', + reversible: 'ledger', + version: 1, + budgetMs: BUDGET_MS, + params: [ + { name: 'server', type: 'string', required: false, example: 'main', source: 'rust.options.servers', + description: 'Which server. A monument already says; otherwise required.' }, + { name: 'difficulty', type: 'string', required: false, example: 'Hard', source: 'rust.options.raid_difficulties', + description: 'One of the server’s own RaidableBases difficulties. Blank: any (or the profile’s or base’s own).' }, + { name: 'profile', type: 'string', required: false, example: 'Hard Bases', source: 'rust.options.raid_profiles', + description: 'A RaidableBases profile; a base is picked from it at random. Needs RunicGatewayRaids.cs.' }, + { name: 'base', type: 'string', required: false, example: 'raidhard2', source: 'rust.options.raid_bases', + description: 'One base by name (its CopyPaste file). Needs RunicGatewayRaids.cs.' }, + { name: 'monument', type: 'string', required: false, example: 'main/powerplant_1', source: 'rust.options.monuments', + description: 'A spot by monument (with the offsets), instead of where RaidableBases picks. Needs RunicGatewayRaids.cs.' }, + { name: 'offsetX', type: 'float', required: false, example: 0, description: "Metres east of the monument's centre (negative is west)." }, + { name: 'offsetZ', type: 'float', required: false, example: 0, description: "Metres north of the monument's centre (negative is south)." }, + { name: 'x', type: 'float', required: false, example: 650, description: 'A spot by world x and z, instead of where RaidableBases picks. Needs RunicGatewayRaids.cs.' }, + { name: 'z', type: 'float', required: false, example: 2037, description: 'World z, with x.' }, + { name: 'heightAdjust', type: 'float', required: false, example: 5, + description: 'At a spot: metres added to the profile’s own paste height. Give this or an exact height, not both.' }, + { name: 'height', type: 'float', required: false, example: 30, + description: 'At a spot: the exact height the base is pasted at (the profile’s forced height).' }, + { name: 'pvp', type: 'string', required: false, example: 'off', source: 'rust.options.raid_switch', + description: 'PvP at this base: on or off. Blank keeps the profile’s (and RaidableBases’ own convert switches).' }, + { name: 'npcs', type: 'string', required: false, example: 'on', source: 'rust.options.raid_switch', + description: 'NPCs at this base: on or off. Blank keeps the profile’s.' }, + { name: 'npcCount', type: 'int', required: false, example: 6, + description: 'How many NPCs, 0 to 20, in the profile’s own mix of scientists and murderers. 0 is none.' }, + { name: 'despawnMinutes', type: 'int', required: false, example: 90, + description: 'Minutes until RaidableBases despawns the base on its own, 1 to 1440. Teardown despawns it sooner.' }, + { name: 'lock', type: 'string', required: false, example: 'on', source: 'rust.options.raid_switch', + description: 'Lock the base to the first player or team to attack it: on or off. Blank follows RaidableBases’ config.' }, + ], + + cost: () => ({ 'rust.raids': 1 }), + + async perform({ runId, idempotencyKey, params, verify }) { + const parsed = wireOf(params || {}) + if (parsed.error) return { ok: false, retry: false, error: parsed.error } + + const found = await serverFor(parsed.serverId) + if (!found.ok) return found + const name = found.server.name || found.server.id + + const report = reportOf(await serversDb.rbReportFor(found.server.id).catch(() => null)) + const missing = missingOn(report, name, parsed.wire, parsed.spot) + if (missing) return { ok: false, retry: false, error: missing } + + if (verify) { + // D391: the spot's check is advice in a dry run. A server that has not + // said it has the helper is not asked. + if (parsed.spot && helperPatched(report)) { + const warnings = await spotAdvice(found.server, name, parsed.wire) + if (warnings.length) return { ok: true, detail: { warnings } } + } + return { ok: true } + } + + const result = await client.raidSpawn(found.server, { key: idempotencyKey, runId: String(runId), ...parsed.wire }) + + if (!result.ok) { + const permanent = result.status === 'not-configured' || result.status === 'no-token' + return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'raid base spawn') } + } + + const data = result.data || {} + if (data.kind !== 'raid.queued') { + if (data.reason === 'rb-not-ready') { + return { ok: false, retry: true, retryAfterMs: NOT_READY_RETRY_MS, error: `${NOT_READY} on ${name}` } + } + const warned = Array.isArray(data.warnings) && data.warnings.length ? ` (also: ${data.warnings.join('; ')})` : '' + return { + ok: false, + ...(PERMANENT.has(data.reason) ? { retry: false } : {}), + error: `${pluginError(data, `${name} did not spawn the raid base`)}${warned}`, + } + } + + const grid = data.x !== undefined && data.z !== undefined ? await gridAt(found.server.id, data.x, data.z) : null + const warnings = Array.isArray(data.warnings) ? data.warnings.map(String) : [] + const detail = { + route: String(data.route || ''), + ...(data.difficulty ? { difficulty: String(data.difficulty) } : {}), + ...(data.profile ? { profile: String(data.profile) } : {}), + ...(data.base ? { base: String(data.base) } : {}), + ...(grid ? { grid } : {}), + ...(warnings.length ? { warnings } : {}), + ...(data.repeat ? { repeat: true } : {}), + } + + const notes = [] + if (data.repeat) notes.push('answered from the first attempt; nothing new was spawned') + if (data.x === undefined) notes.push('RaidableBases picks the spot; the map shows it once the base opens') + if (data.despawns !== 'teardown') notes.push(OWN_TIMER) + if (notes.length) detail.note = notes.join('. ') + + // Only a base the helper made the run's is a resource teardown can take back. + if (!data.id) return { ok: true, detail } + + return { + ok: true, + detail, + resources: [ + { + kind: OWNED_KIND, + ref: `${found.server.id}:${data.id}`, + payload: { + serverId: found.server.id, + what: 'raidbase', + ...(data.base ? { name: String(data.base) } : {}), + ...(data.difficulty ? { difficulty: String(data.difficulty) } : {}), + }, + }, + ], + } + }, + + revert, + reconcile, +} + +const ACTIONS = [raidSpawn] + +module.exports = { BUDGETS, ACTIONS, OPTION_SOURCES, PERMANENT, NOT_READY, NOT_READY_RETRY_MS, OWN_TIMER, wireOf, missingOn, switchOf, needsHelper } diff --git a/server/index.js b/server/index.js index 4e6dcd5..c7dde29 100644 --- a/server/index.js +++ b/server/index.js @@ -62,6 +62,7 @@ module.exports = function register(ctx, api) { const eventWorldCall = require('./eventWorldCall') const eventDangerousTreasures = require('./eventDangerousTreasures') const eventGuardedCrate = require('./eventGuardedCrate') + const eventRaidableBases = require('./eventRaidableBases') const eventPermRewards = require('./eventPermRewards') const eventCommands = require('./eventCommands') const eventTemplates = require('./eventTemplates') @@ -182,9 +183,10 @@ module.exports = function register(ctx, api) { // 14) beside them: kept when the run ends, on a budget of its own. Starting a // Dangerous Treasures event (§14.3.4) and placing a guarded crate (§14.3.5) // likewise, each on its own budget; and console commands an author typed - // (§16, D382-D385), priced in commands. - api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS, ...eventDangerousTreasures.BUDGETS, ...eventGuardedCrate.BUDGETS, ...eventCommands.BUDGETS]) - api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS, ...eventDangerousTreasures.ACTIONS, ...eventGuardedCrate.ACTIONS, ...eventCommands.ACTIONS]) + // (§16, D382-D385), priced in commands; and spawning a RaidableBases base + // (§11.4), ledgered like a zone when the raids helper makes it the run's. + api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS, ...eventDangerousTreasures.BUDGETS, ...eventGuardedCrate.BUDGETS, ...eventCommands.BUDGETS, ...eventRaidableBases.BUDGETS]) + api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS, ...eventDangerousTreasures.ACTIONS, ...eventGuardedCrate.ACTIONS, ...eventCommands.ACTIONS, ...eventRaidableBases.ACTIONS]) // The templates (PLAN_REDESIGNS.md §2.5, MODULE_API 1.13.0): a kit weekend // as a starting point on core's New event screen. Core keeps nothing about a @@ -206,6 +208,7 @@ module.exports = function register(ctx, api) { ...eventWorldCall.OPTION_SOURCES, ...eventDangerousTreasures.OPTION_SOURCES, ...eventGuardedCrate.OPTION_SOURCES, + ...eventRaidableBases.OPTION_SOURCES, ]) // The slash commands (phase 16, R11). The handlers run HERE, in the website @@ -224,10 +227,10 @@ module.exports = function register(ctx, api) { streams: STREAMS.length, audiences: AUDIENCES.length, leases: eventLeases.LEASES.length, - actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length + eventDangerousTreasures.ACTIONS.length + eventGuardedCrate.ACTIONS.length + eventCommands.ACTIONS.length, - budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length + eventGuardedCrate.BUDGETS.length + eventCommands.BUDGETS.length, + actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length + eventDangerousTreasures.ACTIONS.length + eventGuardedCrate.ACTIONS.length + eventCommands.ACTIONS.length + eventRaidableBases.ACTIONS.length, + budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length + eventGuardedCrate.BUDGETS.length + eventCommands.BUDGETS.length + eventRaidableBases.BUDGETS.length, announceLeg: eventRewards.LEG.leg, commands: commands.map((cmd) => cmd.name).join(' '), - optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length + eventDangerousTreasures.OPTION_SOURCES.length + eventGuardedCrate.OPTION_SOURCES.length, + optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length + eventDangerousTreasures.OPTION_SOURCES.length + eventGuardedCrate.OPTION_SOURCES.length + eventRaidableBases.OPTION_SOURCES.length, }) } diff --git a/server/model/config/config.model.js b/server/model/config/config.model.js index 4ac6cdb..b4f62d3 100644 --- a/server/model/config/config.model.js +++ b/server/model/config/config.model.js @@ -43,8 +43,15 @@ const LOCKED_REASON = { */ const PATH_SHAPE = /^(?!.*\.\.)(?!\/)[A-Za-z0-9 _.\-()[\]]+(?:\/[A-Za-z0-9 _.\-()[\]]+)*\.json$/ +/** + * RaidableBases' profiles (PLAN_REDESIGNS §11.4.3, D205): the one named exception + * to the editor never walking a data directory. The bridge lists them under this + * virtual prefix, one file deep, and resolves it to that folder alone. + */ +const PROFILE_PATH = /^@data\/RaidableBases\/Profiles\/(?!.*\.\.)[A-Za-z0-9 _.\-()[\]]+\.json$/ + function isPlausiblePath(path) { - return typeof path === 'string' && path.length > 0 && path.length <= 255 && PATH_SHAPE.test(path) + return typeof path === 'string' && path.length > 0 && path.length <= 255 && (PATH_SHAPE.test(path) || PROFILE_PATH.test(path)) } /** @@ -90,6 +97,8 @@ function shapeCatalogue(catalogue) { modified: file.modified ? Number(file.modified) : null, editable: file.editable !== false, ...(file.reason ? { reason: String(file.reason) } : {}), + // A RaidableBases profile (D205): reloaded through the raids helper, never a plugin reload. + ...(file.profile === true ? { profile: true } : {}), }) } diff --git a/server/model/events/events.db.js b/server/model/events/events.db.js index 737d4d1..1e51b7a 100644 --- a/server/model/events/events.db.js +++ b/server/model/events/events.db.js @@ -155,6 +155,8 @@ const TITLE_SUMS = { treasuresWon: 'treasures_won', // Protocol 14 (D375): Warden. guardedWon: 'guarded_won', + // Protocol 14 (§11.4, D202): Warlord. + raidsCompleted: 'raids_completed', } /** The two *best* fields and their columns: the longest single kill, never a sum. */ @@ -358,6 +360,8 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 treasuresWon: 'treasures_won', // Protocol 14 (D375): guarded crates cleared, Warden's column. guardedWon: 'guarded_won', + // Protocol 14 (§11.4, D202): RaidableBases bases completed, Warlord's column. + raidsCompleted: 'raids_completed', }[sort] || 'kills' const params = [serverId] @@ -386,6 +390,7 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 SUM(s.drops_looted) AS dropsLooted, SUM(s.treasures_won) AS treasuresWon, SUM(s.guarded_won) AS guardedWon, + SUM(s.raids_completed) AS raidsCompleted, MAX(s.balance) AS balance, MAX(s.last_seen) AS lastSeen FROM ${STATS} s diff --git a/server/model/events/events.model.js b/server/model/events/events.model.js index 7c8fd6f..a394638 100644 --- a/server/model/events/events.model.js +++ b/server/model/events/events.model.js @@ -17,6 +17,7 @@ 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') @@ -72,7 +73,7 @@ async function recent({ serverId, admin = false, staff = false, presence = false }) 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))) return shaped + 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 @@ -88,6 +89,7 @@ async function recent({ serverId, admin = false, staff = false, presence = false 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) @@ -124,6 +126,11 @@ 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) @@ -193,6 +200,8 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, pre 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 diff --git a/server/model/map/map.model.js b/server/model/map/map.model.js index a5a6bb9..5af2916 100644 --- a/server/model/map/map.model.js +++ b/server/model/map/map.model.js @@ -15,8 +15,9 @@ // players who is on the server and where, and the sleepers // bases tool cupboards and player vending machines, as positions only // pvp Dynamic PVP's own PvP zones, where they are now (protocol 14) +// raids RaidableBases' live bases, and which a run made (protocol 14) // -// Defaults: world, events and pvp public; players and bases staff. The audiences are the +// Defaults: world, events, pvp and raids public; players and bases staff. The audiences are the // presence rungs (`staff` · `signed_in` · `public`) and follow its asymmetric // fallbacks: an unknown stored word narrows to staff, an unknown viewer is // public (`model/visibility`). @@ -45,9 +46,11 @@ const log = core.logger('map') // `pvp` (protocol 14, PLAN_REDESIGNS §14.3.3) is Dynamic PVP's own zones, public by // default: the game draws them for everyone near one, and they name nobody. -const LAYERS = Object.freeze(['world', 'events', 'players', 'bases', 'pvp']) +// `raids` (protocol 14, PLAN_REDESIGNS §11.4, D114) is RaidableBases' live bases, public +// by default: the plugin marks them on the game's own map for everyone, and they name nobody. +const LAYERS = Object.freeze(['world', 'events', 'players', 'bases', 'pvp', 'raids']) -const DEFAULTS = Object.freeze({ world: 'public', events: 'public', players: 'staff', bases: 'staff', pvp: 'public' }) +const DEFAULTS = Object.freeze({ world: 'public', events: 'public', players: 'staff', bases: 'staff', pvp: 'public', raids: 'public' }) /** D118: on, because it shows a member nothing the game does not already show them. */ const DEFAULT_MATES = true @@ -304,6 +307,13 @@ async function mateIdsFor(serverId, acc) { return { own, mates: new Set(clan.filter((id) => !own.has(id))) } } +/** A raids row without the run's idempotency key, which only the bridge's own bookkeeping needs. */ +function withoutKey(row) { + if (!row || typeof row !== 'object' || !('key' in row)) return row + const { key, ...rest } = row + return rest +} + /** An events row without the health a boss's carries. */ function withoutHealth(row) { if (!row || typeof row !== 'object' || !('health' in row)) return row @@ -334,6 +344,8 @@ function project(live, acc, ids = { own: new Set(), mates: new Set() }) { } // Dynamic PVP's zones (§14.3.3): an older plugin's answer has no `pvp`, and it is empty. if (acc.layers.pvp.visible) out.pvp = Array.isArray(live.pvp) ? live.pvp : [] + // RaidableBases' bases (§11.4): an older plugin's answer has none. A run's key stays off the map. + if (acc.layers.raids.visible) out.raids = Array.isArray(live.raids) ? live.raids.map(withoutKey) : [] if (acc.layers.bases.visible) { out.bases = Array.isArray(live.bases) ? live.bases : [] if (live.basesTruncated) out.basesTruncated = true diff --git a/server/model/servers/servers.db.js b/server/model/servers/servers.db.js index c7c9d96..a3d000e 100644 --- a/server/model/servers/servers.db.js +++ b/server/model/servers/servers.db.js @@ -305,10 +305,41 @@ async function gcReports() { ) } +/** + * 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, diff --git a/server/model/titles/titles.js b/server/model/titles/titles.js index daf8d23..2a5f63c 100644 --- a/server/model/titles/titles.js +++ b/server/model/titles/titles.js @@ -23,7 +23,7 @@ const crypto = require('node:crypto') * conditions and their shipped titles, the richest (§11.2.2, Magnate), the * two world-event counts (§14.3.1, Safecracker and Scavenger), Dangerous * Treasures won (§14.3.4, Treasure Hunter), guarded crates cleared (§14.3.5, - * Warden), plus + * Warden), RaidableBases bases completed (§11.4, Warlord), plus * `playtime`, which predates them and has no default title, so a playtime rule * always carries its own text. * @@ -75,6 +75,8 @@ const STATS = { treasureswon: { label: 'Dangerous Treasures won', title: 'Treasure Hunter', source: { sum: 'treasures_won' } }, // Protocol 14, Guarded Crate (D375): a crate counts for the player who killed its last guard. guardedwon: { label: 'Guarded crates cleared', title: 'Warden', source: { sum: 'guarded_won' } }, + // Protocol 14, RaidableBases (§11.4, D202): a base counts for each of the plugin's own raiders when it is completed. + raidscompleted: { label: 'Raid bases completed', title: 'Warlord', source: { sum: 'raids_completed' } }, richest: { label: 'Richest (Economics)', title: 'Magnate', source: { balance: 'balance' } }, playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } }, profilekills: { label: 'Kills of an NPC profile', title: null, source: { npcProfile: true } }, diff --git a/server/raidableBases.js b/server/raidableBases.js new file mode 100644 index 0000000..1af6724 --- /dev/null +++ b/server/raidableBases.js @@ -0,0 +1,166 @@ +// ── 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 } diff --git a/server/router/admin/visibility.router.js b/server/router/admin/visibility.router.js index 99901ac..c90c100 100644 --- a/server/router/admin/visibility.router.js +++ b/server/router/admin/visibility.router.js @@ -27,7 +27,7 @@ visibilityRouter.get( '/', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Who may see who is online, and who may see a clan roster' - // #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world`, `events` and `pvp` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.' + // #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world`, `events`, `pvp` and `raids` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.' /* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */ requireRole('admin'), visibility.read, @@ -37,7 +37,7 @@ visibilityRouter.put( '/', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat' - // #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`, `pvp`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.' + // #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`, `pvp`, `raids`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.' /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */ /* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */ /* #swagger.responses[400] = { description: 'An audience that does not exist' } */ diff --git a/server/router/public/rust.controller.js b/server/router/public/rust.controller.js index 43b32b3..132bdf9 100644 --- a/server/router/public/rust.controller.js +++ b/server/router/public/rust.controller.js @@ -14,6 +14,7 @@ const core = require('../../core') const clans = require('../../model/clans/clans.model') const dangerousTreasures = require('../../dangerousTreasures') const guardedCrate = require('../../guardedCrate') +const raidableBases = require('../../raidableBases') const economics = require('../../model/economics/economics.model') const events = require('../../model/events/events.model') const map = require('../../model/map/map.model') @@ -126,6 +127,8 @@ async function listLeaderboard(req, res) { const dt = dangerousTreasures.reportOf(await serversDb.dtReportFor(req.params.id).catch(() => null)) // Warden's column (D375), likewise for Guarded Crate. const gc = guardedCrate.reportOf(await serversDb.gcReportFor(req.params.id).catch(() => null)) + // Warlord's column (§11.4, D202), likewise for RaidableBases. + const rb = raidableBases.reportOf(await serversDb.rbReportFor(req.params.id).catch(() => null)) const withBalance = econ.loaded && wipeId ? { startingBalance: econ.startingBalance } : null const [rows, held] = await Promise.all([ @@ -149,6 +152,7 @@ async function listLeaderboard(req, res) { economics: withBalance, dangerousTreasures: Boolean((dt.dangerousTreasures && dt.dangerousTreasures.loaded) || rows.some((r) => r.treasuresWon > 0)), guardedCrate: Boolean((gc.guardedCrate && gc.guardedCrate.loaded) || rows.some((r) => r.guardedWon > 0)), + raidableBases: Boolean((rb.raidableBases && rb.raidableBases.loaded) || rows.some((r) => r.raidsCompleted > 0)), }) } catch (err) { log.error('failed to read the leaderboard', { server: req.params.id, error: err.message }) diff --git a/server/router/public/rust.router.js b/server/router/public/rust.router.js index 23433a4..4c0c615 100644 --- a/server/router/public/rust.router.js +++ b/server/router/public/rust.router.js @@ -82,10 +82,10 @@ rustRouter.get( '/servers/:id/leaderboard', // #swagger.tags = ['Public · Rust'] // #swagger.summary = 'The leaderboard for one Rust server' - // #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336). Each row carries `treasuresWon` (Dangerous Treasures chests emptied, Treasure Hunter’s count, PLAN_REDESIGNS §14.3.4), and the answer carries `dangerousTreasures: true` where the server reports Dangerous Treasures or a row already counts one, which is when a page shows that column. Likewise `guardedWon` (guarded crates cleared, Warden’s count, PLAN_REDESIGNS §14.3.5) and `guardedCrate: true` where the server reports Guarded Crate or a row already counts one.' + // #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336). Each row carries `treasuresWon` (Dangerous Treasures chests emptied, Treasure Hunter’s count, PLAN_REDESIGNS §14.3.4), and the answer carries `dangerousTreasures: true` where the server reports Dangerous Treasures or a row already counts one, which is when a page shows that column. Likewise `guardedWon` (guarded crates cleared, Warden’s count, PLAN_REDESIGNS §14.3.5) and `guardedCrate: true` where the server reports Guarded Crate or a row already counts one. Likewise `raidsCompleted` (RaidableBases bases completed as one of the plugin’s own raiders, Warlord’s count, PLAN_REDESIGNS §11.4) and `raidableBases: true` where the server reports RaidableBases or a row already counts one.' // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } } // #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } } - // #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, guardedWon, or balance (one wipe on a server with Economics)', schema: { type: 'string' } } + // #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, guardedWon, raidsCompleted, or balance (one wipe on a server with Economics)', schema: { type: 'string' } } // #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } } /* #swagger.responses[200] = { description: 'The leaderboard' } */ siteMode, diff --git a/server/sidecarClient.js b/server/sidecarClient.js index 1f049a8..6905905 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -452,6 +452,24 @@ const dtStart = (server, body) => request(server, '/dt/start', { method: 'POST', */ const gcPlace = (server, body) => request(server, '/gc/place', { method: 'POST', body }) +/** + * Spawn one RaidableBases base for a run's step (protocol 14, §20.12): `{ key, + * runId }`, the plugin's own `difficulty`, `profile` and `base` names (D386), a + * spot (`monument` and offsets, or `x` and `z`, with `heightAdjust` or `height`) + * and the spawn's settings (`pvp`, `npcs`, `npcCount`, `despawnMinutes`, + * `lock`). `data.kind` is `raid.queued` — with `id` when the raids helper made + * the base the run's, so `world.revert` takes it back; `despawns` says + * `teardown` or `own-timer` — or `raid.error` with a reason. + */ +const raidSpawn = (server, body) => request(server, '/raid/spawn', { method: 'POST', body }) + +/** + * A spot's checks for a raid base, spawning nothing (§20.12, D208, D388): + * `data.kind` is `raid.checked` with `ok`, a refusal's `reason` and `message`, + * and `warnings`; or `raid.error` when the spot cannot be checked (no helper). + */ +const raidCheck = (server, body) => request(server, '/raid/check', { method: 'POST', body }) + /** * Run console commands a step's author typed (protocol 14, §20.11): `{ key, * commands, teardown? }` once, or `{ key, commands, recipients }` once per @@ -602,6 +620,8 @@ module.exports = { worldCall, dtStart, gcPlace, + raidSpawn, + raidCheck, commandsRun, worldRevert, tallyOpen, diff --git a/server/swagger/doc.js b/server/swagger/doc.js index 9af6a60..e0646a0 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -294,6 +294,46 @@ module.exports = { description: 'Protocol 14 (PLAN_REDESIGNS §14.3.6, D374): whether ZLevels Remastered is loaded, and nothing more. Its levels are never read; its XP boosts are permissions an event lends through a Permissions until the event ends step.', properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '3.3.1' } }, }, + raidableBases: { + type: 'object', + description: 'Protocol 14 (PLAN_REDESIGNS §11.4): where loaded, its bases reach the feed, the map’s raids layer and Warlord, and a step may spawn one. Every difficulty, profile and base name is the installed plugin’s own (D386). `gridReady` and `profiles` come only with a patched raids helper (RunicGatewayRaids.cs), which is also what makes a step’s base the run’s.', + properties: { + loaded: { type: 'boolean', example: true }, + version: { type: 'string', example: '3.2.2' }, + premium: { type: 'boolean', description: 'The plugin’s own IsPremium().', example: true }, + spawnCall: { type: 'boolean', description: 'Whether the paid edition’s SpawnRandomBase call is there, looked for by name (D348).', example: true }, + copyPaste: { type: 'object', properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '4.3.0' } } }, + difficulties: { + type: 'array', + items: { type: 'object', properties: { name: { type: 'string', example: 'Hard' }, level: { type: 'integer', example: 2 } } }, + }, + helper: { + type: 'object', + properties: { + state: { type: 'string', enum: ['missing', 'patched', 'unsupported', 'no-raidablebases'], example: 'patched' }, + version: { type: 'string', example: '0.1.0' }, + reason: { type: 'string', description: 'Why it is not patched.' }, + }, + }, + gridReady: { type: 'boolean', description: 'Whether its spawn grid is built; until it is, it refuses every spawn.', example: true }, + profiles: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', example: 'Hard Bases' }, + difficulty: { type: 'string', example: 'Hard' }, + level: { type: 'integer', example: 2 }, + enabled: { type: 'boolean', example: true }, + pvp: { type: 'boolean', example: true }, + bases: { type: 'array', items: { type: 'string' }, example: ['raidhard1', 'raidhard2'] }, + seabed: { type: 'boolean', description: 'A seabed chance above 0 (D389).', example: false }, + float: { type: 'boolean', description: 'Allows bases to float above water (D389).', example: false }, + }, + }, + }, + }, + }, }, }, console: { @@ -806,7 +846,7 @@ module.exports = { type: 'object', description: 'The live map’s switches and each server’s picture (phase 14).', properties: { - layers: { type: 'array', items: { type: 'string' }, example: ['world', 'events', 'players', 'bases', 'pvp'] }, + layers: { type: 'array', items: { type: 'string' }, example: ['world', 'events', 'players', 'bases', 'pvp', 'raids'] }, minorLabels: { type: 'array', items: { type: 'string' }, @@ -821,6 +861,7 @@ module.exports = { players: { $ref: '#/components/schemas/RustAudience' }, bases: { $ref: '#/components/schemas/RustAudience' }, pvp: { $ref: '#/components/schemas/RustAudience' }, + raids: { $ref: '#/components/schemas/RustAudience' }, mates: { type: 'boolean', example: true }, markers: { type: 'object', description: 'D196: a monument label key (lower case) to true (drawn) or false (hidden). Only the labels somebody switched; every other label follows the default.', additionalProperties: { type: 'boolean' }, example: { substation: true } }, }, @@ -959,6 +1000,7 @@ module.exports = { players: { $ref: '#/components/schemas/RustMapLayer' }, bases: { $ref: '#/components/schemas/RustMapLayer' }, pvp: { $ref: '#/components/schemas/RustMapLayer' }, + raids: { $ref: '#/components/schemas/RustMapLayer' }, }, }, mates: { @@ -1056,6 +1098,23 @@ module.exports = { }, }, }, + raids: { + type: 'array', + description: 'Protocol 14 (§11.4): RaidableBases’ live bases, with the plugin’s own difficulty and base names (D386). `runId` is set for a base this site’s event spawned; the run’s key is never sent.', + items: { + type: 'object', + properties: { + x: { type: 'number' }, + z: { type: 'number' }, + level: { type: 'integer', example: 2 }, + difficulty: { type: 'string', example: 'Hard' }, + base: { type: 'string', example: 'raidhard2' }, + pvp: { type: 'boolean', example: true }, + despawnAt: { type: 'integer', description: 'When the plugin despawns it, unix milliseconds.' }, + runId: { type: 'string', example: '12' }, + }, + }, + }, mates: { type: 'array', description: 'The viewer’s own positions (their sleeper too) and their online first-party clan mates on this server.', diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js index 8b647dd..2c85581 100644 --- a/server/test/catalogue.test.js +++ b/server/test/catalogue.test.js @@ -169,6 +169,14 @@ test('the classification covers exactly the event kinds the protocol defines, th 'gc.started', 'gc.ended', 'gc.failed', + // Protocol 14 (§20.12): RaidableBases. A base appearing, raided, bought or + // locked is public, read per viewer by raidableBases.js; its despawn is + // staff's (PLAN_REDESIGNS §11.4). + 'raid.started', + 'raid.completed', + 'raid.ended', + 'raid.purchased', + 'raid.locked', ] assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort()) @@ -229,6 +237,7 @@ test('every kind that names a player who was on is behind the presence setting', // has taken its names off for a viewer below the presence audience (D376). // Dynamic PVP's opening names nobody at all, and a Dangerous Treasures win names // nobody once `dangerousTreasures.shapeFor` has read it for such a viewer, nor - // a guarded crate cleared once `guardedCrate.shapeFor` has. - assert.deepEqual(catalogue.kindsFor({}).sort(), ['dt.started', 'dt.won', 'gc.ended', 'gc.started', 'npc.boss.spawned', 'pvp.zone.created', 'server.initialized', 'server.shutdown', 'server.wipe', 'world.event']) + // a guarded crate cleared once `guardedCrate.shapeFor` has, nor a raid base raided, + // bought or locked once `raidableBases.shapeFor` has (D390). + assert.deepEqual(catalogue.kindsFor({}).sort(), ['dt.started', 'dt.won', 'gc.ended', 'gc.started', 'npc.boss.spawned', 'pvp.zone.created', 'raid.completed', 'raid.locked', 'raid.purchased', 'raid.started', 'server.initialized', 'server.shutdown', 'server.wipe', 'world.event']) }) diff --git a/server/test/config.test.js b/server/test/config.test.js index 8842af4..eafc849 100644 --- a/server/test/config.test.js +++ b/server/test/config.test.js @@ -555,3 +555,15 @@ test('the page’s poll answers 404 for a write that is not on that server', asy assert.equal(res.statusCode, 404) }) + +// ── RaidableBases' profiles (PLAN_REDESIGNS §11.4.3, D205) ──────────────── + +test('a RaidableBases profile’s virtual path is plausible, one file deep, and nothing else under @data is', () => { + const model = require('../model/config/config.model') + assert.strictEqual(model.isPlausiblePath('@data/RaidableBases/Profiles/Hard Bases.json'), true) + for (const bad of ['@data/RaidableBases/Profiles/../x.json', '@data/RaidableBases/Profiles/a/b.json', '@data/Kits/data.json', '@data/RaidableBases/Profiles/x.txt', '@data/RaidableBases/Profiles/.json']) { + assert.strictEqual(model.isPlausiblePath(bad), false, bad) + } + const shaped = model.shapeCatalogue({ files: [{ path: '@data/RaidableBases/Profiles/Hard Bases.json', plugin: 'RaidableBases', bytes: 10, editable: true, profile: true }], plugins: [{ name: 'RaidableBases' }] }) + assert.strictEqual(shaped.plugins[0].files[0].profile, true) +}) diff --git a/server/test/entry.test.js b/server/test/entry.test.js index b1794bd..a98d339 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -169,6 +169,7 @@ test('the event verbs are registered, and every budget has a verb that spends it 'rust.participation.open', 'rust.perm.give', 'rust.perm.lend', + 'rust.raidbase.spawn', 'rust.world.call', 'rust.zone.open', ]) @@ -184,6 +185,7 @@ test('the event verbs are registered, and every budget has a verb that spends it 'rust.permissions', 'rust.permissions.kept', 'rust.prefabs', + 'rust.raids', 'rust.world.calls', 'rust.zone.minutes', ]) diff --git a/server/test/map.test.js b/server/test/map.test.js index 52156de..ae230bc 100644 --- a/server/test/map.test.js +++ b/server/test/map.test.js @@ -89,7 +89,7 @@ const LIVE = { test('a fresh install shows the world and events to anybody, and positions to staff only', async (t) => { settings(t) - assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', pvp: 'public', mates: true, markers: {} }) + assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', pvp: 'public', raids: 'public', mates: true, markers: {} }) const acc = await map.access({}, 'main') assert.equal(acc.layers.world.visible, true) diff --git a/server/test/raidableBases.test.js b/server/test/raidableBases.test.js new file mode 100644 index 0000000..b8adb9d --- /dev/null +++ b/server/test/raidableBases.test.js @@ -0,0 +1,428 @@ +// ── RaidableBases (PLAN_REDESIGNS §11.4, protocol 14) ─────────────────────── +// +// Each test is one way the feature could look right and be wrong: +// +// a raider's or a buyer's name reaches a viewer below the presence audience +// (D376, D390), or a Steam id, a position or a run's key reaches anybody but staff +// a difficulty, profile or base name is anything but the plugin's own (D386): +// every name below is invented, so a list written into the code cannot pass +// the step asks for a spot, a profile, a base or settings on a server without a +// patched raids helper, or refuses a plain spawn a server can still make (D348) +// a base the helper made is not the run's resource, or one it did not make is +// the dry run's spot check fails the dry run instead of advising (D391) +// the grid's wait is not a ten-minute retry, or a refusal the plugin would +// repeat is retried +// the tally's raids completed is dropped, or Warlord's column and sort are missing +// the map's raids layer is missing, not public by default, or carries the key + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +/** Installs a core whose `db.query` records every statement and answers `rows`. */ +function withRecorder(rows = []) { + const statements = [] + const ctx = fakeCtx({ + db: { + query: (sql, params = []) => { + statements.push({ sql, params }) + return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows) + }, + pool: {}, + }, + }) + require('../core')._reset() + require('../core').init(ctx) + return { ctx, statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) } +} + +withRecorder() + +const rb = require('../raidableBases') +const catalogue = require('../catalogue') +const { gridOf } = require('../worldEvents') + +const A = '76561198000000001' +const B = '76561198000000002' +const NOW = Date.now() +const GEOMETRY = { worldSize: 6000, gridCells: 41, gridCellSize: 146.34146341463415 } + +const frame = (kind, over = {}) => ({ kind, type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', x: 650, z: 2037, level: 7, difficulty: 'Wobbly', pvp: true, base: 'wobble3', ...over }) +const raided = (over = {}) => + frame('raid.completed', { + raiders: [{ steamId: A, name: 'Alice' }, { steamId: B, name: 'Bob' }], + raiderCount: 2, + ownerId: A, + ownerName: 'Alice', + id: 'raid-1-1', + runId: '7', + key: '7:1', + ...over, + }) + +// Invented, every one (D386): no edition ships these. +const PROFILE = { name: 'Wobbly Keeps', difficulty: 'Wobbly', level: 7, enabled: true, pvp: false, bases: ['wobble1', 'wobble3'], seabed: false, float: false } +const REPORT = { + loaded: true, + version: '3.2.2', + premium: true, + spawnCall: true, + copyPaste: { loaded: true, version: '4.3.0' }, + difficulties: [{ name: 'Gentle', level: 0 }, { name: 'Wobbly', level: 7 }], + helper: { state: 'patched', version: '0.1.0' }, + gridReady: true, + profiles: [PROFILE, { name: 'Gentle Huts', difficulty: 'Gentle', level: 0, enabled: true, pvp: true, bases: ['hut9'], seabed: false, float: false }], +} + +// ── Who may read what (D353, D376, D390) ─────────────────────────────────── + +test('the kinds are classified: four public, the despawn staff’s', () => { + for (const kind of ['raid.started', 'raid.completed', 'raid.purchased', 'raid.locked']) assert.ok(catalogue.PUBLIC_KINDS.includes(kind), kind) + assert.ok(catalogue.STAFF_KINDS.includes('raid.ended')) + assert.ok(!catalogue.PUBLIC_KINDS.includes('raid.ended')) +}) + +test('the public reads a base’s difficulty, base, PvP and grid as the plugin named them, never its position or a run’s key', () => { + const out = rb.shapeFor(frame('raid.started', { despawnAt: NOW + 60000, id: 'raid-1-1', runId: '7', key: '7:1', radius: 50 }), { geometry: GEOMETRY }) + assert.strictEqual(out.grid, gridOf(GEOMETRY, 650, 2037)) + assert.strictEqual(out.difficulty, 'Wobbly') + assert.strictEqual(out.level, 7) + assert.strictEqual(out.base, 'wobble3') + assert.strictEqual(out.pvp, true) + assert.strictEqual(out.despawnAt, NOW + 60000) + for (const k of ['x', 'z', 'id', 'runId', 'key', 'radius']) assert.strictEqual(out[k], undefined, k) +}) + +test('a completion names its raiders only to the presence audience, and their Steam ids only to staff', () => { + const signedOut = rb.shapeFor(raided(), { geometry: GEOMETRY }) + assert.strictEqual(signedOut.raiderCount, 2, 'how many is a count, and names nobody') + assert.strictEqual(signedOut.names, undefined) + assert.strictEqual(signedOut.ownerName, undefined) + assert.strictEqual(signedOut.raiders, undefined) + assert.strictEqual(signedOut.ownerId, undefined) + + const member = rb.shapeFor(raided(), { presence: true, geometry: GEOMETRY }) + assert.deepStrictEqual(member.names, ['Alice', 'Bob']) + assert.strictEqual(member.ownerName, 'Alice') + assert.strictEqual(member.raiders, undefined) + + const staff = rb.shapeFor(raided(), { staff: true, geometry: GEOMETRY }) + assert.deepStrictEqual(staff.raiders, [{ steamId: A, name: 'Alice' }, { steamId: B, name: 'Bob' }]) + assert.strictEqual(staff.ownerId, A) + assert.strictEqual(staff.key, '7:1') + assert.strictEqual(staff.x, 650) +}) + +test('a purchase or an admin lock names the player only to the presence audience (D390)', () => { + for (const kind of ['raid.purchased', 'raid.locked']) { + const f = frame(kind, { steamId: A, name: 'Alice' }) + assert.strictEqual(rb.shapeFor(f, {}).name, undefined, kind) + assert.strictEqual(rb.shapeFor(f, { presence: true }).name, 'Alice', kind) + assert.strictEqual(rb.shapeFor(f, { presence: true }).steamId, undefined, kind) + assert.strictEqual(rb.shapeFor(f, { staff: true }).steamId, A, kind) + } +}) + +test('the feed reads a stored completion for its viewer, the row’s own Steam id staff’s', async () => { + withRecorder((sql) => { + if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'raid.completed', t: NOW, wipeId: 'w1', steamId: A, raw: JSON.stringify(raided()) }] + if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }] + return [] + }) + const events = require('../model/events/events.model') + + const [signedOut] = await events.recent({ serverId: 'main', presence: false }) + assert.strictEqual(signedOut.steamId, null) + assert.strictEqual(signedOut.frame.names, undefined) + assert.strictEqual(signedOut.frame.raiderCount, 2) + assert.strictEqual(signedOut.frame.difficulty, 'Wobbly') + assert.ok(signedOut.frame.grid) + + const [staff] = await events.recent({ serverId: 'main', admin: true }) + assert.strictEqual(staff.steamId, A) + assert.deepStrictEqual(staff.frame.names, ['Alice', 'Bob']) +}) + +test('a server’s report is read leniently, and the helper’s profiles only as it sent them', () => { + assert.deepStrictEqual(rb.reportOf(null), { raidableBases: null, consolePlugins: null }) + const read = rb.reportOf({ raidableBases: JSON.stringify(REPORT), console: '{"plugins":false}' }) + assert.strictEqual(read.consolePlugins, false) + assert.deepStrictEqual(read.raidableBases.difficulties, REPORT.difficulties) + assert.deepStrictEqual(read.raidableBases.profiles[0], PROFILE) + assert.strictEqual(rb.helperPatched(read), true) + + const bare = rb.reportOf({ raidableBases: '{"loaded":true,"version":"3.2.2","helper":{"state":"missing"}}', console: null }) + assert.strictEqual(bare.raidableBases.profiles, null, 'no helper, no profile list') + assert.strictEqual(bare.raidableBases.spawnCall, false) + assert.strictEqual(rb.helperPatched(bare), false) + assert.deepStrictEqual(rb.reportOf({ raidableBases: 'not json' }).raidableBases, null) +}) + +// ── The step ─────────────────────────────────────────────────────────────── + +const SERVERS = { + main: { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' }, + pve: { id: 'pve', name: 'PvE', enabled: 1, sidecarBaseUrl: 'http://pve:1' }, +} + +/** The step against `main` and `pve`, whose last reports are `reports`, and a sidecar answering `reply` and `check`. */ +function stubStep(t, { reports = { main: { raidableBases: REPORT, console: { plugins: true } } }, reply, check } = {}) { + const row = (id) => { + const r = reports[id] + return r ? { serverId: id, name: SERVERS[id].name, raidableBases: JSON.stringify(r.raidableBases ?? null), console: JSON.stringify(r.console ?? null) } : null + } + withRecorder((sql, params) => { + if (sql.includes('JOIN rust_server_state')) return Object.keys(reports).map(row) + if (sql.includes('FROM rust_server_state')) return row(params[0]) ? [row(params[0])] : [] + if (sql.includes('FROM rust_map_images')) return [{ serverId: 'main', ...GEOMETRY }] + return [] + }) + const client = require('../sidecarClient') + const serversDb = require('../model/servers/servers.db') + const saved = { raidSpawn: client.raidSpawn, raidCheck: client.raidCheck, getServer: serversDb.getServer } + const calls = [] + serversDb.getServer = async (id) => SERVERS[id] || null + client.raidSpawn = async (server, body) => { + calls.push({ what: 'spawn', server: server.id, body }) + return typeof reply === 'function' ? reply(body) : reply + } + client.raidCheck = async (server, body) => { + calls.push({ what: 'check', server: server.id, body }) + return typeof check === 'function' ? check(body) : check + } + t.after(() => { + client.raidSpawn = saved.raidSpawn + client.raidCheck = saved.raidCheck + serversDb.getServer = saved.getServer + }) + return calls +} + +const module_ = () => require('../eventRaidableBases') +const spawnStep = () => module_().ACTIONS[0] +const ok = (data) => ({ ok: true, status: 'ok', data }) +const queued = (over = {}) => ok({ kind: 'raid.queued', key: '7:1', id: 'raid-1-1', route: 'pick', despawns: 'teardown', difficulty: 'Wobbly', level: 7, warnings: [], repeat: false, ...over }) + +test('the step is ledgered like a zone, priced one base each, and every param has an example', () => { + const step = spawnStep() + assert.strictEqual(step.id, 'rust.raidbase.spawn') + assert.strictEqual(step.reversible, 'ledger') + assert.strictEqual(step.risk, 'change') + assert.strictEqual(typeof step.revert, 'function') + assert.strictEqual(typeof step.reconcile, 'function') + assert.deepStrictEqual(step.cost({}), { 'rust.raids': 1 }) + for (const p of step.params) assert.ok(p.example !== undefined, p.name) +}) + +test('the pickers list each server’s own names, once each, and nothing for a server that has not said (D386)', async (t) => { + const [difficulties, profiles, bases, switches] = module_().OPTION_SOURCES + stubStep(t, { + reports: { + main: { raidableBases: REPORT }, + pve: { raidableBases: { ...REPORT, difficulties: [{ name: 'wobbly', level: 7 }, { name: 'Brutal', level: 9 }], profiles: [] } }, + }, + }) + assert.deepStrictEqual(await difficulties.resolve(), [ + { value: 'Gentle', label: 'Gentle (level 0) — Main' }, + { value: 'Wobbly', label: 'Wobbly (level 7) — Main, PvE' }, + { value: 'Brutal', label: 'Brutal (level 9) — PvE' }, + ]) + assert.deepStrictEqual((await profiles.resolve()).map((p) => p.value), ['Wobbly Keeps', 'Gentle Huts']) + assert.deepStrictEqual((await bases.resolve({ q: 'wobble' })).map((b) => b.value), ['wobble1', 'wobble3']) + assert.strictEqual(bases.searchable, true) + assert.deepStrictEqual((await switches.resolve()).map((s) => s.value), ['on', 'off']) + + stubStep(t, { reports: { main: { raidableBases: { loaded: false } } } }) + assert.deepStrictEqual(await difficulties.resolve(), [], 'nothing is made up for a server without the plugin') +}) + +test('without a patched helper, a spot, a profile, a base or settings are refused; a plain spawn is not (D348, D387)', async (t) => { + const run = (params) => spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', ...params }, verify: true }) + stubStep(t, { reports: { main: { raidableBases: { ...REPORT, helper: { state: 'missing' }, profiles: null }, console: { plugins: true } } } }) + + for (const params of [{ x: 650, z: 2037 }, { profile: 'Wobbly Keeps' }, { base: 'wobble1' }, { pvp: 'off' }, { npcCount: 3 }, { lock: 'on' }, { despawnMinutes: 60 }]) { + const no = await run(params) + assert.strictEqual(no.retry, false, JSON.stringify(params)) + assert.match(no.error, /RunicGatewayRaids\.cs is not installed on Main/) + assert.match(no.error, /despawns the base on its own timer/) + } + assert.deepStrictEqual(await run({ difficulty: 'Wobbly' }), { ok: true }) + assert.deepStrictEqual(await run({}), { ok: true }) + + stubStep(t, { reports: { main: { raidableBases: { ...REPORT, helper: { state: 'missing' }, spawnCall: false, profiles: null }, console: { plugins: false } } } }) + const carbon = await run({}) + assert.match(carbon.error, /cannot run RaidableBases' console command/) +}) + +test('the dry run refuses names the server does not have, and names that do not fit together', async (t) => { + const run = (params) => spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', ...params }, verify: true }) + stubStep(t) + assert.match((await run({ difficulty: 'Easy' })).error, /"Easy" is not one of RaidableBases' difficulties on Main \(Gentle, Wobbly\)/) + assert.match((await run({ profile: 'Nope' })).error, /no profile "Nope"/) + assert.match((await run({ base: 'nope1' })).error, /no base "nope1"/) + assert.match((await run({ profile: 'Gentle Huts', base: 'wobble1' })).error, /belongs to profile "Wobbly Keeps"/) + assert.match((await run({ profile: 'Wobbly Keeps', difficulty: 'Gentle' })).error, /is Wobbly, not Gentle/) + assert.match((await run({ heightAdjust: 5 })).error, /a height needs a spot/) + assert.match((await run({ x: 650, z: 2037, heightAdjust: 5, height: 30 })).error, /not both/) + assert.match((await run({ pvp: 'maybe' })).error, /pvp is on, off or blank/) + assert.match((await run({ npcCount: 21 })).error, /0 to 20/) + assert.deepStrictEqual(await run({ base: 'WOBBLE3' }), { ok: true }, 'names match as the plugin matches them, ignoring case') + + stubStep(t, { reports: { main: { raidableBases: { loaded: false } } } }) + assert.match((await run({})).error, /RaidableBases is not installed on Main/) + stubStep(t, { reports: { main: { raidableBases: { ...REPORT, copyPaste: { loaded: false } } } } }) + assert.match((await run({})).error, /CopyPaste is missing/) + stubStep(t, { reports: {} }) + assert.deepStrictEqual(await run({ x: 650, z: 2037 }), { ok: true }, 'a server that has not said is the plugin’s to answer') +}) + +test('a spot’s check in the dry run is advice: warnings, never a refusal (D208, D388, D391)', async (t) => { + const calls = stubStep(t, { + check: ok({ kind: 'raid.checked', ok: false, reason: 'spot-blocked', message: 'a player is within 50 m (Alice)', warnings: ['Rock is too large: 1 2 3'] }), + }) + const advised = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', x: 650, z: 2037, base: 'wobble3', pvp: 'off' }, verify: true }) + assert.strictEqual(advised.ok, true) + assert.deepStrictEqual(advised.detail.warnings, [ + 'the spot is refused now (spot-blocked): a player is within 50 m (Alice). It is checked again when the event runs', + 'at the spot now: Rock is too large: 1 2 3', + ]) + assert.deepStrictEqual(calls[0].body, { base: 'wobble3', x: 650, z: 2037 }, 'the check asks the spot and the base, not the settings') + assert.ok(!calls.some((c) => c.what === 'spawn'), 'a dry run spawns nothing') + + stubStep(t, { check: ok({ kind: 'raid.checked', ok: true, warnings: [] }) }) + assert.deepStrictEqual(await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', x: 650, z: 2037 }, verify: true }), { ok: true }) + + stubStep(t, { check: { ok: false, status: 'http-503' } }) + const down = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', x: 650, z: 2037 }, verify: true }) + assert.strictEqual(down.ok, true) + assert.match(down.detail.warnings[0], /could not be checked now/) +}) + +test('a spawn sends the plugin’s names, the spot and the settings, and a base the helper made is the run’s resource', async (t) => { + const calls = stubStep(t, { reply: queued({ route: 'spot', x: 650, z: 2037, base: 'wobble3', profile: 'Wobbly Keeps', warnings: ['Rock is too large: 1 2 3'] }) }) + const result = await spawnStep().perform({ + runId: 7, + idempotencyKey: '7:1', + params: { monument: 'main/powerplant_1', offsetX: 10, offsetZ: -5, y: 99, heightAdjust: 5, base: 'wobble3', pvp: 'off', npcs: 'on', npcCount: 4, despawnMinutes: 90, lock: 'on' }, + }) + assert.ok(result.ok) + assert.deepStrictEqual(calls[0].body, { + key: '7:1', + runId: '7', + base: 'wobble3', + monument: 'powerplant_1', + offsetX: 10, + offsetZ: -5, + heightAdjust: 5, + pvp: false, + npcs: true, + npcCount: 4, + despawnMinutes: 90, + lock: true, + }) + assert.deepStrictEqual(result.resources, [ + { kind: 'world', ref: 'main:raid-1-1', payload: { serverId: 'main', what: 'raidbase', name: 'wobble3', difficulty: 'Wobbly' } }, + ]) + assert.strictEqual(result.detail.grid, gridOf(GEOMETRY, 650, 2037)) + assert.deepStrictEqual(result.detail.warnings, ['Rock is too large: 1 2 3'], 'a rock is carried into the run log (D388)') + assert.strictEqual(result.detail.note, undefined) +}) + +test('a base the helper did not make is not the run’s: no resource, and the run log says it despawns on its own', async (t) => { + stubStep(t, { + reports: { main: { raidableBases: { ...REPORT, helper: { state: 'missing' }, profiles: null }, console: { plugins: true } } }, + reply: queued({ id: undefined, route: 'plugin', despawns: 'own-timer' }), + }) + const result = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', difficulty: 'Wobbly' } }) + assert.ok(result.ok) + assert.strictEqual(result.resources, undefined) + assert.match(result.detail.note, /RaidableBases picks the spot/) + assert.match(result.detail.note, new RegExp(module_().OWN_TIMER)) +}) + +test('the grid’s wait is a ten-minute retry; a refusal the plugin would repeat is not retried', async (t) => { + stubStep(t, { + reply: (body) => + ok(body.difficulty === 'Gentle' + ? { kind: 'raid.error', reason: 'rb-not-ready', message: 'still building its spawn grid' } + : body.difficulty === 'Wobbly' + ? { kind: 'raid.error', reason: 'rb-busy', message: 'pasting another base' } + : { kind: 'raid.error', reason: 'spot-blocked', message: 'a player is within 50 m', warnings: ['Rock is too large'] }), + }) + const step = spawnStep() + const wait = await step.perform({ runId: 7, idempotencyKey: '7:2', params: { server: 'main', difficulty: 'Gentle' } }) + assert.strictEqual(wait.retry, true) + assert.strictEqual(wait.retryAfterMs, 10 * 60 * 1000) + assert.match(wait.error, /not ready yet/) + + const busy = await step.perform({ runId: 7, idempotencyKey: '7:3', params: { server: 'main', difficulty: 'Wobbly' } }) + assert.strictEqual(busy.retry, undefined, 'a busy spawner retries on core’s ordinary delay') + + const blocked = await step.perform({ runId: 7, idempotencyKey: '7:4', params: { server: 'main', x: 650, z: 2037 } }) + assert.strictEqual(blocked.retry, false) + assert.match(blocked.error, /a player is within 50 m \(also: Rock is too large\)/) + + for (const reason of ['rb-missing', 'copypaste-missing', 'helper-needed', 'helper-unsupported', 'bad-difficulty', 'bad-profile', 'bad-base', 'spot-blocked', 'in-water', 'off-map', 'console-unavailable', 'refused', 'malformed', 'events-disabled']) { + assert.ok(module_().PERMANENT.has(reason), reason) + } + for (const reason of ['rb-not-ready', 'rb-busy']) assert.ok(!module_().PERMANENT.has(reason), reason) +}) + +test('a repeat of a key spawns nothing new, and says so', async (t) => { + stubStep(t, { reply: queued({ repeat: true }) }) + const again = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main' } }) + assert.ok(again.ok) + assert.strictEqual(again.detail.repeat, true) + assert.match(again.detail.note, /^answered from the first attempt; nothing new was spawned/) + assert.strictEqual(again.resources[0].ref, 'main:raid-1-1', 'the repeat still names the run’s base') +}) + +// ── Warlord (D202) ───────────────────────────────────────────────────────── + +test('the tally’s raids completed is added up', async () => { + const rec = withRecorder() + const { apply } = require('../ingest') + await apply('main', { id: 1, kind: 'player.tally', frame: { kind: 'player.tally', type: 'event', t: NOW, serverId: 'main', wipeId: 'w1', steamId: A, name: 'Alice', raidsCompleted: 2 } }) + const titleAdd = rec.touching('rust_player_wipe_stats').find((s) => s.sql.includes('raids_completed')) + assert.ok(titleAdd, 'the tally reached the title columns') + const cols = titleAdd.sql.match(/\(server_id, wipe_id, steam_id, ([^)]+)\)/)[1].split(', ') + assert.strictEqual(titleAdd.params[3 + cols.indexOf('raids_completed')], 2) +}) + +test('the leaderboard sorts by raids completed and carries the count on every row', async () => { + const rec = withRecorder((sql) => (sql.includes('FROM rust_player_wipe_stats') ? [{ steamId: A, name: 'Alice', kills: 1, raidsCompleted: '3' }] : [])) + const events = require('../model/events/events.model') + const [row] = await events.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'raidsCompleted' }) + assert.strictEqual(row.raidsCompleted, 3) + const board = rec.touching('rust_player_wipe_stats').at(-1) + assert.match(board.sql, /SUM\(s\.raids_completed\) AS raidsCompleted/) + assert.match(board.sql, /ORDER BY[^]*raids_completed/) +}) + +test('Warlord is a title category that sums raids completed', () => { + const titles = require('../model/titles/titles') + const stats = titles.STATS || titles._STATS || null + if (!stats) return + assert.deepStrictEqual(stats.raidscompleted, { label: 'Raid bases completed', title: 'Warlord', source: { sum: 'raids_completed' } }) +}) + +// ── The map's raids layer (D114) ─────────────────────────────────────────── + +test('the map’s raids layer is public by default, and a run’s key never reaches a viewer', () => { + const map = require('../model/map/map.model') + assert.ok(map.LAYERS.includes('raids')) + assert.strictEqual(map.DEFAULTS.raids, 'public') + + const layers = {} + for (const l of map.LAYERS) layers[l] = { visible: l === 'raids', audience: 'public' } + const out = map.project( + { raids: [{ x: 1, z: 2, difficulty: 'Wobbly', runId: '7', key: '7:1' }], world: [{ kind: 'cargo' }] }, + { layers, mates: { visible: false }, steamIds: [] }, + ) + assert.deepStrictEqual(out.raids, [{ x: 1, z: 2, difficulty: 'Wobbly', runId: '7' }]) + assert.strictEqual(out.world, undefined, 'a hidden layer is absent, not empty') + + layers.raids.visible = false + assert.strictEqual(map.project({ raids: [{ x: 1, z: 2 }] }, { layers, mates: { visible: false }, steamIds: [] }).raids, undefined) +}) diff --git a/server/test/titleConditions.test.js b/server/test/titleConditions.test.js index 4c75d12..8be3304 100644 --- a/server/test/titleConditions.test.js +++ b/server/test/titleConditions.test.js @@ -47,8 +47,9 @@ const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ... // ── The categories ──────────────────────────────────────────────────────── -test('twenty-eight categories carry a default title, and playtime carries none (D172, §11.2.2, D356, D375)', () => { - assert.strictEqual(titles.CATEGORY_IDS.length, 28) +test('twenty-nine categories carry a default title, and playtime carries none (D172, §11.2.2, D356, D375, §11.4)', () => { + assert.strictEqual(titles.CATEGORY_IDS.length, 29) + assert.strictEqual(titles.STATS.raidscompleted.title, 'Warlord') assert.strictEqual(titles.STATS.guardedwon.title, 'Warden') assert.strictEqual(titles.STATS.treasureswon.title, 'Treasure Hunter') assert.strictEqual(titles.STATS.richest.title, 'Magnate') diff --git a/swagger-fragment.json b/swagger-fragment.json index a202017..2c8b3fe 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -1948,7 +1948,7 @@ "Admin · Rust" ], "summary": "Who may see who is online, and who may see a clan roster", - "description": "The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world`, `events` and `pvp` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.", + "description": "The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world`, `events`, `pvp` and `raids` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.", "responses": { "200": { "description": "The fleet default and each server", @@ -1970,7 +1970,7 @@ "Admin · Rust" ], "summary": "Change who may see who is online, who may see a clan roster, or which servers say news in chat", - "description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`, `pvp`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.", + "description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`, `pvp`, `raids`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.", "responses": { "200": { "description": "Saved; answers the new state", @@ -3258,7 +3258,7 @@ "Public · Rust" ], "summary": "The leaderboard for one Rust server", - "description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336). Each row carries `treasuresWon` (Dangerous Treasures chests emptied, Treasure Hunter’s count, PLAN_REDESIGNS §14.3.4), and the answer carries `dangerousTreasures: true` where the server reports Dangerous Treasures or a row already counts one, which is when a page shows that column. Likewise `guardedWon` (guarded crates cleared, Warden’s count, PLAN_REDESIGNS §14.3.5) and `guardedCrate: true` where the server reports Guarded Crate or a row already counts one.", + "description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336). Each row carries `treasuresWon` (Dangerous Treasures chests emptied, Treasure Hunter’s count, PLAN_REDESIGNS §14.3.4), and the answer carries `dangerousTreasures: true` where the server reports Dangerous Treasures or a row already counts one, which is when a page shows that column. Likewise `guardedWon` (guarded crates cleared, Warden’s count, PLAN_REDESIGNS §14.3.5) and `guardedCrate: true` where the server reports Guarded Crate or a row already counts one. Likewise `raidsCompleted` (RaidableBases bases completed as one of the plugin’s own raiders, Warlord’s count, PLAN_REDESIGNS §11.4) and `raidableBases: true` where the server reports RaidableBases or a row already counts one.", "parameters": [ { "name": "id", @@ -3282,7 +3282,7 @@ "name": "sort", "in": "query", "required": false, - "description": "kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, guardedWon, or balance (one wipe on a server with Economics)", + "description": "kills, deaths, npcKills, playtime, cratesHacked, dropsLooted, treasuresWon, guardedWon, raidsCompleted, or balance (one wipe on a server with Economics)", "schema": { "type": "string" } @@ -4922,6 +4922,7 @@ "dropslooted", "treasureswon", "guardedwon", + "raidscompleted", "richest", "playtime", "profilekills" @@ -5623,6 +5624,405 @@ } } } + }, + "raidableBases": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Protocol 14 (PLAN_REDESIGNS §11.4): where loaded, its bases reach the feed, the map’s raids layer and Warlord, and a step may spawn one. Every difficulty, profile and base name is the installed plugin’s own (D386). `gridReady` and `profiles` come only with a patched raids helper (RunicGatewayRaids.cs), which is also what makes a step’s base the run’s." + }, + "properties": { + "type": "object", + "properties": { + "loaded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "3.2.2" + } + } + }, + "premium": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "The plugin’s own IsPremium()." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "spawnCall": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Whether the paid edition’s SpawnRandomBase call is there, looked for by name (D348)." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "copyPaste": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "loaded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "4.3.0" + } + } + } + } + } + } + }, + "difficulties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Hard" + } + } + }, + "level": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 2 + } + } + } + } + } + } + } + } + }, + "helper": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "state": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "missing", + "patched", + "unsupported", + "no-raidablebases" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "patched" + } + } + }, + "version": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "0.1.0" + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "Why it is not patched." + } + } + } + } + } + } + }, + "gridReady": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Whether its spawn grid is built; until it is, it refuses every spawn." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "profiles": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Hard Bases" + } + } + }, + "difficulty": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Hard" + } + } + }, + "level": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 2 + } + } + }, + "enabled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "pvp": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "bases": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "raidhard1", + "raidhard2" + ], + "items": { + "type": "string" + } + } + } + }, + "seabed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "A seabed chance above 0 (D389)." + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "float": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Allows bases to float above water (D389)." + }, + "example": { + "type": "boolean", + "example": false + } + } + } + } + } + } + } + } + } + } + } + } } } } @@ -8977,7 +9377,8 @@ "events", "players", "bases", - "pvp" + "pvp", + "raids" ], "items": { "type": "string" @@ -9043,6 +9444,9 @@ "pvp": { "$ref": "#/components/schemas/RustAudience" }, + "raids": { + "$ref": "#/components/schemas/RustAudience" + }, "mates": { "type": "object", "properties": { @@ -9931,6 +10335,9 @@ }, "pvp": { "$ref": "#/components/schemas/RustMapLayer" + }, + "raids": { + "$ref": "#/components/schemas/RustMapLayer" } } } @@ -10686,6 +11093,129 @@ } } }, + "raids": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Protocol 14 (§11.4): RaidableBases’ live bases, with the plugin’s own difficulty and base names (D386). `runId` is set for a base this site’s event spawned; the run’s key is never sent." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "number" + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "number" + } + } + }, + "level": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 2 + } + } + }, + "difficulty": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Hard" + } + } + }, + "base": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "raidhard2" + } + } + }, + "pvp": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "despawnAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "When the plugin despawns it, unix milliseconds." + } + } + }, + "runId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "12" + } + } + } + } + } + } + } + } + }, "mates": { "type": "object", "properties": { -- 2.49.1