fix(rust): protocol 13 step 2 — expiry, plugin loads, the loading hold, NPC names, the link fleet (F2 F5 F6 F7 F8 F13 F14)
Some checks failed
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Failing after 56s
PR Checks / server-tests (pull_request) Successful in 7m45s

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`, `<serverId>:<id>`) 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-26 21:35:46 -05:00
parent 80c05a3c1e
commit b10f11b057
23 changed files with 611 additions and 44 deletions

View File

@@ -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,

View File

@@ -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' }
}

View File

@@ -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)
}
/**

View File

@@ -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),
}
})