feat(rust): the leases and their option sources (phase 12, protocol 8)
Four leases on core.lease: rust.decay.scale, rust.population, rust.spawn.scalar and rust.group.permission. Every lease is targeted and the target names the server (D73). Also the three target option sources plus rust.options.servers, with no budgets (D79). Held for up to seven days (D77). A key that is already held reads as its baseline. Drift is an answer, not a failure. inForce reads the plugin's holds and never compares values. Lease calls get a 4.5s timeout so that two of them fit in core.lease's 10s budget, and a timed-out apply is followed by a release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -33,6 +33,7 @@
|
|||||||
"core.js",
|
"core.js",
|
||||||
"db",
|
"db",
|
||||||
"engagement",
|
"engagement",
|
||||||
|
"eventLeases.js",
|
||||||
"index.js",
|
"index.js",
|
||||||
"ingest.js",
|
"ingest.js",
|
||||||
"model",
|
"model",
|
||||||
|
|||||||
434
server/eventLeases.js
Normal file
434
server/eventLeases.js
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
// ── What an event may BORROW on a Rust server (PLAN.md §27, protocol 8) ─────
|
||||||
|
//
|
||||||
|
// The module never takes a lease and never bounds one. An author puts core's
|
||||||
|
// `core.lease` in a step naming a lease, a target, a value and a number of
|
||||||
|
// minutes; core reads the baseline, reserves `<lease id>#<target>` against the
|
||||||
|
// two-events-one-target index, applies the value with its deadline and restores
|
||||||
|
// it at teardown. What is here is the four callables each lease ships, and the
|
||||||
|
// option sources that fill its target field.
|
||||||
|
//
|
||||||
|
// ── The target names the server (D73) ─────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `core.lease` hands a lease only `{ target }` — never the run's scope — and
|
||||||
|
// reserves `<id>#<target>`. So every lease here is TARGETED and every target
|
||||||
|
// begins with the server id: `srv-a` for a single value, `srv-a/bear.population`
|
||||||
|
// or `srv-a/default/kits.vip` for a family. That makes the ledger's unique index
|
||||||
|
// bite at exactly the granularity Rust has: two runs on two servers never
|
||||||
|
// collide, and one value on one server has one holder.
|
||||||
|
//
|
||||||
|
// ── Game convars only (D74) ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Vanilla Rust has no gather, craft or smelt rate convar; what it has, and what
|
||||||
|
// the plugin's allowlist lends, is decay, the population system, and its two
|
||||||
|
// minimum scalars — plus a group's permissions, the "weekend VIP" (D75). The
|
||||||
|
// plugin holds the allowlist, the bounds, the seven-day ceiling and the deadline
|
||||||
|
// timer. The bounds are declared here AS WELL, because this pair is what core
|
||||||
|
// checks when an author saves — a bad value is a refusal on a form rather than a
|
||||||
|
// step failing unattended at four in the morning.
|
||||||
|
|
||||||
|
const core = require('./core')
|
||||||
|
const client = require('./sidecarClient')
|
||||||
|
const serversDb = require('./model/servers/servers.db')
|
||||||
|
const servers = require('./model/servers/servers.model')
|
||||||
|
|
||||||
|
const log = core.logger('leases')
|
||||||
|
|
||||||
|
/** Seven days (D77). The plugin holds the same ceiling independently and refuses past it. */
|
||||||
|
const MAX_LEASE_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Core's bound on one option source's answer. A source that would exceed it says so in the log. */
|
||||||
|
const MAX_OPTIONS = 2000
|
||||||
|
|
||||||
|
/** The wire key of the one lease that is not a convar. */
|
||||||
|
const GROUP_PERMISSION_KEY = 'group.permission'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a target into its server and the rest (D73).
|
||||||
|
*
|
||||||
|
* At the FIRST slash: a server id is `[a-z0-9-]` and never contains one, while
|
||||||
|
* what follows may (a group name is free text an operator typed).
|
||||||
|
*/
|
||||||
|
function splitTarget(target) {
|
||||||
|
const text = String(target || '').trim()
|
||||||
|
const slash = text.indexOf('/')
|
||||||
|
if (slash < 0) return { serverId: text, rest: '' }
|
||||||
|
return { serverId: text.slice(0, slash), rest: text.slice(slash + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server a target names, with its token — or a refusal.
|
||||||
|
*
|
||||||
|
* **`retry: false`**, because the second attempt carries the same params: a
|
||||||
|
* target naming a server that is not configured (or is switched off) is an
|
||||||
|
* authoring mistake or a deleted server, and neither is fixed by waiting.
|
||||||
|
*/
|
||||||
|
async function serverFor(serverId) {
|
||||||
|
if (!serverId) return { ok: false, retry: false, error: 'the target does not name a server' }
|
||||||
|
const row = await serversDb.getServer(serverId)
|
||||||
|
if (!row) return { ok: false, retry: false, error: `there is no Rust server "${serverId}" on this site` }
|
||||||
|
if (!row.enabled) return { ok: false, retry: false, error: `the Rust server "${row.name || serverId}" is switched off` }
|
||||||
|
return { ok: true, server: servers.withToken(row) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sentence for a transport failure, naming the server — every notice says which (§25.6). */
|
||||||
|
function transportError(server, result, what) {
|
||||||
|
const name = (server && (server.name || server.id)) || 'the server'
|
||||||
|
switch (result.status) {
|
||||||
|
case 'http-503':
|
||||||
|
return `${name} has no game connected, so its ${what} could not be reached`
|
||||||
|
case 'http-504':
|
||||||
|
case 'timeout':
|
||||||
|
return `${name} did not answer about its ${what} in time`
|
||||||
|
case 'protocol-mismatch':
|
||||||
|
return `${name}'s sidecar speaks a different protocol — update the module or the sidecar`
|
||||||
|
default:
|
||||||
|
return `${name} could not be reached about its ${what} (${result.status})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plugin's own refusal, which carries a sentence of its own. */
|
||||||
|
function pluginError(data, fallback) {
|
||||||
|
return (data && (data.message || data.reason)) || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the four callables one lease shares with every other.
|
||||||
|
*
|
||||||
|
* `wire(rest)` turns what follows the server id into the plugin's `{ key,
|
||||||
|
* target }`, or a refusal. The callables differ in nothing else, so they are
|
||||||
|
* built rather than repeated: four copies of this would be four chances for one
|
||||||
|
* of them to forget the drift check, which is the one thing §F says a lease must
|
||||||
|
* not be allowed to skip.
|
||||||
|
*/
|
||||||
|
function lease({ id, label, description, type, min, max, family, targetLabel, source, example, wire }) {
|
||||||
|
async function resolve(target) {
|
||||||
|
const { serverId, rest } = splitTarget(target)
|
||||||
|
const found = await serverFor(serverId)
|
||||||
|
if (!found.ok) return found
|
||||||
|
const w = wire(rest)
|
||||||
|
if (!w.ok) return { ok: false, retry: false, error: w.error }
|
||||||
|
return { ok: true, server: found.server, key: w.key, target: w.target || undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plugin's row for this key and target, or a refusal. */
|
||||||
|
async function row(r) {
|
||||||
|
const result = await client.leaseList(r.server, { key: r.key, target: r.target })
|
||||||
|
if (!result.ok) return { ok: false, error: transportError(r.server, result, 'lease catalogue') }
|
||||||
|
const rows = (result.data && result.data.leases) || []
|
||||||
|
const found = rows.find((x) => x && x.key === r.key && (r.target === undefined || x.target === r.target))
|
||||||
|
if (!found) return { ok: false, retry: false, error: `${r.server.name || r.server.id} does not lend ${r.key}` }
|
||||||
|
if (family && found.family !== family) {
|
||||||
|
return { ok: false, retry: false, error: `${r.key} is not a ${family} value` }
|
||||||
|
}
|
||||||
|
return { ok: true, row: found, data: result.data }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
...(min === undefined ? {} : { min }),
|
||||||
|
...(max === undefined ? {} : { max }),
|
||||||
|
maxDurationMs: MAX_LEASE_MS,
|
||||||
|
target: { label: targetLabel, source, example },
|
||||||
|
|
||||||
|
async read({ target } = {}) {
|
||||||
|
const r = await resolve(target)
|
||||||
|
if (!r.ok) return r
|
||||||
|
const found = await row(r)
|
||||||
|
if (!found.ok) return found
|
||||||
|
|
||||||
|
// **A key the plugin already holds reads as its BASELINE, not its
|
||||||
|
// current value.** Core's reservation means a second run can never get
|
||||||
|
// this far, so a hold core does not know about is the first attempt of
|
||||||
|
// THIS run whose answer was lost — and the baseline to give back at the
|
||||||
|
// end is what was there before anybody borrowed it, not that attempt's
|
||||||
|
// value. Recording the current value here would restore the event's own
|
||||||
|
// change at teardown and call it baseline.
|
||||||
|
if (found.row.held && found.row.baseline !== undefined && found.row.baseline !== null) {
|
||||||
|
return { ok: true, value: String(found.row.baseline) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found.row.unreadable) return { ok: false, retry: false, error: found.row.unreadable }
|
||||||
|
if (found.row.current === undefined || found.row.current === null) {
|
||||||
|
return { ok: false, error: `${r.server.name || r.server.id} could not read ${r.key}` }
|
||||||
|
}
|
||||||
|
return { ok: true, value: String(found.row.current) }
|
||||||
|
},
|
||||||
|
|
||||||
|
async apply(value, until, { target } = {}) {
|
||||||
|
const r = await resolve(target)
|
||||||
|
if (!r.ok) return r
|
||||||
|
|
||||||
|
// **A duration, not the deadline.** `until` is an absolute time computed
|
||||||
|
// here and honoured there, which is a deadline measured against two
|
||||||
|
// clocks; a game host ten minutes fast would end a ten-minute lease the
|
||||||
|
// instant it took it. The absolute time still rides along, for display.
|
||||||
|
const untilMs = new Date(until).getTime()
|
||||||
|
const holdMs = untilMs - Date.now()
|
||||||
|
if (!Number.isFinite(holdMs) || holdMs <= 0) {
|
||||||
|
return { ok: false, error: 'the lease deadline has already passed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
key: r.key,
|
||||||
|
...(r.target === undefined ? {} : { target: r.target }),
|
||||||
|
...(family ? { family } : {}),
|
||||||
|
value: String(value),
|
||||||
|
holdMs: Math.round(holdMs),
|
||||||
|
untilMs,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.leaseApply(r.server, body)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
// **An apply this end gave up on may still land.** The client's lease
|
||||||
|
// timeout is below the sidecar's own, so the command can still reach
|
||||||
|
// the game after core has been told it failed — and core then releases
|
||||||
|
// its reservation, believing nothing was taken. A release follows it
|
||||||
|
// down the same link, which the plugin handles in order: if the apply
|
||||||
|
// landed, the hold's own baseline goes back; if it never did, the
|
||||||
|
// compare finds nothing held and changes nothing. Not awaited: its
|
||||||
|
// answer changes nothing about this one.
|
||||||
|
if (result.status === 'timeout' || result.status === 'http-504') {
|
||||||
|
client
|
||||||
|
.leaseRelease(r.server, { key: r.key, target: r.target, expected: String(value) })
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
return { ok: false, error: transportError(r.server, result, 'lease') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.data || {}
|
||||||
|
if (data.kind === 'lease.ok') return { ok: true }
|
||||||
|
|
||||||
|
// A refusal the second attempt would repeat is `retry: false` — the
|
||||||
|
// switch is off, the key is not lent, the value is out of range. One that
|
||||||
|
// might pass later (a value the game could not read this second) is left
|
||||||
|
// to core's default.
|
||||||
|
const permanent = ['events-disabled', 'unknown-key', 'out-of-range', 'too-long', 'unresolved', 'target-gone', 'malformed']
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
...(permanent.includes(data.reason) ? { retry: false } : {}),
|
||||||
|
error: pluginError(data, `${r.server.name || r.server.id} refused the lease`),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async restore(baseline, { expected, target } = {}) {
|
||||||
|
const r = await resolve(target)
|
||||||
|
if (!r.ok) return r
|
||||||
|
|
||||||
|
const result = await client.leaseRelease(r.server, {
|
||||||
|
key: r.key,
|
||||||
|
...(r.target === undefined ? {} : { target: r.target }),
|
||||||
|
expected: expected === undefined || expected === null ? undefined : String(expected),
|
||||||
|
baseline: baseline === undefined || baseline === null ? undefined : String(baseline),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.ok) return { ok: false, error: transportError(r.server, result, 'lease release') }
|
||||||
|
|
||||||
|
const data = result.data || {}
|
||||||
|
|
||||||
|
// **Drift is a 200 carrying `lease.drifted`, not a failure of the call.**
|
||||||
|
// The plugin did what it was asked: it compared, and declined to
|
||||||
|
// overwrite somebody's deliberate change. Core records that as its own
|
||||||
|
// outcome, with the current value beside it.
|
||||||
|
if (data.kind === 'lease.drifted') return { ok: false, drifted: true, current: data.current }
|
||||||
|
|
||||||
|
// A group deleted mid-hold has nothing to give back and nothing owed: a
|
||||||
|
// successful release, not a failure that would leave a ledger row
|
||||||
|
// unresolved for ever over something that is gone.
|
||||||
|
if (data.kind === 'lease.ok') return { ok: true }
|
||||||
|
|
||||||
|
return { ok: false, error: pluginError(data, `${r.server.name || r.server.id} could not give ${r.key} back`) }
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the plugin still has a record of the hold.
|
||||||
|
*
|
||||||
|
* **Never a comparison with `read()`** (MODULE_API §1.1). A value that
|
||||||
|
* differs from what the run applied is DRIFT, which `restore()` reports so
|
||||||
|
* the row lands `drifted`; answering "not in force" here would orphan the
|
||||||
|
* row first. A convar hold is memory-only on the game, so a restart ends it
|
||||||
|
* and this answers `held: false` — exactly the case core cannot otherwise
|
||||||
|
* see.
|
||||||
|
*/
|
||||||
|
async inForce({ target } = {}) {
|
||||||
|
const r = await resolve(target)
|
||||||
|
if (!r.ok) return r
|
||||||
|
const result = await client.leaseList(r.server, { key: r.key, target: r.target })
|
||||||
|
if (!result.ok) return { ok: false, error: transportError(r.server, result, 'lease catalogue') }
|
||||||
|
const holds = (result.data && result.data.holds) || []
|
||||||
|
const held = holds.some((h) => h && h.key === r.key && String(h.target || '') === String(r.target || ''))
|
||||||
|
return { ok: true, held }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A convar named in the target, of this family. */
|
||||||
|
const convarIn = (family) => (rest) =>
|
||||||
|
rest ? { ok: true, key: rest.toLowerCase() } : { ok: false, error: `name the ${family} value after the server, as server/convar` }
|
||||||
|
|
||||||
|
const LEASES = [
|
||||||
|
lease({
|
||||||
|
id: 'rust.decay.scale',
|
||||||
|
label: 'Decay rate',
|
||||||
|
description:
|
||||||
|
'How fast unprotected buildings decay. 1 is normal, 0 switches decay off, 2 doubles it. Read on every decay tick, so it takes effect at the next one.',
|
||||||
|
type: 'float',
|
||||||
|
min: 0,
|
||||||
|
max: 10,
|
||||||
|
family: 'decay',
|
||||||
|
targetLabel: 'Which server',
|
||||||
|
source: 'rust.options.servers',
|
||||||
|
example: 'main',
|
||||||
|
wire: (rest) => (rest ? { ok: false, error: 'the decay rate takes only a server as its target' } : { ok: true, key: 'decay.scale' }),
|
||||||
|
}),
|
||||||
|
lease({
|
||||||
|
id: 'rust.population',
|
||||||
|
label: 'Population',
|
||||||
|
description:
|
||||||
|
'How many of one animal or vehicle the game keeps topped up, per square kilometre. Applied on the next spawn tick, so the world fills toward the new number rather than jumping to it.',
|
||||||
|
type: 'float',
|
||||||
|
min: 0,
|
||||||
|
max: 50,
|
||||||
|
family: 'population',
|
||||||
|
targetLabel: 'Which server and population',
|
||||||
|
source: 'rust.options.populations',
|
||||||
|
example: 'main/bear.population',
|
||||||
|
wire: convarIn('population'),
|
||||||
|
}),
|
||||||
|
lease({
|
||||||
|
id: 'rust.spawn.scalar',
|
||||||
|
label: 'Spawn scalar',
|
||||||
|
description:
|
||||||
|
"The population system's minimum spawn rate or density — what it runs at on an empty or quiet server, scaling up toward the maximum as players arrive.",
|
||||||
|
type: 'float',
|
||||||
|
min: 0,
|
||||||
|
max: 10,
|
||||||
|
family: 'spawn',
|
||||||
|
targetLabel: 'Which server and scalar',
|
||||||
|
source: 'rust.options.spawnscalars',
|
||||||
|
example: 'main/spawn.min_rate',
|
||||||
|
wire: convarIn('spawn'),
|
||||||
|
}),
|
||||||
|
lease({
|
||||||
|
id: 'rust.group.permission',
|
||||||
|
label: 'Group permission',
|
||||||
|
description:
|
||||||
|
"Whether a permission group carries a permission — \"group default holds kits.vip until Monday\" makes everybody VIP for the weekend. Given back at the end whether or not the site is still up; the game holds the deadline.",
|
||||||
|
type: 'bool',
|
||||||
|
family: null,
|
||||||
|
targetLabel: 'Which server, group and permission',
|
||||||
|
source: 'rust.options.grouppermissions',
|
||||||
|
example: 'main/default/kits.vip',
|
||||||
|
wire: (rest) => {
|
||||||
|
const slash = rest.lastIndexOf('/')
|
||||||
|
if (slash <= 0 || slash >= rest.length - 1) {
|
||||||
|
return { ok: false, error: 'a group permission is named as server/group/permission' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
key: GROUP_PERMISSION_KEY,
|
||||||
|
target: `${rest.slice(0, slash).trim().toLowerCase()}/${rest.slice(slash + 1).trim().toLowerCase()}`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Option sources (D78: only what this phase's leases read) ─────────────────
|
||||||
|
//
|
||||||
|
// Every one resolves live, and a server that does not answer contributes
|
||||||
|
// nothing rather than failing the whole answer — one server being down must
|
||||||
|
// never blank the form for the other five (§9). A source that returns `[]`
|
||||||
|
// degrades its field to free text on core's side, which is the right failure:
|
||||||
|
// the operator very often already knows the value.
|
||||||
|
|
||||||
|
/** Bound one source's answer, and say so in the log when there was more. */
|
||||||
|
function bounded(rows, sourceId) {
|
||||||
|
if (rows.length <= MAX_OPTIONS) return rows
|
||||||
|
log.warn('option source truncated', { source: sourceId, available: rows.length, served: MAX_OPTIONS })
|
||||||
|
return rows.slice(0, MAX_OPTIONS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every enabled server's own answer, in parallel, skipping the ones that fail. */
|
||||||
|
async function perServer(ask) {
|
||||||
|
const list = await servers.listForPolling()
|
||||||
|
const settled = await Promise.allSettled(list.map(async (server) => ({ server, result: await ask(server) })))
|
||||||
|
return settled.filter((s) => s.status === 'fulfilled' && s.value.result && s.value.result.ok).map((s) => s.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The convars one family lends, per server, as whole targets. */
|
||||||
|
async function familyOptions(family, sourceId) {
|
||||||
|
const answers = await perServer((server) => client.leaseList(server))
|
||||||
|
const rows = []
|
||||||
|
for (const { server, result } of answers) {
|
||||||
|
for (const r of (result.data && result.data.leases) || []) {
|
||||||
|
if (!r || r.family !== family || r.unreadable) continue
|
||||||
|
rows.push({ value: `${server.id}/${r.key}`, label: r.key, group: server.name || server.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bounded(rows, sourceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPTION_SOURCES = [
|
||||||
|
{
|
||||||
|
id: 'rust.options.servers',
|
||||||
|
label: 'Rust servers',
|
||||||
|
description: 'Every enabled server on this site. A lease holds a value on one of them (D73).',
|
||||||
|
async resolve() {
|
||||||
|
const list = await servers.listForPolling()
|
||||||
|
return list.map((s) => ({ value: s.id, label: s.name || s.id }))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rust.options.populations',
|
||||||
|
label: 'Populations',
|
||||||
|
description: 'The animal and vehicle populations each server lends, read live from the game.',
|
||||||
|
async resolve() {
|
||||||
|
return familyOptions('population', 'rust.options.populations')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rust.options.spawnscalars',
|
||||||
|
label: 'Spawn scalars',
|
||||||
|
description: "The population system's rate and density scalars each server lends.",
|
||||||
|
async resolve() {
|
||||||
|
return familyOptions('spawn', 'rust.options.spawnscalars')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Groups times registered permissions is a catalogue bigger than a dropdown
|
||||||
|
// holds on any server with a few plugins, so it narrows by the term.
|
||||||
|
id: 'rust.options.grouppermissions',
|
||||||
|
label: 'Group permissions',
|
||||||
|
description: 'A permission group and a permission some loaded plugin registered, on each server.',
|
||||||
|
searchable: true,
|
||||||
|
async resolve({ q } = {}) {
|
||||||
|
const term = String(q || '').trim().toLowerCase()
|
||||||
|
const answers = await perServer((server) => client.permCatalogue(server))
|
||||||
|
const rows = []
|
||||||
|
for (const { server, result } of answers) {
|
||||||
|
const data = result.data || {}
|
||||||
|
const perms = (data.permissions || []).map((p) => String(p).toLowerCase())
|
||||||
|
for (const g of data.groups || []) {
|
||||||
|
const group = g && g.name ? String(g.name).toLowerCase() : null
|
||||||
|
if (!group) continue
|
||||||
|
for (const perm of perms) {
|
||||||
|
const value = `${server.id}/${group}/${perm}`
|
||||||
|
if (term && !value.includes(term)) continue
|
||||||
|
rows.push({ value, label: `${group} · ${perm}`, group: server.name || server.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bounded(rows, 'rust.options.grouppermissions')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MAX_LEASE_MS,
|
||||||
|
LEASES,
|
||||||
|
OPTION_SOURCES,
|
||||||
|
splitTarget,
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ module.exports = function register(ctx, api) {
|
|||||||
const { STREAMS } = require('./engagement/streams')
|
const { STREAMS } = require('./engagement/streams')
|
||||||
const { AUDIENCES } = require('./engagement/audiences')
|
const { AUDIENCES } = require('./engagement/audiences')
|
||||||
const seeds = require('./engagement/seeds')
|
const seeds = require('./engagement/seeds')
|
||||||
|
const eventLeases = require('./eventLeases')
|
||||||
const boot = require('./boot')
|
const boot = require('./boot')
|
||||||
/* eslint-enable global-require */
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
@@ -146,8 +147,20 @@ module.exports = function register(ctx, api) {
|
|||||||
api.onBoot(boot.onBoot)
|
api.onBoot(boot.onBoot)
|
||||||
api.onShutdown(boot.onShutdown)
|
api.onShutdown(boot.onShutdown)
|
||||||
|
|
||||||
// Everything else this module will register — the four event catalogues, the
|
// The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server
|
||||||
// announce leg and the slash commands — is deliberately absent. Each arrives
|
// and must give back. Core's `core.lease` is the verb; these are the values it
|
||||||
|
// may name and the four callables each ships. Every lease is targeted and the
|
||||||
|
// target names the server (D73), which is how one value on one server gets
|
||||||
|
// exactly one holder without core learning what a server is.
|
||||||
|
//
|
||||||
|
// The option sources are the three targets' own (D78). **No budgets** (D79): a
|
||||||
|
// lease spends none, and a dimension with nothing to spend it is a dial on the
|
||||||
|
// operator's cap screen that does nothing. They arrive with the actions.
|
||||||
|
api.registerEventLeases(eventLeases.LEASES)
|
||||||
|
api.registerEventOptionSources(eventLeases.OPTION_SOURCES)
|
||||||
|
|
||||||
|
// Everything else this module will register — the event actions and budgets,
|
||||||
|
// the announce leg and the slash commands — is deliberately absent. Each arrives
|
||||||
// with the phase that has something real to put in it. A registration
|
// with the phase that has something real to put in it. A registration
|
||||||
// with nothing behind it is worse than a missing one: a declared trigger
|
// with nothing behind it is worse than a missing one: a declared trigger
|
||||||
// nothing emits and a declared slot nothing fills are both surfaces an operator
|
// nothing emits and a declared slot nothing fills are both surfaces an operator
|
||||||
@@ -161,5 +174,7 @@ module.exports = function register(ctx, api) {
|
|||||||
triggers: TRIGGERS.length,
|
triggers: TRIGGERS.length,
|
||||||
streams: STREAMS.length,
|
streams: STREAMS.length,
|
||||||
audiences: AUDIENCES.length,
|
audiences: AUDIENCES.length,
|
||||||
|
leases: eventLeases.LEASES.length,
|
||||||
|
optionSources: eventLeases.OPTION_SOURCES.length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,13 +52,15 @@ const TIMEOUT_MS = 12000
|
|||||||
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
|
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
|
||||||
* plugin, and `protocol` in its `overlay.toml`.
|
* plugin, and `protocol` in its `overlay.toml`.
|
||||||
*
|
*
|
||||||
* **7 — the raid frame.** Protocol 2 was the read path, 3 the first
|
* **8 — the leases.** Protocol 2 was the read path, 3 the first
|
||||||
* message the WEBSITE originates (`link.confirm`), 4 the first that writes to
|
* 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
|
* 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
|
* 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,
|
* 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
|
* walls and the cupboard and names who is authorised there, which is what the
|
||||||
* raid alert is sent to (PLAN.md §25). The bump lands here in the same change as the emitters,
|
* 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). The bump lands here in the same change as the emitters,
|
||||||
* because the sidecar refuses a client declaring a different version with a
|
* 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
|
* `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
|
* has been reading all along. A constant that lags the deployment is not a safe
|
||||||
@@ -68,7 +70,7 @@ const TIMEOUT_MS = 12000
|
|||||||
* deployment into a `409` naming both numbers instead of a parse failure three
|
* deployment into a `409` naming both numbers instead of a parse failure three
|
||||||
* layers further in.
|
* layers further in.
|
||||||
*/
|
*/
|
||||||
const PROTOCOL_VERSION = 7
|
const PROTOCOL_VERSION = 8
|
||||||
|
|
||||||
/** What a caller gets back. Shaped once so every call site reads the same. */
|
/** What a caller gets back. Shaped once so every call site reads the same. */
|
||||||
function reply(ok, status, data = null) {
|
function reply(ok, status, data = null) {
|
||||||
@@ -97,8 +99,10 @@ function joinUrl(baseUrl, path) {
|
|||||||
* @param {object} [options]
|
* @param {object} [options]
|
||||||
* @param {string} [options.method]
|
* @param {string} [options.method]
|
||||||
* @param {object} [options.body]
|
* @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 } = {}) {
|
async function request(server, path, { method = 'GET', body = null, timeoutMs = TIMEOUT_MS } = {}) {
|
||||||
if (!server || !server.baseUrl) return reply(false, 'not-configured')
|
if (!server || !server.baseUrl) return reply(false, 'not-configured')
|
||||||
|
|
||||||
// A sidecar with auth off does not exist — it generates and persists a token on
|
// A sidecar with auth off does not exist — it generates and persists a token on
|
||||||
@@ -108,7 +112,7 @@ async function request(server, path, { method = 'GET', body = null } = {}) {
|
|||||||
if (!server.token) return reply(false, 'no-token')
|
if (!server.token) return reply(false, 'no-token')
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(joinUrl(server.baseUrl, path), {
|
const res = await fetch(joinUrl(server.baseUrl, path), {
|
||||||
@@ -276,8 +280,61 @@ const configFile = (server, path) =>
|
|||||||
*/
|
*/
|
||||||
const configWrite = (server, body) => request(server, '/config/write', { method: 'POST', body })
|
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 })
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
TIMEOUT_MS,
|
TIMEOUT_MS,
|
||||||
|
LEASE_TIMEOUT_MS,
|
||||||
|
CORE_LEASE_BUDGET_MS,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
request,
|
request,
|
||||||
health,
|
health,
|
||||||
@@ -292,5 +349,8 @@ module.exports = {
|
|||||||
configFiles,
|
configFiles,
|
||||||
configFile,
|
configFile,
|
||||||
configWrite,
|
configWrite,
|
||||||
|
leaseList,
|
||||||
|
leaseApply,
|
||||||
|
leaseRelease,
|
||||||
joinUrl,
|
joinUrl,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,15 +139,38 @@ test('nothing is registered that has nothing behind it yet', () => {
|
|||||||
// that has something real to put in it, and this assertion is what that phase
|
// that has something real to put in it, and this assertion is what that phase
|
||||||
// deletes. Phase 9 deleted the Team provider's line; phase 10 the four
|
// deletes. Phase 9 deleted the Team provider's line; phase 10 the four
|
||||||
// engagement lines, and the announce leg and post hook it deliberately did
|
// engagement lines, and the announce leg and post hook it deliberately did
|
||||||
// NOT register (D62) moved into the assertions below.
|
// NOT register (D62) moved into the assertions below. Phase 12 deleted the
|
||||||
|
// leases and option sources, and kept budgets here on purpose (D79): a lease
|
||||||
|
// spends none, and a dimension nothing spends is a dial that does nothing.
|
||||||
assert.deepStrictEqual(api.record.legs, [])
|
assert.deepStrictEqual(api.record.legs, [])
|
||||||
assert.strictEqual(api.record.hooks.post, undefined)
|
assert.strictEqual(api.record.hooks.post, undefined)
|
||||||
assert.strictEqual(api.record.eventBudgets, null)
|
assert.strictEqual(api.record.eventBudgets, null)
|
||||||
assert.strictEqual(api.record.eventOptionSources, null)
|
|
||||||
assert.strictEqual(api.record.eventLeases, null)
|
|
||||||
assert.strictEqual(api.record.eventActions, null)
|
assert.strictEqual(api.record.eventActions, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('the leases and their option sources are registered, every source a lease reads (phase 12)', () => {
|
||||||
|
const { api } = register()
|
||||||
|
|
||||||
|
const leases = api.record.eventLeases
|
||||||
|
const sources = api.record.eventOptionSources
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
leases.map((l) => l.id).sort(),
|
||||||
|
['rust.decay.scale', 'rust.group.permission', 'rust.population', 'rust.spawn.scalar'],
|
||||||
|
)
|
||||||
|
|
||||||
|
// D78: exactly the sources the leases' targets name — none without a reader.
|
||||||
|
const read = new Set(leases.map((l) => l.target.source))
|
||||||
|
assert.deepStrictEqual([...read].sort(), sources.map((s) => s.id).sort())
|
||||||
|
|
||||||
|
for (const l of leases) {
|
||||||
|
// D73: every lease is targeted, because the target is what names the server.
|
||||||
|
assert.ok(l.target && l.target.label && l.target.example, `${l.id} has no target`)
|
||||||
|
for (const fn of ['read', 'apply', 'restore', 'inForce']) {
|
||||||
|
assert.strictEqual(typeof l[fn], 'function', `${l.id} has no ${fn}()`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('the engagement set is registered as one decision (phase 10, R7)', () => {
|
test('the engagement set is registered as one decision (phase 10, R7)', () => {
|
||||||
const { api } = register()
|
const { api } = register()
|
||||||
|
|
||||||
|
|||||||
329
server/test/leases.test.js
Normal file
329
server/test/leases.test.js
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
// ── The leases (PLAN.md §27, protocol 8) ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// Core owns the lease: it reads the baseline, reserves the target, applies the
|
||||||
|
// value and restores it. What this module owns is four callables per lease, and
|
||||||
|
// every test here is one of the ways those can be subtly wrong while looking
|
||||||
|
// right:
|
||||||
|
//
|
||||||
|
// the target must name the server, or two servers share one holder (D73)
|
||||||
|
// a duration crosses the wire, not a deadline, or two clocks disagree
|
||||||
|
// drift is an ANSWER, not a failure of the call
|
||||||
|
// "still held" is read from the holds, never inferred from a changed value
|
||||||
|
// a key already held reads as its BASELINE, or teardown restores the event
|
||||||
|
// an apply this end gave up on is followed by a release
|
||||||
|
// two lease calls fit inside core.lease's budget, asserted not trusted
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert')
|
||||||
|
|
||||||
|
const { fakeCtx } = require('./_fakes')
|
||||||
|
|
||||||
|
require('../core')._reset()
|
||||||
|
require('../core').init(fakeCtx())
|
||||||
|
|
||||||
|
const client = require('../sidecarClient')
|
||||||
|
const serversDb = require('../model/servers/servers.db')
|
||||||
|
const servers = require('../model/servers/servers.model')
|
||||||
|
const { LEASES, OPTION_SOURCES, MAX_LEASE_MS, splitTarget } = require('../eventLeases')
|
||||||
|
|
||||||
|
const byId = (id) => LEASES.find((l) => l.id === id)
|
||||||
|
const source = (id) => OPTION_SOURCES.find((s) => s.id === id)
|
||||||
|
|
||||||
|
const ROWS = {
|
||||||
|
main: { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 },
|
||||||
|
off: { id: 'off', name: 'Off', sidecarBaseUrl: 'http://off:1', sidecarTokenEnc: null, enabled: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the module's collaborators for one test, and put them back after. */
|
||||||
|
function stub(t, { list, apply, release, catalogue, polling } = {}) {
|
||||||
|
const calls = { list: [], apply: [], release: [], catalogue: [] }
|
||||||
|
const saved = {
|
||||||
|
getServer: serversDb.getServer,
|
||||||
|
listForPolling: servers.listForPolling,
|
||||||
|
leaseList: client.leaseList,
|
||||||
|
leaseApply: client.leaseApply,
|
||||||
|
leaseRelease: client.leaseRelease,
|
||||||
|
permCatalogue: client.permCatalogue,
|
||||||
|
}
|
||||||
|
|
||||||
|
serversDb.getServer = async (id) => ROWS[id] || null
|
||||||
|
servers.listForPolling = async () =>
|
||||||
|
(polling || ['main']).map((id) => ({ id, name: ROWS[id] ? ROWS[id].name : id, baseUrl: `http://${id}:1`, token: 't' }))
|
||||||
|
client.leaseList = async (server, q) => {
|
||||||
|
calls.list.push({ server: server.id, ...q })
|
||||||
|
return list ? list(server, q) : { ok: false, status: 'http-503' }
|
||||||
|
}
|
||||||
|
client.leaseApply = async (server, body) => {
|
||||||
|
calls.apply.push({ server: server.id, body })
|
||||||
|
return apply ? apply(server, body) : { ok: true, data: { kind: 'lease.ok' } }
|
||||||
|
}
|
||||||
|
client.leaseRelease = async (server, body) => {
|
||||||
|
calls.release.push({ server: server.id, body })
|
||||||
|
return release ? release(server, body) : { ok: true, data: { kind: 'lease.ok' } }
|
||||||
|
}
|
||||||
|
client.permCatalogue = async (server) => {
|
||||||
|
calls.catalogue.push(server.id)
|
||||||
|
return catalogue ? catalogue(server) : { ok: false, status: 'http-503' }
|
||||||
|
}
|
||||||
|
|
||||||
|
t.after(() => {
|
||||||
|
serversDb.getServer = saved.getServer
|
||||||
|
servers.listForPolling = saved.listForPolling
|
||||||
|
client.leaseList = saved.leaseList
|
||||||
|
client.leaseApply = saved.leaseApply
|
||||||
|
client.leaseRelease = saved.leaseRelease
|
||||||
|
client.permCatalogue = saved.permCatalogue
|
||||||
|
})
|
||||||
|
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
||||||
|
test('two lease calls fit inside the budget core.lease runs under', () => {
|
||||||
|
// `core.lease` declares no budgetMs, so the dispatcher's 10s default applies,
|
||||||
|
// and it spends it on read() THEN apply(). At the client's ordinary 12s either
|
||||||
|
// one alone would outlast the step, and the dispatcher would call a retry
|
||||||
|
// while this module was still waiting — the ordering sidecarClient's header
|
||||||
|
// exists to forbid.
|
||||||
|
assert.ok(2 * client.LEASE_TIMEOUT_MS < client.CORE_LEASE_BUDGET_MS)
|
||||||
|
assert.ok(client.LEASE_TIMEOUT_MS < client.TIMEOUT_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every lease may be held for seven days and no longer (D77)', () => {
|
||||||
|
assert.strictEqual(MAX_LEASE_MS, 7 * 24 * 60 * 60 * 1000)
|
||||||
|
for (const l of LEASES) assert.strictEqual(l.maxDurationMs, MAX_LEASE_MS, l.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a target splits at the FIRST slash, so a group name may contain one', () => {
|
||||||
|
assert.deepStrictEqual(splitTarget('main'), { serverId: 'main', rest: '' })
|
||||||
|
assert.deepStrictEqual(splitTarget('main/bear.population'), { serverId: 'main', rest: 'bear.population' })
|
||||||
|
assert.deepStrictEqual(splitTarget('main/a/b/kits.vip'), { serverId: 'main', rest: 'a/b/kits.vip' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a target naming no server, or a switched-off one, is refused for good', async (t) => {
|
||||||
|
stub(t)
|
||||||
|
const decay = byId('rust.decay.scale')
|
||||||
|
|
||||||
|
for (const target of ['', 'nowhere', 'off']) {
|
||||||
|
const answer = await decay.read({ target })
|
||||||
|
assert.strictEqual(answer.ok, false, target)
|
||||||
|
assert.strictEqual(answer.retry, false, `${target}: the second attempt carries the same params`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the decay rate takes only a server, and a population needs one named', async (t) => {
|
||||||
|
stub(t)
|
||||||
|
const decay = await byId('rust.decay.scale').read({ target: 'main/bear.population' })
|
||||||
|
assert.strictEqual(decay.ok, false)
|
||||||
|
assert.strictEqual(decay.retry, false)
|
||||||
|
|
||||||
|
const pop = await byId('rust.population').read({ target: 'main' })
|
||||||
|
assert.strictEqual(pop.ok, false)
|
||||||
|
assert.strictEqual(pop.retry, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('read asks the plugin about one key on the named server, and answers the current value as text', async (t) => {
|
||||||
|
const calls = stub(t, {
|
||||||
|
list: () => ({ ok: true, data: { leases: [{ key: 'bear.population', family: 'population', current: '2', held: false }], holds: [] } }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const answer = await byId('rust.population').read({ target: 'main/Bear.Population' })
|
||||||
|
assert.deepStrictEqual(answer, { ok: true, value: '2' })
|
||||||
|
assert.deepStrictEqual(calls.list, [{ server: 'main', key: 'bear.population', target: undefined }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a key from another family is refused even when the plugin lends it', async (t) => {
|
||||||
|
stub(t, {
|
||||||
|
list: () => ({ ok: true, data: { leases: [{ key: 'spawn.max_rate', family: 'spawn', current: '1' }] } }),
|
||||||
|
})
|
||||||
|
const answer = await byId('rust.population').read({ target: 'main/spawn.max_rate' })
|
||||||
|
assert.strictEqual(answer.ok, false)
|
||||||
|
assert.strictEqual(answer.retry, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a key the plugin already holds reads as its BASELINE, not the value the lease put there', async (t) => {
|
||||||
|
// Core's reservation stops a second run reaching read(). A hold core does not
|
||||||
|
// know about is this run's first attempt with its answer lost, and the value
|
||||||
|
// to give back at the end is what was there before anybody borrowed it.
|
||||||
|
stub(t, {
|
||||||
|
list: () => ({
|
||||||
|
ok: true,
|
||||||
|
data: { leases: [{ key: 'decay.scale', family: 'decay', current: '0', held: true, baseline: '1', applied: '0' }] },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const answer = await byId('rust.decay.scale').read({ target: 'main' })
|
||||||
|
assert.deepStrictEqual(answer, { ok: true, value: '1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('apply sends a DURATION, the family, and the value as text', async (t) => {
|
||||||
|
const calls = stub(t)
|
||||||
|
const until = new Date(Date.now() + 60 * 60 * 1000)
|
||||||
|
|
||||||
|
const answer = await byId('rust.population').apply(4, until, { target: 'main/bear.population' })
|
||||||
|
assert.deepStrictEqual(answer, { ok: true })
|
||||||
|
|
||||||
|
const { body } = calls.apply[0]
|
||||||
|
assert.strictEqual(body.key, 'bear.population')
|
||||||
|
assert.strictEqual(body.family, 'population')
|
||||||
|
assert.strictEqual(body.value, '4')
|
||||||
|
assert.strictEqual(body.untilMs, until.getTime())
|
||||||
|
// holdMs is what the plugin arms its timer with; it must be the remaining
|
||||||
|
// duration, measured here, and never the absolute time.
|
||||||
|
assert.ok(body.holdMs > 59 * 60 * 1000 && body.holdMs <= 60 * 60 * 1000, String(body.holdMs))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a group permission crosses as a key and a lowered group/permission target', async (t) => {
|
||||||
|
const calls = stub(t)
|
||||||
|
await byId('rust.group.permission').apply(true, new Date(Date.now() + 60000), { target: 'main/Default/Kits.VIP' })
|
||||||
|
|
||||||
|
const { body } = calls.apply[0]
|
||||||
|
assert.strictEqual(body.key, 'group.permission')
|
||||||
|
assert.strictEqual(body.target, 'default/kits.vip')
|
||||||
|
assert.strictEqual(body.value, 'true')
|
||||||
|
assert.strictEqual(body.family, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('events switched off on the server is a refusal with the switch named, for good (D76)', async (t) => {
|
||||||
|
stub(t, {
|
||||||
|
apply: () => ({
|
||||||
|
ok: true,
|
||||||
|
data: { kind: 'lease.error', reason: 'events-disabled', message: 'events are switched off on this server — set EventsEnabled' },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const answer = await byId('rust.decay.scale').apply(0, new Date(Date.now() + 60000), { target: 'main' })
|
||||||
|
assert.strictEqual(answer.ok, false)
|
||||||
|
assert.strictEqual(answer.retry, false)
|
||||||
|
assert.match(answer.error, /EventsEnabled/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an apply this end gave up on is followed by a release of the same value', async (t) => {
|
||||||
|
const calls = stub(t, { apply: () => ({ ok: false, status: 'timeout' }) })
|
||||||
|
|
||||||
|
const answer = await byId('rust.decay.scale').apply(0, new Date(Date.now() + 60000), { target: 'main' })
|
||||||
|
assert.strictEqual(answer.ok, false)
|
||||||
|
assert.match(answer.error, /Main/, 'every notice says which server')
|
||||||
|
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
assert.strictEqual(calls.release.length, 1)
|
||||||
|
assert.deepStrictEqual(calls.release[0].body, { key: 'decay.scale', target: undefined, expected: '0' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an apply that failed for any other reason sends no release', async (t) => {
|
||||||
|
const calls = stub(t, { apply: () => ({ ok: false, status: 'http-503' }) })
|
||||||
|
await byId('rust.decay.scale').apply(0, new Date(Date.now() + 60000), { target: 'main' })
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
assert.strictEqual(calls.release.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an apply whose deadline has already passed is refused before anything is sent', async (t) => {
|
||||||
|
const calls = stub(t)
|
||||||
|
const answer = await byId('rust.decay.scale').apply(0, new Date(Date.now() - 1000), { target: 'main' })
|
||||||
|
assert.strictEqual(answer.ok, false)
|
||||||
|
assert.strictEqual(calls.apply.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drift is an answer with the current value beside it, not a failed call', async (t) => {
|
||||||
|
const calls = stub(t, { release: () => ({ ok: true, data: { kind: 'lease.drifted', current: '3' } }) })
|
||||||
|
|
||||||
|
const answer = await byId('rust.decay.scale').restore(1, { expected: 0, target: 'main' })
|
||||||
|
assert.deepStrictEqual(answer, { ok: false, drifted: true, current: '3' })
|
||||||
|
assert.deepStrictEqual(calls.release[0].body, { key: 'decay.scale', expected: '0', baseline: '1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a release the plugin accepted — including a group that is gone — is a success', async (t) => {
|
||||||
|
stub(t, { release: () => ({ ok: true, data: { kind: 'lease.ok', targetGone: true } }) })
|
||||||
|
const answer = await byId('rust.group.permission').restore(false, { expected: true, target: 'main/vip/kits.vip' })
|
||||||
|
assert.deepStrictEqual(answer, { ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a release that could not be made is a failure core will ask again about', async (t) => {
|
||||||
|
stub(t, { release: () => ({ ok: false, status: 'http-503' }) })
|
||||||
|
const answer = await byId('rust.decay.scale').restore(1, { expected: 0, target: 'main' })
|
||||||
|
assert.strictEqual(answer.ok, false)
|
||||||
|
assert.strictEqual(answer.drifted, undefined)
|
||||||
|
assert.strictEqual(answer.retry, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('"still held" is read from the holds, never from a value that changed', async (t) => {
|
||||||
|
// The current value differs from anything a lease applied — somebody moved it.
|
||||||
|
// That is DRIFT, for restore() to report; inForce must still say the hold is
|
||||||
|
// there, or core orphans the row before restore ever gets to say so.
|
||||||
|
stub(t, {
|
||||||
|
list: () => ({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
leases: [{ key: 'decay.scale', family: 'decay', current: '7', held: true }],
|
||||||
|
holds: [{ key: 'decay.scale', applied: '0', baseline: '1' }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
assert.deepStrictEqual(await byId('rust.decay.scale').inForce({ target: 'main' }), { ok: true, held: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('after a restart the plugin holds nothing, and inForce says so', async (t) => {
|
||||||
|
stub(t, { list: () => ({ ok: true, data: { leases: [{ key: 'decay.scale', family: 'decay', current: '1' }], holds: [] } }) })
|
||||||
|
assert.deepStrictEqual(await byId('rust.decay.scale').inForce({ target: 'main' }), { ok: true, held: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inForce that cannot reach the game is not an answer', async (t) => {
|
||||||
|
stub(t)
|
||||||
|
const answer = await byId('rust.decay.scale').inForce({ target: 'main' })
|
||||||
|
assert.strictEqual(answer.ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a group permission hold is matched on its own target', async (t) => {
|
||||||
|
stub(t, {
|
||||||
|
list: () => ({ ok: true, data: { holds: [{ key: 'group.permission', target: 'default/kits.vip' }] } }),
|
||||||
|
})
|
||||||
|
const lease = byId('rust.group.permission')
|
||||||
|
assert.deepStrictEqual(await lease.inForce({ target: 'main/default/kits.vip' }), { ok: true, held: true })
|
||||||
|
assert.deepStrictEqual(await lease.inForce({ target: 'main/default/kits.gold' }), { ok: true, held: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the servers source is every enabled server, with no game call', async (t) => {
|
||||||
|
const calls = stub(t, { polling: ['main', 'creative'] })
|
||||||
|
const rows = await source('rust.options.servers').resolve()
|
||||||
|
assert.deepStrictEqual(rows.map((r) => r.value), ['main', 'creative'])
|
||||||
|
assert.strictEqual(calls.list.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a family source lists whole targets, and one silent server blanks nothing', async (t) => {
|
||||||
|
stub(t, {
|
||||||
|
polling: ['main', 'creative'],
|
||||||
|
list: (server) =>
|
||||||
|
server.id === 'creative'
|
||||||
|
? { ok: false, status: 'timeout' }
|
||||||
|
: {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
leases: [
|
||||||
|
{ key: 'decay.scale', family: 'decay', current: '1' },
|
||||||
|
{ key: 'bear.population', family: 'population', current: '2' },
|
||||||
|
{ key: 'zombie.population', family: 'population', unreadable: 'this server has no convar' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = await source('rust.options.populations').resolve()
|
||||||
|
assert.deepStrictEqual(rows, [{ value: 'main/bear.population', label: 'bear.population', group: 'Main' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the group-permission source is searchable and narrows by the term', async (t) => {
|
||||||
|
stub(t, {
|
||||||
|
catalogue: () => ({
|
||||||
|
ok: true,
|
||||||
|
data: { permissions: ['kits.vip', 'Kits.Gold', 'zonemanager.admin'], groups: [{ name: 'default' }, { name: 'VIP' }] },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const src = source('rust.options.grouppermissions')
|
||||||
|
assert.strictEqual(src.searchable, true)
|
||||||
|
|
||||||
|
const all = await src.resolve({ q: '' })
|
||||||
|
assert.strictEqual(all.length, 6)
|
||||||
|
|
||||||
|
const narrowed = await src.resolve({ q: 'kits' })
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
narrowed.map((r) => r.value).sort(),
|
||||||
|
['main/default/kits.gold', 'main/default/kits.vip', 'main/vip/kits.gold', 'main/vip/kits.vip'],
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user