// ── 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 `#` 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 `#`. 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, }