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

@@ -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 — `<serverId>:<id>` — 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')
})