Compare commits
19 Commits
f93ba86883
...
v0.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
| bc529962b4 | |||
| c4729315cd | |||
| 55a9ea4649 | |||
| 2e80a21597 | |||
| 3741c954f5 | |||
| 3fac860949 | |||
| 42bd6db6ad | |||
| 3453bc1867 | |||
| 72726838b5 | |||
| 9c35906d0e | |||
| 7304d68d0a | |||
| 3aa142bf06 | |||
| d69c2108c6 | |||
| 8aff9b33a6 | |||
| 5decd5cc05 | |||
| 885485d153 | |||
| 47da3a2db8 | |||
| 8172454683 | |||
| b89039142e |
@@ -50,6 +50,7 @@
|
||||
"guardedCrate.js",
|
||||
"index.js",
|
||||
"ingest.js",
|
||||
"liveStatus.js",
|
||||
"mapImages.js",
|
||||
"mapLive.js",
|
||||
"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.",
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change.",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "eb773693d32781f2ec717f9be07816b6afefd162",
|
||||
"refName": "main @ MODULE_API 1.15.0 (website#215 merged)"
|
||||
"ref": "6890a74c7c4ad5690509e8c163e477d7f7b399e7",
|
||||
"refName": "main @ website#220 merge (the cutover), MODULE_API 1.17.0"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
// here is presentation, and the honest presentation of a kind we have no words
|
||||
// for is its own name.
|
||||
|
||||
import { attacker, duration, prefab } from './format.js'
|
||||
import { attacker, duration, weapon } from './format.js'
|
||||
|
||||
/**
|
||||
* Kinds this feed asks for.
|
||||
@@ -205,7 +205,7 @@ export function describe(row) {
|
||||
*/
|
||||
function death(frame, name) {
|
||||
const where = [
|
||||
frame.weapon ? `with ${prefab(frame.weapon)}` : null,
|
||||
frame.weapon ? `with ${weapon(frame)}` : null,
|
||||
frame.distance ? `${Math.round(frame.distance)}m` : null,
|
||||
frame.grid || null,
|
||||
frame.sleeping ? 'while sleeping' : null,
|
||||
@@ -319,7 +319,7 @@ function worldLine(frame) {
|
||||
case 'crate.hack.started':
|
||||
return who
|
||||
? { tone: 'server', actor: who, verb: `started hacking the locked crate ${place}`, subject: null, detail: grid }
|
||||
: say(`A locked crate is being hacked ${place}`)
|
||||
: say(`A locked crate ${place} is being hacked`)
|
||||
case 'crate.unlocked':
|
||||
return say(`The locked crate ${place} is open`)
|
||||
case 'crate.looted':
|
||||
|
||||
@@ -130,6 +130,18 @@ export function prefab(name) {
|
||||
return String(name).replace(/[_.]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* The weapon in a kill. The server sends `weaponName` — the game's own name for
|
||||
* a gun, or the prefab name as words (`server/model/titles/weaponNames.js`) —
|
||||
* and that is what prints. A server too old to send it gets the same fallback
|
||||
* here: the `.entity` / `.weapon` ending dropped, so never "ak47u entity".
|
||||
*/
|
||||
export function weapon(frame) {
|
||||
if (!frame || !frame.weapon) return ''
|
||||
if (frame.weaponName) return String(frame.weaponName)
|
||||
return prefab(String(frame.weapon).trim().replace(/[._](entity|weapon|deployed)$/, ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* The NPC families whose prefab reads badly as words, by the prefix the game's
|
||||
* short names share. First match wins, so a longer prefix sits above a shorter
|
||||
|
||||
@@ -49,7 +49,7 @@ export function titlesSummary(titles, push, categories) {
|
||||
const rules = (titles && titles.rules) || []
|
||||
if (!rules.length) return 'No chat titles.'
|
||||
const list = rules.map((r) => `${ruleTitle(r, categories)} (top ${r.topN} ${statOf(categories, r.stat).label.toLowerCase()})`).join(', ')
|
||||
const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? '' : 's'} hold one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
|
||||
const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? ' holds' : 's hold'} one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
|
||||
return `Chat titles: ${list}.${shown}`
|
||||
}
|
||||
|
||||
|
||||
@@ -542,7 +542,7 @@ function GroupPanel({ view, group, act, busy, chatFields }) {
|
||||
{tab === 'servers' && (
|
||||
<div className="sans" style={{ fontSize: '0.82rem' }}>
|
||||
<p className="dim" style={{ margin: '0 0 8px' }}>
|
||||
A group belongs to one server unless it is shared (D189). A shared group carries the same permissions and players on every server
|
||||
A group belongs to one server unless it is shared. A shared group carries the same permissions and players on every server
|
||||
it is on; a change made in one game gives that server its own copy.
|
||||
</p>
|
||||
<label style={{ display: 'block', marginBottom: 6 }}>
|
||||
@@ -781,11 +781,17 @@ export default function Permissions() {
|
||||
</select>
|
||||
</label>
|
||||
{sync && (
|
||||
sync.inSync ? <Chip tone="good">in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''}</Chip>
|
||||
sync.inSync && sync.waitingToLand > 0
|
||||
? (
|
||||
<Chip tone="warn" title="A grant to a Steam id this server has never seen lands when that player first joins: every sync pushes it again">
|
||||
in sync · {sync.waitingToLand} {sync.waitingToLand === 1 ? 'waits' : 'wait'} for their player to join
|
||||
</Chip>
|
||||
)
|
||||
: sync.inSync ? <Chip tone="good">in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''}</Chip>
|
||||
: sync.state === 'failed' ? <Chip tone="bad" title={sync.error || ''}>last sync failed</Chip>
|
||||
: <Chip tone="warn">waiting for a sync</Chip>
|
||||
)}
|
||||
{sync && !sync.importedAt && <Chip tone="warn" title="The first sync imports everything on the server (D198)">not imported yet</Chip>}
|
||||
{sync && !sync.importedAt && <Chip tone="warn" title="The first sync imports everything on the server">not imported yet</Chip>}
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync(server.id))}>Sync now</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -377,6 +377,15 @@ export default function Account() {
|
||||
<li>Type it below. It works once.</li>
|
||||
</ol>
|
||||
|
||||
{/* D392: core's "Link Steam" is a second way to make this link. Unlinking
|
||||
Steam there removes only the sign-in (D393), so this page says where a
|
||||
Rust link is removed. */}
|
||||
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.86rem', maxWidth: '60ch' }}>
|
||||
If this site offers Steam sign-in, linking Steam on your <a href="/account" className="link-accent">account
|
||||
page</a> links that Steam account here too. Unlinking it there only stops you signing in with it; remove a
|
||||
Rust link here.
|
||||
</p>
|
||||
|
||||
<LinkForm onLinked={reload} />
|
||||
|
||||
{loading && <Loading />}
|
||||
|
||||
@@ -178,7 +178,7 @@ test('below the presence audience the same line names nobody (D376)', () => {
|
||||
|
||||
const hack = describe(world({ event: 'crate.hack.started', where: 'oilrig_large', grid: 'C3' }))
|
||||
assert.equal(hack.actor, null)
|
||||
assert.equal(hack.verb, 'A locked crate is being hacked on Large Oil Rig')
|
||||
assert.equal(hack.verb, 'A locked crate on Large Oil Rig is being hacked')
|
||||
})
|
||||
|
||||
test('a named line reads as the plan writes it', () => {
|
||||
@@ -338,3 +338,18 @@ test('a purchase and an admin lock name the player only when the server sent the
|
||||
assert.equal(describe(raid('raid.locked', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base was locked to a player')
|
||||
assert.equal(describe(raid('raid.ended', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base despawned')
|
||||
})
|
||||
|
||||
test('a kill line says the weapon by name', () => {
|
||||
const named = describe(row('player.death', {
|
||||
name: 'Bob', attackerType: 'player', attackerName: 'Alice',
|
||||
weapon: 'ak47u.entity', weaponName: 'Assault Rifle', distance: 22,
|
||||
}))
|
||||
assert.match(named.detail, /with Assault Rifle/)
|
||||
assert.doesNotMatch(named.detail, /entity/)
|
||||
|
||||
const older = describe(row('player.death', {
|
||||
name: 'Bob', attackerType: 'player', attackerName: 'Alice', weapon: 'bolt_rifle.entity',
|
||||
}))
|
||||
assert.match(older.detail, /with bolt rifle/)
|
||||
assert.doesNotMatch(older.detail, /entity/)
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { ago, attacker, clock, coins, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
|
||||
import { ago, attacker, clock, coins, contrastInk, count, day, duration, nextWipe, prefab, shortId, weapon } from '../src/lib/format.js'
|
||||
|
||||
const NOW = Date.parse('2026-09-16T12:00:00Z')
|
||||
|
||||
@@ -147,3 +147,15 @@ test('an NPC attacker reads as a name: a family, or the prefab without its varia
|
||||
test('a weapon keeps the light touch — attacker() is for what did the killing', () => {
|
||||
assert.equal(prefab('rifle.ak'), 'rifle ak')
|
||||
})
|
||||
|
||||
test('a kill prints the weapon the server named, and never an entity ending', () => {
|
||||
// weaponName is the server's (model/titles/weaponNames.js): the game's own name for a gun.
|
||||
assert.equal(weapon({ weapon: 'ak47u.entity', weaponName: 'Assault Rifle' }), 'Assault Rifle')
|
||||
// A server too old to send it: the ending dropped, the rest as words.
|
||||
assert.equal(weapon({ weapon: 'ak47u.entity' }), 'ak47u')
|
||||
assert.equal(weapon({ weapon: 'bolt_rifle.entity' }), 'bolt rifle')
|
||||
assert.equal(weapon({ weapon: 'machete.weapon' }), 'machete')
|
||||
assert.equal(weapon({ weapon: 'rifle.ak' }), 'rifle ak')
|
||||
assert.equal(weapon({}), '')
|
||||
assert.equal(weapon(null), '')
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.",
|
||||
"coreApi": "^1.15.0",
|
||||
"coreApi": "^1.17.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "rust.base.destroyed",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "rust",
|
||||
"name": "Rust",
|
||||
"version": "0.1.0",
|
||||
"coreApi": "^1.15.0",
|
||||
"coreApi": "^1.17.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
|
||||
@@ -169,7 +169,7 @@ function replyDetail(data) {
|
||||
...(replies.length
|
||||
? { replies: replies.map((r) => ({ command: String(r.command || ''), reply: String(r.reply || ''), ...(r.steamId ? { steamId: String(r.steamId) } : {}) })) }
|
||||
: {}),
|
||||
...(skippedCount ? { skipped, skippedCount, skippedNote: 'skipped: not on the server when the reward ran (D383)' } : {}),
|
||||
...(skippedCount ? { skipped, skippedCount, skippedNote: 'skipped: not on the server when the reward ran' } : {}),
|
||||
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing was run twice' } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
const { gridOf } = require('./worldEvents')
|
||||
const { LEVELS, reportOf } = require('./dangerousTreasures')
|
||||
const live = require('./liveStatus')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const mapDb = require('./model/map/map.db')
|
||||
|
||||
@@ -131,7 +132,8 @@ const dtStart = {
|
||||
if (!found.ok) return found
|
||||
const name = found.server.name || found.server.id
|
||||
|
||||
const missing = missingOn(reportOf(await serversDb.dtReportFor(found.server.id)), name)
|
||||
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
||||
const missing = await live.confirmed(found.server, async () => missingOn(reportOf(await serversDb.dtReportFor(found.server.id)), name), Boolean)
|
||||
if (missing) return { ok: false, retry: false, error: missing }
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
@@ -26,14 +26,19 @@ const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS, location } = require('./eventWorld')
|
||||
const { gridOf } = require('./worldEvents')
|
||||
const { reportOf } = require('./guardedCrate')
|
||||
const live = require('./liveStatus')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const mapDb = require('./model/map/map.db')
|
||||
|
||||
/** Guarded Crate's events as it ships them, offered when no server has said its own. */
|
||||
const DEFAULT_EVENTS = Object.freeze(['Easy', 'Medium', 'Hard', 'Elite'])
|
||||
|
||||
/** What the run log says of a crate the run leaves behind (D373, D380). */
|
||||
const KEPT = 'kept when the run ends: it ends when its last guard dies, or when nobody has killed one for its time limit (30 minutes as Guarded Crate ships)'
|
||||
/**
|
||||
* What the run log says of a crate the run leaves behind (D373, D380). No
|
||||
* number: each of Guarded Crate's events has its own time limit, and the
|
||||
* server's report names the events but not their limits (W18).
|
||||
*/
|
||||
const KEPT = "kept when the run ends: it ends when its last guard dies, or when nobody has killed one within its event's time limit in Guarded Crate"
|
||||
|
||||
/** The plugin's refusals a second attempt would repeat. `gc-too-close` is not one: the other crate may be cleared. */
|
||||
const PERMANENT = new Set([
|
||||
@@ -154,7 +159,8 @@ const gcPlace = {
|
||||
if (!found.ok) return found
|
||||
const name = found.server.name || found.server.id
|
||||
|
||||
const missing = missingOn(reportOf(await serversDb.gcReportFor(found.server.id)), name, wanted)
|
||||
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
||||
const missing = await live.confirmed(found.server, async () => missingOn(reportOf(await serversDb.gcReportFor(found.server.id)), name, wanted), Boolean)
|
||||
if (missing) return { ok: false, retry: false, error: missing }
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
@@ -452,7 +452,7 @@ const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'rust.options.servers',
|
||||
label: 'Rust servers',
|
||||
description: 'Every enabled server on this site. A lease holds a value on one of them (D73).',
|
||||
description: 'Every enabled server on this site. A lease holds a value on one of them.',
|
||||
async resolve() {
|
||||
const list = await servers.listForPolling()
|
||||
return list.map((s) => ({ value: s.id, label: s.name || s.id }))
|
||||
|
||||
@@ -43,6 +43,7 @@ const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS, location, revert, reconcile } = require('./eventWorld')
|
||||
const { gridOf } = require('./worldEvents')
|
||||
const { reportOf, helperPatched } = require('./raidableBases')
|
||||
const live = require('./liveStatus')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const mapDb = require('./model/map/map.db')
|
||||
|
||||
@@ -107,10 +108,19 @@ function byValue(entries) {
|
||||
const out = new Map()
|
||||
for (const { value, label, server } of entries) {
|
||||
const key = value.toLowerCase()
|
||||
if (!out.has(key)) out.set(key, { value, label, servers: [] })
|
||||
if (!out.get(key).servers.includes(server)) out.get(key).servers.push(server)
|
||||
if (!out.has(key)) out.set(key, { value, labels: new Map() })
|
||||
// Labels compare ignoring case, as the names do.
|
||||
const labels = out.get(key).labels
|
||||
const said = label.toLowerCase()
|
||||
if (!labels.has(said)) labels.set(said, { label, servers: [] })
|
||||
if (!labels.get(said).servers.includes(server)) labels.get(said).servers.push(server)
|
||||
}
|
||||
return [...out.values()].map((e) => ({ value: e.value, label: `${e.label} — ${e.servers.join(', ')}` }))
|
||||
// One name, one row. Where servers describe it differently (a profile Hard on
|
||||
// one and Expert on another), each server's own words, not the first's (W13).
|
||||
return [...out.values()].map((e) => ({
|
||||
value: e.value,
|
||||
label: [...e.labels.values()].map(({ label, servers }) => `${label} — ${servers.join(', ')}`).join('; '),
|
||||
}))
|
||||
}
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
@@ -371,8 +381,15 @@ const raidSpawn = {
|
||||
if (!found.ok) return found
|
||||
const name = found.server.name || found.server.id
|
||||
|
||||
const report = reportOf(await serversDb.rbReportFor(found.server.id).catch(() => null))
|
||||
const missing = missingOn(report, name, parsed.wire, parsed.spot)
|
||||
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
||||
const { report, missing } = await live.confirmed(
|
||||
found.server,
|
||||
async () => {
|
||||
const read = reportOf(await serversDb.rbReportFor(found.server.id).catch(() => null))
|
||||
return { report: read, missing: missingOn(read, name, parsed.wire, parsed.spot) }
|
||||
},
|
||||
(r) => Boolean(r.missing),
|
||||
)
|
||||
if (missing) return { ok: false, retry: false, error: missing }
|
||||
|
||||
if (verify) {
|
||||
@@ -421,7 +438,8 @@ const raidSpawn = {
|
||||
if (data.repeat) notes.push('answered from the first attempt; nothing new was spawned')
|
||||
if (data.x === undefined) notes.push('RaidableBases picks the spot; the map shows it once the base opens')
|
||||
if (data.despawns !== 'teardown') notes.push(OWN_TIMER)
|
||||
if (notes.length) detail.note = notes.join('. ')
|
||||
// Each note after the first starts a sentence (W14).
|
||||
if (notes.length) detail.note = notes.map((n, i) => (i ? n[0].toUpperCase() + n.slice(1) : n)).join('. ')
|
||||
|
||||
// Only a base the helper made the run's is a resource teardown can take back.
|
||||
if (!data.id) return { ok: true, detail }
|
||||
|
||||
@@ -47,6 +47,7 @@ const linksDb = require('./model/links/links.db')
|
||||
const emit = require('./engagement/emit')
|
||||
const economics = require('./model/economics/economics.model')
|
||||
const backpacks = require('./model/backpacks/backpacks.model')
|
||||
const live = require('./liveStatus')
|
||||
const voice = require('./model/permissions/voice')
|
||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
@@ -355,7 +356,7 @@ const participationOpen = {
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'Which server counts.' },
|
||||
{ name: 'zone', type: 'string', required: false, example: 'Airfield brawl', source: 'rust.options.runzones',
|
||||
description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts (D100).' },
|
||||
description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts.' },
|
||||
{ name: 'score', type: 'string', required: true, example: 'both', source: 'rust.options.scoremodes',
|
||||
description: 'What earns a place: seconds present, kills, or both.' },
|
||||
{ name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsof',
|
||||
@@ -537,7 +538,7 @@ const kitEntitle = {
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'kit', type: 'string', required: true, example: 'main/vip-starter', source: 'rust.options.kits',
|
||||
description: 'The kit, as server/kit. The reward reaches only that server (D102).' },
|
||||
description: 'The kit, as server/kit. The reward reaches only that server.' },
|
||||
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
|
||||
description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all (a kit weekend).' },
|
||||
{ name: 'count', type: 'float', required: false, example: 3,
|
||||
@@ -741,6 +742,22 @@ function coinsNotice(params, amount) {
|
||||
return { ok: true, text, delivery }
|
||||
}
|
||||
|
||||
/** What each reason in a payment's `failed` list means, in the plugin's order. */
|
||||
const FAILED_REASONS = Object.freeze({
|
||||
'at-limit': "the balance is at Economics' limit",
|
||||
refused: 'Economics did not take the deposit',
|
||||
unmeasured: 'Economics did not report the deposit',
|
||||
})
|
||||
|
||||
/** A payment's `failedNote`: only the reasons that happened, each explained (W16). */
|
||||
function failedNote(failed) {
|
||||
const seen = new Set(failed.map((row) => row.reason))
|
||||
return [...Object.keys(FAILED_REASONS), ...[...seen].filter((r) => !FAILED_REASONS[r])]
|
||||
.filter((reason) => seen.has(reason))
|
||||
.map((reason) => (FAILED_REASONS[reason] ? `${reason}: ${FAILED_REASONS[reason]}` : `${reason}: Economics' own reason`))
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
/** The key the plugin remembers a payment by: core's, or a digest of it past the plugin's bound. */
|
||||
function paymentKey(idempotencyKey, runId, stepId) {
|
||||
const key = String(idempotencyKey || `coins:${runId}:${stepId}`)
|
||||
@@ -806,7 +823,8 @@ const coinsPay = {
|
||||
|
||||
// D199: refused with the reason where Economics is absent, in the dry run
|
||||
// too — the author learns it on the form, not at the run's reward.
|
||||
const econ = await economics.forServer(server.id)
|
||||
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
||||
const econ = await live.confirmed(server, () => economics.forServer(server.id), (e) => !e.loaded)
|
||||
if (!econ.loaded) return { ok: false, retry: false, error: `${economics.MISSING} (${name})` }
|
||||
|
||||
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
|
||||
@@ -906,7 +924,7 @@ const coinsPay = {
|
||||
paid: paid.length,
|
||||
total,
|
||||
received: paid.slice(0, 50),
|
||||
...(failed.length ? { failed: failed.slice(0, 50), failedNote: 'at-limit: the balance is at Economics\' limit; refused or unmeasured: Economics did not take or report the deposit' } : {}),
|
||||
...(failed.length ? { failed: failed.slice(0, 50), failedNote: failedNote(failed) } : {}),
|
||||
...(notice.text && paid.length ? { told: `${paid.length} will be told in game, by ${notice.delivery}` } : {}),
|
||||
...(data.repeat ? { repeat: true, repeatNote: 'answered from the first attempt; nobody was paid twice' } : {}),
|
||||
...missedDetail,
|
||||
@@ -995,7 +1013,8 @@ const backpackSize = {
|
||||
|
||||
// D199: refused with the reason where Backpacks is absent, in the dry run
|
||||
// too, and a size the server does not offer is refused by name.
|
||||
const report = await backpacks.forServer(server.id)
|
||||
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
||||
const report = await live.confirmed(server, () => backpacks.forServer(server.id), (r) => !r.loaded)
|
||||
if (!report.loaded) return { ok: false, retry: false, error: `${backpacks.MISSING} (${name})` }
|
||||
const offered = backpacks.leasable(report)
|
||||
if (!offered.includes(size)) {
|
||||
@@ -1153,13 +1172,13 @@ const announce = {
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.chatservers',
|
||||
description: 'Which server, or * for every server (D105).' },
|
||||
description: 'Which server, or * for every server.' },
|
||||
{ name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!',
|
||||
description: `The line, up to ${MAX_CHAT} characters.` },
|
||||
// Optional, and the action stays version 1: a bump would stop every step
|
||||
// already written from dispatching until somebody re-saved it (§33.2).
|
||||
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
||||
description: 'chat (the default), or popup — which needs PopupNotifications on the server, and is refused with a reason where it is missing (D141).' },
|
||||
description: 'chat (the default), or popup — which needs PopupNotifications on the server, and is refused with a reason where it is missing.' },
|
||||
],
|
||||
|
||||
// One per server reached. `*` is priced at the enabled servers when core asks,
|
||||
@@ -1342,7 +1361,7 @@ const OPTION_SOURCES = [
|
||||
{ value: 'npcs', label: 'NPCs, animals included' },
|
||||
{ value: 'both', label: 'Players and NPCs' },
|
||||
]),
|
||||
fixed('rust.options.recipientmodes', 'Recipients', 'Who a reward goes to (D101).', [
|
||||
fixed('rust.options.recipientmodes', 'Recipients', 'Who a reward goes to.', [
|
||||
{ value: 'everyone', label: 'Everyone who scored' },
|
||||
{ value: 'top', label: 'The top N (ties in)' },
|
||||
{ value: 'minScore', label: 'A score of at least X' },
|
||||
@@ -1350,23 +1369,29 @@ const OPTION_SOURCES = [
|
||||
{ value: 'topPercent', label: 'The top X per cent (ties in)' },
|
||||
{ value: 'linked', label: 'Every linked player of the server (no tally)' },
|
||||
]),
|
||||
fixed('rust.options.delivery', 'Delivery', 'Where a line goes (D141).', [
|
||||
fixed('rust.options.delivery', 'Delivery', 'Where a line goes.', [
|
||||
{ value: 'chat', label: 'Chat' },
|
||||
{ value: 'popup', label: 'A popup — needs PopupNotifications on the server' },
|
||||
]),
|
||||
{
|
||||
// From each server's last hello: the sizes its Backpacks offers above the
|
||||
// smallest, grouped by server (D337). The step checks the pair it is given.
|
||||
// smallest (D337). Each size once, naming every server that offers it, as
|
||||
// the other pickers merge a value (W10). The step checks the pair it is given.
|
||||
id: 'rust.options.backpacksizes',
|
||||
label: 'Backpack sizes',
|
||||
description: "Each server's Backpacks sizes above its smallest — its backpacks.size.N permissions.",
|
||||
async resolve() {
|
||||
const [list, reports] = await Promise.all([servers.listForPolling(), backpacks.byServer()])
|
||||
const rows = []
|
||||
const bySize = new Map()
|
||||
for (const s of list) {
|
||||
for (const n of backpacks.leasable(reports.get(s.id))) rows.push({ value: n, label: `${n} slots`, group: s.name || s.id })
|
||||
for (const n of backpacks.leasable(reports.get(s.id))) {
|
||||
if (!bySize.has(n)) bySize.set(n, [])
|
||||
bySize.get(n).push(s.name || s.id)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
return [...bySize.keys()]
|
||||
.sort((x, y) => x - y)
|
||||
.map((n) => ({ value: n, label: `${n} slots — ${bySize.get(n).join(', ')}` }))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1398,6 +1423,7 @@ module.exports = {
|
||||
usersFor,
|
||||
readTally,
|
||||
entitlementRef,
|
||||
failedNote,
|
||||
pickRecipients,
|
||||
checkCount,
|
||||
splitKit,
|
||||
|
||||
@@ -36,6 +36,7 @@ const npcProfile = require('./model/npcs/npcProfile')
|
||||
const clansDb = require('./model/clans/clans.db')
|
||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||
const eventProgress = require('./eventProgress')
|
||||
const live = require('./liveStatus')
|
||||
|
||||
const log = core.logger('world')
|
||||
|
||||
@@ -145,13 +146,13 @@ const BUDGETS = [
|
||||
id: 'rust.npcs',
|
||||
label: 'NPCs placed',
|
||||
unit: 'NPCs',
|
||||
description: 'Scientists and guards an event puts in the world — its own dial, so fights can be capped apart from loot (D89).',
|
||||
description: 'Scientists and guards an event puts in the world — its own dial, so fights can be capped apart from loot.',
|
||||
},
|
||||
{
|
||||
id: 'rust.zone.minutes',
|
||||
label: 'Zone time',
|
||||
unit: 'minutes',
|
||||
description: 'How long the zones an event opens stand, added up. Every zone declares its minutes, and the game erases it when they run out (D96).',
|
||||
description: 'How long the zones an event opens stand, added up. Every zone declares its minutes, and the game erases it when they run out.',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -323,11 +324,19 @@ async function place(server, send, body, what) {
|
||||
}
|
||||
|
||||
const placed = Array.isArray(data.placed) ? data.placed : []
|
||||
// A zone's deadline, as core's `until` (W1, PLAN_LAST_FIXES). The game erases
|
||||
// the zone `holdMs` after it placed it; reckoned from the answer, on this
|
||||
// clock, it lands a round trip after the game's, never before. With it core's
|
||||
// reconcile files a zone gone past its deadline as expired instead of
|
||||
// orphaned, which a reconnect used to show five seconds before the game's own
|
||||
// `world.expired` arrived. Crates and NPCs have no deadline and send none.
|
||||
const until = Number(body.holdMs) > 0 ? new Date(Date.now() + Number(body.holdMs)).toISOString() : null
|
||||
return {
|
||||
ok: true,
|
||||
resources: placed.map((row) => ({
|
||||
kind: OWNED_KIND,
|
||||
ref: refOf(server.id, row.id),
|
||||
...(until ? { until } : {}),
|
||||
payload: {
|
||||
serverId: server.id,
|
||||
what: row.kind,
|
||||
@@ -617,7 +626,7 @@ const NPC_ORDER_PARAMS = [
|
||||
required: false,
|
||||
example: '76561198000000001',
|
||||
description:
|
||||
"A player's Steam id, or a {placeholder} from the run's start params: the NPCs keep close to that player and fight whoever attacks them, and walk back to their spot if the player dies or leaves (D269). The player must be on the server. RunicNPC profiles only.",
|
||||
"A player's Steam id, or a {placeholder} from the run's start params: the NPCs keep close to that player and fight whoever attacks them, and walk back to their spot if the player dies or leaves. The player must be on the server. RunicNPC profiles only.",
|
||||
},
|
||||
{
|
||||
name: 'allyClan',
|
||||
@@ -626,7 +635,7 @@ const NPC_ORDER_PARAMS = [
|
||||
source: 'rust.options.clans',
|
||||
example: 'main/1234567',
|
||||
description:
|
||||
"A clan on the same server: the NPCs never target its members and defend them and what they own (D257). RunicNPC profiles only; give this or allyTeamOf, not both.",
|
||||
"A clan on the same server: the NPCs never target its members and defend them and what they own. RunicNPC profiles only; give this or allyTeamOf, not both.",
|
||||
},
|
||||
{
|
||||
name: 'allyTeamOf',
|
||||
@@ -634,7 +643,7 @@ const NPC_ORDER_PARAMS = [
|
||||
required: false,
|
||||
example: '76561198000000001',
|
||||
description:
|
||||
"A player's Steam id, or a {placeholder}: the NPCs are allied to that player and their team (D257, D270). RunicNPC profiles only.",
|
||||
"A player's Steam id, or a {placeholder}: the NPCs are allied to that player and their team. RunicNPC profiles only.",
|
||||
},
|
||||
{
|
||||
name: 'tether',
|
||||
@@ -642,7 +651,7 @@ const NPC_ORDER_PARAMS = [
|
||||
required: false,
|
||||
example: true,
|
||||
description:
|
||||
'Keep the NPCs inside the zone this event made around the point (D272): a Make a zone step must come first. RunicNPC profiles only.',
|
||||
'Keep the NPCs inside the zone this event made around the point: a Make a zone step must come first. RunicNPC profiles only.',
|
||||
},
|
||||
{
|
||||
// Runicnpc stage 7 (D296): on by default; a step older than the switch drops loot as before.
|
||||
@@ -651,7 +660,7 @@ const NPC_ORDER_PARAMS = [
|
||||
required: false,
|
||||
example: true,
|
||||
description:
|
||||
"Whether the NPCs drop their profile's loot table when killed (D296). On unless switched off; off, their corpse still holds what the profile starts it with. An event's own rewards are separate. RunicNPC profiles only.",
|
||||
"Whether the NPCs drop their profile's loot table when killed. On unless switched off; off, their corpse still holds what the profile starts it with. An event's own rewards are separate. RunicNPC profiles only.",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -690,8 +699,12 @@ function npcOrders(params, server) {
|
||||
* What the server itself holds is the plugin's to answer (`unknown-profile`).
|
||||
*/
|
||||
async function npcMissing(server, profile) {
|
||||
const [list, profiles] = await Promise.all([npcsDb.listNpcServers(), profile === null ? [] : npcsDb.listProfiles()])
|
||||
const here = list.find((s) => s.id === server.id)
|
||||
const hereNow = async () => (await npcsDb.listNpcServers()).find((s) => s.id === server.id)
|
||||
// Confirmed live before it refuses: the stored status lags a restart (W11).
|
||||
const [here, profiles] = await Promise.all([
|
||||
live.confirmed(server, hereNow, (s) => !npcs.npcReady(s)),
|
||||
profile === null ? [] : npcsDb.listProfiles(),
|
||||
])
|
||||
const name = server.name || server.id
|
||||
if (!npcs.npcReady(here)) return `${name} needs RunicNPC to place NPCs, Rust's own scientists included: ${npcs.npcAbsence(here)}`
|
||||
if (profile === null) return null
|
||||
@@ -794,7 +807,8 @@ const ACTIONS = [
|
||||
// §3.1: the flags and settings, checked against the flag list this server's
|
||||
// last hello carried (D211). A server that has not said is left to the
|
||||
// plugin, which checks every one against its live ZoneManager.
|
||||
const mods = await zones.serverZoneMods(found.server.id)
|
||||
const reread = () => zones.serverZoneMods(found.server.id)
|
||||
let mods = await reread()
|
||||
const known = mods && mods.zoneManager && mods.zoneManager.flags && mods.zoneManager.flags.length ? mods.zoneManager.flags : null
|
||||
const opts = zoneOptions.parse(params.options, known)
|
||||
if (!opts.ok) return { ok: false, retry: false, error: opts.error }
|
||||
@@ -821,12 +835,15 @@ const ACTIONS = [
|
||||
if (Number.isNaN(stack) || (stack !== undefined && (!Number.isInteger(stack) || stack < 1 || stack > DOME_STACK_MAX))) {
|
||||
return { ok: false, retry: false, error: `a dome stacks 1 to ${DOME_STACK_MAX} spheres, not "${params.domeStack}"` }
|
||||
}
|
||||
// Confirmed live before it refuses: the stored status lags a restart (W11).
|
||||
if (domeMissing(mods)) mods = await live.confirmed(found.server, reread, (m) => Boolean(domeMissing(m)))
|
||||
const missing = domeMissing(mods)
|
||||
if (missing) return { ok: false, retry: false, error: missing }
|
||||
dome = { type: kind.type, stack: stack === undefined ? DOME_STACK_DEFAULT : stack }
|
||||
}
|
||||
|
||||
// §14.3.2: TruePVE's rules in the zone, where the server said it has TruePVE.
|
||||
if (!truePveMapping(params, mods).ok) mods = await live.confirmed(found.server, reread, (m) => !truePveMapping(params, m).ok)
|
||||
const truePve = truePveMapping(params, mods)
|
||||
if (!truePve.ok) return { ok: false, retry: false, error: truePve.error }
|
||||
|
||||
@@ -891,7 +908,7 @@ const OPTION_SOURCES = [
|
||||
// cached list would offer monuments that are not there any more.
|
||||
id: 'rust.options.monuments',
|
||||
label: 'Monuments',
|
||||
description: "Each server's monuments on its current map. A kind that repeats is numbered, #1 first (D93).",
|
||||
description: "Each server's monuments on its current map. A kind that repeats is numbered, #1 first.",
|
||||
searchable: true,
|
||||
async resolve({ q } = {}) {
|
||||
const term = String(q || '').trim().toLowerCase()
|
||||
@@ -966,7 +983,7 @@ const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'rust.options.domes',
|
||||
label: 'Domes',
|
||||
description: "ZoneDomes' dome colours (D194). A coloured dome shows only where it meets terrain or a structure.",
|
||||
description: "ZoneDomes' dome colours. A coloured dome shows only where it meets terrain or a structure.",
|
||||
async resolve() {
|
||||
return DOMES.map((d) => ({ value: d.value, label: d.label }))
|
||||
},
|
||||
|
||||
@@ -154,6 +154,17 @@ const worldCall = {
|
||||
}
|
||||
|
||||
const data = result.data || {}
|
||||
|
||||
// The game runs one Travelling Vendor at a time and quietly starts nothing
|
||||
// while one is out. The plugin looks first and says so (W3). Not a failure:
|
||||
// what the step asked for is on the map.
|
||||
if (data.reason === 'already-out') {
|
||||
return {
|
||||
ok: true,
|
||||
detail: { event: event.value, called: 0, note: `a ${event.label} is already out on ${found.server.name || found.server.id}, and the game runs one at a time; nothing new was called` },
|
||||
}
|
||||
}
|
||||
|
||||
if (data.kind !== 'world.called') {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -158,6 +158,16 @@ module.exports = function register(ctx, api) {
|
||||
api.onBoot(boot.onBoot)
|
||||
api.onShutdown(boot.onShutdown)
|
||||
|
||||
// Sign in with Steam (MODULE_API 1.17.0; PLAN_REDESIGNS §15). A Steam account
|
||||
// linked on the site is a linked Rust player (D392): core asks before it writes
|
||||
// the sign-in identity, and this module refuses a Steam id that is another
|
||||
// account's Rust link (D396); once it is written, the Rust link is made. Both
|
||||
// are in the links model beside `/link`'s own write, and neither runs at
|
||||
// registration — core calls them from a signed-in user's link flow.
|
||||
const links = require('./model/links/links.model') // eslint-disable-line global-require
|
||||
api.onIdentityLinking(links.checkSteamSignin)
|
||||
api.onIdentityLinked(links.linkFromSteamSignin)
|
||||
|
||||
// The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server
|
||||
// and must give back. Core's `core.lease` is the verb; these are the values it
|
||||
// may name and the four callables each ships. Every lease is targeted and the
|
||||
|
||||
@@ -72,7 +72,9 @@ const MAX_BATCHES_PER_TICK = 10
|
||||
* how to read.
|
||||
*/
|
||||
async function apply(serverId, item, server = null) {
|
||||
const frame = (item && item.frame) || {}
|
||||
// A downed helicopter, Chinook or Bradley is stored with the rule that
|
||||
// credits it now, so its line and its counts agree for good (W4).
|
||||
const frame = worldEvents.stampRule((item && item.frame) || {}, server && server.creditRule)
|
||||
const kind = item.kind || frame.kind
|
||||
const wipeId = frame.wipeId || null
|
||||
|
||||
@@ -278,8 +280,7 @@ async function apply(serverId, item, server = null) {
|
||||
// A protocol-13 plugin still sends `heliKills` / `apcKills` on its tally
|
||||
// instead, and the tally path above adds those as it always has.
|
||||
case 'world.event': {
|
||||
const rule = worldEvents.creditRuleOf(server && server.creditRule)
|
||||
for (const kill of worldEvents.killsOf(frame, rule)) {
|
||||
for (const kill of worldEvents.killsOf(frame, worldEvents.ruleOf(frame, server && server.creditRule))) {
|
||||
await db.touchPlayer(kill.steamId, kill.name || null)
|
||||
await db.addTitleStats({ serverId, wipeId, steamId: kill.steamId }, { [kill.field]: 1 })
|
||||
}
|
||||
|
||||
95
server/liveStatus.js
Normal file
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. */
|
||||
function isBridgeConfig(path, self) {
|
||||
if (!self) return false
|
||||
const plugin = String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
|
||||
return plugin.toLowerCase() === String(self).toLowerCase()
|
||||
return pluginOf(path).toLowerCase() === String(self).toLowerCase()
|
||||
}
|
||||
|
||||
/** The locked keys for a file: three of them in our own config, none anywhere else. */
|
||||
@@ -279,6 +290,7 @@ module.exports = {
|
||||
isPlausiblePath,
|
||||
shapeCatalogue,
|
||||
shapeFile,
|
||||
pluginOf,
|
||||
isBridgeConfig,
|
||||
lockedKeysFor,
|
||||
lockedChanges,
|
||||
|
||||
@@ -23,6 +23,7 @@ const db = require('./economics.db')
|
||||
const eventsDb = require('../events/events.db')
|
||||
const servers = require('../servers/servers.model')
|
||||
const { pluginError, transportError } = require('../../eventLeases')
|
||||
const live = require('../../liveStatus')
|
||||
|
||||
const log = core.logger('economics')
|
||||
|
||||
@@ -168,7 +169,8 @@ async function adjust({ serverId, steamId, op, amount, key }) {
|
||||
const server = await servers.getForCalling(serverId)
|
||||
if (!server) return { ok: false, status: 404, message: 'No such server' }
|
||||
|
||||
const econ = await forServer(server.id)
|
||||
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
||||
const econ = await live.confirmed(server, () => forServer(server.id), (e) => !e.loaded)
|
||||
if (!econ.loaded) return { ok: false, status: 409, reason: 'economics-missing', message: MISSING }
|
||||
|
||||
const result = await client.economicsAdjust(server, { key, steamId, op, amount: checked.value })
|
||||
@@ -187,9 +189,11 @@ async function adjust({ serverId, steamId, op, amount, key }) {
|
||||
}
|
||||
|
||||
log.warn('Economics refused an adjustment', { server: server.id, op, reason: data.reason })
|
||||
// The game saying no is a 409, as `insufficient` is above: the request was
|
||||
// well formed, and the balance it met refused it (W15).
|
||||
return {
|
||||
ok: false,
|
||||
status: data.reason === 'economics-missing' ? 409 : 400,
|
||||
status: data.reason === 'economics-missing' || data.reason === 'at-limit' ? 409 : 400,
|
||||
reason: data.reason || null,
|
||||
message: pluginError(data, `${name} refused the adjustment`),
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const raidableBases = require('../../raidableBases')
|
||||
const db = require('./events.db')
|
||||
const mapDb = require('../map/map.db')
|
||||
const serversDb = require('../servers/servers.db')
|
||||
const weaponNames = require('../titles/weaponNames')
|
||||
|
||||
/** Hard ceiling on a page, whatever a caller asks for. */
|
||||
const MAX_LIMIT = 200
|
||||
@@ -155,6 +156,13 @@ function shape(row) {
|
||||
frame = {}
|
||||
}
|
||||
|
||||
// The weapon as a page should print it (`model/titles/weaponNames.js`). Added
|
||||
// at read, so rows stored before this existed read the same as new ones, and
|
||||
// a later edit to the table renames every kill the feed still shows.
|
||||
if (typeof frame.weapon === 'string' && frame.weaponName === undefined) {
|
||||
frame.weaponName = weaponNames.nameOf(frame.weapon)
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(row.id),
|
||||
kind: row.kind,
|
||||
@@ -247,4 +255,4 @@ async function online(serverId) {
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, MAX_LIMIT }
|
||||
module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, shape, MAX_LIMIT }
|
||||
|
||||
@@ -286,6 +286,71 @@ async function unlinkFromGame(steamId) {
|
||||
return removed
|
||||
}
|
||||
|
||||
// ── Steam's own sign-in as a second way to link (D392, D393, D396) ─────────
|
||||
//
|
||||
// Core's "Link Steam" (STEAM_SIGNIN.md) proves the account with Steam's OpenID
|
||||
// `check_authentication`, which is at least as strong as a code typed in game:
|
||||
// so a Steam account linked on the site IS a linked Rust player, beside `/link`.
|
||||
// Core asks this module twice through MODULE_API 1.17.0 — before it writes the
|
||||
// sign-in identity, and after. Unlinking Steam on the site removes only the
|
||||
// sign-in (D393), so there is no third call: the Rust link stays until it is
|
||||
// removed on the Rust page or by `/unlink` in game, exactly as one made by code.
|
||||
|
||||
const STEAM_ID = /^\d{17}$/
|
||||
|
||||
/**
|
||||
* Core's pre-link check (`api.onIdentityLinking`). Refuses a Steam id that is
|
||||
* already ANOTHER website account's Rust link (D396) — the same rule as D23 for
|
||||
* a code, for the same reason: a link here is a privilege path, and moving it
|
||||
* would hand that account's grants to whoever signed in. Core shows "already
|
||||
* linked to another account" and nothing is written; staff remove the old link
|
||||
* if the claim is real. Our own words go to core's log, never to the page.
|
||||
*/
|
||||
async function checkSteamSignin({ userId, provider, subject }) {
|
||||
if (provider !== 'steam') return { ok: true }
|
||||
const held = await db.getBySteamId(String(subject))
|
||||
if (held && Number(held.userId) !== Number(userId)) {
|
||||
return { ok: false, reason: 'in_use', message: `steam ${subject} is the Rust link of user ${held.userId}` }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's linked hook (`api.onIdentityLinked`): the Steam account becomes this
|
||||
* user's Rust link (D392).
|
||||
*
|
||||
* The same write as `confirmOne`, with the PRIMARY KEY as the race guard: if
|
||||
* somebody else's `/link` landed between core's check and this, the key refuses
|
||||
* ours and their link stands — the sign-in identity core already wrote is only a
|
||||
* way to sign in. `name` is the Steam persona name, which is what a Rust player is
|
||||
* called in game until they rename. No `server_id`: no server minted anything.
|
||||
*/
|
||||
async function linkFromSteamSignin({ userId, provider, subject, displayName }) {
|
||||
if (provider !== 'steam') return
|
||||
const steamId = String(subject)
|
||||
if (!STEAM_ID.test(steamId)) return
|
||||
|
||||
const held = await db.getBySteamId(steamId)
|
||||
if (held) {
|
||||
if (Number(held.userId) !== Number(userId)) {
|
||||
log.warn('steam sign-in linked an id that is another user\'s Rust link; the Rust link stands', { steamId, userId })
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
await db.insert({ steamId, userId, name: displayName || null, serverId: null })
|
||||
} catch (err) {
|
||||
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
|
||||
log.warn('steam sign-in link lost a race for this id; the other link stands', { steamId, userId })
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
log.info('steam account linked through steam sign-in', { steamId, userId })
|
||||
linksChanged('rust account linked via steam sign-in')
|
||||
engagement.linked({ userId, steamId, name: displayName || null })
|
||||
}
|
||||
|
||||
/** The admin panel's read: every link this user holds, with per-server totals. */
|
||||
async function forAdmin(userId) {
|
||||
const links = await db.listForUserWithPlayer(userId)
|
||||
@@ -326,5 +391,7 @@ module.exports = {
|
||||
unlinkOwned,
|
||||
unlinkAnyOwner,
|
||||
unlinkFromGame,
|
||||
checkSteamSignin,
|
||||
linkFromSteamSignin,
|
||||
forAdmin,
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ function checkFactions(input) {
|
||||
}
|
||||
if (!RELATIONS.includes(relation)) return { ok: false, error: `factions[${i}]: '${relation}' is not hostile, neutral or allied` }
|
||||
const key = pairKey(a, b)
|
||||
if (seen.has(key)) return { ok: false, error: `factions[${i}]: ${a} and ${b} are given twice (one row is both ways, D268)` }
|
||||
if (seen.has(key)) return { ok: false, error: `factions[${i}]: ${a} and ${b} are given twice (one row is both ways)` }
|
||||
seen.add(key)
|
||||
rows.push(a < b ? { a, b, relation } : { a: b, b: a, relation })
|
||||
}
|
||||
@@ -173,7 +173,7 @@ function checkBody(input) {
|
||||
if (names.some((n) => n.length > DISPLAY_NAME_MAX)) return { ok: false, error: `names: each at most ${DISPLAY_NAME_MAX} characters` }
|
||||
|
||||
const kits = Array.isArray(b.kits) ? [...new Set(b.kits.map((k) => String(k === null || k === undefined ? '' : k).trim()).filter(Boolean))] : null
|
||||
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped (D217)' }
|
||||
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped' }
|
||||
if (kits.length > KITS_MAX) return { ok: false, error: `kits: at most ${KITS_MAX}` }
|
||||
|
||||
const prefab = String(b.prefab || '').trim()
|
||||
@@ -242,8 +242,8 @@ function checkBody(input) {
|
||||
const hasBoss = b.boss !== undefined && b.boss !== null
|
||||
const hasUse = b.use !== undefined && b.use !== null
|
||||
if (passive && hasBoss) return { ok: false, error: 'boss: a passive NPC never fights, so it cannot be a boss' }
|
||||
if (passive && !hasUse) return { ok: false, error: 'use: a passive NPC says what pressing E on it does (D278)' }
|
||||
if (hasUse && !passive) return { ok: false, error: 'use: only a passive NPC answers E (D278)' }
|
||||
if (passive && !hasUse) return { ok: false, error: 'use: a passive NPC says what pressing E on it does' }
|
||||
if (hasUse && !passive) return { ok: false, error: 'use: only a passive NPC answers E' }
|
||||
let use = null
|
||||
if (hasUse) {
|
||||
const checked = checkUse(b.use)
|
||||
|
||||
@@ -272,7 +272,7 @@ async function create(input, userId = null) {
|
||||
async function update(id, input) {
|
||||
const existing = await db.getProfile(id)
|
||||
if (!existing) throw new NpcError('no such profile', 404)
|
||||
if (existing.replaced) throw new NpcError('this profile is kept aside as replaced (D251): restore it before editing it', 409)
|
||||
if (existing.replaced) throw new NpcError('this profile is kept aside as replaced: restore it before editing it', 409)
|
||||
const clean = await validate(input, existing.id)
|
||||
await db.saveProfile({ ...clean, id: existing.id })
|
||||
const before = reach(existing)
|
||||
|
||||
@@ -162,11 +162,19 @@ function shapeSync(row) {
|
||||
}
|
||||
}
|
||||
|
||||
// A grant to a player the plugin made and did not find when it read it back
|
||||
// (`steamId:permission`). On Carbon that is a Steam id the server has never
|
||||
// seen: it lands when they first join, because every sync pushes the whole
|
||||
// set. "In sync" alone would hide it (W17).
|
||||
const notLanded = report && Array.isArray(report.notLanded) ? report.notLanded : []
|
||||
const waitingToLand = notLanded.filter((entry) => /^\d{17}:/.test(String(entry))).length
|
||||
|
||||
return {
|
||||
serverId: row.serverId,
|
||||
state: row.state,
|
||||
dirty: Boolean(row.dirty),
|
||||
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
|
||||
waitingToLand,
|
||||
lastAttemptAt: row.lastAttemptAt,
|
||||
lastOkAt: row.lastOkAt,
|
||||
importedAt: row.importedAt || null,
|
||||
|
||||
89
server/model/titles/weaponNames.js
Normal file
89
server/model/titles/weaponNames.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// ── What a weapon is called on a page ──────────────────────────────────────
|
||||
//
|
||||
// The plugin names the weapon in a kill by the held entity's SHORT PREFAB NAME
|
||||
// (`ak47u.entity`) — the same name the title lists beside this file count by,
|
||||
// and the right key for counting. It is the wrong thing to print: the feed said
|
||||
// "with ak47u entity". So every event read gives the frame a `weaponName` from
|
||||
// here, and the site and the app print that, falling back to the prefab name
|
||||
// themselves only for a module too old to send one.
|
||||
//
|
||||
// The table is the ranged weapons — every gun, bow, crossbow, launcher and
|
||||
// flamethrower — named as the game names them. Read off the Oxide rig's own item
|
||||
// list on 2026-10-23 (Rust 26139947) by the same read-only walk the title lists
|
||||
// came from: each item whose held entity is an `AttackEntity`, its short prefab
|
||||
// name and its English display name. Melee, throwables and anything a later Rust
|
||||
// update adds fall back to `stripped()`, which drops the `.entity` / `.weapon`
|
||||
// ending and turns the rest into words ("salvaged sword"), so a new weapon reads
|
||||
// acceptably before anyone edits this list. To refresh, walk the list again.
|
||||
|
||||
const NAMES = new Map([
|
||||
['50cal.left.entity', 'Dual 50 Cal Turret'],
|
||||
['50cal.right.entity', 'Dual 50 Cal Turret'],
|
||||
['50cal.single.entity', '50 Cal Turret'],
|
||||
['ak47u.entity', 'Assault Rifle'],
|
||||
['ak47u.glass.blue.entity', 'Crystal Assault Rifle Sapphire'],
|
||||
['ak47u.glass.entity', 'Crystal Assault Rifle Diamond'],
|
||||
['ak47u.glass.green.entity', 'Crystal Assault Rifle Emerald'],
|
||||
['ak47u.glass.pink.entity', 'Crystal Assault Rifle Pink Diamond'],
|
||||
['ak47u.glass.red.entity', 'Crystal Assault Rifle Ruby'],
|
||||
['ak47u_diver.entity', 'Abyss Assault Rifle'],
|
||||
['ak47u_ice.entity', 'Ice Assault Rifle'],
|
||||
['ak47u_jungle.entity', 'Jungle Relic Assault Rifle'],
|
||||
['ak47u_med.entity', 'Medieval Assault Rifle'],
|
||||
['blunderbuss.entity', 'Blunderbuss'],
|
||||
['bolt_rifle.entity', 'Bolt Action Rifle'],
|
||||
['bow_hunting.entity', 'Hunting Bow'],
|
||||
['compound_bow.entity', 'Compound Bow'],
|
||||
['crossbow.entity', 'Crossbow'],
|
||||
['crossbow_bowless.entity', 'Bowless Crossbow'],
|
||||
['double_shotgun.entity', 'Double Barrel Shotgun'],
|
||||
['flamethrower.entity', 'Flame Thrower'],
|
||||
['glock.entity', 'Prototype 17'],
|
||||
['hc_revolver.entity', 'High Caliber Revolver'],
|
||||
['hmlmg.entity', 'HMLMG'],
|
||||
['krieg_shotgun.entity', 'Krieg Shotgun'],
|
||||
['l96.entity', 'L96 Rifle'],
|
||||
['legacybow.entity', 'Legacy Bow'],
|
||||
['lr300.entity', 'LR-300 Assault Rifle'],
|
||||
['lr300_space.entity', 'Space LR-300 Assault Rifle'],
|
||||
['m16a2.entity', 'M16A2'],
|
||||
['m249.entity', 'M249'],
|
||||
['m39.entity', 'M39 Rifle'],
|
||||
['m4_shotgun.entity', 'M4 Shotgun'],
|
||||
['m92.entity', 'M92 Pistol'],
|
||||
['mgl.entity', 'Multiple Grenade Launcher'],
|
||||
['militaryflamethrower.entity', 'Military Flame Thrower'],
|
||||
['mini_crossbow.entity', 'Mini Crossbow'],
|
||||
['mp5.entity', 'MP5A4'],
|
||||
['nailgun.entity', 'Nailgun'],
|
||||
['pistol_revolver.entity', 'Revolver'],
|
||||
['pistol_semiauto.a.m15.entity', 'M15 Semi-Automatic Pistol'],
|
||||
['pistol_semiauto.entity', 'Semi-Automatic Pistol'],
|
||||
['python.entity', 'Python Revolver'],
|
||||
['rocket_launcher.entity', 'Rocket Launcher'],
|
||||
['rocket_launcher_dragon.entity', 'Dragon Rocket Launcher'],
|
||||
['semi_auto_rifle.entity', 'Semi-Automatic Rifle'],
|
||||
['shotgun_pump.entity', 'Pump Shotgun'],
|
||||
['shotgun_waterpipe.entity', 'Waterpipe Shotgun'],
|
||||
['sks.entity', 'SKS'],
|
||||
['smg.entity', 'Custom SMG'],
|
||||
['spas12.entity', 'Spas-12 Shotgun'],
|
||||
['t1_smg.entity', 'Handmade SMG'],
|
||||
['thompson.entity', 'Thompson'],
|
||||
])
|
||||
|
||||
/** The endings a held entity's prefab name carries that no player says. */
|
||||
const ENDING = /[._](entity|weapon|deployed)$/
|
||||
|
||||
/** A prefab name as words: its ending dropped, its dots and underscores spaces. */
|
||||
function stripped(prefab) {
|
||||
return String(prefab).trim().replace(ENDING, '').replace(/[_.]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** What to print for a weapon's prefab name: the game's own name, or the stripped one. */
|
||||
function nameOf(prefab) {
|
||||
if (typeof prefab !== 'string' || !prefab.trim()) return null
|
||||
return NAMES.get(prefab.trim()) || stripped(prefab)
|
||||
}
|
||||
|
||||
module.exports = { nameOf, stripped, NAMES }
|
||||
@@ -314,13 +314,7 @@ async function syncOne(server, { authored, sync, state, force, policy = 'auto-ad
|
||||
held: planned.hold.size,
|
||||
})
|
||||
|
||||
const result = await sidecar.permSync(server, {
|
||||
setId: desired.hash,
|
||||
groups: withExpect(held.payload.groups, pushed),
|
||||
grants: held.payload.grants,
|
||||
credits: held.payload.credits,
|
||||
retire,
|
||||
})
|
||||
const result = await sidecar.permSync(server, pushBody(desired.hash, held.payload, pushed, retire))
|
||||
|
||||
if (!result.ok) {
|
||||
await failed(server, { reason, error: result.status, desiredHash: desired.hash, sync, bootId, wipeId })
|
||||
@@ -358,6 +352,23 @@ async function syncOne(server, { authored, sync, state, force, policy = 'auto-ad
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/**
|
||||
* The sync as the wire carries it: every field of the built payload, the
|
||||
* groups with their style `expect`s, and what to retire. `notices` rides here
|
||||
* too (D333) — the push once named its fields one by one and left it out, so
|
||||
* no reward message ever reached the game (W21).
|
||||
*/
|
||||
function pushBody(setId, payload, pushed, retire) {
|
||||
return {
|
||||
setId,
|
||||
groups: withExpect(payload.groups, pushed),
|
||||
grants: payload.grants,
|
||||
credits: payload.credits,
|
||||
notices: payload.notices,
|
||||
retire,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The groups as the wire carries them: each style field with the value this
|
||||
* site last pushed to THIS server (`expect`), or null for one it never has.
|
||||
@@ -526,6 +537,7 @@ module.exports = {
|
||||
syncOne,
|
||||
reasonToSync,
|
||||
applyReport,
|
||||
pushBody,
|
||||
withExpect,
|
||||
styleRetirements,
|
||||
withLock,
|
||||
|
||||
@@ -323,7 +323,7 @@ async function record(req, row) {
|
||||
try {
|
||||
id = await db.recordWrite({
|
||||
...row,
|
||||
plugin: model.isBridgeConfig(row.path, row.self) ? row.self : pluginOf(row.path),
|
||||
plugin: model.isBridgeConfig(row.path, row.self) ? row.self : model.pluginOf(row.path),
|
||||
reloadTarget: row.reload,
|
||||
userId: req.user ? req.user.id : null,
|
||||
})
|
||||
@@ -347,10 +347,6 @@ async function record(req, row) {
|
||||
return id
|
||||
}
|
||||
|
||||
function pluginOf(path) {
|
||||
return String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
|
||||
}
|
||||
|
||||
/** A `config.error` frame as a sentence. */
|
||||
function refusalMessage(frame) {
|
||||
const reasons = {
|
||||
|
||||
@@ -159,7 +159,7 @@ usersRustRouter.post(
|
||||
/* #swagger.responses[200] = { description: 'Adjusted', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjusted" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A bad op or amount', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or that Steam account is not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Economics is not installed there, or the balance is too low to withdraw that much', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Economics is not installed there, the balance is too low to withdraw that much, or a deposit would pass Economics’ balance limit', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('serverId').isString().isLength({ min: 1, max: 64 }),
|
||||
|
||||
@@ -68,7 +68,7 @@ rustRouter.get(
|
||||
'/servers/:id/events',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'Recent events on one Rust server'
|
||||
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule`.'
|
||||
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
|
||||
@@ -658,6 +658,11 @@ module.exports = {
|
||||
description: 'True when the last successful push carried the set the site currently authors.',
|
||||
example: true,
|
||||
},
|
||||
waitingToLand: {
|
||||
type: 'integer',
|
||||
description: 'Grants to players (lent, given or authored) the last sync pushed and the game did not keep: the report’s `notLanded` entries for a Steam id. On Carbon that is a player the server has never seen; the grant lands when they first join, because every sync pushes the whole set.',
|
||||
example: 0,
|
||||
},
|
||||
dirty: { type: 'boolean', example: false },
|
||||
lastAttemptAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
lastOkAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
|
||||
@@ -166,6 +166,9 @@ function fakeApi() {
|
||||
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
// MODULE_API 1.17.0 (Steam sign-in, PLAN_REDESIGNS §15).
|
||||
onIdentityLinking(fn) { once('onIdentityLinking'); record.hooks.onIdentityLinking = fn },
|
||||
onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn },
|
||||
}
|
||||
api.record = record
|
||||
return api
|
||||
|
||||
@@ -34,6 +34,10 @@ function withRecorder(rows = []) {
|
||||
|
||||
withRecorder()
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const client = require('../sidecarClient')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const servers = require('../model/servers/servers.model')
|
||||
@@ -194,16 +198,21 @@ test('the revert deletes the step’s rows, and the next sync takes the size bac
|
||||
assert.deepStrictEqual(calls.dirty, ['main'])
|
||||
})
|
||||
|
||||
test('the size list offers each server’s sizes above its smallest, grouped by server', async (t) => {
|
||||
test('the size list offers each size above a server’s smallest once, naming every server that offers it (W10)', async (t) => {
|
||||
const saved = { listForPolling: servers.listForPolling, reports: backpacksDb.reports }
|
||||
t.after(() => Object.assign(servers, { listForPolling: saved.listForPolling }) && Object.assign(backpacksDb, { reports: saved.reports }))
|
||||
servers.listForPolling = async () => [{ id: 'main', name: 'Main' }, { id: 'pve', name: 'PvE' }]
|
||||
servers.listForPolling = async () => [{ id: 'main', name: 'Main' }, { id: 'pve', name: 'PvE' }, { id: 'off', name: 'Off' }]
|
||||
backpacksDb.reports = async () => [
|
||||
{ serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48] }) },
|
||||
{ serverId: 'pve', backpacks: null },
|
||||
{ serverId: 'main', backpacks: JSON.stringify({ loaded: true, sizes: [6, 48, 96] }) },
|
||||
{ serverId: 'pve', backpacks: JSON.stringify({ loaded: true, sizes: [12, 24, 48] }) },
|
||||
{ serverId: 'off', backpacks: null },
|
||||
]
|
||||
const source = rewards.OPTION_SOURCES.find((s) => s.id === 'rust.options.backpacksizes')
|
||||
assert.deepStrictEqual(await source.resolve(), [{ value: 48, label: '48 slots', group: 'Main' }])
|
||||
assert.deepStrictEqual(await source.resolve(), [
|
||||
{ value: 24, label: '24 slots — PvE' },
|
||||
{ value: 48, label: '48 slots — Main, PvE' },
|
||||
{ value: 96, label: '96 slots — Main' },
|
||||
])
|
||||
})
|
||||
|
||||
// ── Reading one ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -262,7 +262,7 @@ test('a reward sends Steam ids and names to the bridge, which fills the placehol
|
||||
assert.strictEqual(result.ok, true)
|
||||
assert.deepStrictEqual([result.detail.people, result.detail.rewarded], [2, 1])
|
||||
assert.deepStrictEqual([result.detail.skipped, result.detail.skippedCount], [['p3'], 1])
|
||||
assert.match(result.detail.skippedNote, /D383/)
|
||||
assert.match(result.detail.skippedNote, /not on the server when the reward ran/)
|
||||
assert.strictEqual(result.resources, undefined)
|
||||
})
|
||||
|
||||
|
||||
@@ -558,6 +558,15 @@ test('the page’s poll answers 404 for a write that is not on that server', asy
|
||||
|
||||
// ── RaidableBases' profiles (PLAN_REDESIGNS §11.4.3, D205) ────────────────
|
||||
|
||||
test('a path names its plugin, and a RaidableBases profile names RaidableBases, not "@data" (W8)', () => {
|
||||
const model = require('../model/config/config.model')
|
||||
assert.strictEqual(model.pluginOf('@data/RaidableBases/Profiles/Hard Bases.json'), 'RaidableBases')
|
||||
assert.strictEqual(model.pluginOf('Kits.json'), 'Kits')
|
||||
assert.strictEqual(model.pluginOf('RaidableBases/Profiles.json'), 'RaidableBases')
|
||||
assert.strictEqual(model.isBridgeConfig('RunicGateway.json', 'RunicGateway'), true)
|
||||
assert.strictEqual(model.isBridgeConfig('@data/RaidableBases/Profiles/x.json', 'RunicGateway'), false)
|
||||
})
|
||||
|
||||
test('a RaidableBases profile’s virtual path is plausible, one file deep, and nothing else under @data is', () => {
|
||||
const model = require('../model/config/config.model')
|
||||
assert.strictEqual(model.isPlausiblePath('@data/RaidableBases/Profiles/Hard Bases.json'), true)
|
||||
|
||||
@@ -37,6 +37,10 @@ function withRecorder(rows = []) {
|
||||
|
||||
withRecorder()
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const dt = require('../dangerousTreasures')
|
||||
const { gridOf } = require('../worldEvents')
|
||||
const { TRIGGERS } = require('../engagement/triggers')
|
||||
|
||||
@@ -34,6 +34,10 @@ function withRecorder(rows = []) {
|
||||
|
||||
withRecorder()
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const client = require('../sidecarClient')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const servers = require('../model/servers/servers.model')
|
||||
@@ -220,10 +224,21 @@ test('coins go once to each website user, with the key, the amount and the messa
|
||||
assert.strictEqual(result.detail.total, 500)
|
||||
assert.deepStrictEqual(result.detail.received, [{ name: 'p1', received: 500 }])
|
||||
assert.deepStrictEqual(result.detail.failed, [{ name: 'p4', reason: 'at-limit' }])
|
||||
assert.strictEqual(result.detail.failedNote, "at-limit: the balance is at Economics' limit", 'only the reason that happened (W16)')
|
||||
assert.deepStrictEqual(result.detail.missed, ['p3'])
|
||||
assert.strictEqual(result.resources, undefined, 'nothing is ledgered')
|
||||
})
|
||||
|
||||
test('a payment’s failedNote explains each reason that happened, and only those (W16)', () => {
|
||||
const { failedNote } = require('../eventRewards')
|
||||
assert.strictEqual(failedNote([{ reason: 'unmeasured' }, { reason: 'unmeasured' }]), 'unmeasured: Economics did not report the deposit')
|
||||
assert.strictEqual(
|
||||
failedNote([{ reason: 'unmeasured' }, { reason: 'at-limit' }, { reason: 'refused' }]),
|
||||
"at-limit: the balance is at Economics' limit; refused: Economics did not take the deposit; unmeasured: Economics did not report the deposit",
|
||||
)
|
||||
assert.strictEqual(failedNote([{ reason: 'odd' }]), "odd: Economics' own reason")
|
||||
})
|
||||
|
||||
test('a repeated key is answered from the first payment, and a lost answer is retried', async (t) => {
|
||||
const person = { steamId: A, name: 'pa', seconds: 60, kills: 0, score: 1, joinedAt: 1 }
|
||||
stub(t, { people: [person], links: { [A]: 1 }, payAnswer: () => ok({ kind: 'economics.paid', repeat: true, paid: [{ steamId: A, before: 1000, after: 1500 }], failed: [] }) })
|
||||
@@ -291,6 +306,12 @@ test('a withdrawal above the balance is refused with the balance', async (t) =>
|
||||
assert.match(result.message, /only 340 coins on Main/)
|
||||
assert.deepStrictEqual(sent, [{ key: 'adj-1', steamId: A, op: 'withdraw', amount: 500 }])
|
||||
|
||||
// A deposit refused at the balance limit is the game saying no too (W15).
|
||||
client.economicsAdjust = async () => ok({ kind: 'economics.error', reason: 'at-limit', message: "this player's balance is at Economics' limit" })
|
||||
const capped = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 50, key: 'adj-3' })
|
||||
assert.strictEqual(capped.status, 409)
|
||||
assert.strictEqual(capped.reason, 'at-limit')
|
||||
|
||||
economicsDb.reportFor = async () => null
|
||||
const missing = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 5, key: 'adj-2' })
|
||||
assert.strictEqual(missing.status, 409)
|
||||
|
||||
@@ -85,6 +85,13 @@ test('registers both lifecycle hooks', () => {
|
||||
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||||
})
|
||||
|
||||
test('registers both identity hooks, so a Steam sign-in link is a Rust link (D392, D396)', () => {
|
||||
const { api } = register()
|
||||
const links = require('../model/links/links.model')
|
||||
assert.strictEqual(api.record.hooks.onIdentityLinking, links.checkSteamSignin)
|
||||
assert.strictEqual(api.record.hooks.onIdentityLinked, links.linkFromSteamSignin)
|
||||
})
|
||||
|
||||
test('the manifest declares what the loader requires', () => {
|
||||
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
|
||||
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
|
||||
|
||||
@@ -40,6 +40,10 @@ function withRecorder(rows = []) {
|
||||
|
||||
withRecorder()
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const gc = require('../guardedCrate')
|
||||
const { gridOf } = require('../worldEvents')
|
||||
const { TRIGGERS } = require('../engagement/triggers')
|
||||
|
||||
@@ -429,3 +429,53 @@ test('a state read that fails asks everybody, which is slow and never wrong', as
|
||||
assert.equal((await links.redeem({ code: 'ZZZZZZ', userId: 4 })).reason, 'rejected')
|
||||
assert.equal(asked.length, 2)
|
||||
})
|
||||
|
||||
|
||||
// ── Steam's own sign-in as a second way to link (D392, D393, D396) ─────────
|
||||
|
||||
const STEAM = '76561197960287930'
|
||||
|
||||
test('core’s pre-link check refuses a Steam id that is another account’s Rust link (D396)', async () => {
|
||||
withCore({ select: [[{ steamId: STEAM, userId: 9, username: 'bob' }]] })
|
||||
const links = require('../model/links/links.model')
|
||||
const answer = await links.checkSteamSignin({ userId: 4, provider: 'steam', subject: STEAM })
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.reason, 'in_use')
|
||||
})
|
||||
|
||||
test('the pre-link check passes a free Steam id, one already yours, and every other provider', async () => {
|
||||
withCore({ select: [[], [{ steamId: STEAM, userId: 4 }]] })
|
||||
const links = require('../model/links/links.model')
|
||||
assert.deepEqual(await links.checkSteamSignin({ userId: 4, provider: 'steam', subject: STEAM }), { ok: true })
|
||||
assert.deepEqual(await links.checkSteamSignin({ userId: 4, provider: 'steam', subject: STEAM }), { ok: true })
|
||||
assert.deepEqual(await links.checkSteamSignin({ userId: 4, provider: 'google', subject: 'x' }), { ok: true })
|
||||
})
|
||||
|
||||
test('a Steam account linked on the site becomes a linked Rust player (D392)', async () => {
|
||||
let inserted = null
|
||||
const { ctx } = withCore({ select: [[]], onInsert: (params) => { inserted = params; return Promise.resolve({ affectedRows: 1 }) } })
|
||||
const links = require('../model/links/links.model')
|
||||
await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: STEAM, displayName: 'CoolGuy99' })
|
||||
assert.deepEqual(inserted, [STEAM, 4, 'CoolGuy99', null])
|
||||
assert.equal(ctx.teams.reconcile.calls.length, 1)
|
||||
assert.equal(ctx.events.emit.calls.length, 1, 'the player hears about the new link, as with /link')
|
||||
})
|
||||
|
||||
test('the linked hook writes nothing for a link that is already there, or for another provider', async () => {
|
||||
let inserts = 0
|
||||
const { ctx } = withCore({ select: [[{ steamId: STEAM, userId: 4 }]], onInsert: () => { inserts += 1; return Promise.resolve({}) } })
|
||||
const links = require('../model/links/links.model')
|
||||
await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: STEAM, displayName: 'x' })
|
||||
await links.linkFromSteamSignin({ userId: 4, provider: 'discord', subject: '123', displayName: 'x' })
|
||||
await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: 'not-an-id', displayName: 'x' })
|
||||
assert.equal(inserts, 0)
|
||||
assert.equal(ctx.teams.reconcile.calls.length, 0)
|
||||
})
|
||||
|
||||
test('a /link that lands between core’s check and the hook keeps its link; the hook does not throw', async () => {
|
||||
const dup = Object.assign(new Error('dup'), { code: 'ER_DUP_ENTRY' })
|
||||
const { ctx } = withCore({ select: [[]], onInsert: () => Promise.reject(dup) })
|
||||
const links = require('../model/links/links.model')
|
||||
await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: STEAM, displayName: 'x' })
|
||||
assert.equal(ctx.teams.reconcile.calls.length, 0)
|
||||
})
|
||||
|
||||
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 })
|
||||
require('../core').init(ctx)
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const client = require('../sidecarClient')
|
||||
const servers = require('../model/servers/servers.model')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
@@ -165,8 +169,8 @@ const BOSS = {
|
||||
test('stage 6: a boss or passive profile is refused for what RunicNPC would refuse, in its own words', () => {
|
||||
const cases = [
|
||||
[{ ...PASSIVE, ...BOSS }, 'boss: a passive NPC never fights, so it cannot be a boss'],
|
||||
[{ role: 'passive' }, 'use: a passive NPC says what pressing E on it does (D278)'],
|
||||
[{ use: { mode: 'chat', lines: ['hi'] } }, 'use: only a passive NPC answers E (D278)'],
|
||||
[{ role: 'passive' }, 'use: a passive NPC says what pressing E on it does'],
|
||||
[{ use: { mode: 'chat', lines: ['hi'] } }, 'use: only a passive NPC answers E'],
|
||||
[{ movement: { mode: 'stand' } }, "movement.mode: stand is a passive NPC's"],
|
||||
[{ ...PASSIVE, use: { mode: 'shout' } }, "use.mode: 'shout' is not chat or window"],
|
||||
[{ ...PASSIVE, use: { mode: 'chat', lines: [] } }, 'use.lines: a chat answer gives at least one line'],
|
||||
@@ -538,7 +542,7 @@ test('the faction table is one row per pair, both ways, refused in RunicNPC\'s w
|
||||
assert.match(shape.checkFactions([{ a: 'red', b: 'red', relation: 'hostile' }]).error, /always allied to itself/)
|
||||
assert.match(shape.checkFactions([{ a: 'scientists', b: 'animals', relation: 'hostile' }]).error, /both are Rust's own/)
|
||||
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'angry' }]).error, /'angry' is not hostile, neutral or allied/)
|
||||
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'hostile' }, { a: 'blue', b: 'red', relation: 'allied' }]).error, /given twice \(one row is both ways, D268\)/)
|
||||
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'hostile' }, { a: 'blue', b: 'red', relation: 'allied' }]).error, /given twice \(one row is both ways\)/)
|
||||
assert.match(shape.checkFactions('red').error, /a list of/)
|
||||
})
|
||||
|
||||
|
||||
@@ -77,6 +77,25 @@ function authored() {
|
||||
}
|
||||
}
|
||||
|
||||
test('a sync whose report did not land a player’s grant is in sync and says how many wait (W17)', () => {
|
||||
withCore()
|
||||
const model = require('../model/permissions/permissions.model')
|
||||
const row = (notLanded) => ({
|
||||
serverId: 'carbon',
|
||||
state: 'ok',
|
||||
desiredHash: 'h',
|
||||
syncedHash: 'h',
|
||||
report: JSON.stringify({ notLanded }),
|
||||
})
|
||||
|
||||
const waiting = model.shapeSync(row(['76561199910000001:backpacks.size.24', 'vip:parent:default']))
|
||||
assert.strictEqual(waiting.inSync, true)
|
||||
assert.strictEqual(waiting.waitingToLand, 1, 'a group’s parent is not a player’s grant')
|
||||
|
||||
assert.strictEqual(model.shapeSync(row([])).waitingToLand, 0)
|
||||
assert.strictEqual(model.shapeSync({ serverId: 'x', state: 'pending' }).waitingToLand, 0)
|
||||
})
|
||||
|
||||
test('a grant reaches every Steam account its holder has linked (D28)', () => {
|
||||
withCore()
|
||||
const model = require('../model/permissions/permissions.model')
|
||||
|
||||
@@ -39,6 +39,10 @@ function withRecorder(rows = []) {
|
||||
|
||||
withRecorder()
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const rb = require('../raidableBases')
|
||||
const catalogue = require('../catalogue')
|
||||
const { gridOf } = require('../worldEvents')
|
||||
@@ -237,6 +241,22 @@ test('the pickers list each server’s own names, once each, and nothing for a s
|
||||
assert.deepStrictEqual(await difficulties.resolve(), [], 'nothing is made up for a server without the plugin')
|
||||
})
|
||||
|
||||
test('a name both servers have, described differently, shows each server’s own description (W13)', async (t) => {
|
||||
const [difficulties, profiles] = module_().OPTION_SOURCES
|
||||
const hard = (difficulty) => ({ ...REPORT, profiles: [{ name: 'Hard Bases', difficulty, level: 2, enabled: true, pvp: true, bases: ['h1'], seabed: false, float: false }] })
|
||||
stubStep(t, {
|
||||
reports: {
|
||||
main: { raidableBases: hard('Hard') },
|
||||
pve: { raidableBases: { ...hard('Expert'), difficulties: [{ name: 'Gentle', level: 1 }, { name: 'Wobbly', level: 7 }] } },
|
||||
},
|
||||
})
|
||||
assert.deepStrictEqual(await profiles.resolve(), [{ value: 'Hard Bases', label: 'Hard Bases · Hard — Main; Hard Bases · Expert — PvE' }])
|
||||
assert.deepStrictEqual(await difficulties.resolve(), [
|
||||
{ value: 'Gentle', label: 'Gentle (level 0) — Main; Gentle (level 1) — PvE' },
|
||||
{ value: 'Wobbly', label: 'Wobbly (level 7) — Main, PvE' },
|
||||
])
|
||||
})
|
||||
|
||||
test('without a patched helper, a spot, a profile, a base or settings are refused; a plain spawn is not (D348, D387)', async (t) => {
|
||||
const run = (params) => spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', ...params }, verify: true })
|
||||
stubStep(t, { reports: { main: { raidableBases: { ...REPORT, helper: { state: 'missing' }, profiles: null }, console: { plugins: true } } } })
|
||||
@@ -337,8 +357,12 @@ test('a base the helper did not make is not the run’s: no resource, and the ru
|
||||
const result = await spawnStep().perform({ runId: 7, idempotencyKey: '7:1', params: { server: 'main', difficulty: 'Wobbly' } })
|
||||
assert.ok(result.ok)
|
||||
assert.strictEqual(result.resources, undefined)
|
||||
assert.match(result.detail.note, /RaidableBases picks the spot/)
|
||||
assert.match(result.detail.note, new RegExp(module_().OWN_TIMER))
|
||||
const own = module_().OWN_TIMER
|
||||
assert.strictEqual(
|
||||
result.detail.note,
|
||||
`RaidableBases picks the spot; the map shows it once the base opens. ${own[0].toUpperCase()}${own.slice(1)}`,
|
||||
'each note after the first starts its own sentence (W14)',
|
||||
)
|
||||
})
|
||||
|
||||
test('the grid’s wait is a ten-minute retry; a refusal the plugin would repeat is not retried', async (t) => {
|
||||
|
||||
@@ -28,6 +28,7 @@ const rewards = require('../eventRewards')
|
||||
const leases = require('../eventLeases')
|
||||
const templates = require('../eventTemplates')
|
||||
const model = require('../model/permissions/permissions.model')
|
||||
const reconcile = require('../model/permissions/reconcile')
|
||||
|
||||
const entitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle')
|
||||
const ok = (data) => ({ ok: true, status: 'ok', data })
|
||||
@@ -163,6 +164,19 @@ test('the sync carries each untold message to the account the reward picked', ()
|
||||
])
|
||||
})
|
||||
|
||||
test('the push carries the notices, and every other field the payload built (W21)', () => {
|
||||
const permSync = require('../permSync')
|
||||
const desired = model.buildDesired('main', authored([grantRow({ notice: 'You earned kit vip', noticeDelivery: 'chat' })]))
|
||||
const held = reconcile.withHold(desired, new Set())
|
||||
const body = permSync.pushBody(desired.hash, held.payload, [], [])
|
||||
assert.deepStrictEqual(body.notices, [
|
||||
{ key: '7:3:11', steamId: '76561190000000001', message: 'You earned kit vip', delivery: 'chat' },
|
||||
])
|
||||
// A field the model adds and the push forgets is how W21 happened.
|
||||
for (const field of Object.keys(held.payload)) assert.ok(field in body, `the push drops payload.${field}`)
|
||||
assert.strictEqual(body.setId, desired.hash)
|
||||
})
|
||||
|
||||
test('a notice moves the digest, so a new one is pushed and a told one is dropped', () => {
|
||||
const none = model.buildDesired('main', authored([grantRow({})]))
|
||||
const one = model.buildDesired('main', authored([grantRow({ notice: 'hi', noticeDelivery: 'chat' })]))
|
||||
|
||||
49
server/test/weaponNames.test.js
Normal file
49
server/test/weaponNames.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// ── What a weapon is called on a page ─────────────────────────────────────
|
||||
//
|
||||
// The feed printed "with ak47u entity". Every event read now carries a
|
||||
// `weaponName`: the game's own name for a ranged weapon, or the prefab name as
|
||||
// words with its ending dropped.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
const weaponNames = require('../model/titles/weaponNames')
|
||||
|
||||
test('a gun is called what the game calls it', () => {
|
||||
assert.equal(weaponNames.nameOf('ak47u.entity'), 'Assault Rifle')
|
||||
assert.equal(weaponNames.nameOf('bolt_rifle.entity'), 'Bolt Action Rifle')
|
||||
assert.equal(weaponNames.nameOf('lr300.entity'), 'LR-300 Assault Rifle')
|
||||
assert.equal(weaponNames.nameOf('semi_auto_rifle.entity'), 'Semi-Automatic Rifle')
|
||||
assert.equal(weaponNames.nameOf('bow_hunting.entity'), 'Hunting Bow')
|
||||
})
|
||||
|
||||
test('anything the table does not hold reads as words, without its ending', () => {
|
||||
assert.equal(weaponNames.nameOf('machete.weapon'), 'machete')
|
||||
assert.equal(weaponNames.nameOf('salvaged_sword.entity'), 'salvaged sword')
|
||||
assert.equal(weaponNames.nameOf('bone.knife.obsidian.entity'), 'bone knife obsidian')
|
||||
assert.equal(weaponNames.nameOf('some_new_gun.entity'), 'some new gun')
|
||||
assert.equal(weaponNames.nameOf('rifle.ak'), 'rifle ak')
|
||||
assert.equal(weaponNames.nameOf(''), null)
|
||||
assert.equal(weaponNames.nameOf(null), null)
|
||||
})
|
||||
|
||||
test('no name in the table still carries a prefab ending', () => {
|
||||
for (const [prefab, name] of weaponNames.NAMES) {
|
||||
assert.ok(prefab.endsWith('.entity'), `${prefab} is a held entity's short prefab name`)
|
||||
assert.doesNotMatch(name, /entity|weapon|_/, `${prefab} -> ${name}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every event read gives a weapon its name, and leaves a frame without one alone', () => {
|
||||
require('../core')._reset()
|
||||
require('../core').init(fakeCtx())
|
||||
const { shape } = require('../model/events/events.model')
|
||||
|
||||
const kill = shape({ id: 1, kind: 'player.death', t: 1, raw: JSON.stringify({ weapon: 'ak47u.entity', distance: 22 }) })
|
||||
assert.equal(kill.frame.weaponName, 'Assault Rifle')
|
||||
assert.equal(kill.frame.weapon, 'ak47u.entity', 'the prefab name stays: the title lists count by it')
|
||||
|
||||
const fall = shape({ id: 2, kind: 'player.death', t: 1, raw: JSON.stringify({ attackerType: 'environment' }) })
|
||||
assert.equal('weaponName' in fall.frame, false)
|
||||
})
|
||||
@@ -178,10 +178,13 @@ test('a zone crosses with its key, its duration, and the monument without the se
|
||||
server: 'main',
|
||||
body: { runId: '7', key: 'k1', monument: 'airfield_1', offsetX: 0, offsetZ: -20, radius: 40, holdMs: 5400000, name: 'Brawl' },
|
||||
})
|
||||
// The ref carries the server: revert and reconcile are handed no params.
|
||||
assert.deepStrictEqual(result.resources, [
|
||||
{ kind: 'world', ref: 'main:rg-7-1-1', payload: { serverId: 'main', what: 'zone', name: 'Z' } },
|
||||
])
|
||||
// The ref carries the server: revert and reconcile are handed no params. The
|
||||
// zone's deadline rides as `until` (W1); zones.test.js checks its value.
|
||||
const [resource] = result.resources
|
||||
assert.strictEqual(typeof resource.until, 'string')
|
||||
const { until, ...rest } = resource
|
||||
assert.deepStrictEqual(rest, { kind: 'world', ref: 'main:rg-7-1-1', payload: { serverId: 'main', what: 'zone', name: 'Z' } })
|
||||
assert.strictEqual(result.resources.length, 1)
|
||||
})
|
||||
|
||||
test('one resource per thing placed, and a repeated key is said to be one', async (t) => {
|
||||
|
||||
@@ -216,6 +216,19 @@ test('a Bradley counts toward Tankbreaker, and an arrival counts nothing', async
|
||||
assert.strictEqual(rec2.touching('rust_player_wipe_stats').length, 0)
|
||||
})
|
||||
|
||||
test('a downed one is stored with the rule that credited it; an arrival is stored as sent (W4)', async () => {
|
||||
const { apply } = require('../ingest')
|
||||
const storedRaw = async (frame) => {
|
||||
const rec = withRecorder()
|
||||
await apply('main', { id: 1, kind: 'world.event', frame }, { id: 'main', creditRule: 'all' })
|
||||
const insert = rec.touching('rust_events').find((s) => /INSERT/i.test(s.sql))
|
||||
return JSON.parse(insert.params.find((p) => typeof p === 'string' && p.startsWith('{')))
|
||||
}
|
||||
|
||||
assert.strictEqual((await storedRaw(downed())).creditRule, 'all')
|
||||
assert.strictEqual((await storedRaw(world('heli.arrived', { x: 1, z: 1 }))).creditRule, undefined)
|
||||
})
|
||||
|
||||
test('the tally’s crates hacked and drops looted are added up (D356)', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
@@ -251,6 +264,21 @@ test('the feed reads each world event for its viewer, with the server’s rule a
|
||||
assert.deepStrictEqual(member.frame.credited, [{ name: 'Bob' }])
|
||||
})
|
||||
|
||||
test('a line keeps the rule it was stored with after the server’s rule changes (W4)', async () => {
|
||||
const stamped = downed({ creditRule: 'all' })
|
||||
withRecorder((sql) => {
|
||||
if (sql.includes('FROM rust_events')) return [{ id: 9, kind: 'world.event', t: NOW, wipeId: 'w1', steamId: null, raw: JSON.stringify(stamped) }]
|
||||
if (sql.includes('FROM rust_servers')) return [{ id: 'main', name: 'Main', creditRule: 'most', enabled: 1 }]
|
||||
return []
|
||||
})
|
||||
const events = require('../model/events/events.model')
|
||||
|
||||
const [member] = await events.recent({ serverId: 'main', presence: true })
|
||||
assert.strictEqual(member.frame.creditRule, 'all')
|
||||
assert.strictEqual(member.frame.creditedCount, 2)
|
||||
assert.deepStrictEqual(member.frame.credited.map((c) => c.name).sort(), ['Alice', 'Bob'])
|
||||
})
|
||||
|
||||
// ── Triggers (D355) ────────────────────────────────────────────────────────
|
||||
|
||||
test('one public trigger per type, none of them declaring a Steam id', () => {
|
||||
@@ -387,6 +415,17 @@ test('a refusal the plugin would repeat is not retried; a game that is down is',
|
||||
assert.match(down.error, /has no game connected/)
|
||||
})
|
||||
|
||||
test('a Travelling Vendor already out is no failure: the step says so and counts nothing called (W3)', async (t) => {
|
||||
stubCall(t, ok({ kind: 'world.error', reason: 'already-out', message: 'a Travelling Vendor is already out' }))
|
||||
const result = await callStep().perform({ runId: 7, idempotencyKey: '7:6', params: { server: 'main', event: 'travelling_vendor' } })
|
||||
assert.strictEqual(result.ok, true)
|
||||
assert.deepStrictEqual(result.detail, {
|
||||
event: 'travelling_vendor',
|
||||
called: 0,
|
||||
note: 'a Travelling Vendor is already out on Main, and the game runs one at a time; nothing new was called',
|
||||
})
|
||||
})
|
||||
|
||||
test('a repeat of a key says so and calls nothing new', async (t) => {
|
||||
stubCall(t, ok({ kind: 'world.called', called: 1, repeat: true }))
|
||||
const result = await callStep().perform({ runId: 7, idempotencyKey: '7:5', params: { server: 'main', event: 'patrol_heli' } })
|
||||
|
||||
@@ -24,6 +24,10 @@ const { fakeCtx } = require('./_fakes')
|
||||
require('../core')._reset()
|
||||
require('../core').init(fakeCtx())
|
||||
|
||||
// The stored status is the whole answer in these tests: nothing asks a
|
||||
// sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask.
|
||||
require('../liveStatus').refresh = async () => false
|
||||
|
||||
const client = require('../sidecarClient')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const zonesDb = require('../model/zones/zones.db')
|
||||
@@ -228,6 +232,29 @@ test('a zone crosses with its flags, settings, messages in the chat voice, and i
|
||||
})
|
||||
})
|
||||
|
||||
test('a zone resource carries its deadline as until, a crate none (W1)', async (t) => {
|
||||
// Core's reconcile reads `until`: a zone gone past it is expired, not
|
||||
// orphaned. Reckoned from the answer, so it never falls before the game's own.
|
||||
stubZones(t)
|
||||
stubWorld(t)
|
||||
const before = Date.now()
|
||||
const result = await action('rust.zone.open').perform({
|
||||
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 30 },
|
||||
})
|
||||
assert.strictEqual(result.ok, true, result.error)
|
||||
const until = Date.parse(result.resources[0].until)
|
||||
assert.ok(until >= before + 1800000 && until <= Date.now() + 1800000, result.resources[0].until)
|
||||
|
||||
const saved = client.worldPlace
|
||||
client.worldPlace = async () => ({ ok: true, status: 'ok', data: { kind: 'world.ok', placed: [{ id: 'rg-7-1-2', kind: 'crate' }] } })
|
||||
t.after(() => { client.worldPlace = saved })
|
||||
const crate = await action('rust.crate.place').perform({
|
||||
runId: 7, idempotencyKey: 'k2', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
|
||||
})
|
||||
assert.strictEqual(crate.ok, true, crate.error)
|
||||
assert.strictEqual('until' in crate.resources[0], false)
|
||||
})
|
||||
|
||||
test('a dome with its stack left blank stacks 3 spheres (D326)', async (t) => {
|
||||
stubZones(t, { servers: [server('main', { domes: PATCHED })] })
|
||||
const calls = stubWorld(t)
|
||||
|
||||
@@ -178,7 +178,8 @@ function columnName(index) {
|
||||
* `staff` the viewer is an admin or a moderator: Steam ids, and a type this
|
||||
* build does not know
|
||||
* `presence` the viewer is in the server's presence audience: names
|
||||
* `rule` the server's credit rule
|
||||
* `rule` the server's credit rule, for a row stored before the frame
|
||||
* carried its own (`stampRule`)
|
||||
* `geometry` the server's map geometry, for the grid
|
||||
*
|
||||
* What comes back keeps the plugin's own fields for what it describes (`event`,
|
||||
@@ -215,8 +216,8 @@ function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDI
|
||||
|
||||
// Credit, by the server's rule.
|
||||
if (known && TYPES[event].credit) {
|
||||
out.creditRule = creditRuleOf(rule)
|
||||
const ids = credited(frame, rule)
|
||||
out.creditRule = ruleOf(frame, rule)
|
||||
const ids = credited(frame, out.creditRule)
|
||||
out.creditedCount = ids.length
|
||||
if (presence || staff) {
|
||||
out.credited = ids.map((id) => ({
|
||||
@@ -235,6 +236,25 @@ function shapeFor(frame, { staff = false, presence = false, rule = DEFAULT_CREDI
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame as it is stored: a downed helicopter, Chinook or Bradley carries
|
||||
* the rule that credited it when it arrived (`creditRule`), so the feed line
|
||||
* keeps naming who the counts were given to after the server's rule changes
|
||||
* (W4). Every other frame is stored as sent.
|
||||
*/
|
||||
function stampRule(frame, rule) {
|
||||
if (!frame || frame.kind !== 'world.event' || !isKnownType(frame.event) || !TYPES[frame.event].credit) return frame
|
||||
return { ...frame, creditRule: creditRuleOf(rule) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule a stored line is read by: its own stamp, or — for a row from before
|
||||
* the stamp — the server's rule now, which is how those rows always read.
|
||||
*/
|
||||
function ruleOf(frame, rule) {
|
||||
return frame && CREDIT_RULE_IDS.includes(frame.creditRule) ? frame.creditRule : creditRuleOf(rule)
|
||||
}
|
||||
|
||||
/**
|
||||
* What one `world.event` adds to the counts: a credited kill of a helicopter,
|
||||
* a Chinook or a Bradley, for each player the server's rule names. `[{ steamId,
|
||||
@@ -270,6 +290,8 @@ module.exports = {
|
||||
creditRuleOf,
|
||||
isKnownType,
|
||||
credited,
|
||||
stampRule,
|
||||
ruleOf,
|
||||
nameOf,
|
||||
gridOf,
|
||||
shapeFor,
|
||||
|
||||
@@ -2387,7 +2387,7 @@
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Economics is not installed there, or the balance is too low to withdraw that much",
|
||||
"description": "Economics is not installed there, the balance is too low to withdraw that much, or a deposit would pass Economics’ balance limit",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -3203,7 +3203,7 @@
|
||||
"Public · Rust"
|
||||
],
|
||||
"summary": "Recent events on one Rust server",
|
||||
"description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule`.",
|
||||
"description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -8257,6 +8257,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"waitingToLand": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Grants to players (lent, given or authored) the last sync pushed and the game did not keep: the report’s `notLanded` entries for a Steam id. On Carbon that is a player the server has never seen; the grant lands when they first join, because every sync pushes the whole set."
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"dirty": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user