Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 161457dbf3 | |||
| 5c9cefdbc2 | |||
| 1f4ad25edb | |||
| 228e178475 | |||
| 0cdb046d30 | |||
| 791f13235e | |||
| c61f9f29ec | |||
| 7a0d0842ba | |||
| bc529962b4 | |||
| c4729315cd | |||
| 55a9ea4649 | |||
| 2e80a21597 | |||
| 3741c954f5 |
@@ -384,6 +384,22 @@ jobs:
|
|||||||
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||||
SIZE="$(stat -c%s "dist/module-rust-${VERSION}.tar.gz")"
|
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
|
# The install manifest. Same shape as the installer's bundle JSON — a
|
||||||
# per-asset sha256 fetched over HTTPS, no signatures — because that is
|
# 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
|
# 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 artifact "module-rust-${VERSION}.tar.gz" \
|
||||||
--arg sha256 "$SHA" \
|
--arg sha256 "$SHA" \
|
||||||
--argjson size "$SIZE" \
|
--argjson size "$SIZE" \
|
||||||
|
--argjson protocol "$PROTOCOL" \
|
||||||
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-rust-${VERSION}.tar.gz" \
|
--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,
|
'{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"
|
> "dist/module-rust-${VERSION}.json"
|
||||||
|
|
||||||
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS
|
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||||
|
|||||||
@@ -34,6 +34,8 @@
|
|||||||
"core.js",
|
"core.js",
|
||||||
"dangerousTreasures.js",
|
"dangerousTreasures.js",
|
||||||
"db",
|
"db",
|
||||||
|
"demo.js",
|
||||||
|
"demoSeed.js",
|
||||||
"dynamicPvp.js",
|
"dynamicPvp.js",
|
||||||
"eventCommands.js",
|
"eventCommands.js",
|
||||||
"engagement",
|
"engagement",
|
||||||
|
|||||||
@@ -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. **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.",
|
"$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",
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
"ref": "6890a74c7c4ad5690509e8c163e477d7f7b399e7",
|
"ref": "69998ec54854d46681def92985a2b299ee3582a6",
|
||||||
"refName": "main @ website#220 merge (the cutover), MODULE_API 1.17.0"
|
"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
|
// here is presentation, and the honest presentation of a kind we have no words
|
||||||
// for is its own name.
|
// for is its own name.
|
||||||
|
|
||||||
import { attacker, duration, prefab } from './format.js'
|
import { attacker, duration, weapon } from './format.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kinds this feed asks for.
|
* Kinds this feed asks for.
|
||||||
@@ -205,7 +205,7 @@ export function describe(row) {
|
|||||||
*/
|
*/
|
||||||
function death(frame, name) {
|
function death(frame, name) {
|
||||||
const where = [
|
const where = [
|
||||||
frame.weapon ? `with ${prefab(frame.weapon)}` : null,
|
frame.weapon ? `with ${weapon(frame)}` : null,
|
||||||
frame.distance ? `${Math.round(frame.distance)}m` : null,
|
frame.distance ? `${Math.round(frame.distance)}m` : null,
|
||||||
frame.grid || null,
|
frame.grid || null,
|
||||||
frame.sleeping ? 'while sleeping' : null,
|
frame.sleeping ? 'while sleeping' : null,
|
||||||
|
|||||||
@@ -130,6 +130,18 @@ export function prefab(name) {
|
|||||||
return String(name).replace(/[_.]+/g, ' ').trim()
|
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
|
* 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
|
* 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) || []
|
const rules = (titles && titles.rules) || []
|
||||||
if (!rules.length) return 'No chat titles.'
|
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 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}`
|
return `Chat titles: ${list}.${shown}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -542,7 +542,7 @@ function GroupPanel({ view, group, act, busy, chatFields }) {
|
|||||||
{tab === 'servers' && (
|
{tab === 'servers' && (
|
||||||
<div className="sans" style={{ fontSize: '0.82rem' }}>
|
<div className="sans" style={{ fontSize: '0.82rem' }}>
|
||||||
<p className="dim" style={{ margin: '0 0 8px' }}>
|
<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.
|
it is on; a change made in one game gives that server its own copy.
|
||||||
</p>
|
</p>
|
||||||
<label style={{ display: 'block', marginBottom: 6 }}>
|
<label style={{ display: 'block', marginBottom: 6 }}>
|
||||||
@@ -791,7 +791,7 @@ export default function Permissions() {
|
|||||||
: sync.state === 'failed' ? <Chip tone="bad" title={sync.error || ''}>last sync failed</Chip>
|
: sync.state === 'failed' ? <Chip tone="bad" title={sync.error || ''}>last sync failed</Chip>
|
||||||
: <Chip tone="warn">waiting for a sync</Chip>
|
: <Chip tone="warn">waiting for a sync</Chip>
|
||||||
)}
|
)}
|
||||||
{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>
|
<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,
|
isNew: false,
|
||||||
id: server.id,
|
id: server.id,
|
||||||
name: server.name,
|
name: server.name,
|
||||||
sidecarBaseUrl: server.sidecarBaseUrl,
|
sidecarBaseUrl: server.sidecarBaseUrl || '',
|
||||||
sidecarToken: '',
|
sidecarToken: '',
|
||||||
enabled: server.enabled,
|
enabled: server.enabled,
|
||||||
sortOrder: server.sortOrder,
|
sortOrder: server.sortOrder,
|
||||||
@@ -264,7 +264,7 @@ export default function ServerSettings() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
|
<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.sidecarProtocol != null && s.sidecarProtocol !== s.protocol ? ` (sidecar speaks ${s.sidecarProtocol})` : ''}
|
||||||
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
|
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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.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')
|
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 test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
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')
|
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', () => {
|
test('a weapon keeps the light touch — attacker() is for what did the killing', () => {
|
||||||
assert.equal(prefab('rifle.ak'), 'rifle ak')
|
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.",
|
"_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": [
|
"triggers": [
|
||||||
{
|
{
|
||||||
"id": "rust.base.destroyed",
|
"id": "rust.base.destroyed",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "rust",
|
"id": "rust",
|
||||||
"name": "Rust",
|
"name": "Rust",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"coreApi": "^1.17.0",
|
"coreApi": "^1.19.0",
|
||||||
"server": "server/index.js",
|
"server": "server/index.js",
|
||||||
"client": { "entry": "client/dist/entry.js" },
|
"client": { "entry": "client/dist/entry.js" },
|
||||||
"schema": "server/db/schema.sql",
|
"schema": "server/db/schema.sql",
|
||||||
@@ -13,5 +13,9 @@
|
|||||||
"player": ["/rust"]
|
"player": ["/rust"]
|
||||||
},
|
},
|
||||||
"extensions": ["admin.users.detail"],
|
"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" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,4 +186,9 @@ module.exports = {
|
|||||||
// origin to be relative to. A getter over core's getter, so an operator who
|
// 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.
|
// changes `APP_BASE_URL` is read at the call, never captured at load.
|
||||||
get baseUrl() { return need().site.baseUrl },
|
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 },
|
||||||
}
|
}
|
||||||
|
|||||||
135
server/demo.js
Normal file
135
server/demo.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// ── The public demo's module-side locks (DEMO_MODE.md §3.4, §3.5) ──────────
|
||||||
|
//
|
||||||
|
// Core locks this module's two link routes 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 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 { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||||
const { BUDGET_MS } = require('./eventWorld')
|
const { BUDGET_MS } = require('./eventWorld')
|
||||||
const rewards = require('./eventRewards')
|
const rewards = require('./eventRewards')
|
||||||
|
const demo = require('./demo')
|
||||||
|
|
||||||
const log = core.logger('commands')
|
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. */
|
/** 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
|
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. */
|
/** A command list's price: at least one, so the switchboard sees the dimension. */
|
||||||
function pricedCommands(raw) {
|
function pricedCommands(raw) {
|
||||||
const checked = checkCommands(raw, { required: false })
|
const checked = checkCommands(raw, { required: false })
|
||||||
@@ -169,7 +173,7 @@ function replyDetail(data) {
|
|||||||
...(replies.length
|
...(replies.length
|
||||||
? { replies: replies.map((r) => ({ command: String(r.command || ''), reply: String(r.reply || ''), ...(r.steamId ? { steamId: String(r.steamId) } : {}) })) }
|
? { 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' } : {}),
|
...(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 })
|
const teardown = checkCommands(params.teardown, { field: 'teardown commands', required: false })
|
||||||
if (!teardown.ok) return { ok: false, retry: false, error: teardown.error }
|
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])
|
const placeholder = withPlaceholder([...commands.value, ...teardown.value])
|
||||||
if (placeholder) {
|
if (placeholder) {
|
||||||
return { ok: false, retry: false, error: `{steamid} and {name} are filled in only by Reward by console command, and "${placeholder}" has one` }
|
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,
|
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
|
// 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".
|
// a teardown not yet run is always in force: "I do not know" is never "gone".
|
||||||
async reconcile({ resources }) {
|
async reconcile({ resources }) {
|
||||||
@@ -397,10 +410,15 @@ const commandReward = {
|
|||||||
|
|
||||||
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) * pricedPeople(p) }),
|
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) * pricedPeople(p) }),
|
||||||
|
|
||||||
|
demoRefuses: (p) => demo.deniedCommand(asList(p.commands)),
|
||||||
|
|
||||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||||
const commands = checkCommands(params.commands)
|
const commands = checkCommands(params.commands)
|
||||||
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
|
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)
|
const confirm = checkConfirm(params, REWARD_SURE)
|
||||||
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
|
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
|
||||||
|
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ const OPTION_SOURCES = [
|
|||||||
{
|
{
|
||||||
id: 'rust.options.servers',
|
id: 'rust.options.servers',
|
||||||
label: 'Rust 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() {
|
async resolve() {
|
||||||
const list = await servers.listForPolling()
|
const list = await servers.listForPolling()
|
||||||
return list.map((s) => ({ value: s.id, label: s.name || s.id }))
|
return list.map((s) => ({ value: s.id, label: s.name || s.id }))
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ const participationOpen = {
|
|||||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||||
description: 'Which server counts.' },
|
description: 'Which server counts.' },
|
||||||
{ name: 'zone', type: 'string', required: false, example: 'Airfield brawl', source: 'rust.options.runzones',
|
{ 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',
|
{ name: 'score', type: 'string', required: true, example: 'both', source: 'rust.options.scoremodes',
|
||||||
description: 'What earns a place: seconds present, kills, or both.' },
|
description: 'What earns a place: seconds present, kills, or both.' },
|
||||||
{ name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsof',
|
{ name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsof',
|
||||||
@@ -538,7 +538,7 @@ const kitEntitle = {
|
|||||||
budgetMs: BUDGET_MS,
|
budgetMs: BUDGET_MS,
|
||||||
params: [
|
params: [
|
||||||
{ name: 'kit', type: 'string', required: true, example: 'main/vip-starter', source: 'rust.options.kits',
|
{ 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',
|
{ 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).' },
|
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,
|
{ name: 'count', type: 'float', required: false, example: 3,
|
||||||
@@ -1172,13 +1172,13 @@ const announce = {
|
|||||||
budgetMs: BUDGET_MS,
|
budgetMs: BUDGET_MS,
|
||||||
params: [
|
params: [
|
||||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.chatservers',
|
{ 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!',
|
{ name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!',
|
||||||
description: `The line, up to ${MAX_CHAT} characters.` },
|
description: `The line, up to ${MAX_CHAT} characters.` },
|
||||||
// Optional, and the action stays version 1: a bump would stop every step
|
// Optional, and the action stays version 1: a bump would stop every step
|
||||||
// already written from dispatching until somebody re-saved it (§33.2).
|
// already written from dispatching until somebody re-saved it (§33.2).
|
||||||
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
{ 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,
|
// One per server reached. `*` is priced at the enabled servers when core asks,
|
||||||
@@ -1361,7 +1361,7 @@ const OPTION_SOURCES = [
|
|||||||
{ value: 'npcs', label: 'NPCs, animals included' },
|
{ value: 'npcs', label: 'NPCs, animals included' },
|
||||||
{ value: 'both', label: 'Players and NPCs' },
|
{ 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: 'everyone', label: 'Everyone who scored' },
|
||||||
{ value: 'top', label: 'The top N (ties in)' },
|
{ value: 'top', label: 'The top N (ties in)' },
|
||||||
{ value: 'minScore', label: 'A score of at least X' },
|
{ value: 'minScore', label: 'A score of at least X' },
|
||||||
@@ -1369,7 +1369,7 @@ const OPTION_SOURCES = [
|
|||||||
{ value: 'topPercent', label: 'The top X per cent (ties in)' },
|
{ value: 'topPercent', label: 'The top X per cent (ties in)' },
|
||||||
{ value: 'linked', label: 'Every linked player of the server (no tally)' },
|
{ 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: 'chat', label: 'Chat' },
|
||||||
{ value: 'popup', label: 'A popup — needs PopupNotifications on the server' },
|
{ value: 'popup', label: 'A popup — needs PopupNotifications on the server' },
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -146,13 +146,13 @@ const BUDGETS = [
|
|||||||
id: 'rust.npcs',
|
id: 'rust.npcs',
|
||||||
label: 'NPCs placed',
|
label: 'NPCs placed',
|
||||||
unit: 'NPCs',
|
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',
|
id: 'rust.zone.minutes',
|
||||||
label: 'Zone time',
|
label: 'Zone time',
|
||||||
unit: 'minutes',
|
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.',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -626,7 +626,7 @@ const NPC_ORDER_PARAMS = [
|
|||||||
required: false,
|
required: false,
|
||||||
example: '76561198000000001',
|
example: '76561198000000001',
|
||||||
description:
|
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',
|
name: 'allyClan',
|
||||||
@@ -635,7 +635,7 @@ const NPC_ORDER_PARAMS = [
|
|||||||
source: 'rust.options.clans',
|
source: 'rust.options.clans',
|
||||||
example: 'main/1234567',
|
example: 'main/1234567',
|
||||||
description:
|
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',
|
name: 'allyTeamOf',
|
||||||
@@ -643,7 +643,7 @@ const NPC_ORDER_PARAMS = [
|
|||||||
required: false,
|
required: false,
|
||||||
example: '76561198000000001',
|
example: '76561198000000001',
|
||||||
description:
|
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',
|
name: 'tether',
|
||||||
@@ -651,7 +651,7 @@ const NPC_ORDER_PARAMS = [
|
|||||||
required: false,
|
required: false,
|
||||||
example: true,
|
example: true,
|
||||||
description:
|
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.
|
// Runicnpc stage 7 (D296): on by default; a step older than the switch drops loot as before.
|
||||||
@@ -660,7 +660,7 @@ const NPC_ORDER_PARAMS = [
|
|||||||
required: false,
|
required: false,
|
||||||
example: true,
|
example: true,
|
||||||
description:
|
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.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -908,7 +908,7 @@ const OPTION_SOURCES = [
|
|||||||
// cached list would offer monuments that are not there any more.
|
// cached list would offer monuments that are not there any more.
|
||||||
id: 'rust.options.monuments',
|
id: 'rust.options.monuments',
|
||||||
label: '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,
|
searchable: true,
|
||||||
async resolve({ q } = {}) {
|
async resolve({ q } = {}) {
|
||||||
const term = String(q || '').trim().toLowerCase()
|
const term = String(q || '').trim().toLowerCase()
|
||||||
@@ -983,7 +983,7 @@ const OPTION_SOURCES = [
|
|||||||
{
|
{
|
||||||
id: 'rust.options.domes',
|
id: 'rust.options.domes',
|
||||||
label: '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() {
|
async resolve() {
|
||||||
return DOMES.map((d) => ({ value: d.value, label: d.label }))
|
return DOMES.map((d) => ({ value: d.value, label: d.label }))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -168,6 +168,13 @@ module.exports = function register(ctx, api) {
|
|||||||
api.onIdentityLinking(links.checkSteamSignin)
|
api.onIdentityLinking(links.checkSteamSignin)
|
||||||
api.onIdentityLinked(links.linkFromSteamSignin)
|
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
|
// 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
|
// 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
|
// may name and the four callables each ships. Every lease is targeted and the
|
||||||
|
|||||||
@@ -174,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.
|
* Which locked keys differ between two versions of a document.
|
||||||
*
|
*
|
||||||
@@ -290,6 +355,8 @@ module.exports = {
|
|||||||
isPlausiblePath,
|
isPlausiblePath,
|
||||||
shapeCatalogue,
|
shapeCatalogue,
|
||||||
shapeFile,
|
shapeFile,
|
||||||
|
redactSecrets,
|
||||||
|
editsTouchSecret,
|
||||||
pluginOf,
|
pluginOf,
|
||||||
isBridgeConfig,
|
isBridgeConfig,
|
||||||
lockedKeysFor,
|
lockedKeysFor,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const raidableBases = require('../../raidableBases')
|
|||||||
const db = require('./events.db')
|
const db = require('./events.db')
|
||||||
const mapDb = require('../map/map.db')
|
const mapDb = require('../map/map.db')
|
||||||
const serversDb = require('../servers/servers.db')
|
const serversDb = require('../servers/servers.db')
|
||||||
|
const weaponNames = require('../titles/weaponNames')
|
||||||
|
|
||||||
/** Hard ceiling on a page, whatever a caller asks for. */
|
/** Hard ceiling on a page, whatever a caller asks for. */
|
||||||
const MAX_LIMIT = 200
|
const MAX_LIMIT = 200
|
||||||
@@ -155,6 +156,13 @@ function shape(row) {
|
|||||||
frame = {}
|
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 {
|
return {
|
||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
kind: row.kind,
|
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` }
|
if (!RELATIONS.includes(relation)) return { ok: false, error: `factions[${i}]: '${relation}' is not hostile, neutral or allied` }
|
||||||
const key = pairKey(a, b)
|
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)
|
seen.add(key)
|
||||||
rows.push(a < b ? { a, b, relation } : { a: b, b: a, relation })
|
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` }
|
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
|
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}` }
|
if (kits.length > KITS_MAX) return { ok: false, error: `kits: at most ${KITS_MAX}` }
|
||||||
|
|
||||||
const prefab = String(b.prefab || '').trim()
|
const prefab = String(b.prefab || '').trim()
|
||||||
@@ -242,8 +242,8 @@ function checkBody(input) {
|
|||||||
const hasBoss = b.boss !== undefined && b.boss !== null
|
const hasBoss = b.boss !== undefined && b.boss !== null
|
||||||
const hasUse = b.use !== undefined && b.use !== 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 && 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 (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 (D278)' }
|
if (hasUse && !passive) return { ok: false, error: 'use: only a passive NPC answers E' }
|
||||||
let use = null
|
let use = null
|
||||||
if (hasUse) {
|
if (hasUse) {
|
||||||
const checked = checkUse(b.use)
|
const checked = checkUse(b.use)
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ async function create(input, userId = null) {
|
|||||||
async function update(id, input) {
|
async function update(id, input) {
|
||||||
const existing = await db.getProfile(id)
|
const existing = await db.getProfile(id)
|
||||||
if (!existing) throw new NpcError('no such profile', 404)
|
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)
|
const clean = await validate(input, existing.id)
|
||||||
await db.saveProfile({ ...clean, id: existing.id })
|
await db.saveProfile({ ...clean, id: existing.id })
|
||||||
const before = reach(existing)
|
const before = reach(existing)
|
||||||
|
|||||||
@@ -203,7 +203,9 @@ async function listForAdmin(now = Date.now()) {
|
|||||||
// The public shape first, so the admin-only fields below cannot be
|
// The public shape first, so the admin-only fields below cannot be
|
||||||
// overwritten by a key the public shape happens to share.
|
// overwritten by a key the public shape happens to share.
|
||||||
...shapePublic(row, state, now),
|
...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),
|
hasToken: Boolean(row.sidecarTokenEnc),
|
||||||
protocol: Number(row.protocol),
|
protocol: Number(row.protocol),
|
||||||
enabled: Boolean(row.enabled),
|
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 }
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
const core = require('../../core')
|
const core = require('../../core')
|
||||||
|
|
||||||
const configEdit = require('../../configEdit')
|
const configEdit = require('../../configEdit')
|
||||||
|
const demo = require('../../demo')
|
||||||
const db = require('../../model/config/config.db')
|
const db = require('../../model/config/config.db')
|
||||||
const model = require('../../model/config/config.model')
|
const model = require('../../model/config/config.model')
|
||||||
const servers = require('../../model/servers/servers.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.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. */
|
/** 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' })
|
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)
|
const server = await serverOr404(req, res)
|
||||||
if (!server) return undefined
|
if (!server) return undefined
|
||||||
|
|
||||||
@@ -116,7 +146,8 @@ async function readFile(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const self = catalogueReply.ok && catalogueReply.data ? catalogueReply.data.self : null
|
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' })
|
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)
|
const server = await serverOr404(req, res)
|
||||||
if (!server) return undefined
|
if (!server) return undefined
|
||||||
|
|
||||||
@@ -151,6 +192,7 @@ async function writeFile(req, res) {
|
|||||||
|
|
||||||
const onDisk = current.data
|
const onDisk = current.data
|
||||||
const self = catalogue.ok && catalogue.data ? catalogue.data.self : null
|
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)
|
const locked = model.lockedKeysFor(path, self)
|
||||||
|
|
||||||
// The browser's version against what is on the host right now. The plugin
|
// 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)) {
|
if (String(req.body.version || '') !== String(onDisk.version)) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
message: 'That file changed on the server since you opened it',
|
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. */
|
/** What has been written to this server's configuration lately, and by whom. */
|
||||||
async function history(req, res) {
|
async function history(req, res) {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
log.error('failed to read the configuration history', { error: err.message })
|
log.error('failed to read the configuration history', { error: err.message })
|
||||||
return res.status(500).json({ message: 'Failed to read the configuration history' })
|
return res.status(500).json({ message: 'Failed to read the configuration history' })
|
||||||
@@ -305,6 +349,7 @@ async function writeStatus(req, res) {
|
|||||||
try {
|
try {
|
||||||
const write = await db.getWrite(req.params.serverId, Number(req.params.writeId))
|
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 (!write) return res.status(404).json({ message: 'No such configuration write' })
|
||||||
|
if (demo.isDemo() && isBridgeWrite(write)) return refuseBridge(res)
|
||||||
|
|
||||||
return res.json({ write })
|
return res.json({ write })
|
||||||
} catch (err) {
|
} 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
|
* 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.
|
* fail a save. Returns the row's id, or null when it could not be written.
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ rustRouter.get(
|
|||||||
'/servers/:id/events',
|
'/servers/:id/events',
|
||||||
// #swagger.tags = ['Public · Rust']
|
// #swagger.tags = ['Public · Rust']
|
||||||
// #swagger.summary = 'Recent events on one Rust server'
|
// #swagger.summary = 'Recent events on one Rust server'
|
||||||
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes.'
|
// #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['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||||
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
|
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
|
||||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ function fakeCtx(overrides = {}) {
|
|||||||
accountChangeLimiter: (req, res, next) => next(),
|
accountChangeLimiter: (req, res, next) => next(),
|
||||||
},
|
},
|
||||||
site: { baseUrl: 'http://localhost:5173' },
|
site: { baseUrl: 'http://localhost:5173' },
|
||||||
|
// MODULE_API 1.18.0: false unless a suite says it is the public demo.
|
||||||
|
demoMode: false,
|
||||||
...rest,
|
...rest,
|
||||||
}
|
}
|
||||||
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
// 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).
|
// MODULE_API 1.17.0 (Steam sign-in, PLAN_REDESIGNS §15).
|
||||||
onIdentityLinking(fn) { once('onIdentityLinking'); record.hooks.onIdentityLinking = fn },
|
onIdentityLinking(fn) { once('onIdentityLinking'); record.hooks.onIdentityLinking = fn },
|
||||||
onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = 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
|
api.record = record
|
||||||
return api
|
return api
|
||||||
|
|||||||
@@ -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.strictEqual(result.ok, true)
|
||||||
assert.deepStrictEqual([result.detail.people, result.detail.rewarded], [2, 1])
|
assert.deepStrictEqual([result.detail.people, result.detail.rewarded], [2, 1])
|
||||||
assert.deepStrictEqual([result.detail.skipped, result.detail.skippedCount], [['p3'], 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)
|
assert.strictEqual(result.resources, undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
457
server/test/demo.test.js
Normal file
457
server/test/demo.test.js
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
// ── 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 link routes 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' },
|
||||||
|
])
|
||||||
|
assert.equal(manifest.coreApi, '^1.19.0')
|
||||||
|
})
|
||||||
@@ -169,8 +169,8 @@ const BOSS = {
|
|||||||
test('stage 6: a boss or passive profile is refused for what RunicNPC would refuse, in its own words', () => {
|
test('stage 6: a boss or passive profile is refused for what RunicNPC would refuse, in its own words', () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
[{ ...PASSIVE, ...BOSS }, 'boss: a passive NPC never fights, so it cannot be a boss'],
|
[{ ...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)'],
|
[{ 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 (D278)'],
|
[{ use: { mode: 'chat', lines: ['hi'] } }, 'use: only a passive NPC answers E'],
|
||||||
[{ movement: { mode: 'stand' } }, "movement.mode: stand is a passive NPC's"],
|
[{ 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: 'shout' } }, "use.mode: 'shout' is not chat or window"],
|
||||||
[{ ...PASSIVE, use: { mode: 'chat', lines: [] } }, 'use.lines: a chat answer gives at least one line'],
|
[{ ...PASSIVE, use: { mode: 'chat', lines: [] } }, 'use.lines: a chat answer gives at least one line'],
|
||||||
@@ -542,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: '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: '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: '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/)
|
assert.match(shape.checkFactions('red').error, /a list of/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
@@ -3203,7 +3203,7 @@
|
|||||||
"Public · Rust"
|
"Public · Rust"
|
||||||
],
|
],
|
||||||
"summary": "Recent events on one Rust server",
|
"summary": "Recent events on one Rust server",
|
||||||
"description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes.",
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
|
|||||||
Reference in New Issue
Block a user