Compare commits
25 Commits
5decd5cc05
...
v0.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 24307a95d4 | |||
| ec90d2d5ae | |||
| fb7511d700 | |||
| 161457dbf3 | |||
| 5c9cefdbc2 | |||
| 1f4ad25edb | |||
| 228e178475 | |||
| 0cdb046d30 | |||
| 791f13235e | |||
| c61f9f29ec | |||
| 7a0d0842ba | |||
| bc529962b4 | |||
| c4729315cd | |||
| 55a9ea4649 | |||
| 2e80a21597 | |||
| 3741c954f5 | |||
| 3fac860949 | |||
| 42bd6db6ad | |||
| 3453bc1867 | |||
| 72726838b5 | |||
| 9c35906d0e | |||
| 7304d68d0a | |||
| 3aa142bf06 | |||
| d69c2108c6 | |||
| 8aff9b33a6 |
@@ -384,6 +384,22 @@ jobs:
|
||||
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||
SIZE="$(stat -c%s "dist/module-rust-${VERSION}.tar.gz")"
|
||||
|
||||
# The wire protocol this release speaks, for the public demo's wipe
|
||||
# (website/DEMO_MODE.md §7.5, DM12). The wipe picks the newest release
|
||||
# whose `protocol` EQUALS the Rust bundle's. The Rust link has no 409,
|
||||
# so a plugin on another protocol would mis-parse rather than be
|
||||
# refused. Read from the one declaration (`server/sidecarClient.js`);
|
||||
# a release that cannot find it fails here rather than ship a manifest
|
||||
# the wipe would skip. Core's installer reads only the fields it names
|
||||
# (modules/install.js), so the extra key changes nothing there.
|
||||
PROTOCOL="$(node -e '
|
||||
const src = require("fs").readFileSync("server/sidecarClient.js", "utf8");
|
||||
const m = /^const PROTOCOL_VERSION = (\d+)$/m.exec(src);
|
||||
if (!m) { console.error("no PROTOCOL_VERSION in server/sidecarClient.js"); process.exit(1); }
|
||||
console.log(m[1]);
|
||||
')"
|
||||
echo "protocol ${PROTOCOL}"
|
||||
|
||||
# The install manifest. Same shape as the installer's bundle JSON — a
|
||||
# per-asset sha256 fetched over HTTPS, no signatures — because that is
|
||||
# the model this project already has and a second one would be a second
|
||||
@@ -396,9 +412,10 @@ jobs:
|
||||
--arg artifact "module-rust-${VERSION}.tar.gz" \
|
||||
--arg sha256 "$SHA" \
|
||||
--argjson size "$SIZE" \
|
||||
--argjson protocol "$PROTOCOL" \
|
||||
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-rust-${VERSION}.tar.gz" \
|
||||
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size, protocol:$protocol}' \
|
||||
> "dist/module-rust-${VERSION}.json"
|
||||
|
||||
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"core.js",
|
||||
"dangerousTreasures.js",
|
||||
"db",
|
||||
"demo.js",
|
||||
"demoSeed.js",
|
||||
"dynamicPvp.js",
|
||||
"eventCommands.js",
|
||||
"engagement",
|
||||
@@ -50,6 +52,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. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover; it moves to `main` when that cutover lands.",
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change. **2026-10-26, the public demo (DEMO_MODE.md §3.4, §3.5, DM18):** this module declares demoLocks, reads ctx.demoMode, registers onDemoSeed (MODULE_API 1.18.0) and gives its two console-command actions demoRefuses (1.19.0), so coreApi is ^1.19.0. It was pinned to the head of website feature/demo-step-refusal (website#224) while that PR was open — a 1.18.0 core never asks demoRefuses — and is back on `main` at the sha #224 merged as (the same tree, so routes.manifest.json does not change).",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "9a1654fc543c0f73323dbf675b347de42efe8ae4",
|
||||
"refName": "edge @ website#218 merge, MODULE_API 1.17.0"
|
||||
"ref": "69998ec54854d46681def92985a2b299ee3582a6",
|
||||
"refName": "website main @ website#224 merge, MODULE_API 1.19.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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -130,7 +130,7 @@ function formFrom(server) {
|
||||
isNew: false,
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
sidecarBaseUrl: server.sidecarBaseUrl,
|
||||
sidecarBaseUrl: server.sidecarBaseUrl || '',
|
||||
sidecarToken: '',
|
||||
enabled: server.enabled,
|
||||
sortOrder: server.sortOrder,
|
||||
@@ -264,7 +264,7 @@ export default function ServerSettings() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
|
||||
{s.sidecarBaseUrl} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
|
||||
{s.sidecarBaseUrl || 'sidecar address hidden'} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
|
||||
{s.sidecarProtocol != null && s.sidecarProtocol !== s.protocol ? ` (sidecar speaks ${s.sidecarProtocol})` : ''}
|
||||
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
|
||||
</div>
|
||||
|
||||
@@ -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.17.0",
|
||||
"coreApi": "^1.19.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "rust.base.destroyed",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "rust",
|
||||
"name": "Rust",
|
||||
"version": "0.1.0",
|
||||
"coreApi": "^1.17.0",
|
||||
"coreApi": "^1.19.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
@@ -13,5 +13,10 @@
|
||||
"player": ["/rust"]
|
||||
},
|
||||
"extensions": ["admin.users.detail"],
|
||||
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"]
|
||||
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"],
|
||||
"demoLocks": [
|
||||
{ "method": "PUT", "path": "/admin/rust/servers/:id" },
|
||||
{ "method": "DELETE", "path": "/admin/rust/servers/:id" },
|
||||
{ "method": "DELETE", "path": "/player/rust/links/:steamId" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -186,4 +186,9 @@ module.exports = {
|
||||
// origin to be relative to. A getter over core's getter, so an operator who
|
||||
// changes `APP_BASE_URL` is read at the call, never captured at load.
|
||||
get baseUrl() { return need().site.baseUrl },
|
||||
|
||||
// Whether this is the public demo (MODULE_API 1.18.0, DEMO_MODE.md §3.4, §3.5).
|
||||
// Read through `demo.js`, which holds every lock it switches on. Only a real
|
||||
// `true` counts, so a core that predates the member reads as "not the demo".
|
||||
get demoMode() { return need().demoMode === true },
|
||||
}
|
||||
|
||||
139
server/demo.js
Normal file
139
server/demo.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// ── The public demo's module-side locks (DEMO_MODE.md §3.4, §3.5) ──────────
|
||||
//
|
||||
// Core locks this module's two rig-link routes and the player's Steam unlink by
|
||||
// `demoLocks` in module.json. What a route list cannot express lives here, behind
|
||||
// `ctx.demoMode` (MODULE_API 1.18.0), and every handler that enforces one of these
|
||||
// asks this file:
|
||||
//
|
||||
// the staff Steam unlink on Admin → Users (step-5 walk, W4): it serves under
|
||||
// core's users mount, which a module's demoLocks may not name, so its handler
|
||||
// refuses instead. The demo player's seeded link stays put either way;
|
||||
// the console-command deny list (§3.4, DM11): the commands that would break
|
||||
// the rig for every visitor until the next wipe. Asked at save, through
|
||||
// core's `demoRefuses` hook (1.19.0, DM18), and again at run;
|
||||
// the bridge's own config (§3.5, DM14): neither readable nor writable, because
|
||||
// it holds the bridge's link to the sidecar and its credentials;
|
||||
// secrets in every other config: redacted on the way out, read-only on the
|
||||
// way in, and no raw-JSON saves.
|
||||
//
|
||||
// Off the demo nothing here refuses anything. The server is the lock (DM10):
|
||||
// the UI may explain a refusal, but no screen is relied on to make one.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const CODE = 'DEMO_LOCKED'
|
||||
const MESSAGE = 'This is turned off on the demo site.'
|
||||
|
||||
/** Whether this is the public demo. */
|
||||
function isDemo() {
|
||||
return core.demoMode
|
||||
}
|
||||
|
||||
/** The demo's refusal, in core's shape, so core's one toast shows it. */
|
||||
function refuse(res, message = MESSAGE, extra = {}) {
|
||||
return res.status(403).json({ message, code: CODE, ...extra })
|
||||
}
|
||||
|
||||
// ── The console-command deny list (§3.4, DM11) ─────────────────────────────
|
||||
|
||||
/** Denied by first word, exactly. */
|
||||
const DENIED = new Set(['quit', 'restart', 'oxide.unload', 'oxide.reload', 'fps.limit', 'server.tickrate'])
|
||||
|
||||
/** Denied by prefix: every RCON command. */
|
||||
const DENIED_PREFIXES = ['rcon.']
|
||||
|
||||
/**
|
||||
* The command a line runs, as the deny list compares it: the first word,
|
||||
* lower-cased, with a leading `global.` stripped (Rust accepts `global.quit`)
|
||||
* and the `o.` short form spelled `oxide.` (`o.unload`). Only the first word,
|
||||
* so arguments cannot hide a denied command; `;` does not split a Rust console
|
||||
* line (the §16 spike), so there is no second command to look for.
|
||||
*/
|
||||
function commandWord(line) {
|
||||
let word = String(line === undefined || line === null ? '' : line).trim().split(/\s+/)[0].toLowerCase()
|
||||
if (word.startsWith('global.')) word = word.slice('global.'.length)
|
||||
if (word.startsWith('o.')) word = `oxide.${word.slice('o.'.length)}`
|
||||
return word
|
||||
}
|
||||
|
||||
/** Whether one console line is denied on the demo, by its first word. */
|
||||
function isDeniedCommand(line) {
|
||||
const word = commandWord(line)
|
||||
if (!word) return false
|
||||
return DENIED.has(word) || DENIED_PREFIXES.some((prefix) => word.startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal sentence for a list of console lines, or null when none is
|
||||
* denied — or when this is not the demo. Names the line as the author typed it.
|
||||
*/
|
||||
function deniedCommand(lines) {
|
||||
if (!isDemo()) return null
|
||||
const items = Array.isArray(lines) ? lines : lines === undefined || lines === null ? [] : [lines]
|
||||
const hit = items.find((line) => isDeniedCommand(line))
|
||||
return hit === undefined ? null : `"${String(hit).trim()}" is turned off on the demo site.`
|
||||
}
|
||||
|
||||
// ── The bridge's own config (§3.5, DM14) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* The bridge plugin's name. The catalogue's `self` is the plugin's own word for
|
||||
* it and is used when it is there; this is the floor, so a catalogue that could
|
||||
* not be read never unlocks the file (the lock fails closed).
|
||||
*/
|
||||
const BRIDGE = 'RunicGateway'
|
||||
|
||||
/**
|
||||
* A config path in the form the host will resolve it to, for the bridge test
|
||||
* only: `.` segments dropped, each segment trimmed of the spaces and trailing
|
||||
* dots Windows ignores, and lower-cased. `./RunicGateway.json` and
|
||||
* `RunicGateway /x.json` are the bridge's files on some host, so they are the
|
||||
* bridge's files here.
|
||||
*/
|
||||
function canonicalSegments(path) {
|
||||
return String(path || '')
|
||||
.split(/[\\/]+/)
|
||||
// A `.` segment trims to nothing and is dropped with the empty ones.
|
||||
.map((segment) => segment.trim().replace(/[. ]+$/, ''))
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a config path belongs to the bridge plugin: its first segment, or its
|
||||
* file name at the root, is the bridge's name. `self` is the catalogue's.
|
||||
*/
|
||||
function isBridgePath(path, self) {
|
||||
const names = new Set([BRIDGE.toLowerCase()])
|
||||
if (self) names.add(String(self).toLowerCase())
|
||||
const parts = canonicalSegments(path)
|
||||
if (!parts.length) return false
|
||||
const first = parts.length > 1 ? parts[0] : parts[0].replace(/\.json$/, '')
|
||||
return names.has(first)
|
||||
}
|
||||
|
||||
/** Whether a plugin name (a catalogue group, an audit row's plugin) is the bridge. */
|
||||
function isBridgePlugin(plugin, self) {
|
||||
const name = String(plugin || '').toLowerCase()
|
||||
return name === BRIDGE.toLowerCase() || (self ? name === String(self).toLowerCase() : false)
|
||||
}
|
||||
|
||||
// ── Secrets in every other config (§3.5) ───────────────────────────────────
|
||||
|
||||
/** What a redacted value reads as, in the text and in the form. */
|
||||
const REDACTED = '(hidden on the demo site)'
|
||||
|
||||
module.exports = {
|
||||
CODE,
|
||||
MESSAGE,
|
||||
BRIDGE,
|
||||
REDACTED,
|
||||
isDemo,
|
||||
refuse,
|
||||
commandWord,
|
||||
isDeniedCommand,
|
||||
deniedCommand,
|
||||
canonicalSegments,
|
||||
isBridgePath,
|
||||
isBridgePlugin,
|
||||
}
|
||||
190
server/demoSeed.js
Normal file
190
server/demoSeed.js
Normal file
@@ -0,0 +1,190 @@
|
||||
// ── The public demo's seed: this module's rows (MODULE_API 1.18.0, DM15) ────
|
||||
//
|
||||
// `npm run seed:demo` hands each module its `modules.<id>` section of the
|
||||
// fixture, with every `"$env:NAME"` already replaced, and the demo accounts' ids
|
||||
// (DEMO_MODE.md §7.3). This module writes two kinds of row from it, through its
|
||||
// own models; core never writes them:
|
||||
//
|
||||
// servers the link to the rig: the sidecar URL and token come from the host's
|
||||
// .env, so the token is never in the repo
|
||||
// links demo-player's stand-in Steam id (DM6), so the player screens have
|
||||
// somebody to show
|
||||
// npcs the NPC faction table and profiles of the demo default set (DM26)
|
||||
// chat a BetterChat style per group (DM26)
|
||||
//
|
||||
// { "servers": [{ "id": "main", "name": "Demo", "sidecarBaseUrl": "$env:RUST_SIDECAR_URL",
|
||||
// "sidecarToken": "$env:RUST_SIDECAR_TOKEN" }],
|
||||
// "links": [{ "username": "demo-player", "steamId": "7656119…", "serverId": "main" }],
|
||||
// "npcs": { "factions": [{ "a": "bandits", "b": "guards", "relation": "allied" }],
|
||||
// "profiles": [{ "name": "bandit", "servers": ["main"], "body": { … } }] },
|
||||
// "chat": [{ "serverId": "main", "group": "vip", "style": { "Title": "[VIP]", … } }] }
|
||||
//
|
||||
// **Why NPCs and chat come from here, not the rig's baseline (DM26).** The rig is
|
||||
// restored before the site is wiped, and a fresh site imports what it finds
|
||||
// (§7.1). That holds for permissions, but not for these two. The restored rig's
|
||||
// RunicNPC says it is already managed, by the site before this one, so the new
|
||||
// site adopts nothing (D244) and its first push empties the profiles. The import
|
||||
// never adopts a group's chat style. So the site holds them from the start and
|
||||
// pushes them, and the placements in the baseline spawn again.
|
||||
//
|
||||
// A chat style names a group the rig's import has not brought in yet, because
|
||||
// the seed runs first. The group is created for that server, and the import
|
||||
// then adopts the rig's title, rank and permissions into it. A style lists only
|
||||
// the fields that differ from BetterChat's defaults.
|
||||
//
|
||||
// **It runs on every seed, so it upserts.** Core seeds content on a database's
|
||||
// first run only, but calls this every time. The section's shape is checked
|
||||
// before anything is written, and a problem throws, which fails the seed. A
|
||||
// profile is checked by the NPC model as it is saved, as the admin page saves
|
||||
// one, so a profile the model refuses fails the seed too.
|
||||
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const npcs = require('./model/npcs/npcs.model')
|
||||
const npcsDb = require('./model/npcs/npcs.db')
|
||||
const npcShape = require('./model/npcs/npcProfile')
|
||||
const permApply = require('./model/permissions/permissions.apply')
|
||||
const permDb = require('./model/permissions/permissions.db')
|
||||
const chatStyle = require('./model/permissions/chatStyle')
|
||||
const sidecar = require('./sidecarClient')
|
||||
const core = require('./core')
|
||||
|
||||
const log = core.logger('demo')
|
||||
|
||||
const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
|
||||
const STEAM_ID = /^7656119\d{10}$/
|
||||
// Oxide group names as the permission model keeps them: trimmed and lower-cased.
|
||||
const GROUP_NAME = /^[a-z0-9_.-]{1,64}$/
|
||||
|
||||
/** BetterChat's defaults for the group, with the fixture's fields over them. */
|
||||
function fullStyle(group, style) {
|
||||
return { ...chatStyle.defaults(String(group || '')), ...(style && typeof style === 'object' ? style : {}) }
|
||||
}
|
||||
|
||||
/** Every problem with the section, as sentences. Empty when it is good to write. */
|
||||
function checkSection(data, accounts = {}) {
|
||||
const problems = []
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return ['modules.rust must be an object']
|
||||
|
||||
const list = (key) => (data[key] === undefined ? [] : data[key])
|
||||
for (const key of ['servers', 'links', 'chat']) {
|
||||
if (data[key] !== undefined && !Array.isArray(data[key])) problems.push(`modules.rust.${key} must be an array`)
|
||||
}
|
||||
const n = data.npcs
|
||||
if (n !== undefined) {
|
||||
if (!n || typeof n !== 'object' || Array.isArray(n)) problems.push('modules.rust.npcs must be an object')
|
||||
else {
|
||||
if (n.factions !== undefined && !Array.isArray(n.factions)) problems.push('modules.rust.npcs.factions must be an array')
|
||||
if (n.profiles !== undefined && !Array.isArray(n.profiles)) problems.push('modules.rust.npcs.profiles must be an array')
|
||||
}
|
||||
}
|
||||
if (problems.length) return problems
|
||||
|
||||
if (n && n.factions !== undefined) {
|
||||
const checked = npcShape.checkFactions(n.factions)
|
||||
if (!checked.ok) problems.push(`modules.rust.npcs.${checked.error}`)
|
||||
}
|
||||
;((n && n.profiles) || []).forEach((p, i) => {
|
||||
const at = `modules.rust.npcs.profiles[${i}]`
|
||||
if (!p || typeof p !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!npcShape.NAME_RULE.test(String(p.name || ''))) problems.push(`${at}.name must be 1 to 40 of a-z, 0-9, _ and -`)
|
||||
if (!p.body || typeof p.body !== 'object' || Array.isArray(p.body)) problems.push(`${at}.body must be an object`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
list('chat').forEach((c, i) => {
|
||||
const at = `modules.rust.chat[${i}]`
|
||||
if (!c || typeof c !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!SERVER_ID.test(String(c.serverId || ''))) problems.push(`${at}.serverId must be a server id`)
|
||||
if (!GROUP_NAME.test(String(c.group || ''))) problems.push(`${at}.group must be a group name`)
|
||||
const checked = chatStyle.validateStyle(fullStyle(c.group, c.style))
|
||||
if (!checked.ok) problems.push(`${at}.style: ${checked.errors.join(' ')}`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
list('servers').forEach((s, i) => {
|
||||
const at = `modules.rust.servers[${i}]`
|
||||
if (!s || typeof s !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!SERVER_ID.test(String(s.id || ''))) problems.push(`${at}.id must be lowercase letters, digits and hyphens`)
|
||||
if (typeof s.name !== 'string' || !s.name.trim()) problems.push(`${at}.name is required`)
|
||||
if (!/^https?:\/\//.test(String(s.sidecarBaseUrl || ''))) problems.push(`${at}.sidecarBaseUrl must be an http(s) URL`)
|
||||
if (typeof s.sidecarToken !== 'string' || !s.sidecarToken) problems.push(`${at}.sidecarToken is required`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
list('links').forEach((l, i) => {
|
||||
const at = `modules.rust.links[${i}]`
|
||||
if (!l || typeof l !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!accounts[l.username]) problems.push(`${at}.username must be one of the demo accounts`)
|
||||
if (!STEAM_ID.test(String(l.steamId || ''))) problems.push(`${at}.steamId must be a 17-digit Steam id`)
|
||||
if (l.serverId !== undefined && !SERVER_ID.test(String(l.serverId))) problems.push(`${at}.serverId must be a server id`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
return problems
|
||||
}
|
||||
|
||||
/** The hook core calls: `onDemoSeed(data, { accounts })`. */
|
||||
async function onDemoSeed(data, { accounts = {} } = {}) {
|
||||
const problems = checkSection(data, accounts)
|
||||
if (problems.length) throw new Error(`the Rust demo seed has problems: ${problems.join('; ')}`)
|
||||
|
||||
for (const s of data.servers || []) {
|
||||
await serversDb.upsertServer({
|
||||
id: s.id,
|
||||
name: s.name.trim(),
|
||||
sidecarBaseUrl: s.sidecarBaseUrl,
|
||||
sidecarTokenEnc: servers.encryptToken(s.sidecarToken),
|
||||
protocol: s.protocol === undefined ? sidecar.PROTOCOL_VERSION : Number(s.protocol),
|
||||
enabled: s.enabled === undefined ? true : Boolean(s.enabled),
|
||||
sortOrder: s.sortOrder === undefined ? 0 : Number(s.sortOrder),
|
||||
})
|
||||
log.info('demo server ready', { server: s.id })
|
||||
}
|
||||
|
||||
// A link is a plain insert by design (D23), so the upsert is spelled out here:
|
||||
// the right owner already holds it, or it is moved to them. Only the seed may
|
||||
// move a link, and only to a demo account.
|
||||
for (const l of data.links || []) {
|
||||
const userId = accounts[l.username]
|
||||
const steamId = String(l.steamId)
|
||||
const existing = await linksDb.getBySteamId(steamId)
|
||||
if (existing && Number(existing.userId) === Number(userId)) continue
|
||||
if (existing) await linksDb.removeBySteamId(steamId)
|
||||
await linksDb.insert({ steamId, userId, name: l.name || null, serverId: l.serverId || null })
|
||||
log.info('demo link ready', { username: l.username })
|
||||
}
|
||||
|
||||
// After the servers: a profile is refused for a server the site does not have.
|
||||
const n = data.npcs || {}
|
||||
if (n.factions !== undefined) {
|
||||
await npcs.setFactions(n.factions)
|
||||
log.info('demo NPC factions ready', { pairs: n.factions.length })
|
||||
}
|
||||
if (n.profiles && n.profiles.length) {
|
||||
const existing = await npcsDb.listProfiles()
|
||||
for (const p of n.profiles) {
|
||||
const input = { name: p.name, body: p.body, servers: p.servers, allServers: p.allServers, killsScope: p.killsScope }
|
||||
const same = existing.find((e) => e.name === p.name && !e.replaced)
|
||||
if (same) await npcs.update(same.id, input)
|
||||
else await npcs.create(input)
|
||||
log.info('demo NPC profile ready', { profile: p.name })
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of data.chat || []) {
|
||||
const name = String(c.group)
|
||||
let group = await permApply.groupOn(name, c.serverId)
|
||||
if (!group) {
|
||||
const id = await permDb.insertGroup({ name, title: name })
|
||||
await permDb.setGroupServers(id, { allServers: false, servers: [c.serverId] })
|
||||
group = { id }
|
||||
}
|
||||
await permDb.setGroupChat(group.id, chatStyle.validateStyle(fullStyle(name, c.style)).fields)
|
||||
await permDb.markDirty(c.serverId)
|
||||
log.info('demo chat style ready', { server: c.serverId, group: name })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkSection, onDemoSeed }
|
||||
@@ -41,6 +41,7 @@ const core = require('./core')
|
||||
const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
const rewards = require('./eventRewards')
|
||||
const demo = require('./demo')
|
||||
|
||||
const log = core.logger('commands')
|
||||
|
||||
@@ -115,6 +116,9 @@ function checkConfirm(params, sentence) {
|
||||
/** The first command that names a placeholder, or null: only a reward has someone to fill one with. */
|
||||
const withPlaceholder = (lines) => lines.find((line) => PLACEHOLDER.test(line)) || null
|
||||
|
||||
/** A `multiple` param as a list: a bare value is a list of one, nothing is none. */
|
||||
const asList = (raw) => (raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw])
|
||||
|
||||
/** A command list's price: at least one, so the switchboard sees the dimension. */
|
||||
function pricedCommands(raw) {
|
||||
const checked = checkCommands(raw, { required: false })
|
||||
@@ -169,7 +173,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' } : {}),
|
||||
}
|
||||
}
|
||||
@@ -190,6 +194,11 @@ async function checkRun(params) {
|
||||
const teardown = checkCommands(params.teardown, { field: 'teardown commands', required: false })
|
||||
if (!teardown.ok) return { ok: false, retry: false, error: teardown.error }
|
||||
|
||||
// The demo's deny list (DEMO_MODE.md §3.4), at run: core asked at save
|
||||
// (demoRefuses), but a definition can predate the list.
|
||||
const denied = demo.deniedCommand([...commands.value, ...teardown.value])
|
||||
if (denied) return { ok: false, retry: false, error: denied }
|
||||
|
||||
const placeholder = withPlaceholder([...commands.value, ...teardown.value])
|
||||
if (placeholder) {
|
||||
return { ok: false, retry: false, error: `{steamid} and {name} are filled in only by Reward by console command, and "${placeholder}" has one` }
|
||||
@@ -324,6 +333,10 @@ const commandRun = {
|
||||
|
||||
revert: revertRun,
|
||||
|
||||
// The demo's deny list at save (MODULE_API 1.19.0, DM18). Core asks only on the
|
||||
// demo; null lets the step through.
|
||||
demoRefuses: (p) => demo.deniedCommand([...asList(p.commands), ...asList(p.teardown)]),
|
||||
|
||||
// The site cannot ask a server whether a command's effect is still there, so
|
||||
// a teardown not yet run is always in force: "I do not know" is never "gone".
|
||||
async reconcile({ resources }) {
|
||||
@@ -397,10 +410,15 @@ const commandReward = {
|
||||
|
||||
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) * pricedPeople(p) }),
|
||||
|
||||
demoRefuses: (p) => demo.deniedCommand(asList(p.commands)),
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const commands = checkCommands(params.commands)
|
||||
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
|
||||
|
||||
const denied = demo.deniedCommand(commands.value)
|
||||
if (denied) return { ok: false, retry: false, error: denied }
|
||||
|
||||
const confirm = checkConfirm(params, REWARD_SURE)
|
||||
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -168,6 +168,13 @@ module.exports = function register(ctx, api) {
|
||||
api.onIdentityLinking(links.checkSteamSignin)
|
||||
api.onIdentityLinked(links.linkFromSteamSignin)
|
||||
|
||||
// The public demo's seed (MODULE_API 1.18.0, DM15): the rig's server row and
|
||||
// demo-player's Steam link, written by this module from its part of the
|
||||
// fixture. Called only by `npm run seed:demo` on a DEMO_MODE deployment. The
|
||||
// demo's other locks are `demoLocks` in module.json and `demo.js`.
|
||||
const demoSeed = require('./demoSeed') // eslint-disable-line global-require
|
||||
api.onDemoSeed(demoSeed.onDemoSeed)
|
||||
|
||||
// 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. */
|
||||
@@ -163,6 +174,71 @@ function shapeFile(file, { self = null, maxDepth = 6 } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A shaped file with every credential-shaped value hidden, for the public demo
|
||||
* (DEMO_MODE.md §3.5, DM14). The form's mask is not a lock (DM10), so on the
|
||||
* demo the value never leaves the module:
|
||||
*
|
||||
* - in `text`, each flagged string or number is spliced out for the
|
||||
* placeholder, from the end backwards so the spans stay true;
|
||||
* - in `fields`, a flagged field carries the placeholder and is `locked`,
|
||||
* so the form shows it read-only, and the save refuses an edit to it anyway.
|
||||
*
|
||||
* "Flagged" is the editor's own reading (`configEdit.isSecretKey`, inherited by
|
||||
* everything under a flagged key), so the demo hides exactly what the form masks.
|
||||
* A file that does not parse cannot be read for its secrets, so its text is
|
||||
* withheld whole: the lock fails closed.
|
||||
*/
|
||||
function redactSecrets(shaped, placeholder) {
|
||||
if (!shaped) return shaped
|
||||
|
||||
let root
|
||||
try {
|
||||
root = configEdit.scan(shaped.text)
|
||||
} catch {
|
||||
return { ...shaped, text: '', fields: null, redacted: true, withheld: 'This file does not parse, so it is not shown on the demo site.' }
|
||||
}
|
||||
|
||||
const spans = []
|
||||
const visit = (node, secret) => {
|
||||
if (node.type === 'object') {
|
||||
for (const child of node.children) visit(child, secret || configEdit.isSecretKey(child.key))
|
||||
} else if (node.type === 'array') {
|
||||
for (const child of node.children) visit(child, secret)
|
||||
} else if (secret && (node.type === 'number' || (node.type === 'string' && node.value !== ''))) {
|
||||
spans.push(node)
|
||||
}
|
||||
}
|
||||
visit(root, false)
|
||||
|
||||
let text = shaped.text
|
||||
for (const node of [...spans].sort((a, b) => b.start - a.start)) {
|
||||
text = text.slice(0, node.start) + JSON.stringify(placeholder) + text.slice(node.end)
|
||||
}
|
||||
|
||||
const fields = Array.isArray(shaped.fields)
|
||||
? shaped.fields.map((field) => {
|
||||
if (!field.secret) return field
|
||||
const leaf = field.type === 'number' || field.type === 'string'
|
||||
const { raw, ...rest } = field
|
||||
return {
|
||||
...rest,
|
||||
...(leaf && !(field.type === 'string' && field.value === '') ? { value: placeholder } : {}),
|
||||
locked: true,
|
||||
}
|
||||
})
|
||||
: shaped.fields
|
||||
|
||||
return { ...shaped, text, fields, redacted: true }
|
||||
}
|
||||
|
||||
/** Whether any edit names a credential-shaped field, as `applyEdits` marks them. */
|
||||
function editsTouchSecret(edits) {
|
||||
return (edits || []).some(
|
||||
(edit) => Array.isArray(edit && edit.pointer) && edit.pointer.some((step) => typeof step === 'string' && configEdit.isSecretKey(step)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which locked keys differ between two versions of a document.
|
||||
*
|
||||
@@ -279,6 +355,9 @@ module.exports = {
|
||||
isPlausiblePath,
|
||||
shapeCatalogue,
|
||||
shapeFile,
|
||||
redactSecrets,
|
||||
editsTouchSecret,
|
||||
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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -203,7 +203,9 @@ async function listForAdmin(now = Date.now()) {
|
||||
// The public shape first, so the admin-only fields below cannot be
|
||||
// overwritten by a key the public shape happens to share.
|
||||
...shapePublic(row, state, now),
|
||||
sidecarBaseUrl: row.sidecarBaseUrl,
|
||||
// Left out on the public demo, so the screen gives nothing away that helps
|
||||
// someone reach the sidecar (DEMO_MODE.md §3.5); editing the link is locked.
|
||||
...(core.demoMode ? {} : { sidecarBaseUrl: row.sidecarBaseUrl }),
|
||||
hasToken: Boolean(row.sidecarTokenEnc),
|
||||
protocol: Number(row.protocol),
|
||||
enabled: Boolean(row.enabled),
|
||||
|
||||
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,
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const configEdit = require('../../configEdit')
|
||||
const demo = require('../../demo')
|
||||
const db = require('../../model/config/config.db')
|
||||
const model = require('../../model/config/config.model')
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
@@ -87,7 +88,32 @@ async function listFiles(req, res) {
|
||||
return res.status(502).json({ message: refusalMessage(reply.data) })
|
||||
}
|
||||
|
||||
return res.json(model.shapeCatalogue(reply.data))
|
||||
return res.json(forDemo(model.shapeCatalogue(reply.data)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalogue without the bridge's own files, on the demo (DEMO_MODE.md §3.5,
|
||||
* DM14): they hold its link to the sidecar, so they are not listed, read or
|
||||
* written. Off the demo the catalogue is untouched.
|
||||
*/
|
||||
function forDemo(catalogue) {
|
||||
if (!demo.isDemo() || !catalogue) return catalogue
|
||||
return {
|
||||
...catalogue,
|
||||
plugins: catalogue.plugins
|
||||
.filter((group) => !demo.isBridgePlugin(group.plugin, catalogue.self))
|
||||
.map((group) => ({ ...group, files: group.files.filter((file) => !demo.isBridgePath(file.path, catalogue.self)) })),
|
||||
}
|
||||
}
|
||||
|
||||
/** A shaped file as the caller may see it: secrets hidden on the demo. */
|
||||
function visible(shaped) {
|
||||
return demo.isDemo() ? model.redactSecrets(shaped, demo.REDACTED) : shaped
|
||||
}
|
||||
|
||||
/** The demo's refusal for the bridge's config, in one place. */
|
||||
function refuseBridge(res) {
|
||||
return demo.refuse(res, 'The bridge plugin’s configuration is not shown on the demo site.')
|
||||
}
|
||||
|
||||
/** One file: its text, and the reading of it the form is drawn from. */
|
||||
@@ -98,6 +124,10 @@ async function readFile(req, res) {
|
||||
return res.status(400).json({ message: 'That is not a configuration path' })
|
||||
}
|
||||
|
||||
// Before the host is asked, by the bridge's own name; and again below, by the
|
||||
// name its catalogue gives, in case the two ever differ.
|
||||
if (demo.isDemo() && demo.isBridgePath(path, null)) return refuseBridge(res)
|
||||
|
||||
const server = await serverOr404(req, res)
|
||||
if (!server) return undefined
|
||||
|
||||
@@ -116,7 +146,8 @@ async function readFile(req, res) {
|
||||
}
|
||||
|
||||
const self = catalogueReply.ok && catalogueReply.data ? catalogueReply.data.self : null
|
||||
return res.json(model.shapeFile(fileReply.data, { self }))
|
||||
if (demo.isDemo() && demo.isBridgePath(path, self)) return refuseBridge(res)
|
||||
return res.json(visible(model.shapeFile(fileReply.data, { self })))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,6 +166,16 @@ async function writeFile(req, res) {
|
||||
return res.status(400).json({ message: 'That is not a configuration path' })
|
||||
}
|
||||
|
||||
if (demo.isDemo()) {
|
||||
if (demo.isBridgePath(path, null)) return refuseBridge(res)
|
||||
// A raw document sent back from the demo holds the placeholders, and would
|
||||
// write them over the real values. The form splices only what changed.
|
||||
if (tier === 'raw') return demo.refuse(res, 'Raw JSON saves are turned off on the demo site. Use the form.')
|
||||
if (model.editsTouchSecret(req.body.edits)) {
|
||||
return demo.refuse(res, 'Credential fields are read-only on the demo site.')
|
||||
}
|
||||
}
|
||||
|
||||
const server = await serverOr404(req, res)
|
||||
if (!server) return undefined
|
||||
|
||||
@@ -151,6 +192,7 @@ async function writeFile(req, res) {
|
||||
|
||||
const onDisk = current.data
|
||||
const self = catalogue.ok && catalogue.data ? catalogue.data.self : null
|
||||
if (demo.isDemo() && demo.isBridgePath(path, self)) return refuseBridge(res)
|
||||
const locked = model.lockedKeysFor(path, self)
|
||||
|
||||
// The browser's version against what is on the host right now. The plugin
|
||||
@@ -160,7 +202,7 @@ async function writeFile(req, res) {
|
||||
if (String(req.body.version || '') !== String(onDisk.version)) {
|
||||
return res.status(409).json({
|
||||
message: 'That file changed on the server since you opened it',
|
||||
current: model.shapeFile(onDisk, { self }),
|
||||
current: visible(model.shapeFile(onDisk, { self })),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -287,7 +329,9 @@ async function writeFile(req, res) {
|
||||
/** What has been written to this server's configuration lately, and by whom. */
|
||||
async function history(req, res) {
|
||||
try {
|
||||
return res.json({ writes: await db.recentWrites(req.params.serverId, req.query.limit) })
|
||||
const writes = await db.recentWrites(req.params.serverId, req.query.limit)
|
||||
// The bridge's own writes are left out on the demo, as its files are (§3.5).
|
||||
return res.json({ writes: demo.isDemo() ? writes.filter((w) => !isBridgeWrite(w)) : writes })
|
||||
} catch (err) {
|
||||
log.error('failed to read the configuration history', { error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to read the configuration history' })
|
||||
@@ -305,6 +349,7 @@ async function writeStatus(req, res) {
|
||||
try {
|
||||
const write = await db.getWrite(req.params.serverId, Number(req.params.writeId))
|
||||
if (!write) return res.status(404).json({ message: 'No such configuration write' })
|
||||
if (demo.isDemo() && isBridgeWrite(write)) return refuseBridge(res)
|
||||
|
||||
return res.json({ write })
|
||||
} catch (err) {
|
||||
@@ -313,6 +358,11 @@ async function writeStatus(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an audit row is a write to the bridge's own config. */
|
||||
function isBridgeWrite(write) {
|
||||
return demo.isBridgePlugin(write.plugin, null) || demo.isBridgePath(write.path, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* One audit row, plus the activity entry core owns. Never lets a logging failure
|
||||
* fail a save. Returns the row's id, or null when it could not be written.
|
||||
@@ -323,7 +373,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 +397,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 = {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const core = require('../../core')
|
||||
const demo = require('../../demo')
|
||||
|
||||
const backpacks = require('../../model/backpacks/backpacks.model')
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
@@ -54,6 +55,10 @@ async function removeLink(req, res) {
|
||||
const { steamId } = req.params
|
||||
const userId = req.params.id
|
||||
|
||||
// On the demo the player's own unlink is locked by demoLocks; this route serves
|
||||
// under core's users mount, which a module may not lock by route, so it asks here.
|
||||
if (demo.isDemo()) return demo.refuse(res)
|
||||
|
||||
try {
|
||||
const removed = await links.unlinkOwned(steamId, userId)
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -104,6 +104,8 @@ function fakeCtx(overrides = {}) {
|
||||
accountChangeLimiter: (req, res, next) => next(),
|
||||
},
|
||||
site: { baseUrl: 'http://localhost:5173' },
|
||||
// MODULE_API 1.18.0: false unless a suite says it is the public demo.
|
||||
demoMode: false,
|
||||
...rest,
|
||||
}
|
||||
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
||||
@@ -169,6 +171,8 @@ function fakeApi() {
|
||||
// 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 },
|
||||
// MODULE_API 1.18.0 (the public demo's seed, DM15).
|
||||
onDemoSeed(fn) { once('onDemoSeed'); record.hooks.onDemoSeed = 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')
|
||||
|
||||
486
server/test/demo.test.js
Normal file
486
server/test/demo.test.js
Normal file
@@ -0,0 +1,486 @@
|
||||
// ── The public demo's module-side locks (website/DEMO_MODE.md §3.4, §3.5, §7.3) ──
|
||||
//
|
||||
// Everything a route list cannot lock, behind `ctx.demoMode` (MODULE_API
|
||||
// 1.18.0): the console-command deny list (DM11) at save through core's
|
||||
// `demoRefuses` (1.19.0, DM18) and at run; the bridge's config, neither read nor
|
||||
// written (DM14); secrets in every other config, redacted and read-only; the
|
||||
// sidecar URL left off the Servers view; and the seed hook (DM15). Each lock is
|
||||
// held twice: on the demo it refuses, and off it nothing changes.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
function withCore({ demo = true, select = () => [] } = {}) {
|
||||
const queries = []
|
||||
require('../core')._reset()
|
||||
require('../core').init(
|
||||
fakeCtx({
|
||||
demoMode: demo,
|
||||
db: {
|
||||
query: (sql, params) => {
|
||||
const flat = sql.trim().replace(/\s+/g, ' ')
|
||||
queries.push({ sql: flat, params })
|
||||
const verb = flat.split(' ')[0].toUpperCase()
|
||||
if (verb === 'SELECT') return Promise.resolve(select(flat, params))
|
||||
return Promise.resolve({ affectedRows: 1, insertId: 1 })
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
return queries
|
||||
}
|
||||
|
||||
function fakeRes() {
|
||||
const res = { statusCode: 200, body: null }
|
||||
res.status = (code) => {
|
||||
res.statusCode = code
|
||||
return res
|
||||
}
|
||||
res.json = (body) => {
|
||||
res.body = body
|
||||
return res
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// ── The console-command deny list (§3.4) ───────────────────────────────────
|
||||
|
||||
test('the deny list compares the first word, normalised', () => {
|
||||
withCore()
|
||||
const demo = require('../demo')
|
||||
for (const line of [
|
||||
'quit', 'QUIT', ' quit ', 'global.quit', 'restart 60 "bye"',
|
||||
'oxide.unload RunicGateway', 'o.unload RunicNPC', 'oxide.reload *', 'o.reload Kits', 'global.oxide.reload x',
|
||||
'rcon.password hunter2', 'RCON.web 0', 'fps.limit 5', 'server.tickrate 1',
|
||||
]) {
|
||||
assert.equal(demo.isDeniedCommand(line), true, line)
|
||||
}
|
||||
for (const line of [
|
||||
'say hello', 'server.writecfg', 'oxide.grant group default kits.use', 'kick someone', 'rbe 1',
|
||||
'quitter', 'restarts', 'oxide.load Foo', 'entity.spawn bear', 'weather.rain 1', '',
|
||||
]) {
|
||||
assert.equal(demo.isDeniedCommand(line), false, line)
|
||||
}
|
||||
assert.equal(demo.commandWord('Global.O.Unload X'), 'oxide.unload')
|
||||
})
|
||||
|
||||
test('the deny list refuses nothing off the demo', () => {
|
||||
withCore({ demo: false })
|
||||
assert.equal(require('../demo').deniedCommand(['quit']), null)
|
||||
})
|
||||
|
||||
test('both command actions refuse a denied command at save, through demoRefuses', () => {
|
||||
withCore()
|
||||
const { ACTIONS } = require('../eventCommands')
|
||||
const run = ACTIONS.find((a) => a.id === 'rust.command.run')
|
||||
const reward = ACTIONS.find((a) => a.id === 'rust.command.reward')
|
||||
|
||||
assert.equal(run.demoRefuses({ commands: ['say hi', 'quit'] }), '"quit" is turned off on the demo site.')
|
||||
// A teardown runs at the end of the event, so it is checked as well.
|
||||
assert.equal(run.demoRefuses({ commands: ['rbe 1'], teardown: ['o.unload RunicGateway'] }), '"o.unload RunicGateway" is turned off on the demo site.')
|
||||
assert.equal(run.demoRefuses({ commands: 'say hi' }), null)
|
||||
assert.equal(reward.demoRefuses({ commands: ['sr add {steamid} 100', 'rcon.password x'] }), '"rcon.password x" is turned off on the demo site.')
|
||||
assert.equal(reward.demoRefuses({ commands: ['sr add {steamid} 100'] }), null)
|
||||
})
|
||||
|
||||
test('a denied command is refused at run as well, permanently, before the rig is asked', async () => {
|
||||
withCore()
|
||||
const sidecar = require('../sidecarClient')
|
||||
let asked = 0
|
||||
sidecar.commandsRun = async () => { asked += 1; return { ok: true, data: { kind: 'command.ran', ran: 1 } } }
|
||||
const { ACTIONS } = require('../eventCommands')
|
||||
const run = ACTIONS.find((a) => a.id === 'rust.command.run')
|
||||
const reward = ACTIONS.find((a) => a.id === 'rust.command.reward')
|
||||
|
||||
const ran = await run.perform({ runId: 1, stepId: 's', idempotencyKey: 'k', params: { server: 'main', commands: ['quit'], confirm: true } })
|
||||
assert.deepEqual(ran, { ok: false, retry: false, error: '"quit" is turned off on the demo site.' })
|
||||
|
||||
const rewarded = await reward.perform({ runId: 1, stepId: 's', idempotencyKey: 'k', params: { server: 'main', recipients: 'linked', commands: ['restart'], confirm: true } })
|
||||
assert.deepEqual(rewarded, { ok: false, retry: false, error: '"restart" is turned off on the demo site.' })
|
||||
assert.equal(asked, 0)
|
||||
})
|
||||
|
||||
// ── The bridge's own config (§3.5) ─────────────────────────────────────────
|
||||
|
||||
test('a bridge path is recognised however it is spelled', () => {
|
||||
withCore()
|
||||
const demo = require('../demo')
|
||||
for (const p of ['RunicGateway.json', './RunicGateway.json', 'runicgateway.json', 'RunicGateway/extra.json', ' RunicGateway.json ', 'RunicGateway./x.json', 'Bridge.json']) {
|
||||
assert.equal(demo.isBridgePath(p, 'Bridge'), true, p)
|
||||
}
|
||||
for (const p of ['ZoneManager.json', 'Kits/RunicGateway.json', 'RunicGatewayExtra.json', '@data/RaidableBases/Profiles/a.json']) {
|
||||
assert.equal(demo.isBridgePath(p, 'Bridge'), false, p)
|
||||
}
|
||||
})
|
||||
|
||||
const SECRET_FILE = `{
|
||||
"Discord Webhook": "https://discord.example/hook/abc",
|
||||
"Api Key": 123456789,
|
||||
"Empty Token": "",
|
||||
"Use Auth": true,
|
||||
"Credentials": { "User": "admin", "List": ["one", "two"] },
|
||||
"Gather": { "Wood": 1.0 },
|
||||
"Enabled": true
|
||||
}`
|
||||
|
||||
function stubSidecar({ file = SECRET_FILE, self = 'RunicGateway' } = {}) {
|
||||
const sidecar = require('../sidecarClient')
|
||||
const calls = []
|
||||
sidecar.configFile = async (server, asked) => {
|
||||
calls.push(['read', asked])
|
||||
return { ok: true, status: 'ok', data: { kind: 'config.file', path: asked, text: file, version: `v-${asked}`, bytes: file.length } }
|
||||
}
|
||||
sidecar.configFiles = async () => {
|
||||
calls.push(['list'])
|
||||
return {
|
||||
ok: true,
|
||||
status: 'ok',
|
||||
data: {
|
||||
kind: 'config.catalogue',
|
||||
root: '/home/container/oxide/config',
|
||||
self,
|
||||
files: [
|
||||
{ path: 'Discord.json', bytes: 40, editable: true, plugin: 'Discord' },
|
||||
{ path: 'RunicGateway.json', bytes: 90, editable: true, plugin: 'RunicGateway' },
|
||||
],
|
||||
plugins: [{ name: 'Discord', title: 'Discord', version: '1.0.0' }, { name: 'RunicGateway', title: 'Runic Gateway', version: '1.0.0' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
sidecar.configWrite = async (server, body) => {
|
||||
calls.push(['write', body])
|
||||
return { ok: true, status: 'ok', data: { kind: 'config.report', ok: true, reloaded: true, files: [{ path: body.files[0].path, version: 'v-after' }] } }
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function stubServer() {
|
||||
const row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://x', sidecarTokenEnc: null, protocol: 14 }
|
||||
require('../model/servers/servers.db').getServer = async () => row
|
||||
require('../model/servers/servers.model').withToken = () => ({ id: row.id, baseUrl: row.sidecarBaseUrl, token: 't' })
|
||||
}
|
||||
|
||||
const controller = () => require('../router/admin/config.controller')
|
||||
|
||||
test('the file list leaves the bridge out on the demo, and keeps it off the demo', async () => {
|
||||
withCore()
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, res)
|
||||
assert.deepEqual(res.body.plugins.map((p) => p.plugin), ['Discord'])
|
||||
|
||||
withCore({ demo: false })
|
||||
const off = fakeRes()
|
||||
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, off)
|
||||
assert.deepEqual(off.body.plugins.map((p) => p.plugin), ['Discord', 'RunicGateway'])
|
||||
})
|
||||
|
||||
test('the bridge\'s config cannot be read on the demo, and the host is never asked for it', async () => {
|
||||
withCore()
|
||||
const calls = stubSidecar()
|
||||
stubServer()
|
||||
for (const path of ['RunicGateway.json', './RunicGateway.json']) {
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path } }, res)
|
||||
assert.equal(res.statusCode, 403, path)
|
||||
assert.equal(res.body.code, 'DEMO_LOCKED')
|
||||
}
|
||||
assert.deepEqual(calls, [])
|
||||
|
||||
// Named by the catalogue's own word for the plugin, when that differs.
|
||||
const renamed = stubSidecar({ self: 'Bridge' })
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Bridge.json' } }, res)
|
||||
assert.equal(res.statusCode, 403)
|
||||
assert.ok(renamed.some(([kind]) => kind === 'list'))
|
||||
})
|
||||
|
||||
test('another plugin\'s secrets are redacted in the text and the form, and made read-only', async () => {
|
||||
withCore()
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Discord.json' } }, res)
|
||||
|
||||
assert.equal(res.statusCode, 200)
|
||||
const { text, fields } = res.body
|
||||
assert.doesNotMatch(text, /discord\.example|123456789|"admin"|"one"/)
|
||||
assert.match(text, /"Discord Webhook": "\(hidden on the demo site\)"/)
|
||||
// Still JSON, and the values that are not credentials are untouched.
|
||||
const parsed = JSON.parse(text)
|
||||
assert.equal(parsed['Empty Token'], '')
|
||||
assert.equal(parsed['Use Auth'], true)
|
||||
assert.equal(parsed.Gather.Wood, 1)
|
||||
assert.match(text, /"Wood": 1\.0/)
|
||||
|
||||
const byPath = Object.fromEntries(fields.map((f) => [f.path, f]))
|
||||
assert.equal(byPath['Discord Webhook'].value, '(hidden on the demo site)')
|
||||
assert.equal(byPath['Discord Webhook'].locked, true)
|
||||
assert.equal(byPath['Api Key'].value, '(hidden on the demo site)')
|
||||
assert.equal('raw' in byPath['Api Key'], false)
|
||||
assert.equal(byPath['Credentials.List[0]'].value, '(hidden on the demo site)')
|
||||
assert.equal(byPath['Gather.Wood'].locked, false)
|
||||
assert.equal(res.body.redacted, true)
|
||||
})
|
||||
|
||||
test('off the demo the file is returned as it is', async () => {
|
||||
withCore({ demo: false })
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().readFile({ params: { serverId: 'main' }, query: { path: 'Discord.json' } }, res)
|
||||
assert.equal(res.body.text, SECRET_FILE)
|
||||
assert.equal(res.body.redacted, undefined)
|
||||
})
|
||||
|
||||
test('a file that does not parse is withheld whole on the demo', () => {
|
||||
withCore()
|
||||
const model = require('../model/config/config.model')
|
||||
const shaped = model.shapeFile({ path: 'Broken.json', version: 'v', text: '{ "Api Key": "abc", ' })
|
||||
const out = model.redactSecrets(shaped, '(hidden)')
|
||||
assert.equal(out.text, '')
|
||||
assert.match(out.withheld, /does not parse/)
|
||||
})
|
||||
|
||||
test('on the demo a save refuses the bridge, raw JSON and a credential field, before the host is asked', async () => {
|
||||
withCore()
|
||||
const calls = stubSidecar()
|
||||
stubServer()
|
||||
const save = async (body) => {
|
||||
const res = fakeRes()
|
||||
await controller().writeFile({ params: { serverId: 'main' }, body: { version: 'v-Discord.json', ...body } }, res)
|
||||
return res
|
||||
}
|
||||
|
||||
const bridge = await save({ path: 'RunicGateway.json', edits: [{ pointer: ['QueueCap'], raw: '1' }] })
|
||||
assert.equal(bridge.statusCode, 403)
|
||||
const raw = await save({ path: 'Discord.json', text: SECRET_FILE })
|
||||
assert.equal(raw.statusCode, 403)
|
||||
assert.match(raw.body.message, /Raw JSON saves/)
|
||||
const secret = await save({ path: 'Discord.json', edits: [{ pointer: ['Discord Webhook'], value: 'https://evil' }] })
|
||||
assert.equal(secret.statusCode, 403)
|
||||
const nested = await save({ path: 'Discord.json', edits: [{ pointer: ['Credentials', 'User'], value: 'x' }] })
|
||||
assert.equal(nested.statusCode, 403)
|
||||
for (const r of [bridge, raw, secret, nested]) assert.equal(r.body.code, 'DEMO_LOCKED')
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('on the demo a form edit to an ordinary field saves, and leaves the secrets on disk alone', async () => {
|
||||
withCore()
|
||||
const calls = stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().writeFile(
|
||||
{ params: { serverId: 'main' }, body: { path: 'Discord.json', version: 'v-Discord.json', edits: [{ pointer: ['Enabled'], value: false }] } },
|
||||
res,
|
||||
)
|
||||
assert.equal(res.statusCode, 200)
|
||||
const [, body] = calls.find(([kind]) => kind === 'write')
|
||||
assert.match(body.files[0].text, /discord\.example\/hook\/abc/)
|
||||
assert.match(body.files[0].text, /"Enabled": false/)
|
||||
})
|
||||
|
||||
test('a conflict\'s current file is redacted too', async () => {
|
||||
withCore()
|
||||
stubSidecar()
|
||||
stubServer()
|
||||
const res = fakeRes()
|
||||
await controller().writeFile(
|
||||
{ params: { serverId: 'main' }, body: { path: 'Discord.json', version: 'v-stale', edits: [{ pointer: ['Enabled'], value: false }] } },
|
||||
res,
|
||||
)
|
||||
assert.equal(res.statusCode, 409)
|
||||
assert.doesNotMatch(res.body.current.text, /discord\.example/)
|
||||
})
|
||||
|
||||
test('the write history leaves the bridge\'s writes out, and one of them cannot be read', async () => {
|
||||
const rows = [
|
||||
{ id: 2, serverId: 'main', path: 'RunicGateway.json', plugin: 'RunicGateway', outcome: 'applied', changes: null },
|
||||
{ id: 1, serverId: 'main', path: 'Discord.json', plugin: 'Discord', outcome: 'applied', changes: null },
|
||||
]
|
||||
withCore({ select: (sql, params) => (sql.includes('WHERE server_id = ? AND id = ?') ? rows.filter((r) => r.id === params[1]) : rows) })
|
||||
const res = fakeRes()
|
||||
await controller().history({ params: { serverId: 'main' }, query: {} }, res)
|
||||
assert.deepEqual(res.body.writes.map((w) => w.id), [1])
|
||||
|
||||
const one = fakeRes()
|
||||
const db = require('../model/config/config.db')
|
||||
db.getWrite = async () => rows[0]
|
||||
await controller().writeStatus({ params: { serverId: 'main', writeId: '2' } }, one)
|
||||
assert.equal(one.statusCode, 403)
|
||||
assert.equal(one.body.code, 'DEMO_LOCKED')
|
||||
})
|
||||
|
||||
// ── The Servers view (§3.5) ────────────────────────────────────────────────
|
||||
|
||||
test('the admin server list leaves the sidecar URL out on the demo, and keeps hasToken', async () => {
|
||||
const row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://10.0.0.5:7790', sidecarTokenEnc: 'enc:t', protocol: 14, enabled: 1, sortOrder: 0 }
|
||||
for (const demoMode of [true, false]) {
|
||||
withCore({ demo: demoMode })
|
||||
const db = require('../model/servers/servers.db')
|
||||
db.listServers = async () => [row]
|
||||
db.listState = async () => []
|
||||
const [shaped] = await require('../model/servers/servers.model').listForAdmin()
|
||||
assert.equal('sidecarBaseUrl' in shaped, !demoMode)
|
||||
assert.equal(shaped.hasToken, true)
|
||||
assert.equal(JSON.stringify(shaped).includes('10.0.0.5'), !demoMode)
|
||||
}
|
||||
})
|
||||
|
||||
// ── The seed hook (§7.3, DM15) ─────────────────────────────────────────────
|
||||
|
||||
const SECTION = {
|
||||
servers: [{ id: 'main', name: 'Demo rig', sidecarBaseUrl: 'http://rig:7790', sidecarToken: 'tok' }],
|
||||
links: [{ username: 'demo-player', steamId: '76561190000000001', serverId: 'main' }],
|
||||
}
|
||||
|
||||
test('the seed section is checked whole before anything is written', async () => {
|
||||
const queries = withCore()
|
||||
const seed = require('../demoSeed')
|
||||
const accounts = { 'demo-player': 3 }
|
||||
assert.deepEqual(seed.checkSection(SECTION, accounts), [])
|
||||
const problems = seed.checkSection({
|
||||
servers: [{ id: 'Main', name: '', sidecarBaseUrl: 'rig', sidecarToken: '' }],
|
||||
links: [{ username: 'someone', steamId: '123', serverId: 'Other' }],
|
||||
}, accounts)
|
||||
assert.equal(problems.length, 7)
|
||||
await assert.rejects(seed.onDemoSeed({ servers: 'x' }, { accounts }), /must be an array/)
|
||||
assert.deepEqual(queries, [])
|
||||
})
|
||||
|
||||
test('the seed upserts the server with an encrypted token, and links demo-player', async () => {
|
||||
const queries = withCore()
|
||||
await require('../demoSeed').onDemoSeed(SECTION, { accounts: { 'demo-player': 3 } })
|
||||
const upsert = queries.find((q) => q.sql.startsWith('INSERT INTO rust_servers'))
|
||||
assert.match(upsert.sql, /ON DUPLICATE KEY UPDATE/)
|
||||
assert.deepEqual(upsert.params.slice(0, 4), ['main', 'Demo rig', 'http://rig:7790', 'enc:tok'])
|
||||
const link = queries.find((q) => q.sql.startsWith('INSERT INTO rust_account_links'))
|
||||
assert.deepEqual(link.params, ['76561190000000001', 3, null, 'main'])
|
||||
})
|
||||
|
||||
test('a re-seed leaves a link the right owner holds, and moves one held by someone else', async () => {
|
||||
let owner = 3
|
||||
const queries = withCore({ select: (sql) => (sql.includes('rust_account_links') ? [{ steamId: '76561190000000001', userId: owner }] : []) })
|
||||
const seed = require('../demoSeed')
|
||||
await seed.onDemoSeed({ links: SECTION.links }, { accounts: { 'demo-player': 3 } })
|
||||
assert.equal(queries.some((q) => /^(INSERT|DELETE)/.test(q.sql)), false)
|
||||
|
||||
owner = 9
|
||||
await seed.onDemoSeed({ links: SECTION.links }, { accounts: { 'demo-player': 3 } })
|
||||
const writes = queries.filter((q) => /^(INSERT|DELETE)/.test(q.sql)).map((q) => q.sql.split(' ')[0])
|
||||
assert.deepEqual(writes, ['DELETE', 'INSERT'])
|
||||
})
|
||||
|
||||
// ── NPCs and chat styles from the seed (DM26) ──────────────────────────────
|
||||
|
||||
const NPC_SECTION = {
|
||||
npcs: {
|
||||
factions: [{ a: 'bandits', b: 'guards', relation: 'allied' }],
|
||||
profiles: [
|
||||
{ name: 'bandit', servers: ['main'], body: { kits: ['rnpc_raider'], role: 'roamer' } },
|
||||
{ name: 'guard', servers: ['main'], body: { kits: ['rnpc_campguard'], role: 'sentry' } },
|
||||
],
|
||||
},
|
||||
chat: [
|
||||
{ serverId: 'main', group: 'vip', style: { Title: '[VIP]', TitleColor: '#ffaa00' } },
|
||||
{ serverId: 'main', group: 'moderator', style: { Title: '[MOD]' } },
|
||||
],
|
||||
}
|
||||
|
||||
/** Stubs the models the seed writes NPCs and chat styles through; returns the calls. */
|
||||
function stubSeedModels({ profiles = [], groups = {} } = {}) {
|
||||
const calls = []
|
||||
const npcs = require('../model/npcs/npcs.model')
|
||||
const npcsDb = require('../model/npcs/npcs.db')
|
||||
const apply = require('../model/permissions/permissions.apply')
|
||||
const permDb = require('../model/permissions/permissions.db')
|
||||
npcs.setFactions = async (f) => calls.push(['setFactions', f])
|
||||
npcs.create = async (input) => calls.push(['create', input.name, input.servers])
|
||||
npcs.update = async (id, input) => calls.push(['update', id, input.name])
|
||||
npcsDb.listProfiles = async () => profiles
|
||||
apply.groupOn = async (name) => groups[name] || null
|
||||
permDb.insertGroup = async ({ name }) => {
|
||||
calls.push(['insertGroup', name])
|
||||
return 50
|
||||
}
|
||||
permDb.setGroupServers = async (id, s) => calls.push(['setGroupServers', id, s.servers])
|
||||
permDb.setGroupChat = async (id, style) => calls.push(['setGroupChat', id, style.Title, style.TitleColor, Object.keys(style).length])
|
||||
permDb.markDirty = async (scope) => calls.push(['markDirty', scope])
|
||||
return calls
|
||||
}
|
||||
|
||||
test('the NPC and chat parts of the seed section are checked before anything is written', async () => {
|
||||
withCore()
|
||||
const seed = require('../demoSeed')
|
||||
assert.deepEqual(seed.checkSection(NPC_SECTION, {}), [])
|
||||
const problems = seed.checkSection({
|
||||
npcs: { factions: [{ a: 'scientists', b: 'animals', relation: 'hostile' }], profiles: [{ name: 'Bad Name', body: [] }] },
|
||||
chat: [{ serverId: 'Main', group: '', style: { Title: '[VIP]', Nope: 1 } }],
|
||||
}, {})
|
||||
assert.equal(problems.length, 6, problems.join(' | '))
|
||||
assert.deepEqual(seed.checkSection({ npcs: [] }, {}), ['modules.rust.npcs must be an object'])
|
||||
const calls = stubSeedModels()
|
||||
await assert.rejects(seed.onDemoSeed({ chat: [{ serverId: 'main', group: 'vip', style: { Priority: 'x' } }] }, { accounts: {} }), /style/)
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('the seed sets the faction table, saves each profile, and styles each group', async () => {
|
||||
withCore()
|
||||
const calls = stubSeedModels({ profiles: [{ id: 7, name: 'bandit', replaced: false }], groups: { vip: { id: 13 } } })
|
||||
await require('../demoSeed').onDemoSeed(NPC_SECTION, { accounts: {} })
|
||||
assert.deepEqual(calls, [
|
||||
['setFactions', NPC_SECTION.npcs.factions],
|
||||
['update', 7, 'bandit'], // already there: updated, not created twice
|
||||
['create', 'guard', ['main']],
|
||||
['setGroupChat', 13, '[VIP]', '#ffaa00', 12], // all twelve fields, the defaults filled in
|
||||
['markDirty', 'main'],
|
||||
['insertGroup', 'moderator'], // the rig's import has not brought it in yet
|
||||
['setGroupServers', 50, ['main']],
|
||||
['setGroupChat', 50, '[MOD]', '#55aaff', 12],
|
||||
['markDirty', 'main'],
|
||||
])
|
||||
})
|
||||
|
||||
// ── The manifest (§3.2) ────────────────────────────────────────────────────
|
||||
|
||||
test('module.json locks the two rig-link routes and the player Steam unlink, and needs a core that asks demoRefuses', () => {
|
||||
const manifest = require('../../module.json')
|
||||
assert.deepEqual(manifest.demoLocks, [
|
||||
{ method: 'PUT', path: '/admin/rust/servers/:id' },
|
||||
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
|
||||
{ method: 'DELETE', path: '/player/rust/links/:steamId' },
|
||||
])
|
||||
assert.equal(manifest.coreApi, '^1.19.0')
|
||||
})
|
||||
|
||||
// ── The staff Steam unlink (W4) ────────────────────────────────────────────
|
||||
|
||||
test('staff cannot sever a Steam link on the demo, and the link is never touched', async () => {
|
||||
for (const demoMode of [true, false]) {
|
||||
withCore({ demo: demoMode })
|
||||
const links = require('../model/links/links.model')
|
||||
const asked = []
|
||||
links.unlinkOwned = async (steamId, userId) => {
|
||||
asked.push([steamId, userId])
|
||||
return true
|
||||
}
|
||||
const res = fakeRes()
|
||||
await require('../router/admin/usersRust.controller').removeLink(
|
||||
{ params: { id: '3', steamId: '76561190000000001' }, user: { id: 1 } },
|
||||
res,
|
||||
)
|
||||
if (demoMode) {
|
||||
assert.equal(res.statusCode, 403)
|
||||
assert.equal(res.body.code, 'DEMO_LOCKED')
|
||||
assert.deepEqual(asked, [])
|
||||
} else {
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.deepEqual(res.body, { unlinked: true })
|
||||
assert.deepEqual(asked, [['76561190000000001', '3']])
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
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