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:
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user