fix(rust): protocol 13 step 2 — expiry, plugin loads, the loading hold, NPC names, the link fleet (F2 F5 F6 F7 F8 F13 F14) #22
@@ -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.",
|
||||
"$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: pinned OFF main, to website#209's head** — the case the sentence above allows. This module now calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0 and the 1.10.0 main pin refuses to load it (\"needs core API ^1.11.0, this core is 1.10.0\") — red by construction, proving nothing. Move this back to the main sha #209 merges as.",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "efa9db73304552dd8bb7a84030b258c6320f79f7",
|
||||
"refName": "main @ MODULE_API 1.10.0, the Asset Bridge cutover 2 of 5 (website#202)"
|
||||
"ref": "cc1f49af29f0c33a6562045806528b2a614d25de",
|
||||
"refName": "feat/events-expired-status @ MODULE_API 1.11.0 (website#209, unmerged) — re-pin to its main merge sha"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
// here is presentation, and the honest presentation of a kind we have no words
|
||||
// for is its own name.
|
||||
|
||||
import { duration, prefab } from './format.js'
|
||||
import { attacker, duration, prefab } from './format.js'
|
||||
|
||||
/**
|
||||
* Kinds this feed asks for.
|
||||
@@ -161,7 +161,7 @@ function death(frame, name) {
|
||||
case 'npc':
|
||||
return {
|
||||
tone: 'death',
|
||||
actor: prefab(frame.attackerName) || 'Something',
|
||||
actor: attacker(frame.attackerName) || 'Something',
|
||||
verb: 'killed',
|
||||
subject: name,
|
||||
detail: where,
|
||||
|
||||
@@ -119,6 +119,58 @@ export function prefab(name) {
|
||||
return String(name).replace(/[_.]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* one it starts with.
|
||||
*/
|
||||
const NPC_FAMILIES = [
|
||||
['scientistnpc_heavy', 'Heavy scientist'],
|
||||
['scientistnpc', 'Scientist'],
|
||||
['npc_bandit_guard', 'Bandit guard'],
|
||||
['bandit_guard', 'Bandit guard'],
|
||||
['npc_tunneldweller', 'Tunnel dweller'],
|
||||
['npc_underwaterdweller', 'Underwater dweller'],
|
||||
['bradleyapc', 'Bradley APC'],
|
||||
['patrolhelicopter', 'Patrol helicopter'],
|
||||
['ch47scientists', 'Chinook'],
|
||||
['sentry.scientist', 'Outpost sentry'],
|
||||
['sentry.bandit', 'Bandit Camp sentry'],
|
||||
['autoturret', 'Auto turret'],
|
||||
['flameturret', 'Flame turret'],
|
||||
['guntrap', 'Shotgun trap'],
|
||||
['sam_site', 'SAM site'],
|
||||
['sam_static', 'SAM site'],
|
||||
['polarbear', 'Polar bear'],
|
||||
['simpleshark', 'Shark'],
|
||||
]
|
||||
|
||||
/**
|
||||
* What killed somebody, when it was not a player (PLAN_FIXES F2, D185) — the
|
||||
* killfeed's `npc` attacker, which the plugin sends as a prefab short name.
|
||||
*
|
||||
* The first walk read "killed by wolf2". `prefab` keeps its light touch for a
|
||||
* weapon, where the short name is what a Rust player reads on their own console;
|
||||
* an ATTACKER is a creature or a machine, and a number stuck to its name is the
|
||||
* game's variant, not something a reader needs. So a known family is named
|
||||
* (above), and anything else has its variant digits and deploy suffixes trimmed
|
||||
* and reads as capitalised words — `wolf2` is "Wolf", `boar` is "Boar". Still no
|
||||
* table of every prefab: a new animal reads fine without one.
|
||||
*/
|
||||
export function attacker(name) {
|
||||
if (!name) return ''
|
||||
const text = String(name).toLowerCase()
|
||||
const family = NPC_FAMILIES.find(([prefix]) => text.startsWith(prefix))
|
||||
if (family) return family[1]
|
||||
|
||||
const words = text
|
||||
.replace(/(\.deployed|_deployed|\.entity|\.prefab)$/, '')
|
||||
.replace(/\d+$/, '')
|
||||
.replace(/[_.]+/g, ' ')
|
||||
.trim()
|
||||
return words ? words[0].toUpperCase() + words.slice(1) : ''
|
||||
}
|
||||
|
||||
/** A steam id, shortened for a table cell, without pretending it is a name. */
|
||||
export function shortId(steamId) {
|
||||
const id = String(steamId || '')
|
||||
|
||||
@@ -61,6 +61,22 @@ const ZONES = (() => {
|
||||
})()
|
||||
|
||||
/** An ISO instant as the value a `datetime-local` input takes, in the browser's clock. */
|
||||
/**
|
||||
* What the missing ZoneManager helper costs this server (PLAN_FIXES F12, D182), or
|
||||
* null when there is nothing to say — the helper patched, or a plugin that
|
||||
* reported no ZoneManager state at all. Scoring is never what is lost: the bridge
|
||||
* falls back to the zone's shape. ZoneManager's own flags are.
|
||||
*/
|
||||
export function zoneHelperNote(helper) {
|
||||
if (!helper || helper.state === 'patched' || helper.state === 'no-zonemanager') return null
|
||||
const cost =
|
||||
'Event zones still score everybody inside, but ZoneManager’s own flags and messages miss a player who was already standing in a zone when it opened or came back after a restart.'
|
||||
if (helper.state === 'missing') {
|
||||
return `The ZoneManager helper (RunicGatewayZones.cs) is not installed. ${cost} Reinstall or update the plugin to put it back.`
|
||||
}
|
||||
return `The ZoneManager helper could not patch this ZoneManager${helper.reason ? ` (${helper.reason})` : ''}. ${cost}`
|
||||
}
|
||||
|
||||
function toLocalInput(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
@@ -235,6 +251,9 @@ export default function ServerSettings() {
|
||||
: 'No wipe schedule set.'}
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{titlesSummary(s.titles, s.titlePush)}</div>
|
||||
{zoneHelperNote(s.zoneHelper) && (
|
||||
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{zoneHelperNote(s.zoneHelper)}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||
<button type="button" className="btn" onClick={() => { setError(''); setTitling(null); setForm(formFrom(s)) }}>Edit</button>
|
||||
<button type="button" className="btn" onClick={() => test(s)}>Test the sidecar</button>
|
||||
|
||||
@@ -43,7 +43,8 @@ test('the four attacker types are four different sentences', () => {
|
||||
const victim = { name: 'Bob' }
|
||||
|
||||
const npc = describe(row('player.death', { ...victim, attackerType: 'npc', attackerName: 'scientistnpc_full_any' }))
|
||||
assert.equal(npc.actor, 'scientistnpc full any')
|
||||
// A family is named, not spelled out as a prefab (PLAN_FIXES F2, D185).
|
||||
assert.equal(npc.actor, 'Scientist')
|
||||
assert.equal(npc.subject, 'Bob')
|
||||
|
||||
const self = describe(row('player.death', { ...victim, attackerType: 'self' }))
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { ago, clock, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
|
||||
import { ago, attacker, clock, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
|
||||
|
||||
const NOW = Date.parse('2026-09-16T12:00:00Z')
|
||||
|
||||
@@ -115,3 +115,28 @@ test('a title chip’s ink is whichever of black and white reads on its colour',
|
||||
assert.equal(contrastInk('#ff0000'), '#000000')
|
||||
assert.equal(contrastInk('red'), '#000000', 'not a hex colour: black, on the caller’s fallback')
|
||||
})
|
||||
|
||||
test('an NPC attacker reads as a name: a family, or the prefab without its variant (F2, D185)', () => {
|
||||
// The first walk's "killed by wolf2".
|
||||
assert.equal(attacker('wolf2'), 'Wolf')
|
||||
assert.equal(attacker('wolf'), 'Wolf')
|
||||
assert.equal(attacker('boar'), 'Boar')
|
||||
assert.equal(attacker('polarbear'), 'Polar bear')
|
||||
assert.equal(attacker('scientistnpc_full_any'), 'Scientist')
|
||||
assert.equal(attacker('scientistnpc_roam'), 'Scientist')
|
||||
assert.equal(attacker('scientistnpc_heavy'), 'Heavy scientist')
|
||||
assert.equal(attacker('npc_bandit_guard'), 'Bandit guard')
|
||||
assert.equal(attacker('bradleyapc'), 'Bradley APC')
|
||||
assert.equal(attacker('patrolhelicopter'), 'Patrol helicopter')
|
||||
assert.equal(attacker('autoturret_deployed'), 'Auto turret')
|
||||
assert.equal(attacker('sam_site_turret_deployed'), 'SAM site')
|
||||
// Anything unknown still reads as words, capitalised, with no suffix or variant.
|
||||
assert.equal(attacker('some_new_beast3'), 'Some new beast')
|
||||
assert.equal(attacker('spikes.floor.deployed'), 'Spikes floor')
|
||||
assert.equal(attacker(null), '')
|
||||
assert.equal(attacker(''), '')
|
||||
})
|
||||
|
||||
test('a weapon keeps the light touch — attacker() is for what did the killing', () => {
|
||||
assert.equal(prefab('rifle.ak'), 'rifle ak')
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.",
|
||||
"coreApi": "^1.10.0",
|
||||
"coreApi": "^1.11.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "rust.base.destroyed",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "rust",
|
||||
"name": "Rust",
|
||||
"version": "0.1.0",
|
||||
"coreApi": "^1.10.0",
|
||||
"coreApi": "^1.11.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
|
||||
@@ -84,6 +84,18 @@ const STAFF_KINDS = Object.freeze([
|
||||
// the tail of the game server's own log, which is an operator's console and
|
||||
// can hold anything a plugin chose to print.
|
||||
'config.outcome',
|
||||
// Protocol 13 (F8, D184). Which plugins a server runs, and which permissions
|
||||
// each one registers: an operator's inventory, and the map of what can be
|
||||
// granted there — not a public page's business.
|
||||
'plugin.loaded',
|
||||
'plugin.unloaded',
|
||||
// The world an event borrowed or made, ending on its own deadline:
|
||||
// `lease.expired` since protocol 8 and `world.expired` since protocol 9 — the
|
||||
// latter recognisable only from protocol 13 (F13). Neither was ever classified
|
||||
// (default deny kept both off public pages); both are an event's machinery,
|
||||
// and the public learns what an event did from core's own announcements.
|
||||
'lease.expired',
|
||||
'world.expired',
|
||||
// Protocol 6. Clan membership, which the org lead made members-only (D49):
|
||||
// who joined which clan, and who threw whom out, is the clan's business. It
|
||||
// reaches a clan's own members through core's Team feed, where core resolves
|
||||
@@ -122,7 +134,7 @@ const PRESENCE_KINDS = Object.freeze([
|
||||
'player.tally',
|
||||
])
|
||||
|
||||
/** Every kind the protocol defines, through protocol 6. */
|
||||
/** Every event kind the protocol defines, through protocol 13. */
|
||||
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
||||
|
||||
const PUBLIC = new Set(PUBLIC_KINDS)
|
||||
|
||||
@@ -149,6 +149,14 @@ module.exports = {
|
||||
// would be worse, since a module has more than one thing it could reconcile.
|
||||
reconcileEvents: () => need().events.reconcile(),
|
||||
|
||||
// MODULE_API 1.11.0 (PLAN_FIXES F14, D170, D183). The game ended one of this
|
||||
// module's ledgered resources at its own deadline — a zone the plugin erased
|
||||
// when its time was up (`world.expired`). Core files it `expired`: terminal,
|
||||
// never taken back at teardown, and not `orphaned`, which is reconcile's word
|
||||
// for something that vanished with nobody asking. Fire and forget, and a
|
||||
// `{ kind, ref }` no run ledgered is not an error.
|
||||
expireEvent: (resource) => need().events.expired(resource),
|
||||
|
||||
// Teams (MODULE_API.md §2.3, 1.6.0) — the push half of the provider this
|
||||
// module registers (`model/clans/teamProvider.js`). Three calls, all
|
||||
// fire-and-forget, and core's contract is that none of them can make this
|
||||
|
||||
@@ -177,6 +177,31 @@ function splitRef(ref) {
|
||||
return colon <= 0 ? { serverId: null, id: text } : { serverId: text.slice(0, colon), id: text.slice(colon + 1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A zone the plugin erased at its deadline (`world.expired`, PLAN_FIXES F13, F14).
|
||||
*
|
||||
* D96 said the website maps this frame to nothing and learns of it through
|
||||
* `reconcile` and `revert`. The first player walk showed what that costs: three
|
||||
* zones expired in the game on time and sat `confirmed` on the run console until
|
||||
* the runs were cancelled, when teardown found them "already gone" and called that
|
||||
* a success. D170 changed it, and D183 put the record in core: the resource row is
|
||||
* marked `expired`.
|
||||
*
|
||||
* Until protocol 13 this frame could not be recognised at all — the plugin wrote
|
||||
* the zone's kind over the frame's — so `what` is new with it, and a frame without
|
||||
* an `id` names nothing to expire.
|
||||
*/
|
||||
function expired(serverId, frame) {
|
||||
if (!serverId || !frame || frame.id === undefined || frame.id === null || frame.id === '') return false
|
||||
try {
|
||||
core.expireEvent({ kind: OWNED_KIND, ref: refOf(serverId, String(frame.id)) })
|
||||
} catch (err) {
|
||||
log.warn('could not tell core a zone expired', { server: serverId, id: frame.id, error: err.message })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Resources grouped by the server each one is on. */
|
||||
function byServer(resources) {
|
||||
const groups = new Map()
|
||||
@@ -658,5 +683,6 @@ module.exports = {
|
||||
revert,
|
||||
reconcile,
|
||||
observeServer,
|
||||
expired,
|
||||
resetWatch,
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ const configDb = require('./model/config/config.db')
|
||||
const configModel = require('./model/config/config.model')
|
||||
const db = require('./model/events/events.db')
|
||||
const engagement = require('./engagement/emit')
|
||||
const eventWorld = require('./eventWorld')
|
||||
const links = require('./model/links/links.model')
|
||||
const permissionsDb = require('./model/permissions/permissions.db')
|
||||
const sidecar = require('./sidecarClient')
|
||||
@@ -201,6 +202,29 @@ async function apply(serverId, item, server = null) {
|
||||
await permissionsDb.markDirty(serverId)
|
||||
break
|
||||
|
||||
// ── Protocol 13: a plugin loaded or unloaded (F8, D184) ─────────────────
|
||||
//
|
||||
// A grant for a plugin that was not loaded stays `unresolved` until that
|
||||
// plugin comes back, and the first walk watched one land thirteen minutes
|
||||
// late, on the fifteen-minute audit. The frame carries the permissions the
|
||||
// plugin added or removed, and only a non-empty list is a reason to sync —
|
||||
// a plugin that registers nothing cannot have changed what a grant resolves
|
||||
// to. `perm.drift`'s posture: a reason, and the next tick (30 s) is the answer.
|
||||
case 'plugin.loaded':
|
||||
case 'plugin.unloaded':
|
||||
if (Array.isArray(frame.permissions) && frame.permissions.length > 0) {
|
||||
await permissionsDb.markDirty(serverId)
|
||||
}
|
||||
break
|
||||
|
||||
// ── Protocol 13: a zone reached its deadline (F13, F14, D170, D183) ──────
|
||||
//
|
||||
// Core records the run's resource row as `expired`. Not awaited: it is
|
||||
// core's bookkeeping, fire-and-forget by contract.
|
||||
case 'world.expired':
|
||||
eventWorld.expired(serverId, frame)
|
||||
break
|
||||
|
||||
// ── Protocol 13: how a configuration save's reload ended (F9, D179) ─────
|
||||
//
|
||||
// The save was answered before the reload finished and recorded as
|
||||
|
||||
@@ -8,6 +8,7 @@ const core = require('../../core')
|
||||
const LINKS = 'rust_account_links'
|
||||
const PLAYERS = 'rust_players'
|
||||
const STATS = 'rust_player_wipe_stats'
|
||||
const EVENTS = 'rust_events'
|
||||
|
||||
/**
|
||||
* The link for one Steam id, or undefined.
|
||||
@@ -165,7 +166,27 @@ async function userIdsForSteamIds(steamIds) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The servers that handed out a link code in the last `windowSec` (PLAN_FIXES F5).
|
||||
*
|
||||
* Every `/link` in game emits `account.link.requested`, which ingest stores like
|
||||
* any frame — without the code, which travels through the player. So the site
|
||||
* cannot know WHICH server minted a code, but it does know which servers minted
|
||||
* one at all. Read on the database's clock (`created_at`, set at ingest) rather
|
||||
* than the frame's `t`, which is the game host's clock.
|
||||
*/
|
||||
async function recentLinkIssuers(windowSec) {
|
||||
const rows = await core.query(
|
||||
`SELECT DISTINCT server_id AS serverId FROM ${EVENTS}
|
||||
WHERE kind = 'account.link.requested'
|
||||
AND created_at >= NOW() - INTERVAL ? SECOND`,
|
||||
[Number(windowSec)],
|
||||
)
|
||||
return rows.map((row) => String(row.serverId))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
recentLinkIssuers,
|
||||
getBySteamId,
|
||||
listForUser,
|
||||
listForUserWithPlayer,
|
||||
|
||||
@@ -24,6 +24,14 @@ const sidecar = require('../../sidecarClient')
|
||||
|
||||
const log = core.logger('links')
|
||||
|
||||
/**
|
||||
* How far back a code's mint counts (F5): the plugin's five-minute `CodeTtl`,
|
||||
* plus a minute for a frame that reached this site late — a sidecar that
|
||||
* reconnected, a cursor catching up. Too wide costs nothing but the old
|
||||
* "unsure" answer for a little longer; too narrow would call a live code wrong.
|
||||
*/
|
||||
const LINK_WINDOW_SEC = 6 * 60
|
||||
|
||||
/**
|
||||
* A link changed, so a clan member's website account changed (D57).
|
||||
*
|
||||
@@ -183,25 +191,35 @@ async function redeem({ code, userId }) {
|
||||
|
||||
if (fleet.length === 0) return { ok: false, reason: 'no-servers' }
|
||||
|
||||
let refused = 0
|
||||
let unreachable = 0
|
||||
// **Who could hold this code** (PLAN_FIXES F5). The first walk, with five of
|
||||
// seven servers down, answered a spent code and a made-up `ZZZZZZ` alike with
|
||||
// "one of the servers could not be reached — your code is still good", and
|
||||
// would have for as long as any server stayed down. A code lives five minutes
|
||||
// on the server that minted it, and every mint is an `account.link.requested`
|
||||
// this site has stored — so only a server that minted one recently can hold it,
|
||||
// and only one of THOSE being unreachable is a reason to be unsure.
|
||||
const recent = new Set(await db.recentLinkIssuers(LINK_WINDOW_SEC))
|
||||
const issuers = fleet.filter((server) => recent.has(String(server.id)))
|
||||
const others = fleet.filter((server) => !recent.has(String(server.id)))
|
||||
|
||||
for (const server of fleet) {
|
||||
// Sequential, deliberately. In parallel every server would be asked even
|
||||
// after one had already answered, and a code spent on the right server would
|
||||
// still be travelling to five others — for a fleet of six and a five-minute
|
||||
// TTL, there is nothing to win by racing them.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await confirmOne({ server, code, userId })
|
||||
// **In parallel** (F6). One at a time, the walk's redeem waited about four
|
||||
// seconds on each dead server in turn — twenty-one in all, the successful link
|
||||
// included, because the rig sorted last. The issuers first, since the code is
|
||||
// almost always on one of them; the rest only when none of them had it, which
|
||||
// covers a code typed in the few seconds before its frame was ingested. Asking
|
||||
// a server that does not hold the code costs nothing: it answers `unknown`, and
|
||||
// a code is only ever spent where it was minted.
|
||||
const asked = await Promise.all(issuers.map((server) => confirmOne({ server, code, userId })))
|
||||
const settled = asked.find((result) => result.ok || result.reason === 'taken')
|
||||
if (settled) return settled
|
||||
|
||||
if (result.ok || result.reason === 'taken') return result
|
||||
const rest = await Promise.all(others.map((server) => confirmOne({ server, code, userId })))
|
||||
const late = rest.find((result) => result.ok || result.reason === 'taken')
|
||||
if (late) return late
|
||||
|
||||
if (result.reason === 'offline') unreachable += 1
|
||||
else refused += 1
|
||||
}
|
||||
|
||||
if (refused === 0) return { ok: false, reason: 'offline' }
|
||||
if (unreachable > 0) return { ok: false, reason: 'unsure' }
|
||||
const every = [...asked, ...rest]
|
||||
if (every.every((result) => result.reason === 'offline')) return { ok: false, reason: 'offline' }
|
||||
if (asked.some((result) => result.reason === 'offline')) return { ok: false, reason: 'unsure' }
|
||||
|
||||
return { ok: false, reason: 'rejected' }
|
||||
}
|
||||
|
||||
@@ -101,15 +101,54 @@ async function deleteServer(id) {
|
||||
await core.query(`DELETE FROM ${SERVERS} WHERE id = ?`, [id])
|
||||
}
|
||||
|
||||
/**
|
||||
* `worldReady` from the hello the row keeps whole (`raw`): true, false, or null for
|
||||
* a plugin that never says — which PLAN.md §28.6 reads as ready. The permission and
|
||||
* title pushes hold while it is false (PLAN_FIXES F7): the plugin connects before the
|
||||
* save loads, and a sync sent then waits on a main thread that is busy loading, times
|
||||
* out, and the restart it was for is never shown as restored.
|
||||
*/
|
||||
function withWorldReady(row) {
|
||||
if (!row) return row
|
||||
const value = row.worldReady
|
||||
const ready = value === null || value === undefined ? null : value === true || value === 1 || String(value) === 'true'
|
||||
return { ...row, worldReady: ready, zoneHelper: helperOf(row.zoneHelper) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The ZoneManager helper's state from the same hello (PLAN_FIXES D182): `{ state,
|
||||
* version?, reason? }`, or null when the plugin reported none — no ZoneManager, or
|
||||
* a plugin older than protocol 13. The driver hands JSON_EXTRACT back as text.
|
||||
*/
|
||||
function helperOf(value) {
|
||||
if (value === null || value === undefined) return null
|
||||
let parsed = value
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || typeof parsed.state !== 'string') return null
|
||||
return {
|
||||
state: parsed.state,
|
||||
...(typeof parsed.version === 'string' ? { version: parsed.version } : {}),
|
||||
...(typeof parsed.reason === 'string' ? { reason: parsed.reason } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** The last thing each server said about itself, keyed by server id. */
|
||||
async function listState() {
|
||||
return core.query(
|
||||
return (await core.query(
|
||||
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
|
||||
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
|
||||
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
|
||||
last_seen_at AS lastSeenAt, updated_at AS updatedAt
|
||||
last_seen_at AS lastSeenAt, updated_at AS updatedAt,
|
||||
JSON_EXTRACT(raw, '$.worldReady') AS worldReady,
|
||||
JSON_EXTRACT(raw, '$.zoneHelper') AS zoneHelper
|
||||
FROM ${STATE}`,
|
||||
)
|
||||
)).map(withWorldReady)
|
||||
}
|
||||
|
||||
/** One server's observed state, or `null`. The single-row twin of `listState`. */
|
||||
@@ -118,12 +157,14 @@ async function getState(serverId) {
|
||||
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
|
||||
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
|
||||
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
|
||||
last_seen_at AS lastSeenAt, updated_at AS updatedAt
|
||||
last_seen_at AS lastSeenAt, updated_at AS updatedAt,
|
||||
JSON_EXTRACT(raw, '$.worldReady') AS worldReady,
|
||||
JSON_EXTRACT(raw, '$.zoneHelper') AS zoneHelper
|
||||
FROM ${STATE}
|
||||
WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
return withWorldReady(rows[0] || null)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -208,6 +208,9 @@ async function listForAdmin(now = Date.now()) {
|
||||
reachable: Boolean(state && state.reachable),
|
||||
bootId: (state && state.bootId) || null,
|
||||
sidecarProtocol: state && state.protocol != null ? Number(state.protocol) : null,
|
||||
// D182: whether ZoneManager counts somebody already standing in a zone the
|
||||
// bridge makes. Anything but `patched` is said on the servers page.
|
||||
zoneHelper: (state && state.zoneHelper) || null,
|
||||
schedule: scheduleOf(row),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -138,6 +138,14 @@ async function tick({ force = null } = {}) {
|
||||
*/
|
||||
function reasonToSync({ desiredHash, sync, state, force }) {
|
||||
if (force) return 'requested'
|
||||
// Not while the world is loading (PLAN_FIXES F7). The plugin connects before
|
||||
// the save loads, and the first walk's restart sync went 35 s before "Server
|
||||
// startup complete", timed out behind the busy main thread, and was retried
|
||||
// 2.5 minutes later as "0 applied" — so nothing said what the restart had
|
||||
// restored. The plugin says `worldReady: true` in the hello it sends the moment
|
||||
// the world is up, and the sync goes on the next tick. A human's "sync now" is
|
||||
// not held: they asked, and a failure then is theirs to read.
|
||||
if (state && state.worldReady === false) return null
|
||||
if (!sync) return 'first'
|
||||
if (sync.state !== 'ok' && sync.lastAttemptAt && age(sync.lastAttemptAt) < FAIL_BACKOFF_MS && !sync.dirty) {
|
||||
return null
|
||||
@@ -224,6 +232,10 @@ async function syncOne(server, { authored, sync, state, force }) {
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
// Said in the log as well as on the row (F7): the first walk's restart sync
|
||||
// timed out with nothing in the log at all, while the titles push that failed
|
||||
// beside it did log.
|
||||
log.warn('permission sync failed', { server: server.id, reason, status: result.status })
|
||||
await db.putSyncResult(server.id, {
|
||||
state: 'failed',
|
||||
desiredHash: desired.hash,
|
||||
@@ -244,6 +256,7 @@ async function syncOne(server, { authored, sync, state, force }) {
|
||||
// rather than transport failures, exactly like a refused link code, so they
|
||||
// arrive as a 200 and are told apart by `kind`.
|
||||
if (report.kind === 'perm.error') {
|
||||
log.warn('permission sync refused by the game', { server: server.id, reason, refused: report.reason || 'unknown' })
|
||||
await db.putSyncResult(server.id, {
|
||||
state: 'failed',
|
||||
desiredHash: desired.hash,
|
||||
|
||||
@@ -135,6 +135,16 @@ module.exports = {
|
||||
},
|
||||
bootId: { type: 'string', nullable: true, example: 'boot-20260915T194502Z' },
|
||||
sidecarProtocol: { type: 'integer', nullable: true, example: 1 },
|
||||
zoneHelper: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'The ZoneManager helper the game reported at hello (protocol 13, PLAN_FIXES D182). `patched` means ZoneManager counts a player already standing in a zone when it is created or restored. `missing`, `unsupported` or `no-zonemanager` mean it does not: the bridge scores its zones by position instead, and the flags ZoneManager applies miss that player. Null when the plugin reported none.',
|
||||
properties: {
|
||||
state: { type: 'string', enum: ['patched', 'unsupported', 'missing', 'no-zonemanager'], example: 'patched' },
|
||||
version: { type: 'string', example: '0.1.0' },
|
||||
reason: { type: 'string', example: 'this ZoneManager (3.2.0) has no Zone.InitializeZone' },
|
||||
},
|
||||
},
|
||||
online: { type: 'boolean', example: true },
|
||||
players: { type: 'integer', example: 42 },
|
||||
stale: { type: 'boolean', example: false },
|
||||
|
||||
@@ -64,7 +64,9 @@ function fakeCtx(overrides = {}) {
|
||||
// `ctx`, because an action is called BY core and is handed what it needs in
|
||||
// the envelope. Only the module knows when the game restarted, so only the
|
||||
// module can ask for the sweep.
|
||||
events: { emit: spy(undefined), reconcile: spy(undefined) },
|
||||
// `expired` joined at 1.11.0 (PLAN_FIXES D183): the game ended a ledgered
|
||||
// resource at its own deadline, and only the module hears it happen.
|
||||
events: { emit: spy(undefined), reconcile: spy(undefined), expired: spy(undefined) },
|
||||
// Teams (§2.3, 1.6.0). Push only — there is no reader, because a module
|
||||
// ANSWERS questions about Teams rather than asking them. `publish` and
|
||||
// `activity.push` resolve like core's; `reconcile` returns nothing, because
|
||||
|
||||
@@ -95,7 +95,7 @@ test('every kind is classified exactly once', () => {
|
||||
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
|
||||
})
|
||||
|
||||
test('the classification covers exactly the kinds the protocol defines, through protocol 6, and protocol 13’s config.outcome', () => {
|
||||
test('the classification covers exactly the event kinds the protocol defines, through protocol 13', () => {
|
||||
// The spec lives in another repository, so the list is restated here rather
|
||||
// than parsed — and restating it is the point: adding a kind to the protocol
|
||||
// without deciding who may see it has to fail somewhere, and this is where.
|
||||
@@ -127,9 +127,18 @@ test('the classification covers exactly the kinds the protocol defines, through
|
||||
'clan.member.added',
|
||||
'clan.member.left',
|
||||
'clan.member.kicked',
|
||||
// Protocol 8 (§14) and protocol 9 (§15): an event's machinery ending on its
|
||||
// own deadline. Never classified until protocol 13 made `world.expired`
|
||||
// recognisable (F13) — default deny kept both off public pages meanwhile.
|
||||
'lease.expired',
|
||||
'world.expired',
|
||||
// Protocol 13 (§19). A configuration save's outcome carries the server's
|
||||
// log tail, which is an operator's console: staff only.
|
||||
'config.outcome',
|
||||
// Protocol 13 (§19, F8, D184). Which plugins a server runs and what each
|
||||
// registers: an operator's inventory.
|
||||
'plugin.loaded',
|
||||
'plugin.unloaded',
|
||||
]
|
||||
|
||||
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
|
||||
@@ -139,6 +148,9 @@ test('the classification covers exactly the kinds the protocol defines, through
|
||||
}
|
||||
|
||||
assert.equal(catalogue.isPublic('config.outcome'), false, 'a server’s log tail must not be public')
|
||||
for (const kind of ['plugin.loaded', 'plugin.unloaded', 'lease.expired', 'world.expired']) {
|
||||
assert.equal(catalogue.isPublic(kind), false, `${kind} is staff-only`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every kind that names a player who was on is behind the presence setting', () => {
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
// telling answers apart that a naive implementation collapses:
|
||||
//
|
||||
// • **A code is minted by ONE server** and the player types six characters into
|
||||
// a browser. Every server is asked in turn (D24), and "every reachable server
|
||||
// said no" is NOT the same answer as "a server could not be reached" — the
|
||||
// second is the case where the player's code is perfectly good and the advice
|
||||
// "run /link again" is useless, because it sends them back to the server that
|
||||
// is down.
|
||||
// a browser. The servers that minted a code recently are asked first, the
|
||||
// rest only when none of them had it, each group in parallel (D24, F5, F6) —
|
||||
// and "every reachable server said no" is NOT the same answer as "a server
|
||||
// that minted a code could not be reached". The second is the case where the
|
||||
// player's code is perfectly good and the advice "run /link again" is useless,
|
||||
// because it sends them back to the server that is down.
|
||||
//
|
||||
// • **A Steam id another account holds is refused, never moved** (D23). Once
|
||||
// phase 7 grants permissions against a link and phase 13 hangs entitlements
|
||||
@@ -56,15 +57,23 @@ function withCore({ select = [], onInsert = null } = {}) {
|
||||
return { ctx, queries }
|
||||
}
|
||||
|
||||
/** A fleet of `n` servers, and a sidecar that answers from a script. */
|
||||
function fleetOf(replies) {
|
||||
/**
|
||||
* A fleet of `n` servers, and a sidecar that answers from a script.
|
||||
*
|
||||
* `issuers` are the servers the site saw mint a link code in the last few
|
||||
* minutes (`account.link.requested`, F5) — none by default, which is also what a
|
||||
* code typed before its frame was ingested looks like.
|
||||
*/
|
||||
function fleetOf(replies, issuers = []) {
|
||||
const servers = require('../model/servers/servers.model')
|
||||
const sidecar = require('../sidecarClient')
|
||||
const linksDb = require('../model/links/links.db')
|
||||
|
||||
const asked = []
|
||||
const ids = Object.keys(replies)
|
||||
|
||||
servers.listForPolling = async () => ids.map((id) => ({ id, baseUrl: `http://${id}`, token: 't' }))
|
||||
linksDb.recentLinkIssuers = async () => [...issuers]
|
||||
|
||||
sidecar.confirmLink = async (server, code) => {
|
||||
asked.push({ server: server.id, code })
|
||||
@@ -100,20 +109,32 @@ test('every server is asked until one recognises the code, and the one that answ
|
||||
assert.deepEqual(insert.params, ['7656', 4, 'Wanderer', 'b'])
|
||||
})
|
||||
|
||||
test('a server after the one that answered is never asked', async () => {
|
||||
test('when a server that minted a code answers it, the rest are never asked', async () => {
|
||||
withCore({ select: [[], [{ steamId: '7656', userId: 4 }]] })
|
||||
const links = require('../model/links/links.model')
|
||||
|
||||
const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused, c: linkRefused })
|
||||
const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused, c: linkRefused }, ['a'])
|
||||
|
||||
await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
||||
|
||||
// A code is spent on the plugin's FIRST lookup, so carrying on after a yes
|
||||
// would be asking four other game hosts to look up a secret that has already
|
||||
// been redeemed.
|
||||
// would be asking other game hosts to look up a secret that has already been
|
||||
// redeemed.
|
||||
assert.deepEqual(asked.map((a) => a.server), ['a'])
|
||||
})
|
||||
|
||||
test('a code typed before its mint was ingested is still found, on a server nobody expected', async () => {
|
||||
// Ingest polls every few seconds; a fast typist can beat it. With no recent
|
||||
// mint on record the whole fleet is asked, and the code is where it is.
|
||||
withCore({ select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'c' }]] })
|
||||
const links = require('../model/links/links.model')
|
||||
|
||||
fleetOf({ a: linkRefused, b: unreachable, c: linkOk('7656', 'Wanderer') })
|
||||
|
||||
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
||||
assert.equal(result.ok, true)
|
||||
})
|
||||
|
||||
test('a Steam id another account holds is refused, not moved — and the loop stops', async () => {
|
||||
// The whole of D23 in one assertion. The holder is named because the player is
|
||||
// signed in and the advice ("sign in as that account, or run /unlink") is
|
||||
@@ -121,7 +142,7 @@ test('a Steam id another account holds is refused, not moved — and the loop st
|
||||
withCore({ select: [[{ steamId: '7656', userId: 9, username: 'someone-else' }]] })
|
||||
const links = require('../model/links/links.model')
|
||||
|
||||
const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused })
|
||||
const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused }, ['a'])
|
||||
|
||||
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
||||
|
||||
@@ -149,11 +170,11 @@ test('a code already redeemed by the SAME user is a success, not an error', asyn
|
||||
assert.equal(result.already, true)
|
||||
})
|
||||
|
||||
test('"every reachable server refused" is not the same answer as "a server was unreachable"', async () => {
|
||||
test('"every reachable server refused" is not the same answer as "a server that minted a code was unreachable"', async () => {
|
||||
withCore()
|
||||
const links = require('../model/links/links.model')
|
||||
|
||||
fleetOf({ a: linkRefused, b: unreachable })
|
||||
fleetOf({ a: linkRefused, b: unreachable }, ['b'])
|
||||
|
||||
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
||||
|
||||
@@ -317,3 +338,40 @@ test('a link that was already there asks for nothing', async () => {
|
||||
assert.equal(result.already, true)
|
||||
assert.equal(ctx.teams.reconcile.calls.length, 0)
|
||||
})
|
||||
|
||||
test('a dead server that minted nothing does not make a wrong code "still good" (F5)', async () => {
|
||||
// The first walk: five of seven servers down, and a made-up ZZZZZZ was answered
|
||||
// "one of the servers could not be reached — your code is still good". A server
|
||||
// that has not minted a code in the window cannot hold this one.
|
||||
withCore()
|
||||
const links = require('../model/links/links.model')
|
||||
|
||||
fleetOf({ a: linkRefused, b: unreachable, c: unreachable, d: unreachable }, ['a'])
|
||||
|
||||
assert.equal((await links.redeem({ code: 'ZZZZZZ', userId: 4 })).reason, 'rejected')
|
||||
})
|
||||
|
||||
test('the fleet is asked in parallel, not one dead server after another (F6)', async () => {
|
||||
// One at a time, the walk waited about four seconds on each dead server in turn —
|
||||
// twenty-one seconds, a successful link included.
|
||||
withCore({ select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'e' }]] })
|
||||
const links = require('../model/links/links.model')
|
||||
const sidecar = require('../sidecarClient')
|
||||
|
||||
fleetOf({ a: unreachable, b: unreachable, c: unreachable, d: unreachable, e: linkOk('7656', 'Wanderer') })
|
||||
|
||||
let inFlight = 0
|
||||
let most = 0
|
||||
const scripted = sidecar.confirmLink
|
||||
sidecar.confirmLink = async (server, code) => {
|
||||
inFlight += 1
|
||||
most = Math.max(most, inFlight)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
inFlight -= 1
|
||||
return scripted(server, code)
|
||||
}
|
||||
|
||||
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(most, 5, 'every server was being asked at once')
|
||||
})
|
||||
|
||||
146
server/test/planFixesStep2.test.js
Normal file
146
server/test/planFixesStep2.test.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// ── PLAN_FIXES §6 step 2, the module's half ───────────────────────────────
|
||||
//
|
||||
// Each test here is one finding of the first player walk (2026-09-26), held
|
||||
// down so it cannot come back:
|
||||
//
|
||||
// • F13/F14 — a zone that expired in the game is recorded `expired` by core,
|
||||
// through `ctx.events.expired` (D170, D183)
|
||||
// • F8 — a plugin that loads with permissions re-syncs within a tick (D184)
|
||||
// • F7 — nothing is pushed to a game whose world is still loading
|
||||
// • D182 — the ZoneManager helper's state reaches the servers page
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
/** A core whose `db.query` records every statement and answers from `answer`. */
|
||||
function withCore(answer = () => []) {
|
||||
const statements = []
|
||||
const ctx = fakeCtx({
|
||||
db: {
|
||||
query: (sql, params = []) => {
|
||||
statements.push({ sql, params })
|
||||
return Promise.resolve(answer(sql, params))
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
})
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
return { ctx, statements }
|
||||
}
|
||||
|
||||
const item = (kind, over = {}) => ({
|
||||
id: 1,
|
||||
t: 1,
|
||||
kind,
|
||||
frame: { kind, type: 'event', t: 1, serverId: 'main', wipeId: 'w-1', ...over },
|
||||
})
|
||||
|
||||
// ── F13, F14 ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('an expired zone is handed to core as the resource the zone step ledgered', async () => {
|
||||
const { ctx } = withCore()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
// Protocol 13's frame: the entity's kind is `what`, and the frame keeps its own.
|
||||
await apply('srv-a', item('world.expired', { id: 'rg-13-35875416-1', what: 'zone', runId: '13' }))
|
||||
|
||||
const calls = ctx.events.expired.calls
|
||||
assert.equal(calls.length, 1)
|
||||
// The same kind and ref `place()` filed it under — `<serverId>:<id>` — or core
|
||||
// would find no row to close.
|
||||
assert.deepEqual(calls[0][0], { kind: 'world', ref: 'srv-a:rg-13-35875416-1' })
|
||||
})
|
||||
|
||||
test('a world.expired that names nothing is not passed on', async () => {
|
||||
const { ctx } = withCore()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
await apply('srv-a', item('world.expired', { what: 'zone' }))
|
||||
assert.equal(ctx.events.expired.calls.length, 0)
|
||||
})
|
||||
|
||||
// ── F8 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a plugin that loads with permissions marks the permission sync dirty; one without does not', async () => {
|
||||
const { statements } = withCore()
|
||||
const { apply } = require('../ingest')
|
||||
const dirtying = () => statements.filter((s) => /UPDATE\s+rust_perm_sync\s+SET\s+dirty\s*=\s*1/i.test(s.sql))
|
||||
|
||||
await apply('srv-a', item('plugin.loaded', { name: 'PopupNotifications', version: '0.2.1', permissions: [] }))
|
||||
assert.equal(dirtying().length, 0, 'a plugin that registers nothing cannot change what a grant resolves to')
|
||||
|
||||
await apply('srv-a', item('plugin.loaded', { name: 'Kits', version: '4.4.9', permissions: ['kits.admin', 'kits.vip'] }))
|
||||
assert.equal(dirtying().length, 1)
|
||||
assert.deepEqual(dirtying()[0].params, ['srv-a'])
|
||||
|
||||
await apply('srv-a', item('plugin.unloaded', { name: 'Kits', permissions: ['kits.admin', 'kits.vip'] }))
|
||||
assert.equal(dirtying().length, 2, 'an unload is a reason too: its grants are now unresolved')
|
||||
})
|
||||
|
||||
// ── F7 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('no permission sync goes to a world that is still loading — unless a human asked', () => {
|
||||
withCore()
|
||||
const permSync = require('../permSync')
|
||||
const sync = { state: 'ok', dirty: false, syncedHash: 'h1', bootId: 'boot-1', wipeId: 'w-1', lastAttemptAt: new Date() }
|
||||
const at = (state, force = false) => permSync.reasonToSync({ desiredHash: 'h1', sync, state, force })
|
||||
|
||||
// The first walk's restart sync: a new boot id, arriving before the save loaded.
|
||||
assert.equal(at({ bootId: 'boot-2', wipeId: 'w-1', worldReady: false }), null)
|
||||
assert.equal(at({ bootId: 'boot-2', wipeId: 'w-1', worldReady: true }), 'restart')
|
||||
// A plugin that never says is ready, as it always was (§28.6).
|
||||
assert.equal(at({ bootId: 'boot-2', wipeId: 'w-1', worldReady: null }), 'restart')
|
||||
assert.equal(permSync.reasonToSync({ desiredHash: 'h1', sync: null, state: { worldReady: false }, force: false }), null)
|
||||
assert.equal(at({ worldReady: false }, true), 'requested')
|
||||
})
|
||||
|
||||
test('no title push goes to a world that is still loading', async () => {
|
||||
withCore()
|
||||
const titleSync = require('../titleSync')
|
||||
const client = require('../sidecarClient')
|
||||
const original = client.titles
|
||||
let pushed = 0
|
||||
client.titles = async () => {
|
||||
pushed += 1
|
||||
return { ok: true, data: { kind: 'titles.ok' } }
|
||||
}
|
||||
try {
|
||||
const answer = await titleSync.syncOne(
|
||||
{ id: 'srv-a', name: 'A' },
|
||||
{ online: true, protocol: 13, bootId: 'boot-2', wipeId: 'w-1', worldReady: false },
|
||||
)
|
||||
assert.equal(answer, null)
|
||||
assert.equal(pushed, 0)
|
||||
} finally {
|
||||
client.titles = original
|
||||
}
|
||||
})
|
||||
|
||||
test('worldReady and the zone helper are read out of the stored hello', async () => {
|
||||
withCore((sql) =>
|
||||
/FROM rust_server_state/.test(sql)
|
||||
? [
|
||||
{ serverId: 'a', worldReady: 'false', zoneHelper: '{"state":"missing"}' },
|
||||
{ serverId: 'b', worldReady: 'true', zoneHelper: '{"state":"patched","version":"0.1.0"}' },
|
||||
{ serverId: 'c', worldReady: null, zoneHelper: null },
|
||||
{ serverId: 'd', worldReady: 'true', zoneHelper: '{"state":"unsupported","reason":"no Zone.InitializeZone"}' },
|
||||
{ serverId: 'e', worldReady: 'true', zoneHelper: 'not json' },
|
||||
]
|
||||
: [],
|
||||
)
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const rows = await serversDb.listState()
|
||||
const by = Object.fromEntries(rows.map((r) => [r.serverId, r]))
|
||||
|
||||
assert.equal(by.a.worldReady, false)
|
||||
assert.equal(by.b.worldReady, true)
|
||||
assert.equal(by.c.worldReady, null)
|
||||
assert.deepEqual(by.a.zoneHelper, { state: 'missing' })
|
||||
assert.deepEqual(by.b.zoneHelper, { state: 'patched', version: '0.1.0' })
|
||||
assert.equal(by.c.zoneHelper, null)
|
||||
assert.deepEqual(by.d.zoneHelper, { state: 'unsupported', reason: 'no Zone.InitializeZone' })
|
||||
assert.equal(by.e.zoneHelper, null, 'an unreadable value is no report, not a crash')
|
||||
})
|
||||
@@ -78,6 +78,9 @@ function reasonToPush({ digest, state, last }) {
|
||||
|
||||
async function syncOne(server, state) {
|
||||
if (!state || !state.online || Number(state.protocol) < TITLES_PROTOCOL) return null
|
||||
// Not while the world is loading — permSync's reason (PLAN_FIXES F7). The first
|
||||
// walk's titles push went with the restart sync, before the save had loaded.
|
||||
if (state.worldReady === false) return null
|
||||
|
||||
const held = await model.heldFor(server.id, { wipeId: state.wipeId || null })
|
||||
const set = titles.wireSet(held)
|
||||
|
||||
@@ -2744,6 +2744,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"zoneHelper": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The ZoneManager helper the game reported at hello (protocol 13, PLAN_FIXES D182). `patched` means ZoneManager counts a player already standing in a zone when it is created or restored. `missing`, `unsupported` or `no-zonemanager` mean it does not: the bridge scores its zones by position instead, and the flags ZoneManager applies miss that player. Null when the plugin reported none."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"patched",
|
||||
"unsupported",
|
||||
"missing",
|
||||
"no-zonemanager"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "patched"
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "0.1.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reason": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "this ZoneManager (3.2.0) has no Zone.InitializeZone"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"online": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user