11 Commits

Author SHA1 Message Date
3fac860949 Merge pull request 'chore(release): cut edge over to main — protocol 14 and Steam sign-in (MODULE_API 1.17.0)' (#58) from edge into main
All checks were successful
Release / release (push) Successful in 36s
Reviewed-on: #58
2026-10-22 11:16:11 +00:00
42bd6db6ad Merge pull request 'ci(rust): pin core to main at the cutover (website#220)' (#57) from chore/pin-core-main into edge
All checks were successful
PR Checks / server-tests (pull_request) Successful in 35s
PR Checks / frozen-manifest (pull_request) Successful in 1m9s
PR Checks / client-build (pull_request) Successful in 7m9s
Reviewed-on: #57
2026-10-22 11:13:51 +00:00
3453bc1867 ci(rust): pin core to main at the cutover (website#220)
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / frozen-manifest (pull_request) Successful in 59s
PR Checks / server-tests (pull_request) Successful in 7m15s
website#220 cut website edge over to main as 6890a74. The pin pointed at
9a1654f, the sha website#218 merged into edge as, which its own note said
moves to main when the cutover lands. The new tree adds the last fixes'
core half (website#219, no MODULE_API bump), whose route changes are only
core's own event routes, so routes.manifest.json does not change. The
frozen-manifest job, replayed locally at this ref, passes (89 routes, all
documented).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 05:45:25 -05:00
72726838b5 Merge pull request 'fix(rust): a zone resource carries its deadline as until (W1)' (#56) from fix/last-fixes-zone-until into edge
Reviewed-on: #56
2026-10-22 06:26:56 +00:00
9c35906d0e fix(rust): a zone resource carries its deadline as until (W1)
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / frozen-manifest (pull_request) Successful in 1m1s
PR Checks / server-tests (pull_request) Successful in 7m20s
Stage 4 of docs/modules/rust/PLAN_LAST_FIXES.md, the module's half of W1.
A placed zone now reports `until`, reckoned from the answer as now plus
holdMs, so it lands a round trip after the game's own deadline and never
before it. Core's reconcile then files a zone gone past that deadline as
expired, where a reconnect used to log resource.orphaned five seconds
before the game's world.expired arrived. Crates and NPCs send no
holdMs, so they carry no until.

No core pin change: `until` has been in the resource contract all along,
and a core without the reconcile half only shows it on the console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 00:38:49 -05:00
7304d68d0a Merge pull request 'fix(rust): the last fixes' small ones (W3 W6 W7/W11 W8 W10 W13-W18)' (#55) from fix/last-fixes-small into edge
Reviewed-on: #55
2026-10-21 18:45:37 +00:00
3aa142bf06 fix(rust): the last fixes' small ones (W3 W6 W7/W11 W8 W10 W13-W18)
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 32s
PR Checks / frozen-manifest (pull_request) Successful in 44s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-21 07:42:24 -05:00
d69c2108c6 Merge pull request 'fix(rust): send reward notices, and keep a downed line's credit rule (W21, W4)' (#54) from fix/last-fixes-bugs into edge
Reviewed-on: #54
2026-10-21 12:00:33 +00:00
8aff9b33a6 fix(rust): send reward notices, and keep a downed line's credit rule (W21, W4)
All checks were successful
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 48s
PR Checks / server-tests (pull_request) Successful in 7m16s
W21: the permission push named its fields one by one and left out
`notices`, so no kit reward, lend or give ever told its player
anything (D333). The body is now built by `pushBody`, which carries
every field the payload has; a test fails if a payload field is
dropped again.

W4: a downed patrol helicopter, Chinook or Bradley is stored with the
credit rule in force when it arrived, and the feed reads that stamp.
Before, the line re-credited past rows with the server's current rule
while the Rotorbane/Tankbreaker counts kept the old one. Rows stored
before this change keep today's reading.

PLAN_LAST_FIXES.md stage 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-21 06:16:32 -05:00
5decd5cc05 Merge pull request 'ci(rust): pin core to the sha website#218 merged as' (#53) from chore/core-ref-steam-merge into edge
Reviewed-on: #53
2026-10-21 05:38:58 +00:00
885485d153 ci(rust): pin core to the sha website#218 merged as
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / server-tests (pull_request) Successful in 7m14s
#218 (Steam sign-in, MODULE_API 1.17.0) merged into website edge as
9a1654f. The pin pointed at the PR's branch head, 5e398d8, while it was
open. The two trees differ by one core test file, so routes.manifest.json
does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-21 00:38:15 -05:00
36 changed files with 612 additions and 63 deletions

View File

@@ -50,6 +50,7 @@
"guardedCrate.js",
"index.js",
"ingest.js",
"liveStatus.js",
"mapImages.js",
"mapLive.js",
"model",

View File

@@ -1,6 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and moves to the sha it merges as.",
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "5e398d8acdce90cb003a023e5520d6329624aecb",
"refName": "feat/steam-signin @ MODULE_API 1.17.0 (website PR open)"
"ref": "6890a74c7c4ad5690509e8c163e477d7f7b399e7",
"refName": "main @ website#220 merge (the cutover), MODULE_API 1.17.0"
}

View File

@@ -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':

View File

@@ -781,7 +781,13 @@ export default function Permissions() {
</select>
</label>
{sync && (
sync.inSync ? <Chip tone="good">in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''}</Chip>
sync.inSync && sync.waitingToLand > 0
? (
<Chip tone="warn" title="A grant to a Steam id this server has never seen lands when that player first joins: every sync pushes it again">
in sync · {sync.waitingToLand} {sync.waitingToLand === 1 ? 'waits' : 'wait'} for their player to join
</Chip>
)
: sync.inSync ? <Chip tone="good">in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''}</Chip>
: sync.state === 'failed' ? <Chip tone="bad" title={sync.error || ''}>last sync failed</Chip>
: <Chip tone="warn">waiting for a sync</Chip>
)}

View File

@@ -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', () => {

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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,

View File

@@ -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')
@@ -323,11 +324,19 @@ async function place(server, send, body, what) {
}
const placed = Array.isArray(data.placed) ? data.placed : []
// A zone's deadline, as core's `until` (W1, PLAN_LAST_FIXES). The game erases
// the zone `holdMs` after it placed it; reckoned from the answer, on this
// clock, it lands a round trip after the game's, never before. With it core's
// reconcile files a zone gone past its deadline as expired instead of
// orphaned, which a reconnect used to show five seconds before the game's own
// `world.expired` arrived. Crates and NPCs have no deadline and send none.
const until = Number(body.holdMs) > 0 ? new Date(Date.now() + Number(body.holdMs)).toISOString() : null
return {
ok: true,
resources: placed.map((row) => ({
kind: OWNED_KIND,
ref: refOf(server.id, row.id),
...(until ? { until } : {}),
payload: {
serverId: server.id,
what: row.kind,
@@ -690,8 +699,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 +807,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 +835,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 }

View File

@@ -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,

View File

@@ -72,7 +72,9 @@ const MAX_BATCHES_PER_TICK = 10
* how to read.
*/
async function apply(serverId, item, server = null) {
const frame = (item && item.frame) || {}
// A downed helicopter, Chinook or Bradley is stored with the rule that
// credits it now, so its line and its counts agree for good (W4).
const frame = worldEvents.stampRule((item && item.frame) || {}, server && server.creditRule)
const kind = item.kind || frame.kind
const wipeId = frame.wipeId || null
@@ -278,8 +280,7 @@ async function apply(serverId, item, server = null) {
// A protocol-13 plugin still sends `heliKills` / `apcKills` on its tally
// instead, and the tally path above adds those as it always has.
case 'world.event': {
const rule = worldEvents.creditRuleOf(server && server.creditRule)
for (const kill of worldEvents.killsOf(frame, rule)) {
for (const kill of worldEvents.killsOf(frame, worldEvents.ruleOf(frame, server && server.creditRule))) {
await db.touchPlayer(kill.steamId, kill.name || null)
await db.addTitleStats({ serverId, wipeId, steamId: kill.steamId }, { [kill.field]: 1 })
}

95
server/liveStatus.js Normal file
View File

@@ -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 }

View File

@@ -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,

View File

@@ -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`),
}

View File

@@ -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,

View File

@@ -314,13 +314,7 @@ async function syncOne(server, { authored, sync, state, force, policy = 'auto-ad
held: planned.hold.size,
})
const result = await sidecar.permSync(server, {
setId: desired.hash,
groups: withExpect(held.payload.groups, pushed),
grants: held.payload.grants,
credits: held.payload.credits,
retire,
})
const result = await sidecar.permSync(server, pushBody(desired.hash, held.payload, pushed, retire))
if (!result.ok) {
await failed(server, { reason, error: result.status, desiredHash: desired.hash, sync, bootId, wipeId })
@@ -358,6 +352,23 @@ async function syncOne(server, { authored, sync, state, force, policy = 'auto-ad
return 'ok'
}
/**
* The sync as the wire carries it: every field of the built payload, the
* groups with their style `expect`s, and what to retire. `notices` rides here
* too (D333) — the push once named its fields one by one and left it out, so
* no reward message ever reached the game (W21).
*/
function pushBody(setId, payload, pushed, retire) {
return {
setId,
groups: withExpect(payload.groups, pushed),
grants: payload.grants,
credits: payload.credits,
notices: payload.notices,
retire,
}
}
/**
* The groups as the wire carries them: each style field with the value this
* site last pushed to THIS server (`expect`), or null for one it never has.
@@ -526,6 +537,7 @@ module.exports = {
syncOne,
reasonToSync,
applyReport,
pushBody,
withExpect,
styleRetirements,
withLock,

View File

@@ -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 = {

View File

@@ -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 }),

View File

@@ -68,7 +68,7 @@ rustRouter.get(
'/servers/:id/events',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Recent events on one Rust server'
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule`.'
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }

View File

@@ -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 },

View File

@@ -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 ────────────────────────────────────────────────────────────

View File

@@ -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)

View File

@@ -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')

View File

@@ -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)

View File

@@ -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')

View File

@@ -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 })
})

View File

@@ -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')

View File

@@ -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')

View File

@@ -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) => {

View File

@@ -28,6 +28,7 @@ const rewards = require('../eventRewards')
const leases = require('../eventLeases')
const templates = require('../eventTemplates')
const model = require('../model/permissions/permissions.model')
const reconcile = require('../model/permissions/reconcile')
const entitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle')
const ok = (data) => ({ ok: true, status: 'ok', data })
@@ -163,6 +164,19 @@ test('the sync carries each untold message to the account the reward picked', ()
])
})
test('the push carries the notices, and every other field the payload built (W21)', () => {
const permSync = require('../permSync')
const desired = model.buildDesired('main', authored([grantRow({ notice: 'You earned kit vip', noticeDelivery: 'chat' })]))
const held = reconcile.withHold(desired, new Set())
const body = permSync.pushBody(desired.hash, held.payload, [], [])
assert.deepStrictEqual(body.notices, [
{ key: '7:3:11', steamId: '76561190000000001', message: 'You earned kit vip', delivery: 'chat' },
])
// A field the model adds and the push forgets is how W21 happened.
for (const field of Object.keys(held.payload)) assert.ok(field in body, `the push drops payload.${field}`)
assert.strictEqual(body.setId, desired.hash)
})
test('a notice moves the digest, so a new one is pushed and a told one is dropped', () => {
const none = model.buildDesired('main', authored([grantRow({})]))
const one = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeDelivery: 'chat' })]))

