module-uo registers its first event actions: `uo.broadcast`, `uo.towncrier.post` and `uo.news.post`, plus the `uo.broadcasts` budget dimension and the three spawn-atlas option sources. The write plane they use has existed since protocol 2.1; what is new is the declaration that lets the event engine drive it unattended. Three things the tree corrected about the plan: - The plan's `on_failure: 'skip'` for `uo.broadcast` is already the default for `risk: 'notify'`, and `on_failure` is what happens AFTER the retries. The lever a module actually has is the failure envelope, so the action answers `retry: false` to everything — and every action declares `budgetMs: 15000`, because core's 10s default deadline fires before `uoLinkClient`'s 12s timeout and `classify()` answers `retry` for a timeout without asking the module. Without the budget the retry refusal is unreachable. - `reconcile()` needs no protocol work. A shard restart wipes both the crier lines and an event's news article, so `perform()` stamps the shard `bootId` into the resource payload and `reconcile()` reports in force exactly the rows whose stamp still matches — correct for the module's own trigger and for core's boot sweep alike. `shardIngest` fires `ctx.events.reconcile()` on a changed `bootId`, after `recordStatus` so the comparison reads the new boot. - Event articles post under `evt-<idempotencyKey>`, because `newsGump.js` uses the bare website post id and re-pushes that set on every reconnect. `ci/core-ref.json` moves to a website `edge` sha for the length of this workstream: `registerEventActions` exists only from MODULE_API 1.10.0, so under the old `main` pin the module does not load at all. Verified locally — the frozen-manifest rig passes against the new pin. Co-Authored-By: Claude <noreply@anthropic.com>
98 lines
4.4 KiB
JavaScript
98 lines
4.4 KiB
JavaScript
// A shard restart makes the event resource ledger a claim about a world that no
|
|
// longer exists (EVENTS.md §F, EVENTS_PLAN.md Phases 8 and 9).
|
|
//
|
|
// Core cannot notice that on its own — it has no concept of the game being up —
|
|
// so the module says when, and `server.hello` carrying a *changed* `bootId` is
|
|
// the only signal that distinguishes a shard restart from a sidecar reconnect.
|
|
// Getting that wrong in either direction is a real failure: never asking leaves
|
|
// core believing a ledger of things that are gone, and asking on every reconnect
|
|
// makes core orphan rows that are perfectly alive.
|
|
|
|
const { test, beforeEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const shardIngest = require('../utils/shardIngest')
|
|
|
|
function makeDeps() {
|
|
const order = []
|
|
const noop = async () => {}
|
|
return {
|
|
order,
|
|
shardEvents: { append: noop },
|
|
shardState: { clearOnline: async () => { order.push('clearOnline') }, upsertOnline: noop, setOffline: noop },
|
|
shardLinks: {},
|
|
shardMarket: {},
|
|
uoLinkConfig: { recordStatus: async (row) => { order.push(`recordStatus:${row.bootId}`) } },
|
|
settings: { getInstanceName: async () => 'Rig' },
|
|
broadcast: () => {},
|
|
pushDispatch: () => {},
|
|
engagement: () => {},
|
|
eventsReconcile: () => { order.push('reconcile') },
|
|
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
|
}
|
|
}
|
|
|
|
const hello = (bootId) => ({ kind: 'server.hello', t: '2026-09-04T10:00:00Z', shard: 'Rig', bootId })
|
|
|
|
beforeEach(() => shardIngest.reset())
|
|
|
|
test('the first hello of a process is not a restart', async () => {
|
|
// The website has just come up and the shard has not moved. Everything in the
|
|
// ledger is still in force, and asking would be core spending a round trip per
|
|
// module to be told so.
|
|
const deps = makeDeps()
|
|
await shardIngest.ingest(hello('boot-1'), deps)
|
|
assert.ok(!deps.order.includes('reconcile'))
|
|
})
|
|
|
|
test('a sidecar reconnect is not a restart either', async () => {
|
|
// `server.hello` is sent on EVERY reconnect, and the sidecar dropping its
|
|
// socket changes nothing in the game. Reconciling here would orphan every live
|
|
// row — the ledger would still be right and core would stop believing it.
|
|
const deps = makeDeps()
|
|
await shardIngest.ingest(hello('boot-1'), deps)
|
|
await shardIngest.ingest(hello('boot-1'), deps)
|
|
assert.ok(!deps.order.includes('reconcile'))
|
|
})
|
|
|
|
test('a changed bootId asks every module to reconcile its ledger', async () => {
|
|
const deps = makeDeps()
|
|
await shardIngest.ingest(hello('boot-1'), deps)
|
|
await shardIngest.ingest(hello('boot-2'), deps)
|
|
assert.equal(deps.order.filter((s) => s === 'reconcile').length, 1)
|
|
})
|
|
|
|
test('the reconcile happens AFTER the new bootId is recorded', async () => {
|
|
// The ordering is load-bearing rather than tidy. Every action decides what is
|
|
// still in force by comparing its stamp against the CURRENT boot id, which it
|
|
// reads back out of the row `recordStatus` writes. Asking first would compare
|
|
// every resource against the boot that has just ended — and every one of them
|
|
// would look live, which is the exact opposite of what a restart means.
|
|
const deps = makeDeps()
|
|
await shardIngest.ingest(hello('boot-1'), deps)
|
|
await shardIngest.ingest(hello('boot-2'), deps)
|
|
|
|
const recordedAt = deps.order.lastIndexOf('recordStatus:boot-2')
|
|
const askedAt = deps.order.indexOf('reconcile')
|
|
assert.ok(recordedAt >= 0 && askedAt >= 0)
|
|
assert.ok(askedAt > recordedAt, 'reconcile must not run before the new boot id is stored')
|
|
})
|
|
|
|
test('a hello with no bootId at all changes nothing', async () => {
|
|
// An older plugin, or a frame that lost the field. Not knowing which boot this
|
|
// is cannot be allowed to read as "a new one".
|
|
const deps = makeDeps()
|
|
await shardIngest.ingest(hello('boot-1'), deps)
|
|
await shardIngest.ingest({ kind: 'server.hello', t: '2026-09-04T10:00:00Z', shard: 'Rig' }, deps)
|
|
assert.ok(!deps.order.includes('reconcile'))
|
|
})
|
|
|
|
test('a reconcile that throws does not take the ingest down with it', async () => {
|
|
// Fire-and-forget by the contract, and the feed must survive one bad module:
|
|
// `ingest()` never throws, because a single event may not kill the socket.
|
|
const deps = makeDeps()
|
|
deps.eventsReconcile = () => { throw new Error('registry exploded') }
|
|
await shardIngest.ingest(hello('boot-1'), deps)
|
|
await assert.doesNotReject(() => shardIngest.ingest(hello('boot-2'), deps))
|
|
})
|