fix(rust): protocol 13 step 2 — expiry, plugin loads, the loading hold, NPC names, the link fleet (F2 F5 F6 F7 F8 F13 F14)
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:
@@ -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' }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user