View File

@@ -178,10 +178,13 @@ test('a zone crosses with its key, its duration, and the monument without the se
server: 'main',
body: { runId: '7', key: 'k1', monument: 'airfield_1', offsetX: 0, offsetZ: -20, radius: 40, holdMs: 5400000, name: 'Brawl' },
})
// The ref carries the server: revert and reconcile are handed no params.
assert.deepStrictEqual(result.resources, [
{ kind: 'world', ref: 'main:rg-7-1-1', payload: { serverId: 'main', what: 'zone', name: 'Z' } },
])
// The ref carries the server: revert and reconcile are handed no params. The
// zone's deadline rides as `until` (W1); zones.test.js checks its value.
const [resource] = result.resources
assert.strictEqual(typeof resource.until, 'string')
const { until, ...rest } = resource
assert.deepStrictEqual(rest, { kind: 'world', ref: 'main:rg-7-1-1', payload: { serverId: 'main', what: 'zone', name: 'Z' } })
assert.strictEqual(result.resources.length, 1)
})
test('one resource per thing placed, and a repeated key is said to be one', async (t) => {

View File

@@ -216,6 +216,19 @@ test('a Bradley counts toward Tankbreaker, and an arrival counts nothing', async
assert.strictEqual(rec2.touching('rust_player_wipe_stats').length, 0)
})
test('a downed one is stored with the rule that credited it; an arrival is stored as sent (W4)', async () => {
const { apply } = require('../ingest')
const storedRaw = async (frame) => {
const rec = withRecorder()
await apply('main', { id: 1, kind: 'world.event', frame }, { id: 'main', creditRule: 'all' })
const insert = rec.touching('rust_events').find((s) => /INSERT/i.test(s.sql))
return JSON.parse(insert.params.find((p) => typeof p === 'string' && p.startsWith('{')))
}
assert.strictEqual((await storedRaw(downed())).creditRule, 'all')
assert.strictEqual((await storedRaw(world('heli.arrived', { x: 1, z: 1 }))).creditRule, undefined)
})
test('the tally’s crates hacked and drops looted are added up (D356)', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
@@ -251,6 +264,21 @@ test('the feed reads each world event for its viewer, with the server’s rule a
assert.deepStrictEqual(member.frame.credited, [{ name: 'Bob' }])
})
test('a line keeps the rule it was stored with after the server’s rule changes (W4)', async () => {
const stamped = downed({ creditRule: 'all' })
withRecorder((sql) => {
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'world.event', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stamped) }]
if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', creditRule: 'most', enabled: 1 }]
return []
})
const events = require('../model/events/events.model')
const [member] = await events.recent({ serverId: 'main', presence: true })
assert.strictEqual(member.frame.creditRule, 'all')
assert.strictEqual(member.frame.creditedCount, 2)
assert.deepStrictEqual(member.frame.credited.map((c) => c.name).sort(), ['Alice', 'Bob'])
})
// ── Triggers (D355) ────────────────────────────────────────────────────────
test('one public trigger per type, none of them declaring a Steam id', () => {
@@ -387,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' } })

View File

@@ -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')
@@ -228,6 +232,29 @@ test('a zone crosses with its flags, settings, messages in the chat voice, and i
})
})
test('a zone resource carries its deadline as until, a crate none (W1)', async (t) => {
// Core's reconcile reads `until`: a zone gone past it is expired, not
// orphaned. Reckoned from the answer, so it never falls before the game's own.
stubZones(t)
stubWorld(t)
const before = Date.now()
const result = await action('rust.zone.open').perform({
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 30 },
})
assert.strictEqual(result.ok, true, result.error)
const until = Date.parse(result.resources[0].until)
assert.ok(until >= before + 1800000 && until <= Date.now() + 1800000, result.resources[0].until)
const saved = client.worldPlace
client.worldPlace = async () => ({ ok: true, status: 'ok', data: { kind: 'world.ok', placed: [{ id: 'rg-7-1-2', kind: 'crate' }] } })
t.after(() => { client.worldPlace = saved })
const crate = await action('rust.crate.place').perform({
runId: 7, idempotencyKey: 'k2', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
})
assert.strictEqual(crate.ok, true, crate.error)
assert.strictEqual('until' in crate.resources[0], false)
})
test('a dome with its stack left blank stacks 3 spheres (D326)', async (t) => {
stubZones(t, { servers: [server('main', { domes: PATCHED })] })
const calls = stubWorld(t)

View File

@@ -178,7 +178,8 @@ function columnName(index) {
* `staff` the viewer is an admin or a moderator: Steam ids, and a type this
* build does not know
* `presence` the viewer is in the server's presence audience: names
* `rule` the server's credit rule
* `rule` the server's credit rule, for a row stored before the frame
* carried its own (`stampRule`)
* `geometry` the server's map geometry, for the grid
*
* What comes back keeps the plugin's own fields for what it describes (`event`,
@@ -215,8 +216,8 @@ function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDI
// Credit, by the server's rule.
if (known && TYPES[event].credit) {
out.creditRule = creditRuleOf(rule)
const ids = credited(frame, rule)
out.creditRule = ruleOf(frame, rule)
const ids = credited(frame, out.creditRule)
out.creditedCount = ids.length
if (presence || staff) {
out.credited = ids.map((id) => ({
@@ -235,6 +236,25 @@ function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDI
return out
}
/**
* The frame as it is stored: a downed helicopter, Chinook or Bradley carries
* the rule that credited it when it arrived (`creditRule`), so the feed line
* keeps naming who the counts were given to after the server's rule changes
* (W4). Every other frame is stored as sent.
*/
function stampRule(frame, rule) {
if (!frame || frame.kind !== 'world.event' || !isKnownType(frame.event) || !TYPES[frame.event].credit) return frame
return { ...frame, creditRule: creditRuleOf(rule) }
}
/**
* The rule a stored line is read by: its own stamp, or — for a row from before
* the stamp — the server's rule now, which is how those rows always read.
*/
function ruleOf(frame, rule) {
return frame && CREDIT_RULE_IDS.includes(frame.creditRule) ? frame.creditRule : creditRuleOf(rule)
}
/**
* What one `world.event` adds to the counts: a credited kill of a helicopter,
* a Chinook or a Bradley, for each player the server's rule names. `[{ steamId,
@@ -270,6 +290,8 @@ module.exports = {
creditRuleOf,
isKnownType,
credited,
stampRule,
ruleOf,
nameOf,
gridOf,
shapeFor,

View File

@@ -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": {
@@ -3203,7 +3203,7 @@
"Public · Rust"
],
"summary": "Recent events on one Rust server",
"description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule`.",
"description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes.",
"parameters": [
{
"name": "id",
@@ -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": {