From 3aa142bf06ba496c9f49d4f90dce2b6b27fe8c20 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 21 Oct 2026 07:42:24 -0500 Subject: [PATCH] fix(rust): the last fixes' small ones (W3 W6 W7/W11 W8 W10 W13-W18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of PLAN_LAST_FIXES (docs#366). - W7/W11: an "is it loaded" refusal is confirmed live before it is given. New liveStatus.js asks the sidecar once (boot.refreshOne, as a poll tick would), at most once per server per 5 s and waiting at most 3 s, then reads the stored status again. Used by Economics (step and staff page), Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes, TruePVE and RunicNPC. - W3: a world call answered `already-out` (the bridge half comes in stage 3) succeeds as called 0, saying a Travelling Vendor is already out. - W6: "A locked crate dropped by a Chinook is being hacked." - W8: a RaidableBases profile's audit row names RaidableBases, not "@data". - W10: the backpack sizes picker lists each size once, naming its servers. - W13: a RaidableBases name both servers describe differently shows each server's own description. - W14: each RaidableBases note after the first starts its own sentence. - W15: a staff deposit refused at Economics' limit answers 409. - W16: a payment's failedNote explains only the reasons that happened. - W17: the sync state counts player grants that did not land (waitingToLand), and the Permissions page says "in sync · 1 waits for their player to join" instead of plain "in sync". - W18: Guarded Crate's kept note drops the "30 minutes" it could not know. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- ci/bundle.json | 1 + client/src/lib/feed.js | 2 +- client/src/routes/admin/Permissions.jsx | 8 +- client/test/feed.test.js | 2 +- server/eventDangerousTreasures.js | 4 +- server/eventGuardedCrate.js | 12 +- server/eventRaidableBases.js | 30 ++++- server/eventRewards.js | 40 +++++-- server/eventWorld.js | 15 ++- server/eventWorldCall.js | 11 ++ server/liveStatus.js | 95 +++++++++++++++ server/model/config/config.model.js | 16 ++- server/model/economics/economics.model.js | 8 +- server/model/permissions/permissions.model.js | 8 ++ server/router/admin/config.controller.js | 6 +- server/router/admin/usersRust.router.js | 2 +- server/swagger/doc.js | 5 + server/test/backpacks.test.js | 19 ++- server/test/config.test.js | 9 ++ server/test/dangerousTreasures.test.js | 4 + server/test/economics.test.js | 21 ++++ server/test/guardedCrate.test.js | 4 + server/test/liveStatus.test.js | 113 ++++++++++++++++++ server/test/npcs.test.js | 4 + server/test/permissions.test.js | 19 +++ server/test/raidableBases.test.js | 28 ++++- server/test/worldEvents.test.js | 11 ++ server/test/zones.test.js | 4 + swagger-fragment.json | 19 ++- 29 files changed, 479 insertions(+), 41 deletions(-) create mode 100644 server/liveStatus.js create mode 100644 server/test/liveStatus.test.js diff --git a/ci/bundle.json b/ci/bundle.json index bf9dfba..6112661 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -50,6 +50,7 @@ "guardedCrate.js", "index.js", "ingest.js", + "liveStatus.js", "mapImages.js", "mapLive.js", "model", diff --git a/client/src/lib/feed.js b/client/src/lib/feed.js index 5f5f12c..da09b8a 100644 --- a/client/src/lib/feed.js +++ b/client/src/lib/feed.js @@ -319,7 +319,7 @@ function worldLine(frame) { case 'crate.hack.started': return who ? { tone: 'server', actor: who, verb: `started hacking the locked crate ${place}`, subject: null, detail: grid } - : say(`A locked crate is being hacked ${place}`) + : say(`A locked crate ${place} is being hacked`) case 'crate.unlocked': return say(`The locked crate ${place} is open`) case 'crate.looted': diff --git a/client/src/routes/admin/Permissions.jsx b/client/src/routes/admin/Permissions.jsx index 7aed7a1..c76d486 100644 --- a/client/src/routes/admin/Permissions.jsx +++ b/client/src/routes/admin/Permissions.jsx @@ -781,7 +781,13 @@ export default function Permissions() { {sync && ( - sync.inSync ? in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''} + sync.inSync && sync.waitingToLand > 0 + ? ( + + in sync · {sync.waitingToLand} {sync.waitingToLand === 1 ? 'waits' : 'wait'} for their player to join + + ) + : sync.inSync ? in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''} : sync.state === 'failed' ? last sync failed : waiting for a sync )} diff --git a/client/test/feed.test.js b/client/test/feed.test.js index ea76202..b17233e 100644 --- a/client/test/feed.test.js +++ b/client/test/feed.test.js @@ -178,7 +178,7 @@ test('below the presence audience the same line names nobody (D376)', () => { const hack = describe(world({ event: 'crate.hack.started', where: 'oilrig_large', grid: 'C3' })) assert.equal(hack.actor, null) - assert.equal(hack.verb, 'A locked crate is being hacked on Large Oil Rig') + assert.equal(hack.verb, 'A locked crate on Large Oil Rig is being hacked') }) test('a named line reads as the plan writes it', () => { diff --git a/server/eventDangerousTreasures.js b/server/eventDangerousTreasures.js index c455573..69aa297 100644 --- a/server/eventDangerousTreasures.js +++ b/server/eventDangerousTreasures.js @@ -31,6 +31,7 @@ const { serverFor, transportError, pluginError } = require('./eventLeases') const { BUDGET_MS } = require('./eventWorld') const { gridOf } = require('./worldEvents') const { LEVELS, reportOf } = require('./dangerousTreasures') +const live = require('./liveStatus') const serversDb = require('./model/servers/servers.db') const mapDb = require('./model/map/map.db') @@ -131,7 +132,8 @@ const dtStart = { if (!found.ok) return found const name = found.server.name || found.server.id - const missing = missingOn(reportOf(await serversDb.dtReportFor(found.server.id)), name) + // Confirmed live before it refuses: the stored status lags a restart (W7). + const missing = await live.confirmed(found.server, async () => missingOn(reportOf(await serversDb.dtReportFor(found.server.id)), name), Boolean) if (missing) return { ok: false, retry: false, error: missing } if (verify) return { ok: true } diff --git a/server/eventGuardedCrate.js b/server/eventGuardedCrate.js index 1e2ef5a..a1b4747 100644 --- a/server/eventGuardedCrate.js +++ b/server/eventGuardedCrate.js @@ -26,14 +26,19 @@ const { serverFor, transportError, pluginError } = require('./eventLeases') const { BUDGET_MS, location } = require('./eventWorld') const { gridOf } = require('./worldEvents') const { reportOf } = require('./guardedCrate') +const live = require('./liveStatus') const serversDb = require('./model/servers/servers.db') const mapDb = require('./model/map/map.db') /** Guarded Crate's events as it ships them, offered when no server has said its own. */ const DEFAULT_EVENTS = Object.freeze(['Easy', 'Medium', 'Hard', 'Elite']) -/** What the run log says of a crate the run leaves behind (D373, D380). */ -const KEPT = 'kept when the run ends: it ends when its last guard dies, or when nobody has killed one for its time limit (30 minutes as Guarded Crate ships)' +/** + * What the run log says of a crate the run leaves behind (D373, D380). No + * number: each of Guarded Crate's events has its own time limit, and the + * server's report names the events but not their limits (W18). + */ +const KEPT = "kept when the run ends: it ends when its last guard dies, or when nobody has killed one within its event's time limit in Guarded Crate" /** The plugin's refusals a second attempt would repeat. `gc-too-close` is not one: the other crate may be cleared. */ const PERMANENT = new Set([ @@ -154,7 +159,8 @@ const gcPlace = { if (!found.ok) return found const name = found.server.name || found.server.id - const missing = missingOn(reportOf(await serversDb.gcReportFor(found.server.id)), name, wanted) + // Confirmed live before it refuses: the stored status lags a restart (W7). + const missing = await live.confirmed(found.server, async () => missingOn(reportOf(await serversDb.gcReportFor(found.server.id)), name, wanted), Boolean) if (missing) return { ok: false, retry: false, error: missing } if (verify) return { ok: true } diff --git a/server/eventRaidableBases.js b/server/eventRaidableBases.js index 4787cdc..012ff1d 100644 --- a/server/eventRaidableBases.js +++ b/server/eventRaidableBases.js @@ -43,6 +43,7 @@ const { serverFor, transportError, pluginError } = require('./eventLeases') const { BUDGET_MS, location, revert, reconcile } = require('./eventWorld') const { gridOf } = require('./worldEvents') const { reportOf, helperPatched } = require('./raidableBases') +const live = require('./liveStatus') const serversDb = require('./model/servers/servers.db') const mapDb = require('./model/map/map.db') @@ -107,10 +108,19 @@ function byValue(entries) { const out = new Map() for (const { value, label, server } of entries) { const key = value.toLowerCase() - if (!out.has(key)) out.set(key, { value, label, servers: [] }) - if (!out.get(key).servers.includes(server)) out.get(key).servers.push(server) + if (!out.has(key)) out.set(key, { value, labels: new Map() }) + // Labels compare ignoring case, as the names do. + const labels = out.get(key).labels + const said = label.toLowerCase() + if (!labels.has(said)) labels.set(said, { label, servers: [] }) + if (!labels.get(said).servers.includes(server)) labels.get(said).servers.push(server) } - return [...out.values()].map((e) => ({ value: e.value, label: `${e.label} — ${e.servers.join(', ')}` })) + // One name, one row. Where servers describe it differently (a profile Hard on + // one and Expert on another), each server's own words, not the first's (W13). + return [...out.values()].map((e) => ({ + value: e.value, + label: [...e.labels.values()].map(({ label, servers }) => `${label} — ${servers.join(', ')}`).join('; '), + })) } const OPTION_SOURCES = [ @@ -371,8 +381,15 @@ const raidSpawn = { if (!found.ok) return found const name = found.server.name || found.server.id - const report = reportOf(await serversDb.rbReportFor(found.server.id).catch(() => null)) - const missing = missingOn(report, name, parsed.wire, parsed.spot) + // Confirmed live before it refuses: the stored status lags a restart (W7). + const { report, missing } = await live.confirmed( + found.server, + async () => { + const read = reportOf(await serversDb.rbReportFor(found.server.id).catch(() => null)) + return { report: read, missing: missingOn(read, name, parsed.wire, parsed.spot) } + }, + (r) => Boolean(r.missing), + ) if (missing) return { ok: false, retry: false, error: missing } if (verify) { @@ -421,7 +438,8 @@ const raidSpawn = { if (data.repeat) notes.push('answered from the first attempt; nothing new was spawned') if (data.x === undefined) notes.push('RaidableBases picks the spot; the map shows it once the base opens') if (data.despawns !== 'teardown') notes.push(OWN_TIMER) - if (notes.length) detail.note = notes.join('. ') + // Each note after the first starts a sentence (W14). + if (notes.length) detail.note = notes.map((n, i) => (i ? n[0].toUpperCase() + n.slice(1) : n)).join('. ') // Only a base the helper made the run's is a resource teardown can take back. if (!data.id) return { ok: true, detail } diff --git a/server/eventRewards.js b/server/eventRewards.js index 9e979da..9ead587 100644 --- a/server/eventRewards.js +++ b/server/eventRewards.js @@ -47,6 +47,7 @@ const linksDb = require('./model/links/links.db') const emit = require('./engagement/emit') const economics = require('./model/economics/economics.model') const backpacks = require('./model/backpacks/backpacks.model') +const live = require('./liveStatus') const voice = require('./model/permissions/voice') const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases') const { BUDGET_MS } = require('./eventWorld') @@ -741,6 +742,22 @@ function coinsNotice(params, amount) { return { ok: true, text, delivery } } +/** What each reason in a payment's `failed` list means, in the plugin's order. */ +const FAILED_REASONS = Object.freeze({ + 'at-limit': "the balance is at Economics' limit", + refused: 'Economics did not take the deposit', + unmeasured: 'Economics did not report the deposit', +}) + +/** A payment's `failedNote`: only the reasons that happened, each explained (W16). */ +function failedNote(failed) { + const seen = new Set(failed.map((row) => row.reason)) + return [...Object.keys(FAILED_REASONS), ...[...seen].filter((r) => !FAILED_REASONS[r])] + .filter((reason) => seen.has(reason)) + .map((reason) => (FAILED_REASONS[reason] ? `${reason}: ${FAILED_REASONS[reason]}` : `${reason}: Economics' own reason`)) + .join('; ') +} + /** The key the plugin remembers a payment by: core's, or a digest of it past the plugin's bound. */ function paymentKey(idempotencyKey, runId, stepId) { const key = String(idempotencyKey || `coins:${runId}:${stepId}`) @@ -806,7 +823,8 @@ const coinsPay = { // D199: refused with the reason where Economics is absent, in the dry run // too — the author learns it on the form, not at the run's reward. - const econ = await economics.forServer(server.id) + // Confirmed live before it refuses: the stored status lags a restart (W7). + const econ = await live.confirmed(server, () => economics.forServer(server.id), (e) => !e.loaded) if (!econ.loaded) return { ok: false, retry: false, error: `${economics.MISSING} (${name})` } if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) { @@ -906,7 +924,7 @@ const coinsPay = { paid: paid.length, total, received: paid.slice(0, 50), - ...(failed.length ? { failed: failed.slice(0, 50), failedNote: 'at-limit: the balance is at Economics\' limit; refused or unmeasured: Economics did not take or report the deposit' } : {}), + ...(failed.length ? { failed: failed.slice(0, 50), failedNote: failedNote(failed) } : {}), ...(notice.text && paid.length ? { told: `${paid.length} will be told in game, by ${notice.delivery}` } : {}), ...(data.repeat ? { repeat: true, repeatNote: 'answered from the first attempt; nobody was paid twice' } : {}), ...missedDetail, @@ -995,7 +1013,8 @@ const backpackSize = { // D199: refused with the reason where Backpacks is absent, in the dry run // too, and a size the server does not offer is refused by name. - const report = await backpacks.forServer(server.id) + // Confirmed live before it refuses: the stored status lags a restart (W7). + const report = await live.confirmed(server, () => backpacks.forServer(server.id), (r) => !r.loaded) if (!report.loaded) return { ok: false, retry: false, error: `${backpacks.MISSING} (${name})` } const offered = backpacks.leasable(report) if (!offered.includes(size)) { @@ -1356,17 +1375,23 @@ const OPTION_SOURCES = [ ]), { // From each server's last hello: the sizes its Backpacks offers above the - // smallest, grouped by server (D337). The step checks the pair it is given. + // smallest (D337). Each size once, naming every server that offers it, as + // the other pickers merge a value (W10). The step checks the pair it is given. id: 'rust.options.backpacksizes', label: 'Backpack sizes', description: "Each server's Backpacks sizes above its smallest — its backpacks.size.N permissions.", async resolve() { const [list, reports] = await Promise.all([servers.listForPolling(), backpacks.byServer()]) - const rows = [] + const bySize = new Map() for (const s of list) { - for (const n of backpacks.leasable(reports.get(s.id))) rows.push({ value: n, label: `${n} slots`, group: s.name || s.id }) + for (const n of backpacks.leasable(reports.get(s.id))) { + if (!bySize.has(n)) bySize.set(n, []) + bySize.get(n).push(s.name || s.id) + } } - return rows + return [...bySize.keys()] + .sort((x, y) => x - y) + .map((n) => ({ value: n, label: `${n} slots — ${bySize.get(n).join(', ')}` })) }, }, { @@ -1398,6 +1423,7 @@ module.exports = { usersFor, readTally, entitlementRef, + failedNote, pickRecipients, checkCount, splitKit, diff --git a/server/eventWorld.js b/server/eventWorld.js index fb32a4e..1a55c87 100644 --- a/server/eventWorld.js +++ b/server/eventWorld.js @@ -36,6 +36,7 @@ const npcProfile = require('./model/npcs/npcProfile') const clansDb = require('./model/clans/clans.db') const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases') const eventProgress = require('./eventProgress') +const live = require('./liveStatus') const log = core.logger('world') @@ -690,8 +691,12 @@ function npcOrders(params, server) { * What the server itself holds is the plugin's to answer (`unknown-profile`). */ async function npcMissing(server, profile) { - const [list, profiles] = await Promise.all([npcsDb.listNpcServers(), profile === null ? [] : npcsDb.listProfiles()]) - const here = list.find((s) => s.id === server.id) + const hereNow = async () => (await npcsDb.listNpcServers()).find((s) => s.id === server.id) + // Confirmed live before it refuses: the stored status lags a restart (W11). + const [here, profiles] = await Promise.all([ + live.confirmed(server, hereNow, (s) => !npcs.npcReady(s)), + profile === null ? [] : npcsDb.listProfiles(), + ]) const name = server.name || server.id if (!npcs.npcReady(here)) return `${name} needs RunicNPC to place NPCs, Rust's own scientists included: ${npcs.npcAbsence(here)}` if (profile === null) return null @@ -794,7 +799,8 @@ const ACTIONS = [ // §3.1: the flags and settings, checked against the flag list this server's // last hello carried (D211). A server that has not said is left to the // plugin, which checks every one against its live ZoneManager. - const mods = await zones.serverZoneMods(found.server.id) + const reread = () => zones.serverZoneMods(found.server.id) + let mods = await reread() const known = mods && mods.zoneManager && mods.zoneManager.flags && mods.zoneManager.flags.length ? mods.zoneManager.flags : null const opts = zoneOptions.parse(params.options, known) if (!opts.ok) return { ok: false, retry: false, error: opts.error } @@ -821,12 +827,15 @@ const ACTIONS = [ if (Number.isNaN(stack) || (stack !== undefined && (!Number.isInteger(stack) || stack < 1 || stack > DOME_STACK_MAX))) { return { ok: false, retry: false, error: `a dome stacks 1 to ${DOME_STACK_MAX} spheres, not "${params.domeStack}"` } } + // Confirmed live before it refuses: the stored status lags a restart (W11). + if (domeMissing(mods)) mods = await live.confirmed(found.server, reread, (m) => Boolean(domeMissing(m))) const missing = domeMissing(mods) if (missing) return { ok: false, retry: false, error: missing } dome = { type: kind.type, stack: stack === undefined ? DOME_STACK_DEFAULT : stack } } // §14.3.2: TruePVE's rules in the zone, where the server said it has TruePVE. + if (!truePveMapping(params, mods).ok) mods = await live.confirmed(found.server, reread, (m) => !truePveMapping(params, m).ok) const truePve = truePveMapping(params, mods) if (!truePve.ok) return { ok: false, retry: false, error: truePve.error } diff --git a/server/eventWorldCall.js b/server/eventWorldCall.js index 394a443..e52c8ee 100644 --- a/server/eventWorldCall.js +++ b/server/eventWorldCall.js @@ -154,6 +154,17 @@ const worldCall = { } const data = result.data || {} + + // The game runs one Travelling Vendor at a time and quietly starts nothing + // while one is out. The plugin looks first and says so (W3). Not a failure: + // what the step asked for is on the map. + if (data.reason === 'already-out') { + return { + ok: true, + detail: { event: event.value, called: 0, note: `a ${event.label} is already out on ${found.server.name || found.server.id}, and the game runs one at a time; nothing new was called` }, + } + } + if (data.kind !== 'world.called') { return { ok: false, diff --git a/server/liveStatus.js b/server/liveStatus.js new file mode 100644 index 0000000..52d14a9 --- /dev/null +++ b/server/liveStatus.js @@ -0,0 +1,95 @@ +// ── Asking a server how it is NOW, before saying no (W7, W11) ────────────── +// +// Every "is it loaded" refusal — Economics (its step and the staff page), +// Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes, +// TruePVE, RunicNPC, and the console probe — reads the status the board poll +// stored, and the poll runs every 30 seconds +// (`boot.REFRESH_MS`). Right after a restart, or a plugin loaded by hand, the +// stored status still says no for up to a minute while the game already says +// yes. +// +// So a refusal is CONFIRMED before it is given: the step asks the sidecar once +// more, writes what it said exactly as a poll tick would, and reads the stored +// status again. Only a "no" that survives that is a refusal. A "yes" is never +// re-asked: a plugin unloaded since the last poll still passes the dry run, and +// the plugin itself refuses at the run, as it always has. +// +// One ask per server at a time, and none within `FRESH_MS` of the last, so a +// form's dry run of ten steps against one server asks once. + +const core = require('./core') + +const log = core.logger('live-status') + +/** A status asked for this recently is as fresh as asking again. */ +const FRESH_MS = 5 * 1000 + +/** + * How long a step waits for the ask. `/boards` and `/health` are the sidecar's + * own, no game round trip, so a sidecar that is up answers well inside it. The + * bound keeps the ask plus the step's own call inside the step's `budgetMs` + * (15 s) when a sidecar is slow; past it the stored status stands, and the ask + * still finishes and writes, as a poll tick would. + */ +const ASK_MS = 3 * 1000 + +const inFlight = new Map() +const askedAt = new Map() + +/** + * Ask `server`'s sidecar now and store the answer, as a board poll does. + * Resolves true once the stored status is current, false when it could not be + * asked (the stored status stands). Never rejects. + */ +function refresh(server) { + if (!server || !server.id) return Promise.resolve(false) + const id = server.id + + if (inFlight.has(id)) return inFlight.get(id) + if (Date.now() - (askedAt.get(id) || 0) < FRESH_MS) return Promise.resolve(true) + + // Required here, not at the top: boot.js requires the event steps that + // require this file. + const ask = Promise.resolve() + .then(() => require('./boot').refreshOne(server)) + .then( + () => { + askedAt.set(id, Date.now()) + return true + }, + (err) => { + log.warn('could not ask a server how it is', { server: id, error: err.message }) + return false + }, + ) + .finally(() => inFlight.delete(id)) + + inFlight.set(id, ask) + + // The caller waits at most `ASK_MS`; the ask itself runs to the end. + let timer + const late = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), ASK_MS) + if (timer.unref) timer.unref() + }) + return Promise.race([ask, late]).finally(() => clearTimeout(timer)) +} + +/** + * `read()`'s answer, confirmed live when `refuses` says it would refuse: one + * `refresh(server)`, then `read()` again. The answer the caller acts on. + */ +async function confirmed(server, read, refuses) { + const first = await read() + if (!refuses(first)) return first + if (!(await module.exports.refresh(server))) return first + return read() +} + +/** For tests: forget every ask. */ +function _reset() { + inFlight.clear() + askedAt.clear() +} + +module.exports = { FRESH_MS, ASK_MS, refresh, confirmed, _reset } diff --git a/server/model/config/config.model.js b/server/model/config/config.model.js index b4f62d3..f741e49 100644 --- a/server/model/config/config.model.js +++ b/server/model/config/config.model.js @@ -114,11 +114,22 @@ function shapeCatalogue(catalogue) { } } +/** + * The plugin a path belongs to, by its first segment, or its file name for a + * file at the root. A RaidableBases profile's virtual path names the plugin + * after its `@data/` prefix, so the audit row says RaidableBases (W8). + */ +function pluginOf(path) { + const text = String(path) + const parts = text.split('/') + if (parts[0] === '@data' && parts.length > 2) return parts[1] + return parts.length > 1 ? parts[0] : text.replace(/\.json$/i, '') +} + /** Whether this file is the bridge's own config, by the name the plugin gave. */ function isBridgeConfig(path, self) { if (!self) return false - const plugin = String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '') - return plugin.toLowerCase() === String(self).toLowerCase() + return pluginOf(path).toLowerCase() === String(self).toLowerCase() } /** The locked keys for a file: three of them in our own config, none anywhere else. */ @@ -279,6 +290,7 @@ module.exports = { isPlausiblePath, shapeCatalogue, shapeFile, + pluginOf, isBridgeConfig, lockedKeysFor, lockedChanges, diff --git a/server/model/economics/economics.model.js b/server/model/economics/economics.model.js index 13cfcec..4adb414 100644 --- a/server/model/economics/economics.model.js +++ b/server/model/economics/economics.model.js @@ -23,6 +23,7 @@ const db = require('./economics.db') const eventsDb = require('../events/events.db') const servers = require('../servers/servers.model') const { pluginError, transportError } = require('../../eventLeases') +const live = require('../../liveStatus') const log = core.logger('economics') @@ -168,7 +169,8 @@ async function adjust({ serverId, steamId, op, amount, key }) { const server = await servers.getForCalling(serverId) if (!server) return { ok: false, status: 404, message: 'No such server' } - const econ = await forServer(server.id) + // Confirmed live before it refuses: the stored status lags a restart (W7). + const econ = await live.confirmed(server, () => forServer(server.id), (e) => !e.loaded) if (!econ.loaded) return { ok: false, status: 409, reason: 'economics-missing', message: MISSING } const result = await client.economicsAdjust(server, { key, steamId, op, amount: checked.value }) @@ -187,9 +189,11 @@ async function adjust({ serverId, steamId, op, amount, key }) { } log.warn('Economics refused an adjustment', { server: server.id, op, reason: data.reason }) + // The game saying no is a 409, as `insufficient` is above: the request was + // well formed, and the balance it met refused it (W15). return { ok: false, - status: data.reason === 'economics-missing' ? 409 : 400, + status: data.reason === 'economics-missing' || data.reason === 'at-limit' ? 409 : 400, reason: data.reason || null, message: pluginError(data, `${name} refused the adjustment`), } diff --git a/server/model/permissions/permissions.model.js b/server/model/permissions/permissions.model.js index c207ba9..d7591cc 100644 --- a/server/model/permissions/permissions.model.js +++ b/server/model/permissions/permissions.model.js @@ -162,11 +162,19 @@ function shapeSync(row) { } } + // A grant to a player the plugin made and did not find when it read it back + // (`steamId:permission`). On Carbon that is a Steam id the server has never + // seen: it lands when they first join, because every sync pushes the whole + // set. "In sync" alone would hide it (W17). + const notLanded = report && Array.isArray(report.notLanded) ? report.notLanded : [] + const waitingToLand = notLanded.filter((entry) => /^\d{17}:/.test(String(entry))).length + return { serverId: row.serverId, state: row.state, dirty: Boolean(row.dirty), inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok', + waitingToLand, lastAttemptAt: row.lastAttemptAt, lastOkAt: row.lastOkAt, importedAt: row.importedAt || null, diff --git a/server/router/admin/config.controller.js b/server/router/admin/config.controller.js index a3c6608..ecbb268 100644 --- a/server/router/admin/config.controller.js +++ b/server/router/admin/config.controller.js @@ -323,7 +323,7 @@ async function record(req, row) { try { id = await db.recordWrite({ ...row, - plugin: model.isBridgeConfig(row.path, row.self) ? row.self : pluginOf(row.path), + plugin: model.isBridgeConfig(row.path, row.self) ? row.self : model.pluginOf(row.path), reloadTarget: row.reload, userId: req.user ? req.user.id : null, }) @@ -347,10 +347,6 @@ async function record(req, row) { return id } -function pluginOf(path) { - return String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '') -} - /** A `config.error` frame as a sentence. */ function refusalMessage(frame) { const reasons = { diff --git a/server/router/admin/usersRust.router.js b/server/router/admin/usersRust.router.js index 2ffcc2d..da888f4 100644 --- a/server/router/admin/usersRust.router.js +++ b/server/router/admin/usersRust.router.js @@ -159,7 +159,7 @@ usersRustRouter.post( /* #swagger.responses[200] = { description: 'Adjusted', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjusted" } } } } */ /* #swagger.responses[400] = { description: 'A bad op or amount', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[404] = { description: 'No such server, or that Steam account is not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Economics is not installed there, or the balance is too low to withdraw that much', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Economics is not installed there, the balance is too low to withdraw that much, or a deposit would pass Economics’ balance limit', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ param('id').isInt(), body('serverId').isString().isLength({ min: 1, max: 64 }), diff --git a/server/swagger/doc.js b/server/swagger/doc.js index e0646a0..e9566f8 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -658,6 +658,11 @@ module.exports = { description: 'True when the last successful push carried the set the site currently authors.', example: true, }, + waitingToLand: { + type: 'integer', + description: 'Grants to players (lent, given or authored) the last sync pushed and the game did not keep: the report’s `notLanded` entries for a Steam id. On Carbon that is a player the server has never seen; the grant lands when they first join, because every sync pushes the whole set.', + example: 0, + }, dirty: { type: 'boolean', example: false }, lastAttemptAt: { type: 'string', format: 'date-time', nullable: true }, lastOkAt: { type: 'string', format: 'date-time', nullable: true }, diff --git a/server/test/backpacks.test.js b/server/test/backpacks.test.js index 79a1d1d..2b6ad7b 100644 --- a/server/test/backpacks.test.js +++ b/server/test/backpacks.test.js @@ -34,6 +34,10 @@ function withRecorder(rows = []) { withRecorder() +// The stored status is the whole answer in these tests: nothing asks a +// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask. +require('../liveStatus').refresh = async () => false + const client = require('../sidecarClient') const serversDb = require('../model/servers/servers.db') const servers = require('../model/servers/servers.model') @@ -194,16 +198,21 @@ test('the revert deletes the step’s rows, and the next sync takes the size bac assert.deepStrictEqual(calls.dirty, ['main']) }) -test('the size list offers each server’s sizes above its smallest, grouped by server', async (t) => { +test('the size list offers each size above a server’s smallest once, naming every server that offers it (W10)', async (t) => { const saved = { listForPolling: servers.listForPolling, reports: backpacksDb.reports } t.after(() => Object.assign(servers, { listForPolling: saved.listForPolling }) && Object.assign(backpacksDb, { reports: saved.reports })) - servers.listForPolling = async () => [{ id: 'main', name: 'Main' }, { id: 'pve', name: 'PvE' }] + servers.listForPolling = async () => [{ id: 'main', name: 'Main' }, { id: 'pve', name: 'PvE' }, { id: 'off', name: 'Off' }] backpacksDb.reports = async () => [ - { serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48] }) }, - { serverId: 'pve', backpacks: null }, + { serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48, 96] }) }, + { serverId: 'pve', backpacks: JSON.stringify({ loaded: true, sizes: [12, 24, 48] }) }, + { serverId: 'off', backpacks: null }, ] const source = rewards.OPTION_SOURCES.find((s) => s.id === 'rust.options.backpacksizes') - assert.deepStrictEqual(await source.resolve(), [{ value: 48, label: '48 slots', group: 'Main' }]) + assert.deepStrictEqual(await source.resolve(), [ + { value: 24, label: '24 slots — PvE' }, + { value: 48, label: '48 slots — Main, PvE' }, + { value: 96, label: '96 slots — Main' }, + ]) }) // ── Reading one ──────────────────────────────────────────────────────────── diff --git a/server/test/config.test.js b/server/test/config.test.js index eafc849..940fafd 100644 --- a/server/test/config.test.js +++ b/server/test/config.test.js @@ -558,6 +558,15 @@ test('the page’s poll answers 404 for a write that is not on that server', asy // ── RaidableBases' profiles (PLAN_REDESIGNS §11.4.3, D205) ──────────────── +test('a path names its plugin, and a RaidableBases profile names RaidableBases, not "@data" (W8)', () => { + const model = require('../model/config/config.model') + assert.strictEqual(model.pluginOf('@data/RaidableBases/Profiles/Hard Bases.json'), 'RaidableBases') + assert.strictEqual(model.pluginOf('Kits.json'), 'Kits') + assert.strictEqual(model.pluginOf('RaidableBases/Profiles.json'), 'RaidableBases') + assert.strictEqual(model.isBridgeConfig('RunicGateway.json', 'RunicGateway'), true) + assert.strictEqual(model.isBridgeConfig('@data/RaidableBases/Profiles/x.json', 'RunicGateway'), false) +}) + test('a RaidableBases profile’s virtual path is plausible, one file deep, and nothing else under @data is', () => { const model = require('../model/config/config.model') assert.strictEqual(model.isPlausiblePath('@data/RaidableBases/Profiles/Hard Bases.json'), true) diff --git a/server/test/dangerousTreasures.test.js b/server/test/dangerousTreasures.test.js index fbd7399..04e9dc2 100644 --- a/server/test/dangerousTreasures.test.js +++ b/server/test/dangerousTreasures.test.js @@ -37,6 +37,10 @@ function withRecorder(rows = []) { withRecorder() +// The stored status is the whole answer in these tests: nothing asks a +// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask. +require('../liveStatus').refresh = async () => false + const dt = require('../dangerousTreasures') const { gridOf } = require('../worldEvents') const { TRIGGERS } = require('../engagement/triggers') diff --git a/server/test/economics.test.js b/server/test/economics.test.js index 29f9d23..90ac1b8 100644 --- a/server/test/economics.test.js +++ b/server/test/economics.test.js @@ -34,6 +34,10 @@ function withRecorder(rows = []) { withRecorder() +// The stored status is the whole answer in these tests: nothing asks a +// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask. +require('../liveStatus').refresh = async () => false + const client = require('../sidecarClient') const serversDb = require('../model/servers/servers.db') const servers = require('../model/servers/servers.model') @@ -220,10 +224,21 @@ test('coins go once to each website user, with the key, the amount and the messa assert.strictEqual(result.detail.total, 500) assert.deepStrictEqual(result.detail.received, [{ name: 'p1', received: 500 }]) assert.deepStrictEqual(result.detail.failed, [{ name: 'p4', reason: 'at-limit' }]) + assert.strictEqual(result.detail.failedNote, "at-limit: the balance is at Economics' limit", 'only the reason that happened (W16)') assert.deepStrictEqual(result.detail.missed, ['p3']) assert.strictEqual(result.resources, undefined, 'nothing is ledgered') }) +test('a payment’s failedNote explains each reason that happened, and only those (W16)', () => { + const { failedNote } = require('../eventRewards') + assert.strictEqual(failedNote([{ reason: 'unmeasured' }, { reason: 'unmeasured' }]), 'unmeasured: Economics did not report the deposit') + assert.strictEqual( + failedNote([{ reason: 'unmeasured' }, { reason: 'at-limit' }, { reason: 'refused' }]), + "at-limit: the balance is at Economics' limit; refused: Economics did not take the deposit; unmeasured: Economics did not report the deposit", + ) + assert.strictEqual(failedNote([{ reason: 'odd' }]), "odd: Economics' own reason") +}) + test('a repeated key is answered from the first payment, and a lost answer is retried', async (t) => { const person = { steamId: A, name: 'pa', seconds: 60, kills: 0, score: 1, joinedAt: 1 } stub(t, { people: [person], links: { [A]: 1 }, payAnswer: () => ok({ kind: 'economics.paid', repeat: true, paid: [{ steamId: A, before: 1000, after: 1500 }], failed: [] }) }) @@ -291,6 +306,12 @@ test('a withdrawal above the balance is refused with the balance', async (t) => assert.match(result.message, /only 340 coins on Main/) assert.deepStrictEqual(sent, [{ key: 'adj-1', steamId: A, op: 'withdraw', amount: 500 }]) + // A deposit refused at the balance limit is the game saying no too (W15). + client.economicsAdjust = async () => ok({ kind: 'economics.error', reason: 'at-limit', message: "this player's balance is at Economics' limit" }) + const capped = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 50, key: 'adj-3' }) + assert.strictEqual(capped.status, 409) + assert.strictEqual(capped.reason, 'at-limit') + economicsDb.reportFor = async () => null const missing = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 5, key: 'adj-2' }) assert.strictEqual(missing.status, 409) diff --git a/server/test/guardedCrate.test.js b/server/test/guardedCrate.test.js index 3a728bb..2f496b5 100644 --- a/server/test/guardedCrate.test.js +++ b/server/test/guardedCrate.test.js @@ -40,6 +40,10 @@ function withRecorder(rows = []) { withRecorder() +// The stored status is the whole answer in these tests: nothing asks a +// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask. +require('../liveStatus').refresh = async () => false + const gc = require('../guardedCrate') const { gridOf } = require('../worldEvents') const { TRIGGERS } = require('../engagement/triggers') diff --git a/server/test/liveStatus.test.js b/server/test/liveStatus.test.js new file mode 100644 index 0000000..0c66eaf --- /dev/null +++ b/server/test/liveStatus.test.js @@ -0,0 +1,113 @@ +// ── Asking a server how it is now, before saying no (W7, W11) ────────────── +// +// Each test is one way the feature could look right and be wrong: +// +// a "no" from the stored status is asked again, and the fresh answer is the one acted on +// a "yes" is never asked again: the plugin refuses at the run, as it always has +// a "no" the sidecar cannot change is still a "no" +// ten steps against one server in one dry run ask it once +// a step refused for Backpacks just after a restart passes once the server says it is back + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +require('../core')._reset() +require('../core').init(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } })) + +const boot = require('../boot') +const live = require('../liveStatus') + +const MAIN = { id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' } + +/** `boot.refreshOne` replaced by `onAsk`, counted. */ +function stubAsk(t, onAsk = () => {}) { + const saved = boot.refreshOne + const asked = [] + live._reset() + boot.refreshOne = async (server) => { + asked.push(server.id) + await onAsk(server) + } + t.after(() => { + boot.refreshOne = saved + live._reset() + }) + return asked +} + +test('a refusal from the stored status is asked again, and the fresh answer is the one acted on', async (t) => { + let stored = { loaded: false } + const asked = stubAsk(t, () => { + stored = { loaded: true } + }) + const answer = await live.confirmed(MAIN, async () => stored, (r) => !r.loaded) + assert.deepStrictEqual(answer, { loaded: true }) + assert.deepStrictEqual(asked, ['main']) +}) + +test('a pass is never asked again', async (t) => { + const asked = stubAsk(t) + const answer = await live.confirmed(MAIN, async () => ({ loaded: true }), (r) => !r.loaded) + assert.deepStrictEqual(answer, { loaded: true }) + assert.deepStrictEqual(asked, []) +}) + +test('a refusal the fresh answer agrees with is still a refusal, and a failed ask leaves the stored one', async (t) => { + stubAsk(t) + assert.strictEqual(await live.confirmed(MAIN, async () => 'Backpacks is not installed', Boolean), 'Backpacks is not installed') + + stubAsk(t, () => { + throw new Error('boom') + }) + assert.strictEqual(await live.refresh(MAIN), false) + assert.strictEqual(await live.confirmed(MAIN, async () => 'still no', Boolean), 'still no') +}) + +test('many refusals against one server at once ask it once, and again only after a while', async (t) => { + let release + const gate = new Promise((resolve) => { + release = resolve + }) + const asked = stubAsk(t, () => gate) + const all = Promise.all(Array.from({ length: 10 }, () => live.confirmed(MAIN, async () => 'no', Boolean))) + release() + await all + assert.deepStrictEqual(asked, ['main'], 'one ask in flight serves every step') + + await live.confirmed(MAIN, async () => 'no', Boolean) + assert.deepStrictEqual(asked, ['main'], `an ask within ${live.FRESH_MS} ms is as fresh as asking again`) + + await live.confirmed({ ...MAIN, id: 'pve' }, async () => 'no', Boolean) + assert.deepStrictEqual(asked, ['main', 'pve'], 'another server is its own') +}) + +test('a step waits at most ASK_MS for the ask, and a late ask leaves the stored answer', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }) + stubAsk(t, () => new Promise(() => {})) + const answer = live.confirmed(MAIN, async () => 'not installed', Boolean) + // Let the first read and the ask start before the clock moves. + for (let i = 0; i < 5; i++) await Promise.resolve() + t.mock.timers.tick(live.ASK_MS) + assert.strictEqual(await answer, 'not installed') +}) + +test('a bigger backpack refused on a stale "not installed" passes once the server says Backpacks is back (W7)', async (t) => { + const rewards = require('../eventRewards') + const serversDb = require('../model/servers/servers.db') + const backpacksDb = require('../model/backpacks/backpacks.db') + const saved = { getServer: serversDb.getServer, reportFor: backpacksDb.reportFor } + t.after(() => Object.assign(serversDb, { getServer: saved.getServer }) && Object.assign(backpacksDb, { reportFor: saved.reportFor })) + + serversDb.getServer = async (id) => (id === 'main' ? { id: 'main', name: 'Main', enabled: 1, sidecarBaseUrl: 'http://main:1' } : null) + let report = null + backpacksDb.reportFor = async () => ({ serverId: 'main', backpacks: report }) + const asked = stubAsk(t, () => { + report = JSON.stringify({ loaded: true, sizes: [6, 48] }) + }) + const step = rewards.ACTIONS.find((a) => a.id === 'rust.backpack.size') + const result = await step.perform({ runId: 7, stepId: 1, idempotencyKey: '7:1', params: { server: 'main', size: 48, recipients: 'linked' }, verify: true }) + assert.deepStrictEqual(asked, ['main']) + assert.deepStrictEqual(result, { ok: true }) +}) diff --git a/server/test/npcs.test.js b/server/test/npcs.test.js index 1f62b9f..e2a10c6 100644 --- a/server/test/npcs.test.js +++ b/server/test/npcs.test.js @@ -27,6 +27,10 @@ require('../core')._reset() const ctx = fakeCtx({ freeze: false }) require('../core').init(ctx) +// The stored status is the whole answer in these tests: nothing asks a +// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask. +require('../liveStatus').refresh = async () => false + const client = require('../sidecarClient') const servers = require('../model/servers/servers.model') const serversDb = require('../model/servers/servers.db') diff --git a/server/test/permissions.test.js b/server/test/permissions.test.js index b1c216a..ae4f076 100644 --- a/server/test/permissions.test.js +++ b/server/test/permissions.test.js @@ -77,6 +77,25 @@ function authored() { } } +test('a sync whose report did not land a player’s grant is in sync and says how many wait (W17)', () => { + withCore() + const model = require('../model/permissions/permissions.model') + const row = (notLanded) => ({ + serverId: 'carbon', + state: 'ok', + desiredHash: 'h', + syncedHash: 'h', + report: JSON.stringify({ notLanded }), + }) + + const waiting = model.shapeSync(row(['76561199910000001:backpacks.size.24', 'vip:parent:default'])) + assert.strictEqual(waiting.inSync, true) + assert.strictEqual(waiting.waitingToLand, 1, 'a group’s parent is not a player’s grant') + + assert.strictEqual(model.shapeSync(row([])).waitingToLand, 0) + assert.strictEqual(model.shapeSync({ serverId: 'x', state: 'pending' }).waitingToLand, 0) +}) + test('a grant reaches every Steam account its holder has linked (D28)', () => { withCore() const model = require('../model/permissions/permissions.model') diff --git a/server/test/raidableBases.test.js b/server/test/raidableBases.test.js index b8adb9d..7e77d06 100644 --- a/server/test/raidableBases.test.js +++ b/server/test/raidableBases.test.js @@ -39,6 +39,10 @@ function withRecorder(rows = []) { withRecorder() +// The stored status is the whole answer in these tests: nothing asks a +// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask. +require('../liveStatus').refresh = async () => false + const rb = require('../raidableBases') const catalogue = require('../catalogue') const { gridOf } = require('../worldEvents') @@ -237,6 +241,22 @@ test('the pickers list each server’s own names, once each, and nothing for a s assert.deepStrictEqual(await difficulties.resolve(), [], 'nothing is made up for a server without the plugin') }) +test('a name both servers have, described differently, shows each server’s own description (W13)', async (t) => { + const [difficulties, profiles] = module_().OPTION_SOURCES + const hard = (difficulty) => ({ ...REPORT, profiles: [{ name: 'Hard Bases', difficulty, level: 2, enabled: true, pvp: true, bases: ['h1'], seabed: false, float: false }] }) + stubStep(t, { + reports: { + main: { raidableBases: hard('Hard') }, + pve: { raidableBases: { ...hard('Expert'), difficulties: [{ name: 'Gentle', level: 1 }, { name: 'Wobbly', level: 7 }] } }, + }, + }) + assert.deepStrictEqual(await profiles.resolve(), [{ value: 'Hard Bases', label: 'Hard Bases · Hard — Main; Hard Bases · Expert — PvE' }]) + assert.deepStrictEqual(await difficulties.resolve(), [ + { value: 'Gentle', label: 'Gentle (level 0) — Main; Gentle (level 1) — PvE' }, + { value: 'Wobbly', label: 'Wobbly (level 7) — Main, PvE' }, + ]) +}) + test('without a patched helper, a spot, a profile, a base or settings are refused; a plain spawn is not (D348, D387)', async (t) => { const run = (params) => spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', ...params }, verify: true }) stubStep(t, { reports: { main: { raidableBases: { ...REPORT, helper: { state: 'missing' }, profiles: null }, console: { plugins: true } } } }) @@ -337,8 +357,12 @@ test('a base the helper did not make is not the run’s: no resource, and the ru const result = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', difficulty: 'Wobbly' } }) assert.ok(result.ok) assert.strictEqual(result.resources, undefined) - assert.match(result.detail.note, /RaidableBases picks the spot/) - assert.match(result.detail.note, new RegExp(module_().OWN_TIMER)) + const own = module_().OWN_TIMER + assert.strictEqual( + result.detail.note, + `RaidableBases picks the spot; the map shows it once the base opens. ${own[0].toUpperCase()}${own.slice(1)}`, + 'each note after the first starts its own sentence (W14)', + ) }) test('the grid’s wait is a ten-minute retry; a refusal the plugin would repeat is not retried', async (t) => { diff --git a/server/test/worldEvents.test.js b/server/test/worldEvents.test.js index ee818d5..7558a3a 100644 --- a/server/test/worldEvents.test.js +++ b/server/test/worldEvents.test.js @@ -415,6 +415,17 @@ test('a refusal the plugin would repeat is not retried; a game that is down is', assert.match(down.error, /has no game connected/) }) +test('a Travelling Vendor already out is no failure: the step says so and counts nothing called (W3)', async (t) => { + stubCall(t, ok({ kind: 'world.error', reason: 'already-out', message: 'a Travelling Vendor is already out' })) + const result = await callStep().perform({ runId: 7, idempotencyKey: '7:6', params: { server: 'main', event: 'travelling_vendor' } }) + assert.strictEqual(result.ok, true) + assert.deepStrictEqual(result.detail, { + event: 'travelling_vendor', + called: 0, + note: 'a Travelling Vendor is already out on Main, and the game runs one at a time; nothing new was called', + }) +}) + test('a repeat of a key says so and calls nothing new', async (t) => { stubCall(t, ok({ kind: 'world.called', called: 1, repeat: true })) const result = await callStep().perform({ runId: 7, idempotencyKey: '7:5', params: { server: 'main', event: 'patrol_heli' } }) diff --git a/server/test/zones.test.js b/server/test/zones.test.js index 4801fd0..c3e1c9c 100644 --- a/server/test/zones.test.js +++ b/server/test/zones.test.js @@ -24,6 +24,10 @@ const { fakeCtx } = require('./_fakes') require('../core')._reset() require('../core').init(fakeCtx()) +// The stored status is the whole answer in these tests: nothing asks a +// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask. +require('../liveStatus').refresh = async () => false + const client = require('../sidecarClient') const serversDb = require('../model/servers/servers.db') const zonesDb = require('../model/zones/zones.db') diff --git a/swagger-fragment.json b/swagger-fragment.json index 8142f49..ab27e91 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -2387,7 +2387,7 @@ } }, "409": { - "description": "Economics is not installed there, or the balance is too low to withdraw that much", + "description": "Economics is not installed there, the balance is too low to withdraw that much, or a deposit would pass Economics’ balance limit", "content": { "application/json": { "schema": { @@ -8257,6 +8257,23 @@ } } }, + "waitingToLand": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Grants to players (lent, given or authored) the last sync pushed and the game did not keep: the report’s `notLanded` entries for a Steam id. On Carbon that is a player the server has never seen; the grant lands when they first join, because every sync pushes the whole set." + }, + "example": { + "type": "number", + "example": 0 + } + } + }, "dirty": { "type": "object", "properties": {