diff --git a/ci/core-ref.json b/ci/core-ref.json
index d9c692d..bdcf9e0 100644
--- a/ci/core-ref.json
+++ b/ci/core-ref.json
@@ -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"
}
diff --git a/client/src/lib/feed.js b/client/src/lib/feed.js
index 5c8bb1f..3322595 100644
--- a/client/src/lib/feed.js
+++ b/client/src/lib/feed.js
@@ -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,
diff --git a/client/src/lib/format.js b/client/src/lib/format.js
index 6b6b1c1..087632e 100644
--- a/client/src/lib/format.js
+++ b/client/src/lib/format.js
@@ -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 || '')
diff --git a/client/src/routes/admin/ServerSettings.jsx b/client/src/routes/admin/ServerSettings.jsx
index c05147e..810bddc 100644
--- a/client/src/routes/admin/ServerSettings.jsx
+++ b/client/src/routes/admin/ServerSettings.jsx
@@ -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.'}
{titlesSummary(s.titles, s.titlePush)}
+ {zoneHelperNote(s.zoneHelper) && (
+
{zoneHelperNote(s.zoneHelper)}
+ )}
diff --git a/client/test/feed.test.js b/client/test/feed.test.js
index 4dcbb79..2c9405a 100644
--- a/client/test/feed.test.js
+++ b/client/test/feed.test.js
@@ -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' }))
diff --git a/client/test/format.test.js b/client/test/format.test.js
index 5852a17..a51bf56 100644
--- a/client/test/format.test.js
+++ b/client/test/format.test.js
@@ -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')
+})
diff --git a/engagement-triggers.json b/engagement-triggers.json
index e3a9027..2c740be 100644
--- a/engagement-triggers.json
+++ b/engagement-triggers.json
@@ -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",
diff --git a/module.json b/module.json
index 506edab..8a5db29 100644
--- a/module.json
+++ b/module.json
@@ -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",
diff --git a/server/catalogue.js b/server/catalogue.js
index 2b61949..10d0888 100644
--- a/server/catalogue.js
+++ b/server/catalogue.js
@@ -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)
diff --git a/server/core.js b/server/core.js
index 4d932c6..28f79a5 100644
--- a/server/core.js
+++ b/server/core.js
@@ -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
diff --git a/server/eventWorld.js b/server/eventWorld.js
index e869074..8f153e2 100644
--- a/server/eventWorld.js
+++ b/server/eventWorld.js
@@ -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,
}
diff --git a/server/ingest.js b/server/ingest.js
index c06363e..cd4c143 100644
--- a/server/ingest.js
+++ b/server/ingest.js
@@ -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
diff --git a/server/model/links/links.db.js b/server/model/links/links.db.js
index 613eacf..c7798c0 100644
--- a/server/model/links/links.db.js
+++ b/server/model/links/links.db.js
@@ -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,
diff --git a/server/model/links/links.model.js b/server/model/links/links.model.js
index 24fa4dc..a55d727 100644
--- a/server/model/links/links.model.js
+++ b/server/model/links/links.model.js
@@ -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' }
}
diff --git a/server/model/servers/servers.db.js b/server/model/servers/servers.db.js
index 8ff61a6..a0f9c69 100644
--- a/server/model/servers/servers.db.js
+++ b/server/model/servers/servers.db.js
@@ -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)
}
/**
diff --git a/server/model/servers/servers.model.js b/server/model/servers/servers.model.js
index 073267e..3fdef50 100644
--- a/server/model/servers/servers.model.js
+++ b/server/model/servers/servers.model.js
@@ -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),
}
})
diff --git a/server/permSync.js b/server/permSync.js
index cfa4a2a..375fe84 100644
--- a/server/permSync.js
+++ b/server/permSync.js
@@ -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,
diff --git a/server/swagger/doc.js b/server/swagger/doc.js
index 34a6097..ef221aa 100644
--- a/server/swagger/doc.js
+++ b/server/swagger/doc.js
@@ -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 },
diff --git a/server/test/_fakes.js b/server/test/_fakes.js
index 41567bf..b53ddf6 100644
--- a/server/test/_fakes.js
+++ b/server/test/_fakes.js
@@ -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
diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js
index 6099bda..bf7ed0a 100644
--- a/server/test/catalogue.test.js
+++ b/server/test/catalogue.test.js
@@ -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', () => {
diff --git a/server/test/links.test.js b/server/test/links.test.js
index 4d9ab51..db28ef1 100644
--- a/server/test/links.test.js
+++ b/server/test/links.test.js
@@ -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')
+})
diff --git a/server/test/planFixesStep2.test.js b/server/test/planFixesStep2.test.js
new file mode 100644
index 0000000..0f1d232
--- /dev/null
+++ b/server/test/planFixesStep2.test.js
@@ -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 — `:` — 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')
+})
diff --git a/server/titleSync.js b/server/titleSync.js
index 4d307be..28a9a81 100644
--- a/server/titleSync.js
+++ b/server/titleSync.js
@@ -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)
diff --git a/swagger-fragment.json b/swagger-fragment.json
index b984565..ab50e63 100644
--- a/swagger-fragment.json
+++ b/swagger-fragment.json
@@ -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": {