Stage 5 of the §14.3 integration plan, on protocol 14 (docs rust-link/PROTOCOL.md §20.8). - dt.started and dt.won are public kinds, dt.ended a staff kind. The new dangerousTreasures.js reads each row per viewer: grid and invaderCount for everyone, the winner's name for the presence audience (D376), the position, the step's key, the winner's Steam id and the invaders for staff. - Feed lines in the World events filter: "A Dangerous Treasures event has started · K12", "Alice won the treasure · K12 · 3 players fought for it". - Triggers rust.dt.started (with byEvent) and rust.dt.won, public, cooldown subject the server, names only. - eventDangerousTreasures.js: rust.dt.start, "Start a Dangerous Treasures event" (risk change, reversible none, budget rust.dt.events), server and difficulty. Refused at verify where the server said DT is not loaded or console.plugins is false (Rust-Plugins#34). dt-not-ready answers "Dangerous Treasures is not ready yet" with retryAfterMs 600000 (D365, D371); dt-no-spot retries on core's delay; DT's own refusals are permanent. - Treasure Hunter (D375): treasuresWon -> treasures_won, the title category treasureswon, and a sortable Treasures column shown where the server reports DT or a row counts one. - The map draws a running chest on the world layer; the server settings' plugin line names DT and whether a step can start one, from the integrations read's new `console`. - coreApi ^1.15.0, ci/core-ref.json pinned to website main at #215's merge. ci/bundle.json ships the two new files; swagger fragment and engagement manifest regenerated; README lists DT. Server 627/627 (16 new in test/dangerousTreasures.test.js) and every check; client 92/92, build and externals. Booted against the pinned core: 45 triggers, 13 actions, 11 budgets, 21 option sources; 89 routes unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
606 lines
26 KiB
JavaScript
606 lines
26 KiB
JavaScript
// ── The near end of a call whose far end is a Rust server ─────────────────
|
||
//
|
||
// Every other file in this module reads its own tables. This one is different in
|
||
// kind: it is the only place that leaves the process.
|
||
//
|
||
// **The website process never opens a connection to a game server**
|
||
// (MODULE_API.md §2.7). It opens one to a `rust-link` sidecar, which owns the
|
||
// socket to the game, persists what the game says before forwarding it, and
|
||
// answers reads from that store. `test/noGameConnection.test.js` enforces the
|
||
// decidable half of that rule and names this file as the one that may reach the
|
||
// network:
|
||
//
|
||
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
|
||
//
|
||
// ── One client per configured server ──────────────────────────────────────
|
||
//
|
||
// R8: the bridge is one game server to one sidecar. So this file takes the
|
||
// server row as an argument rather than holding a single configured endpoint —
|
||
// six servers is six base URLs and six tokens, and core never learns there is
|
||
// more than one.
|
||
//
|
||
// ── TIMEOUT_MS is not a tuning knob. It is half of a rule. ────────────────
|
||
//
|
||
// An event action declares `budgetMs`, and core's dispatcher enforces it: when
|
||
// the budget expires it stops waiting and classifies the failure as **retry**,
|
||
// unconditionally, without asking the action — it cannot ask, the action is still
|
||
// awaiting a socket. So if core's deadline is shorter than this one, an action
|
||
// never gets to classify its own failure and `{ ok: false, retry: false }` is
|
||
// unreachable code. `budgetMs` must EXCEED this.
|
||
//
|
||
// It is also bounded from the other side: the sidecar's own RPC reply timeout is
|
||
// ten seconds, so a value below that would give up while the sidecar is still
|
||
// legitimately waiting for the game. The ordering is
|
||
// `sidecar RPC timeout < TIMEOUT_MS < budgetMs`, and every one of the three
|
||
// is written down somewhere the other two can be checked against.
|
||
//
|
||
// ── This file never throws ────────────────────────────────────────────────
|
||
//
|
||
// Every call answers `{ ok, status, data }`. A module that let a socket failure
|
||
// escape into a controller would hand an exception to a page whose whole job is
|
||
// to render while the game is off. The public site degrades; it does not 500.
|
||
|
||
const core = require('./core')
|
||
|
||
const log = core.logger('sidecar')
|
||
|
||
/** How long this client waits before giving up on a sidecar. See the header. */
|
||
const TIMEOUT_MS = 12000
|
||
|
||
/**
|
||
* The wire version this module speaks. Declared in FOUR places that must agree:
|
||
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
|
||
* plugin, and `protocol` in its `overlay.toml`.
|
||
*
|
||
* **8 — the leases.** Protocol 2 was the read path, 3 the first
|
||
* message the WEBSITE originates (`link.confirm`), 4 the first that writes to
|
||
* the game's permission store, 5 the first that writes to the game HOST'S
|
||
* FILESYSTEM; 6 adds the `clans` board and five clan events core's Teams are
|
||
* built from, and no route at all; **7** widens `entity.destroyed` to doors,
|
||
* walls and the cupboard and names who is authorised there, which is what the
|
||
* raid alert is sent to (PLAN.md §25); **8** adds the leases — `GET /lease`,
|
||
* `POST /lease` and `POST /lease/release` — which is what lets an event borrow
|
||
* a value on a server and give it back (PLAN.md §27); **9** adds the world
|
||
* verbs — `/world/monuments`, `/world/owned`, `/world/zone`, `/world/place` and
|
||
* `/world/revert` — what an event places in the world and gives back (PLAN.md
|
||
* §28); **10** adds the rewards — `/tally/open`, `/tally/snapshot`,
|
||
* `/tally/close`, `/kits` and `/chat`, and a `credits` field on the permission
|
||
* sync (PLAN.md §29); **11** adds the map — `/map`, `/map/chunk`,
|
||
* `/map/render` and `/map/live`, its picture and what moves on it (PLAN.md
|
||
* §30); **12** adds `/titles`, the chat titles BetterChat shows, and gives a
|
||
* permission sync a group's BetterChat style and a chat line a delivery and a
|
||
* voice (PLAN.md §33); **13** is the first player walk's fixes and redesigns and
|
||
* RunicNPC (PLAN_FIXES.md, PLAN_REDESIGNS.md §1–§6); **14** adds `/kits/uses`
|
||
* and a permission sync's `notices` — the step editor's kit warning and reward
|
||
* message (PLAN_REDESIGNS.md §2, D332, D333). The bump lands here in the same change as the emitters,
|
||
* because the sidecar refuses a client declaring a different version with a
|
||
* `409`: a module left on 2 would stop being able to read the server board it
|
||
* has been reading all along. A constant that lags the deployment is not a safe
|
||
* default; it is an outage with a version number on it.
|
||
*
|
||
* It is sent on every request as `X-RustLink-Version`, which turns a mismatched
|
||
* deployment into a `409` naming both numbers instead of a parse failure three
|
||
* layers further in.
|
||
*/
|
||
const PROTOCOL_VERSION = 14
|
||
|
||
/** What a caller gets back. Shaped once so every call site reads the same. */
|
||
function reply(ok, status, data = null) {
|
||
return { ok, status, data }
|
||
}
|
||
|
||
/**
|
||
* Normalises a configured base URL into something `new URL(path, base)` will not
|
||
* surprise anybody with.
|
||
*
|
||
* A trailing slash on the base and a leading slash on the path is the classic
|
||
* way to lose a path segment, and an operator pasting a URL out of a terminal
|
||
* supplies the trailing slash about half the time.
|
||
*/
|
||
function joinUrl(baseUrl, path) {
|
||
return `${String(baseUrl).replace(/\/+$/, '')}${path}`
|
||
}
|
||
|
||
/**
|
||
* One request to one sidecar.
|
||
*
|
||
* @param {object} server a `rust_servers` row, token already decrypted
|
||
* @param {string} server.baseUrl
|
||
* @param {string|null} server.token
|
||
* @param {string} path e.g. `/server`
|
||
* @param {object} [options]
|
||
* @param {string} [options.method]
|
||
* @param {object} [options.body]
|
||
* @param {number} [options.timeoutMs] shorter than `TIMEOUT_MS` only where a
|
||
* caller has a tighter budget of its own to fit inside — see `LEASE_TIMEOUT_MS`
|
||
*/
|
||
async function request(server, path, { method = 'GET', body = null, timeoutMs = TIMEOUT_MS } = {}) {
|
||
if (!server || !server.baseUrl) return reply(false, 'not-configured')
|
||
|
||
// A sidecar with auth off does not exist — it generates and persists a token on
|
||
// first start — so a missing token here is a half-finished admin form, not a
|
||
// sidecar to try unauthenticated. Saying so beats a 401 the operator has to
|
||
// interpret.
|
||
if (!server.token) return reply(false, 'no-token')
|
||
|
||
const controller = new AbortController()
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||
|
||
try {
|
||
const res = await fetch(joinUrl(server.baseUrl, path), {
|
||
method,
|
||
signal: controller.signal,
|
||
headers: {
|
||
Authorization: `Bearer ${server.token}`,
|
||
'X-RustLink-Version': String(PROTOCOL_VERSION),
|
||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||
},
|
||
...(body ? { body: JSON.stringify(body) } : {}),
|
||
})
|
||
|
||
// A protocol mismatch is a deployment fault and deserves its own status, not
|
||
// to be folded into "the sidecar said no". The operator's fix is an upgrade
|
||
// of one component, and the message has to be able to say which.
|
||
if (res.status === 409) {
|
||
const detail = await safeJson(res)
|
||
log.warn('protocol mismatch', {
|
||
server: server.id,
|
||
module: PROTOCOL_VERSION,
|
||
sidecar: detail && detail.sidecar_protocol,
|
||
})
|
||
return reply(false, 'protocol-mismatch', detail)
|
||
}
|
||
|
||
if (res.status === 401) return reply(false, 'unauthorized')
|
||
|
||
// 204 is an ANSWER, not an absence of one: the sidecar is up and reports that
|
||
// the game has never connected. Collapsing it into a failure would make a
|
||
// freshly installed server indistinguishable from an unreachable one.
|
||
if (res.status === 204) return reply(true, 'empty', null)
|
||
|
||
if (!res.ok) return reply(false, `http-${res.status}`)
|
||
|
||
return reply(true, 'ok', await safeJson(res))
|
||
} catch (err) {
|
||
// `AbortError` is this client's own deadline firing, and it is worth telling
|
||
// apart from a refused connection: one means the sidecar is slow or the game
|
||
// is not answering, the other means nothing is listening.
|
||
const status = err && err.name === 'AbortError' ? 'timeout' : 'transport-error'
|
||
log.warn('sidecar request failed', { server: server.id, path, status, error: err.message })
|
||
return reply(false, status)
|
||
} finally {
|
||
clearTimeout(timer)
|
||
}
|
||
}
|
||
|
||
async function safeJson(res) {
|
||
try {
|
||
return await res.json()
|
||
} catch {
|
||
// A sidecar that answered 200 with something that is not JSON is a sidecar
|
||
// this module cannot use, but it is not a reason to throw at a page.
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** Liveness, the protocol version, and whether the plugin is connected. Unauthenticated at the far end, but sent authenticated anyway so one code path covers every call. */
|
||
const health = (server) => request(server, '/health')
|
||
|
||
/** The last `server.hello` the sidecar stored. Answers while the game is off. */
|
||
const serverBoard = (server) => request(server, '/server')
|
||
|
||
/** A live round trip through the sidecar to the game. Fails when the game is down, by design. */
|
||
const liveStatus = (server) => request(server, '/status')
|
||
|
||
/** Every board at once: what is true now, before following what happens next. */
|
||
const boards = (server) => request(server, '/boards')
|
||
|
||
/**
|
||
* The ingest cursor: events after `since`, oldest first.
|
||
*
|
||
* **`since` is required here, unlike on the wire.** The sidecar treats an omitted
|
||
* cursor as "tell me where the end is", which is a genuinely useful question and
|
||
* a catastrophic default for an ingest loop that would silently store nothing
|
||
* and advance past everything. So the question is asked explicitly, by name, and
|
||
* a caller cannot get it by forgetting an argument.
|
||
*/
|
||
const feed = (server, since, limit = 200) =>
|
||
request(server, `/feed?since=${encodeURIComponent(since)}&limit=${encodeURIComponent(limit)}`)
|
||
|
||
/** Where the sidecar's history currently ends. What a new server's cursor starts at. */
|
||
const feedTail = (server) => request(server, '/feed')
|
||
|
||
/**
|
||
* Redeem a one-time link code against one server (protocol 3).
|
||
*
|
||
* **The only call in this file that is not a GET**, and the only one that asks
|
||
* the game a question rather than reading what it already said. The sidecar
|
||
* forwards the code to the plugin, which holds the pending codes in memory, and
|
||
* hands back what it answers.
|
||
*
|
||
* **A refused code comes back `{ ok: true }`.** `link.ok` and `link.error` are
|
||
* both answers — the sidecar reserves its own failures for the transport (503
|
||
* when the game is down, 504 when it is up and silent) — and the caller has to
|
||
* tell "that code is wrong" from "the game never replied" to say the right thing
|
||
* to a player. So the discrimination happens on `data.kind`, not on `ok`.
|
||
*
|
||
* A code is spent on the plugin's FIRST lookup whether or not it turns out to be
|
||
* expired, so this must never be called speculatively for its answer alone.
|
||
*/
|
||
const confirmLink = (server, code) =>
|
||
request(server, '/link/confirm', { method: 'POST', body: { code } })
|
||
|
||
/**
|
||
* What one server's loaded plugins have registered, and the groups its store
|
||
* holds (protocol 4).
|
||
*
|
||
* The option source behind the authoring form (D33). It is a live read through
|
||
* to the game rather than anything cached at the sidecar, because the answer
|
||
* changes when an operator loads a plugin — and the whole reason to ask is to
|
||
* offer names that will actually resolve. It therefore fails when the game is
|
||
* down, like `/status` and unlike every store-backed read.
|
||
*/
|
||
const permCatalogue = (server) => request(server, '/permissions/catalogue')
|
||
|
||
/**
|
||
* Push the whole permission set this site authors for one server (protocol 4).
|
||
*
|
||
* **The second call in this file that is not a GET, and the first that changes
|
||
* the game.** The body is the desired set plus what the site has withdrawn; the
|
||
* plugin diffs it against the live store, applies the difference and answers
|
||
* with a report — counts, the names it could not resolve, the memberships that
|
||
* are waiting on a first connection, and every holder the site did not author.
|
||
*
|
||
* **A refusal comes back `{ ok: true }`**, like a refused link code: `perm.error`
|
||
* and `perm.report` are both answers, and the sidecar keeps its own status codes
|
||
* for the transport. The caller discriminates on `data.kind`.
|
||
*/
|
||
const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set })
|
||
|
||
/**
|
||
* One page of the game's whole permission store (protocol 13, PLAN_REDESIGNS
|
||
* §1.2): every permission with the plugin that registered it, every group, every
|
||
* holder, and what event leases hold. `{ page: 0 }` starts a fresh read; later
|
||
* pages name the `snapshotId` page 0 answered with.
|
||
*
|
||
* Like the sync, a refusal (`perm.error`: `busy`, `stale`, `too-large`) comes
|
||
* back `{ ok: true }` and is told apart by `data.kind`.
|
||
*/
|
||
const permInventory = (server, { snapshotId = null, page = 0 } = {}) =>
|
||
request(server, '/permissions/inventory', {
|
||
method: 'POST',
|
||
body: snapshotId ? { snapshotId, page } : { page },
|
||
})
|
||
|
||
/**
|
||
* The whole inventory, every page, or `{ ok: false, error }`. A snapshot that
|
||
* goes stale mid-read is started again once from page 0: a page from a different
|
||
* snapshot would tear the answer.
|
||
*/
|
||
async function readInventory(server) {
|
||
for (let attempt = 0; attempt < 2; attempt++) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const first = await permInventory(server, { page: 0 })
|
||
if (!first.ok) return { ok: false, error: first.status || 'unreachable' }
|
||
|
||
const head = first.data || {}
|
||
if (head.kind === 'perm.error') return { ok: false, error: `the game refused the read: ${head.reason || 'unknown'}` }
|
||
if (head.kind !== 'perm.inventory') return { ok: false, error: 'the game does not know perm.inventory (protocol 13)' }
|
||
|
||
let users = Array.isArray(head.users) ? head.users : []
|
||
let stale = false
|
||
|
||
for (let page = 1; page < Number(head.pages || 1); page++) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const next = await permInventory(server, { snapshotId: head.snapshotId, page })
|
||
const data = (next.ok && next.data) || {}
|
||
|
||
if (data.kind !== 'perm.inventory') {
|
||
stale = true
|
||
break
|
||
}
|
||
|
||
users = users.concat(Array.isArray(data.users) ? data.users : [])
|
||
}
|
||
|
||
if (stale) continue
|
||
|
||
return {
|
||
ok: true,
|
||
inventory: {
|
||
snapshotId: head.snapshotId,
|
||
permissions: Array.isArray(head.permissions) ? head.permissions : [],
|
||
groups: Array.isArray(head.groups) ? head.groups : [],
|
||
leased: Array.isArray(head.leased) ? head.leased : [],
|
||
users,
|
||
stats: head.stats || null,
|
||
},
|
||
}
|
||
}
|
||
|
||
return { ok: false, error: 'the inventory changed while it was being read, twice' }
|
||
}
|
||
|
||
/**
|
||
* Every settings file on one game host, and every plugin loaded to reload one
|
||
* (protocol 5, R18).
|
||
*
|
||
* A description of the tree, never its contents: paths, sizes, which files are
|
||
* too large to edit, and the plugin each one probably belongs to. **Probably**
|
||
* is the operative word and it survives all the way to the form — a folder name
|
||
* is convention, not contract, and reloading the wrong plugin would report
|
||
* success while the edited one never re-read anything.
|
||
*
|
||
* Live, like `/status`: what is on a host's disk has no stale answer worth
|
||
* giving, and a cached one would be an edit an operator made over SSH that the
|
||
* website then overwrote.
|
||
*/
|
||
const configFiles = (server) => request(server, '/config/files')
|
||
|
||
/** One settings file as text, with the version a write has to present back. */
|
||
const configFile = (server, path) =>
|
||
request(server, `/config/file?path=${encodeURIComponent(path)}`)
|
||
|
||
/**
|
||
* Replace a set of settings files and reload what owns them (protocol 5).
|
||
*
|
||
* **The only call in this module that writes to a filesystem**, and the only one
|
||
* whose reply routinely takes seconds: the plugin holds it open across the
|
||
* reload it is watching, and across the rollback if that reload never arrives.
|
||
*
|
||
* Like every other write on this bridge, a refusal comes back `{ ok: true }`
|
||
* with the answer in `data.kind` — `config.report` or `config.error`. The
|
||
* transport keeps its own codes, and a `504` here is the one case worth reading
|
||
* carefully: the plugin writes a whole set or restores a whole set, never half
|
||
* of either, so the state is knowable by re-reading rather than by guessing.
|
||
*/
|
||
const configWrite = (server, body) => request(server, '/config/write', { method: 'POST', body })
|
||
|
||
/**
|
||
* How long ONE lease call waits — shorter than every other call here, and for
|
||
* the same rule `TIMEOUT_MS` is written for, applied to a caller with a tighter
|
||
* budget.
|
||
*
|
||
* A lease is taken by core's `core.lease`, which declares no `budgetMs` and so
|
||
* runs under the dispatcher's default of 10 seconds — and inside that it makes
|
||
* TWO calls into this module, `read()` for the baseline and then `apply()`. At
|
||
* `TIMEOUT_MS` each, one slow read would let the dispatcher give up and call the
|
||
* attempt a retry while the module is still waiting, which is the ordering the
|
||
* header of this file exists to forbid. Two of these fit inside core's budget
|
||
* with a second to spare, and `leases.test.js` asserts the arithmetic rather than
|
||
* trusting it.
|
||
*
|
||
* It is below the sidecar's own ten-second reply timeout, so this end can give
|
||
* up on a call the game is still going to answer. For `read` that costs nothing.
|
||
* For `apply` it would leave a value held that core believes it never took — so
|
||
* `eventLeases.js` follows a timed-out apply with a release (§27.3).
|
||
*/
|
||
const LEASE_TIMEOUT_MS = 4500
|
||
|
||
/** The dispatcher's default action budget, which is what `core.lease` runs under. Mirrored, not imported: core does not export it. */
|
||
const CORE_LEASE_BUDGET_MS = 10000
|
||
|
||
/**
|
||
* What one server lends, what it holds now, and every hold in force
|
||
* (protocol 8). Narrowed to one key and target when given.
|
||
*/
|
||
const leaseList = (server, { key, target } = {}) => {
|
||
const q = []
|
||
if (key) q.push(`key=${encodeURIComponent(key)}`)
|
||
if (target) q.push(`target=${encodeURIComponent(target)}`)
|
||
return request(server, `/lease${q.length ? `?${q.join('&')}` : ''}`, { timeoutMs: LEASE_TIMEOUT_MS })
|
||
}
|
||
|
||
/**
|
||
* Borrow a value (protocol 8). Like every write on this bridge, a refusal
|
||
* comes back `{ ok: true }` with `data.kind` of `lease.error`; the transport
|
||
* keeps its own codes.
|
||
*/
|
||
const leaseApply = (server, body) =>
|
||
request(server, '/lease', { method: 'POST', body, timeoutMs: LEASE_TIMEOUT_MS })
|
||
|
||
/**
|
||
* Give a value back — compare-and-set at the far end. `data.kind` is
|
||
* `lease.ok`, `lease.drifted` (a 200: the plugin compared and declined to
|
||
* overwrite somebody's change) or `lease.error`.
|
||
*/
|
||
const leaseRelease = (server, body) =>
|
||
request(server, '/lease/release', { method: 'POST', body, timeoutMs: LEASE_TIMEOUT_MS })
|
||
|
||
/**
|
||
* This wipe's monuments, the plugin's placeable allowlist and its bounds
|
||
* (protocol 9). Live, because a map changes at every wipe.
|
||
*/
|
||
const worldMonuments = (server) => request(server, '/world/monuments')
|
||
|
||
/**
|
||
* What the world still holds of what events made, looked for by net id on the
|
||
* game (a restart is not proof a crate is gone, §28.1). One run, or all.
|
||
*/
|
||
const worldOwned = (server, { runId } = {}) =>
|
||
request(server, `/world/owned${runId ? `?runId=${encodeURIComponent(runId)}` : ''}`)
|
||
|
||
/**
|
||
* Open a zone, or place crates or NPCs, for a run. `data.kind` is `world.ok`
|
||
* (with `placed`) or `world.error` (with `reason`); a repeated idempotency key
|
||
* is answered with the first call's ids and `repeat: true`.
|
||
*/
|
||
const worldZone = (server, body) => request(server, '/world/zone', { method: 'POST', body })
|
||
const worldPlace = (server, body) => request(server, '/world/place', { method: 'POST', body })
|
||
|
||
/**
|
||
* Run one of the game's own world events for a run (protocol 14, §20.5). `data.kind`
|
||
* is `world.called` (with `called`, and `repeat: true` for a key the plugin has
|
||
* seen) or `world.error` with a reason. What it starts is kept: there is no revert.
|
||
*/
|
||
const worldCall = (server, body) => request(server, '/world/call', { method: 'POST', body })
|
||
|
||
/**
|
||
* Open one Dangerous Treasures event for a run's step (protocol 14, §20.8):
|
||
* `{ key, level }`. `data.kind` is `dt.opened` (with `x`, `z`, `level`, and
|
||
* `repeat: true` for a key the plugin has seen) or `dt.error` with a reason,
|
||
* `dt-not-ready` among them (D365). DT despawns its own chest: there is no revert.
|
||
*/
|
||
const dtStart = (server, body) => request(server, '/dt/start', { method: 'POST', body })
|
||
|
||
/**
|
||
* Give back what a run owns: named ids, else everything under a key, else the
|
||
* whole run. `data` lists `removed`, `gone` (already not there — a success)
|
||
* and `refused` (there, and not this run's to erase).
|
||
*/
|
||
const worldRevert = (server, body) => request(server, '/world/revert', { method: 'POST', body })
|
||
|
||
/**
|
||
* Start counting who takes part in a run (protocol 10). `data.kind` is
|
||
* `tally.ok` or `tally.error`; a repeated key is answered `repeat: true`.
|
||
*/
|
||
const tallyOpen = (server, body) => request(server, '/tally/open', { method: 'POST', body })
|
||
|
||
/** Who has taken part in a run so far, scored by the plugin. `tally.error` `no-tally` when it holds none. */
|
||
const tallySnapshot = (server, runId) =>
|
||
request(server, `/tally/snapshot?runId=${encodeURIComponent(String(runId))}`)
|
||
|
||
/** Stop counting and forget. `closed: false` means it was already gone, which is a success. */
|
||
const tallyClose = (server, body) => request(server, '/tally/close', { method: 'POST', body })
|
||
|
||
/** The kits this server's Kits plugin has, and the plugin's reward bounds. `kits.error` when Kits is not loaded. */
|
||
const kits = (server) => request(server, '/kits')
|
||
|
||
/**
|
||
* Protocol 14 (PLAN_REDESIGNS.md §2.6). Of these players, how many have used `kit`
|
||
* up and how many are on its cooldown: `kits.uses` with `{ kit, max, cooldown,
|
||
* asked, exhausted, onCooldown }`, counts only. `kits.error` when Kits is not
|
||
* loaded or has no such kit.
|
||
*/
|
||
const kitsUses = (server, kit, steamIds) =>
|
||
request(server, '/kits/uses', { method: 'POST', body: { kit, steamIds } })
|
||
|
||
/**
|
||
* Economics (protocol 14, PLAN_REDESIGNS.md §11.2.2). `economics.paid` lists each
|
||
* player's balance before and after; a key already paid answers `repeat: true`
|
||
* and pays nobody, so a retry is safe. `economics.error` has a reason.
|
||
*/
|
||
const economicsPay = (server, body) => request(server, '/economics/pay', { method: 'POST', body })
|
||
|
||
/** Each player's balance, and the server's starting balance when its config says. */
|
||
const economicsBalances = (server, steamIds) =>
|
||
request(server, '/economics/balances', { method: 'POST', body: { steamIds } })
|
||
|
||
/** Staff: `{ key, steamId, op, amount }`, op deposit · withdraw · set. `economics.adjusted` has before and after. */
|
||
const economicsAdjust = (server, body) => request(server, '/economics/adjust', { method: 'POST', body })
|
||
|
||
/**
|
||
* One player's backpack (protocol 14, PLAN_REDESIGNS.md §11.3.2), read live.
|
||
* `backpacks.contents` has `exists`, and with a backpack its capacity, page
|
||
* size and stacks; `backpacks.error` has a reason. Nothing is written.
|
||
*/
|
||
const backpacksRead = (server, steamId) => request(server, '/backpacks/read', { method: 'POST', body: { steamId } })
|
||
|
||
/**
|
||
* Say one line in the server's chat. `chat.ok` carries `said: false` when the
|
||
* key was said in the last ten minutes — a retry, answered and not repeated.
|
||
*/
|
||
const chat = (server, body) => request(server, '/chat', { method: 'POST', body })
|
||
|
||
/**
|
||
* Replace the chat titles a server's plugin holds (protocol 12). Whole, so a
|
||
* retry is harmless; `titles.ok` says whether BetterChat is there to show them.
|
||
*/
|
||
const titles = (server, body) => request(server, '/titles', { method: 'POST', body })
|
||
|
||
/**
|
||
* What one server's map is and where its picture comes from (protocol 11,
|
||
* stage one): `mapKey`, `source` (`companion`, `rendered` or `none`), the
|
||
* picture's size, `sha256` and slice count, the grid, and the monuments.
|
||
* `hashing: true` in place of `sha256` means ask again in a moment. A refusal
|
||
* is `data.kind` `map.error`, like every other write-shaped answer here.
|
||
*/
|
||
const mapInfo = (server) => request(server, '/map')
|
||
|
||
/**
|
||
* One slice of the picture, base64 in `data.data` (stage two). `map.error`
|
||
* `stale` when the map or picture moved since `mapInfo` — the caller starts
|
||
* again rather than splicing two maps.
|
||
*/
|
||
const mapChunk = (server, { mapKey, sha256, n }) =>
|
||
request(
|
||
server,
|
||
`/map/chunk?mapKey=${encodeURIComponent(mapKey)}&sha256=${encodeURIComponent(sha256)}&n=${encodeURIComponent(n)}`,
|
||
)
|
||
|
||
/**
|
||
* Ask the game to draw its own map (D109). Answered `accepted` at once; the
|
||
* render stalls the game on a LATER frame, and `mapInfo` says `rendered` when
|
||
* it is done.
|
||
*/
|
||
const mapRender = (server, body) => request(server, '/map/render', { method: 'POST', body })
|
||
|
||
/** Everything that moves on one server's map, every layer, unfiltered. The caller filters (§8.5). */
|
||
const mapLive = (server) => request(server, '/map/live')
|
||
|
||
/**
|
||
* RunicNPC (runicnpc PLAN.md stage 4, protocol 13). The server's own profiles as
|
||
* RunicNPC holds them (`npc.profiles`: `managed`, `profiles`, `refused`), read
|
||
* before the site's first push so it can adopt them (D244); a push, which
|
||
* replaces them whole and marks the server managed (`npc.ok` lists what
|
||
* RunicNPC refused); every placement; and one change to one placement, by `op`.
|
||
* Each refusal is `data.kind` `npc.error` with a `reason` and RunicNPC's own sentence.
|
||
*/
|
||
const npcProfiles = (server) => request(server, '/npc/profiles')
|
||
const npcProfilesSet = (server, profiles, factions) =>
|
||
request(server, '/npc/profiles', { method: 'POST', body: factions ? { profiles, factions } : { profiles } })
|
||
const npcPlacements = (server) => request(server, '/npc/placements')
|
||
const npcPlacement = (server, body) => request(server, '/npc/placement', { method: 'POST', body })
|
||
|
||
module.exports = {
|
||
TIMEOUT_MS,
|
||
LEASE_TIMEOUT_MS,
|
||
CORE_LEASE_BUDGET_MS,
|
||
PROTOCOL_VERSION,
|
||
request,
|
||
health,
|
||
serverBoard,
|
||
liveStatus,
|
||
boards,
|
||
feed,
|
||
feedTail,
|
||
confirmLink,
|
||
permCatalogue,
|
||
permSync,
|
||
permInventory,
|
||
readInventory,
|
||
configFiles,
|
||
configFile,
|
||
configWrite,
|
||
leaseList,
|
||
leaseApply,
|
||
leaseRelease,
|
||
worldMonuments,
|
||
worldOwned,
|
||
worldZone,
|
||
worldPlace,
|
||
worldCall,
|
||
dtStart,
|
||
worldRevert,
|
||
tallyOpen,
|
||
tallySnapshot,
|
||
tallyClose,
|
||
kits,
|
||
kitsUses,
|
||
economicsPay,
|
||
economicsBalances,
|
||
economicsAdjust,
|
||
backpacksRead,
|
||
chat,
|
||
titles,
|
||
mapInfo,
|
||
mapChunk,
|
||
mapRender,
|
||
mapLive,
|
||
npcProfiles,
|
||
npcProfilesSet,
|
||
npcPlacements,
|
||
npcPlacement,
|
||
joinUrl,
|
||
}
|