From b10f11b057cc10f01f978ad4548d27677f8614dd Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 21:35:46 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(rust):=20protocol=2013=20step=202=20?= =?UTF-8?q?=E2=80=94=20expiry,=20plugin=20loads,=20the=20loading=20hold,?= =?UTF-8?q?=20NPC=20names,=20the=20link=20fleet=20(F2=20F5=20F6=20F7=20F8?= =?UTF-8?q?=20F13=20F14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module's half of PLAN_FIXES §6 step 2 (decisions D181-D185, docs#288). - F13/F14 (D170, D183): `world.expired`, recognisable from protocol 13 by its `what`, is handed to core as the resource the zone step ledgered (`world`, `:`) through ctx.events.expired, which records it `expired`. coreApi moves to ^1.11.0 (website#209). - F8 (D184): `plugin.loaded` / `plugin.unloaded` mark the permission sync dirty when the plugin added or removed permissions, so an unresolved grant lands on the next tick instead of the fifteen-minute audit. - Catalogue: plugin.loaded/unloaded, world.expired and lease.expired are staff kinds. The last two were never classified (default deny kept them off public pages); the test now covers every event kind through protocol 13. - F7: permission and title pushes hold while the stored hello says `worldReady: false` (a human's "sync now" does not); a failed or refused permission sync now logs at warn. - F2 (D185): the killfeed names an NPC attacker — a family (Scientist, Bandit guard, Bradley APC…) or the prefab without its variant digits (wolf2 → Wolf). - F5/F6: a link code is asked of the servers that minted one in the last six minutes first, then of the rest, each group in parallel; "unsure" only when one of the minting servers is unreachable. - D182: the admin server list carries the ZoneManager helper's state from the hello, and the servers page says what a missing or failed helper costs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/lib/feed.js | 4 +- client/src/lib/format.js | 52 ++++++++ client/src/routes/admin/ServerSettings.jsx | 19 +++ client/test/feed.test.js | 3 +- client/test/format.test.js | 27 +++- engagement-triggers.json | 2 +- module.json | 2 +- server/catalogue.js | 14 +- server/core.js | 8 ++ server/eventWorld.js | 26 ++++ server/ingest.js | 24 ++++ server/model/links/links.db.js | 21 +++ server/model/links/links.model.js | 50 ++++--- server/model/servers/servers.db.js | 51 ++++++- server/model/servers/servers.model.js | 3 + server/permSync.js | 13 ++ server/swagger/doc.js | 10 ++ server/test/_fakes.js | 4 +- server/test/catalogue.test.js | 14 +- server/test/links.test.js | 86 ++++++++++-- server/test/planFixesStep2.test.js | 146 +++++++++++++++++++++ server/titleSync.js | 3 + swagger-fragment.json | 73 +++++++++++ 23 files changed, 611 insertions(+), 44 deletions(-) create mode 100644 server/test/planFixesStep2.test.js 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": { -- 2.49.1 From 478c52e7a1a31fdb7618b387fb4d86924b183d58 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 22:08:39 -0500 Subject: [PATCH 2/2] ci(rust): pin frozen-manifest to website#209 while coreApi is ^1.11.0 The job cloned a MODULE_API 1.10.0 main and the loader refused the module ("needs core API ^1.11.0, this core is 1.10.0"), so it added no routes and failed by construction. Pinned to #209's head (cc1f49a), where the job's own steps pass: core alone 280 routes up to date, with this module 49 routes all documented. Re-pin to #209's main merge sha once it lands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- ci/core-ref.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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" } -- 2.49.1