Compare commits
9 Commits
5decd5cc05
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fac860949 | |||
| 42bd6db6ad | |||
| 3453bc1867 | |||
| 72726838b5 | |||
| 9c35906d0e | |||
| 7304d68d0a | |||
| 3aa142bf06 | |||
| d69c2108c6 | |||
| 8aff9b33a6 |
@@ -50,6 +50,7 @@
|
|||||||
"guardedCrate.js",
|
"guardedCrate.js",
|
||||||
"index.js",
|
"index.js",
|
||||||
"ingest.js",
|
"ingest.js",
|
||||||
|
"liveStatus.js",
|
||||||
"mapImages.js",
|
"mapImages.js",
|
||||||
"mapLive.js",
|
"mapLive.js",
|
||||||
"model",
|
"model",
|
||||||
|
|||||||
@@ -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 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; it moves to `main` when that cutover lands.",
|
"$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",
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
"ref": "9a1654fc543c0f73323dbf675b347de42efe8ae4",
|
"ref": "6890a74c7c4ad5690509e8c163e477d7f7b399e7",
|
||||||
"refName": "edge @ website#218 merge, MODULE_API 1.17.0"
|
"refName": "main @ website#220 merge (the cutover), MODULE_API 1.17.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ function worldLine(frame) {
|
|||||||
case 'crate.hack.started':
|
case 'crate.hack.started':
|
||||||
return who
|
return who
|
||||||
? { tone: 'server', actor: who, verb: `started hacking the locked crate ${place}`, subject: null, detail: grid }
|
? { 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':
|
case 'crate.unlocked':
|
||||||
return say(`The locked crate ${place} is open`)
|
return say(`The locked crate ${place} is open`)
|
||||||
case 'crate.looted':
|
case 'crate.looted':
|
||||||
|
|||||||
@@ -781,7 +781,13 @@ export default function Permissions() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{sync && (
|
{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>
|
: sync.state === 'failed' ? <Chip tone="bad" title={sync.error || ''}>last sync failed</Chip>
|
||||||
: <Chip tone="warn">waiting for a sync</Chip>
|
: <Chip tone="warn">waiting for a sync</Chip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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' }))
|
const hack = describe(world({ event: 'crate.hack.started', where: 'oilrig_large', grid: 'C3' }))
|
||||||
assert.equal(hack.actor, null)
|
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', () => {
|
test('a named line reads as the plan writes it', () => {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const { serverFor, transportError, pluginError } = require('./eventLeases')
|
|||||||
const { BUDGET_MS } = require('./eventWorld')
|
const { BUDGET_MS } = require('./eventWorld')
|
||||||
const { gridOf } = require('./worldEvents')
|
const { gridOf } = require('./worldEvents')
|
||||||
const { LEVELS, reportOf } = require('./dangerousTreasures')
|
const { LEVELS, reportOf } = require('./dangerousTreasures')
|
||||||
|
const live = require('./liveStatus')
|
||||||
const serversDb = require('./model/servers/servers.db')
|
const serversDb = require('./model/servers/servers.db')
|
||||||
const mapDb = require('./model/map/map.db')
|
const mapDb = require('./model/map/map.db')
|
||||||
|
|
||||||
@@ -131,7 +132,8 @@ const dtStart = {
|
|||||||
if (!found.ok) return found
|
if (!found.ok) return found
|
||||||
const name = found.server.name || found.server.id
|
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 (missing) return { ok: false, retry: false, error: missing }
|
||||||
|
|
||||||
if (verify) return { ok: true }
|
if (verify) return { ok: true }
|
||||||
|
|||||||
@@ -26,14 +26,19 @@ const { serverFor, transportError, pluginError } = require('./eventLeases')
|
|||||||
const { BUDGET_MS, location } = require('./eventWorld')
|
const { BUDGET_MS, location } = require('./eventWorld')
|
||||||
const { gridOf } = require('./worldEvents')
|
const { gridOf } = require('./worldEvents')
|
||||||
const { reportOf } = require('./guardedCrate')
|
const { reportOf } = require('./guardedCrate')
|
||||||
|
const live = require('./liveStatus')
|
||||||
const serversDb = require('./model/servers/servers.db')
|
const serversDb = require('./model/servers/servers.db')
|
||||||
const mapDb = require('./model/map/map.db')
|
const mapDb = require('./model/map/map.db')
|
||||||
|
|
||||||
/** Guarded Crate's events as it ships them, offered when no server has said its own. */
|
/** 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'])
|
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. */
|
/** 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([
|
const PERMANENT = new Set([
|
||||||
@@ -154,7 +159,8 @@ const gcPlace = {
|
|||||||
if (!found.ok) return found
|
if (!found.ok) return found
|
||||||
const name = found.server.name || found.server.id
|
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 (missing) return { ok: false, retry: false, error: missing }
|
||||||
|
|
||||||
if (verify) return { ok: true }
|
if (verify) return { ok: true }
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const { serverFor, transportError, pluginError } = require('./eventLeases')
|
|||||||
const { BUDGET_MS, location, revert, reconcile } = require('./eventWorld')
|
const { BUDGET_MS, location, revert, reconcile } = require('./eventWorld')
|
||||||
const { gridOf } = require('./worldEvents')
|
const { gridOf } = require('./worldEvents')
|
||||||
const { reportOf, helperPatched } = require('./raidableBases')
|
const { reportOf, helperPatched } = require('./raidableBases')
|
||||||
|
const live = require('./liveStatus')
|
||||||
const serversDb = require('./model/servers/servers.db')
|
const serversDb = require('./model/servers/servers.db')
|
||||||
const mapDb = require('./model/map/map.db')
|
const mapDb = require('./model/map/map.db')
|
||||||
|
|
||||||
@@ -107,10 +108,19 @@ function byValue(entries) {
|
|||||||
const out = new Map()
|
const out = new Map()
|
||||||
for (const { value, label, server } of entries) {
|
for (const { value, label, server } of entries) {
|
||||||
const key = value.toLowerCase()
|
const key = value.toLowerCase()
|
||||||
if (!out.has(key)) out.set(key, { value, label, servers: [] })
|
if (!out.has(key)) out.set(key, { value, labels: new Map() })
|
||||||
if (!out.get(key).servers.includes(server)) out.get(key).servers.push(server)
|
// 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 = [
|
const OPTION_SOURCES = [
|
||||||
@@ -371,8 +381,15 @@ const raidSpawn = {
|
|||||||
if (!found.ok) return found
|
if (!found.ok) return found
|
||||||
const name = found.server.name || found.server.id
|
const name = found.server.name || found.server.id
|
||||||
|
|
||||||
const report = reportOf(await serversDb.rbReportFor(found.server.id).catch(() => null))
|
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
||||||
const missing = missingOn(report, name, parsed.wire, parsed.spot)
|
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 (missing) return { ok: false, retry: false, error: missing }
|
||||||
|
|
||||||
if (verify) {
|
if (verify) {
|
||||||
@@ -421,7 +438,8 @@ const raidSpawn = {
|
|||||||
if (data.repeat) notes.push('answered from the first attempt; nothing new was spawned')
|
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.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 (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.
|
// Only a base the helper made the run's is a resource teardown can take back.
|
||||||
if (!data.id) return { ok: true, detail }
|
if (!data.id) return { ok: true, detail }
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const linksDb = require('./model/links/links.db')
|
|||||||
const emit = require('./engagement/emit')
|
const emit = require('./engagement/emit')
|
||||||
const economics = require('./model/economics/economics.model')
|
const economics = require('./model/economics/economics.model')
|
||||||
const backpacks = require('./model/backpacks/backpacks.model')
|
const backpacks = require('./model/backpacks/backpacks.model')
|
||||||
|
const live = require('./liveStatus')
|
||||||
const voice = require('./model/permissions/voice')
|
const voice = require('./model/permissions/voice')
|
||||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||||
const { BUDGET_MS } = require('./eventWorld')
|
const { BUDGET_MS } = require('./eventWorld')
|
||||||
@@ -741,6 +742,22 @@ function coinsNotice(params, amount) {
|
|||||||
return { ok: true, text, delivery }
|
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. */
|
/** 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) {
|
function paymentKey(idempotencyKey, runId, stepId) {
|
||||||
const key = String(idempotencyKey || `coins:${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
|
// 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.
|
// 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 (!econ.loaded) return { ok: false, retry: false, error: `${economics.MISSING} (${name})` }
|
||||||
|
|
||||||
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
|
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
|
||||||
@@ -906,7 +924,7 @@ const coinsPay = {
|
|||||||
paid: paid.length,
|
paid: paid.length,
|
||||||
total,
|
total,
|
||||||
received: paid.slice(0, 50),
|
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}` } : {}),
|
...(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' } : {}),
|
...(data.repeat ? { repeat: true, repeatNote: 'answered from the first attempt; nobody was paid twice' } : {}),
|
||||||
...missedDetail,
|
...missedDetail,
|
||||||
@@ -995,7 +1013,8 @@ const backpackSize = {
|
|||||||
|
|
||||||
// D199: refused with the reason where Backpacks is absent, in the dry run
|
// 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.
|
// 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})` }
|
if (!report.loaded) return { ok: false, retry: false, error: `${backpacks.MISSING} (${name})` }
|
||||||
const offered = backpacks.leasable(report)
|
const offered = backpacks.leasable(report)
|
||||||
if (!offered.includes(size)) {
|
if (!offered.includes(size)) {
|
||||||
@@ -1356,17 +1375,23 @@ const OPTION_SOURCES = [
|
|||||||
]),
|
]),
|
||||||
{
|
{
|
||||||
// From each server's last hello: the sizes its Backpacks offers above the
|
// 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',
|
id: 'rust.options.backpacksizes',
|
||||||
label: 'Backpack sizes',
|
label: 'Backpack sizes',
|
||||||
description: "Each server's Backpacks sizes above its smallest — its backpacks.size.N permissions.",
|
description: "Each server's Backpacks sizes above its smallest — its backpacks.size.N permissions.",
|
||||||
async resolve() {
|
async resolve() {
|
||||||
const [list, reports] = await Promise.all([servers.listForPolling(), backpacks.byServer()])
|
const [list, reports] = await Promise.all([servers.listForPolling(), backpacks.byServer()])
|
||||||
const rows = []
|
const bySize = new Map()
|
||||||
for (const s of list) {
|
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,
|
usersFor,
|
||||||
readTally,
|
readTally,
|
||||||
entitlementRef,
|
entitlementRef,
|
||||||
|
failedNote,
|
||||||
pickRecipients,
|
pickRecipients,
|
||||||
checkCount,
|
checkCount,
|
||||||
splitKit,
|
splitKit,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const npcProfile = require('./model/npcs/npcProfile')
|
|||||||
const clansDb = require('./model/clans/clans.db')
|
const clansDb = require('./model/clans/clans.db')
|
||||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||||
const eventProgress = require('./eventProgress')
|
const eventProgress = require('./eventProgress')
|
||||||
|
const live = require('./liveStatus')
|
||||||
|
|
||||||
const log = core.logger('world')
|
const log = core.logger('world')
|
||||||
|
|
||||||
@@ -323,11 +324,19 @@ async function place(server, send, body, what) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const placed = Array.isArray(data.placed) ? data.placed : []
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
resources: placed.map((row) => ({
|
resources: placed.map((row) => ({
|
||||||
kind: OWNED_KIND,
|
kind: OWNED_KIND,
|
||||||
ref: refOf(server.id, row.id),
|
ref: refOf(server.id, row.id),
|
||||||
|
...(until ? { until } : {}),
|
||||||
payload: {
|
payload: {
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
what: row.kind,
|
what: row.kind,
|
||||||
@@ -690,8 +699,12 @@ function npcOrders(params, server) {
|
|||||||
* What the server itself holds is the plugin's to answer (`unknown-profile`).
|
* What the server itself holds is the plugin's to answer (`unknown-profile`).
|
||||||
*/
|
*/
|
||||||
async function npcMissing(server, profile) {
|
async function npcMissing(server, profile) {
|
||||||
const [list, profiles] = await Promise.all([npcsDb.listNpcServers(), profile === null ? [] : npcsDb.listProfiles()])
|
const hereNow = async () => (await npcsDb.listNpcServers()).find((s) => s.id === server.id)
|
||||||
const here = list.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
|
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 (!npcs.npcReady(here)) return `${name} needs RunicNPC to place NPCs, Rust's own scientists included: ${npcs.npcAbsence(here)}`
|
||||||
if (profile === null) return null
|
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
|
// §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
|
// last hello carried (D211). A server that has not said is left to the
|
||||||
// plugin, which checks every one against its live ZoneManager.
|
// 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 known = mods && mods.zoneManager && mods.zoneManager.flags && mods.zoneManager.flags.length ? mods.zoneManager.flags : null
|
||||||
const opts = zoneOptions.parse(params.options, known)
|
const opts = zoneOptions.parse(params.options, known)
|
||||||
if (!opts.ok) return { ok: false, retry: false, error: opts.error }
|
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))) {
|
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}"` }
|
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)
|
const missing = domeMissing(mods)
|
||||||
if (missing) return { ok: false, retry: false, error: missing }
|
if (missing) return { ok: false, retry: false, error: missing }
|
||||||
dome = { type: kind.type, stack: stack === undefined ? DOME_STACK_DEFAULT : stack }
|
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.
|
// §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)
|
const truePve = truePveMapping(params, mods)
|
||||||
if (!truePve.ok) return { ok: false, retry: false, error: truePve.error }
|
if (!truePve.ok) return { ok: false, retry: false, error: truePve.error }
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,17 @@ const worldCall = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = result.data || {}
|
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') {
|
if (data.kind !== 'world.called') {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ const MAX_BATCHES_PER_TICK = 10
|
|||||||
* how to read.
|
* how to read.
|
||||||
*/
|
*/
|
||||||
async function apply(serverId, item, server = null) {
|
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 kind = item.kind || frame.kind
|
||||||
const wipeId = frame.wipeId || null
|
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
|
// A protocol-13 plugin still sends `heliKills` / `apcKills` on its tally
|
||||||
// instead, and the tally path above adds those as it always has.
|
// instead, and the tally path above adds those as it always has.
|
||||||
case 'world.event': {
|
case 'world.event': {
|
||||||
const rule = worldEvents.creditRuleOf(server && server.creditRule)
|
for (const kill of worldEvents.killsOf(frame, worldEvents.ruleOf(frame, server && server.creditRule))) {
|
||||||
for (const kill of worldEvents.killsOf(frame, rule)) {
|
|
||||||
await db.touchPlayer(kill.steamId, kill.name || null)
|
await db.touchPlayer(kill.steamId, kill.name || null)
|
||||||
await db.addTitleStats({ serverId, wipeId, steamId: kill.steamId }, { [kill.field]: 1 })
|
await db.addTitleStats({ serverId, wipeId, steamId: kill.steamId }, { [kill.field]: 1 })
|
||||||
}
|
}
|
||||||
|
|||||||
95
server/liveStatus.js
Normal file
95
server/liveStatus.js
Normal 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 }
|
||||||
@@ -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. */
|
/** Whether this file is the bridge's own config, by the name the plugin gave. */
|
||||||
function isBridgeConfig(path, self) {
|
function isBridgeConfig(path, self) {
|
||||||
if (!self) return false
|
if (!self) return false
|
||||||
const plugin = String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
|
return pluginOf(path).toLowerCase() === String(self).toLowerCase()
|
||||||
return plugin.toLowerCase() === String(self).toLowerCase()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The locked keys for a file: three of them in our own config, none anywhere else. */
|
/** The locked keys for a file: three of them in our own config, none anywhere else. */
|
||||||
@@ -279,6 +290,7 @@ module.exports = {
|
|||||||
isPlausiblePath,
|
isPlausiblePath,
|
||||||
shapeCatalogue,
|
shapeCatalogue,
|
||||||
shapeFile,
|
shapeFile,
|
||||||
|
pluginOf,
|
||||||
isBridgeConfig,
|
isBridgeConfig,
|
||||||
lockedKeysFor,
|
lockedKeysFor,
|
||||||
lockedChanges,
|
lockedChanges,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const db = require('./economics.db')
|
|||||||
const eventsDb = require('../events/events.db')
|
const eventsDb = require('../events/events.db')
|
||||||
const servers = require('../servers/servers.model')
|
const servers = require('../servers/servers.model')
|
||||||
const { pluginError, transportError } = require('../../eventLeases')
|
const { pluginError, transportError } = require('../../eventLeases')
|
||||||
|
const live = require('../../liveStatus')
|
||||||
|
|
||||||
const log = core.logger('economics')
|
const log = core.logger('economics')
|
||||||
|
|
||||||
@@ -168,7 +169,8 @@ async function adjust({ serverId, steamId, op, amount, key }) {
|
|||||||
const server = await servers.getForCalling(serverId)
|
const server = await servers.getForCalling(serverId)
|
||||||
if (!server) return { ok: false, status: 404, message: 'No such server' }
|
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 }
|
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 })
|
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 })
|
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 {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: data.reason === 'economics-missing' ? 409 : 400,
|
status: data.reason === 'economics-missing' || data.reason === 'at-limit' ? 409 : 400,
|
||||||
reason: data.reason || null,
|
reason: data.reason || null,
|
||||||
message: pluginError(data, `${name} refused the adjustment`),
|
message: pluginError(data, `${name} refused the adjustment`),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
serverId: row.serverId,
|
serverId: row.serverId,
|
||||||
state: row.state,
|
state: row.state,
|
||||||
dirty: Boolean(row.dirty),
|
dirty: Boolean(row.dirty),
|
||||||
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
|
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
|
||||||
|
waitingToLand,
|
||||||
lastAttemptAt: row.lastAttemptAt,
|
lastAttemptAt: row.lastAttemptAt,
|
||||||
lastOkAt: row.lastOkAt,
|
lastOkAt: row.lastOkAt,
|
||||||
importedAt: row.importedAt || null,
|
importedAt: row.importedAt || null,
|
||||||
|
|||||||
@@ -314,13 +314,7 @@ async function syncOne(server, { authored, sync, state, force, policy = 'auto-ad
|
|||||||
held: planned.hold.size,
|
held: planned.hold.size,
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await sidecar.permSync(server, {
|
const result = await sidecar.permSync(server, pushBody(desired.hash, held.payload, pushed, retire))
|
||||||
setId: desired.hash,
|
|
||||||
groups: withExpect(held.payload.groups, pushed),
|
|
||||||
grants: held.payload.grants,
|
|
||||||
credits: held.payload.credits,
|
|
||||||
retire,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
await failed(server, { reason, error: result.status, desiredHash: desired.hash, sync, bootId, wipeId })
|
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'
|
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
|
* 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.
|
* site last pushed to THIS server (`expect`), or null for one it never has.
|
||||||
@@ -526,6 +537,7 @@ module.exports = {
|
|||||||
syncOne,
|
syncOne,
|
||||||
reasonToSync,
|
reasonToSync,
|
||||||
applyReport,
|
applyReport,
|
||||||
|
pushBody,
|
||||||
withExpect,
|
withExpect,
|
||||||
styleRetirements,
|
styleRetirements,
|
||||||
withLock,
|
withLock,
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ async function record(req, row) {
|
|||||||
try {
|
try {
|
||||||
id = await db.recordWrite({
|
id = await db.recordWrite({
|
||||||
...row,
|
...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,
|
reloadTarget: row.reload,
|
||||||
userId: req.user ? req.user.id : null,
|
userId: req.user ? req.user.id : null,
|
||||||
})
|
})
|
||||||
@@ -347,10 +347,6 @@ async function record(req, row) {
|
|||||||
return id
|
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. */
|
/** A `config.error` frame as a sentence. */
|
||||||
function refusalMessage(frame) {
|
function refusalMessage(frame) {
|
||||||
const reasons = {
|
const reasons = {
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ usersRustRouter.post(
|
|||||||
/* #swagger.responses[200] = { description: 'Adjusted', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjusted" } } } } */
|
/* #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[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[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" } } } } */
|
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
param('id').isInt(),
|
param('id').isInt(),
|
||||||
body('serverId').isString().isLength({ min: 1, max: 64 }),
|
body('serverId').isString().isLength({ min: 1, max: 64 }),
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ rustRouter.get(
|
|||||||
'/servers/:id/events',
|
'/servers/:id/events',
|
||||||
// #swagger.tags = ['Public · Rust']
|
// #swagger.tags = ['Public · Rust']
|
||||||
// #swagger.summary = 'Recent events on one Rust server'
|
// #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['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['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' } }
|
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||||
|
|||||||
@@ -658,6 +658,11 @@ module.exports = {
|
|||||||
description: 'True when the last successful push carried the set the site currently authors.',
|
description: 'True when the last successful push carried the set the site currently authors.',
|
||||||
example: true,
|
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 },
|
dirty: { type: 'boolean', example: false },
|
||||||
lastAttemptAt: { type: 'string', format: 'date-time', nullable: true },
|
lastAttemptAt: { type: 'string', format: 'date-time', nullable: true },
|
||||||
lastOkAt: { type: 'string', format: 'date-time', nullable: true },
|
lastOkAt: { type: 'string', format: 'date-time', nullable: true },
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ function withRecorder(rows = []) {
|
|||||||
|
|
||||||
withRecorder()
|
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 client = require('../sidecarClient')
|
||||||
const serversDb = require('../model/servers/servers.db')
|
const serversDb = require('../model/servers/servers.db')
|
||||||
const servers = require('../model/servers/servers.model')
|
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'])
|
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 }
|
const saved = { listForPolling: servers.listForPolling, reports: backpacksDb.reports }
|
||||||
t.after(() => Object.assign(servers, { listForPolling: saved.listForPolling }) && Object.assign(backpacksDb, { reports: saved.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 () => [
|
backpacksDb.reports = async () => [
|
||||||
{ serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48] }) },
|
{ serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48, 96] }) },
|
||||||
{ serverId: 'pve', backpacks: null },
|
{ 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')
|
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 ────────────────────────────────────────────────────────────
|
// ── Reading one ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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) ────────────────
|
// ── 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', () => {
|
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')
|
const model = require('../model/config/config.model')
|
||||||
assert.strictEqual(model.isPlausiblePath('@data/RaidableBases/Profiles/Hard Bases.json'), true)
|
assert.strictEqual(model.isPlausiblePath('@data/RaidableBases/Profiles/Hard Bases.json'), true)
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ function withRecorder(rows = []) {
|
|||||||
|
|
||||||
withRecorder()
|
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 dt = require('../dangerousTreasures')
|
||||||
const { gridOf } = require('../worldEvents')
|
const { gridOf } = require('../worldEvents')
|
||||||
const { TRIGGERS } = require('../engagement/triggers')
|
const { TRIGGERS } = require('../engagement/triggers')
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ function withRecorder(rows = []) {
|
|||||||
|
|
||||||
withRecorder()
|
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 client = require('../sidecarClient')
|
||||||
const serversDb = require('../model/servers/servers.db')
|
const serversDb = require('../model/servers/servers.db')
|
||||||
const servers = require('../model/servers/servers.model')
|
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.strictEqual(result.detail.total, 500)
|
||||||
assert.deepStrictEqual(result.detail.received, [{ name: 'p1', received: 500 }])
|
assert.deepStrictEqual(result.detail.received, [{ name: 'p1', received: 500 }])
|
||||||
assert.deepStrictEqual(result.detail.failed, [{ name: 'p4', reason: 'at-limit' }])
|
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.deepStrictEqual(result.detail.missed, ['p3'])
|
||||||
assert.strictEqual(result.resources, undefined, 'nothing is ledgered')
|
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) => {
|
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 }
|
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: [] }) })
|
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.match(result.message, /only 340 coins on Main/)
|
||||||
assert.deepStrictEqual(sent, [{ key: 'adj-1', steamId: A, op: 'withdraw', amount: 500 }])
|
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
|
economicsDb.reportFor = async () => null
|
||||||
const missing = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 5, key: 'adj-2' })
|
const missing = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 5, key: 'adj-2' })
|
||||||
assert.strictEqual(missing.status, 409)
|
assert.strictEqual(missing.status, 409)
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ function withRecorder(rows = []) {
|
|||||||
|
|
||||||
withRecorder()
|
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 gc = require('../guardedCrate')
|
||||||
const { gridOf } = require('../worldEvents')
|
const { gridOf } = require('../worldEvents')
|
||||||
const { TRIGGERS } = require('../engagement/triggers')
|
const { TRIGGERS } = require('../engagement/triggers')
|
||||||
|
|||||||
113
server/test/liveStatus.test.js
Normal file
113
server/test/liveStatus.test.js
Normal 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 })
|
||||||
|
})
|
||||||
@@ -27,6 +27,10 @@ require('../core')._reset()
|
|||||||
const ctx = fakeCtx({ freeze: false })
|
const ctx = fakeCtx({ freeze: false })
|
||||||
require('../core').init(ctx)
|
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 client = require('../sidecarClient')
|
||||||
const servers = require('../model/servers/servers.model')
|
const servers = require('../model/servers/servers.model')
|
||||||
const serversDb = require('../model/servers/servers.db')
|
const serversDb = require('../model/servers/servers.db')
|
||||||
|
|||||||
@@ -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)', () => {
|
test('a grant reaches every Steam account its holder has linked (D28)', () => {
|
||||||
withCore()
|
withCore()
|
||||||
const model = require('../model/permissions/permissions.model')
|
const model = require('../model/permissions/permissions.model')
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ function withRecorder(rows = []) {
|
|||||||
|
|
||||||
withRecorder()
|
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 rb = require('../raidableBases')
|
||||||
const catalogue = require('../catalogue')
|
const catalogue = require('../catalogue')
|
||||||
const { gridOf } = require('../worldEvents')
|
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')
|
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) => {
|
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 })
|
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 } } } })
|
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' } })
|
const result = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', difficulty: 'Wobbly' } })
|
||||||
assert.ok(result.ok)
|
assert.ok(result.ok)
|
||||||
assert.strictEqual(result.resources, undefined)
|
assert.strictEqual(result.resources, undefined)
|
||||||
assert.match(result.detail.note, /RaidableBases picks the spot/)
|
const own = module_().OWN_TIMER
|
||||||
assert.match(result.detail.note, new RegExp(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) => {
|
test('the grid’s wait is a ten-minute retry; a refusal the plugin would repeat is not retried', async (t) => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const rewards = require('../eventRewards')
|
|||||||
const leases = require('../eventLeases')
|
const leases = require('../eventLeases')
|
||||||
const templates = require('../eventTemplates')
|
const templates = require('../eventTemplates')
|
||||||
const model = require('../model/permissions/permissions.model')
|
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 entitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle')
|
||||||
const ok = (data) => ({ ok: true, status: 'ok', data })
|
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', () => {
|
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 none = model.buildDesired('main', authored([grantRow({})]))
|
||||||
const one = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeDelivery: 'chat' })]))
|
const one = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeDelivery: 'chat' })]))
|
||||||
|
|||||||
@@ -178,10 +178,13 @@ test('a zone crosses with its key, its duration, and the monument without the se
|
|||||||
server: 'main',
|
server: 'main',
|
||||||
body: { runId: '7', key: 'k1', monument: 'airfield_1', offsetX: 0, offsetZ: -20, radius: 40, holdMs: 5400000, name: 'Brawl' },
|
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.
|
// The ref carries the server: revert and reconcile are handed no params. The
|
||||||
assert.deepStrictEqual(result.resources, [
|
// zone's deadline rides as `until` (W1); zones.test.js checks its value.
|
||||||
{ kind: 'world', ref: 'main:rg-7-1-1', payload: { serverId: 'main', what: 'zone', name: 'Z' } },
|
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) => {
|
test('one resource per thing placed, and a repeated key is said to be one', async (t) => {
|
||||||
|
|||||||
@@ -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)
|
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 () => {
|
test('the tally’s crates hacked and drops looted are added up (D356)', async () => {
|
||||||
const rec = withRecorder()
|
const rec = withRecorder()
|
||||||
const { apply } = require('../ingest')
|
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' }])
|
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) ────────────────────────────────────────────────────────
|
// ── Triggers (D355) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('one public trigger per type, none of them declaring a Steam id', () => {
|
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/)
|
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) => {
|
test('a repeat of a key says so and calls nothing new', async (t) => {
|
||||||
stubCall(t, ok({ kind: 'world.called', called: 1, repeat: true }))
|
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' } })
|
const result = await callStep().perform({ runId: 7, idempotencyKey: '7:5', params: { server: 'main', event: 'patrol_heli' } })
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ const { fakeCtx } = require('./_fakes')
|
|||||||
require('../core')._reset()
|
require('../core')._reset()
|
||||||
require('../core').init(fakeCtx())
|
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 client = require('../sidecarClient')
|
||||||
const serversDb = require('../model/servers/servers.db')
|
const serversDb = require('../model/servers/servers.db')
|
||||||
const zonesDb = require('../model/zones/zones.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) => {
|
test('a dome with its stack left blank stacks 3 spheres (D326)', async (t) => {
|
||||||
stubZones(t, { servers: [server('main', { domes: PATCHED })] })
|
stubZones(t, { servers: [server('main', { domes: PATCHED })] })
|
||||||
const calls = stubWorld(t)
|
const calls = stubWorld(t)
|
||||||
|
|||||||
@@ -178,7 +178,8 @@ function columnName(index) {
|
|||||||
* `staff` the viewer is an admin or a moderator: Steam ids, and a type this
|
* `staff` the viewer is an admin or a moderator: Steam ids, and a type this
|
||||||
* build does not know
|
* build does not know
|
||||||
* `presence` the viewer is in the server's presence audience: names
|
* `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
|
* `geometry` the server's map geometry, for the grid
|
||||||
*
|
*
|
||||||
* What comes back keeps the plugin's own fields for what it describes (`event`,
|
* 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.
|
// Credit, by the server's rule.
|
||||||
if (known && TYPES[event].credit) {
|
if (known && TYPES[event].credit) {
|
||||||
out.creditRule = creditRuleOf(rule)
|
out.creditRule = ruleOf(frame, rule)
|
||||||
const ids = credited(frame, rule)
|
const ids = credited(frame, out.creditRule)
|
||||||
out.creditedCount = ids.length
|
out.creditedCount = ids.length
|
||||||
if (presence || staff) {
|
if (presence || staff) {
|
||||||
out.credited = ids.map((id) => ({
|
out.credited = ids.map((id) => ({
|
||||||
@@ -235,6 +236,25 @@ function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDI
|
|||||||
return out
|
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,
|
* 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,
|
* a Chinook or a Bradley, for each player the server's rule names. `[{ steamId,
|
||||||
@@ -270,6 +290,8 @@ module.exports = {
|
|||||||
creditRuleOf,
|
creditRuleOf,
|
||||||
isKnownType,
|
isKnownType,
|
||||||
credited,
|
credited,
|
||||||
|
stampRule,
|
||||||
|
ruleOf,
|
||||||
nameOf,
|
nameOf,
|
||||||
gridOf,
|
gridOf,
|
||||||
shapeFor,
|
shapeFor,
|
||||||
|
|||||||
@@ -2387,7 +2387,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"409": {
|
"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": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -3203,7 +3203,7 @@
|
|||||||
"Public · Rust"
|
"Public · Rust"
|
||||||
],
|
],
|
||||||
"summary": "Recent events on one Rust server",
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"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": {
|
"dirty": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
Reference in New Issue
Block a user