- registerEventActions: rust.zone.open and rust.prefab.place, both reversible 'ledger' with revert() and reconcile(), budgetMs 15000 above the client's 12 s. A location is a monument (kind + instance, carrying its server) or raw coordinates, exactly one (D87, D93); bounds mirrored from the plugin so a bad step is refused on the form (D95); zone minutes required and held by the game (D96). - registerEventBudgets: rust.prefabs, rust.npcs and rust.zone.minutes, each beside the verb that spends it (D79, D89). - Option sources rust.options.monuments (live, searchable) and rust.options.prefabs (mirrored, answers with every server off), registered in the one batch core accepts alongside the lease sources. - Refs are <serverId>:<id>, since revert and reconcile get no params. The undo sends no idempotency key; a lost answer is reverted by key on every server. reconcile asks the plugin, and a server that cannot be asked keeps its rows. - The refresh's bootId/wipeId watch calls ctx.events.reconcile() on a restart or a wipe, never on a first sighting or a reconnect (§11.1). - The permission mirror keeps the plugin's new notLanded grants out of what it records as pushed, and the admin page says so (D85). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
255 lines
12 KiB
JavaScript
255 lines
12 KiB
JavaScript
// ── The registration handshake ────────────────────────────────────────────
|
||
//
|
||
// The one suite every module should have, whatever else it does. Core validates
|
||
// all of this at boot and refuses to mount a module that fails — so testing it
|
||
// here is the difference between finding out in half a second and finding out on
|
||
// an operator's install.
|
||
|
||
const test = require('node:test')
|
||
const assert = require('node:assert')
|
||
|
||
const { fakeCtx, fakeApi } = require('./_fakes')
|
||
const manifest = require('../../module.json')
|
||
|
||
/** A fresh registration. `core.js` holds a module-level `ctx`, so reset it. */
|
||
function register(ctx = fakeCtx()) {
|
||
require('../core')._reset()
|
||
const api = fakeApi()
|
||
require('../index')(ctx, api)
|
||
return { api, ctx }
|
||
}
|
||
|
||
test('registers exactly the mounts module.json declares', () => {
|
||
const { api } = register()
|
||
|
||
// Core compares these two and rejects a mismatch in EITHER direction: a prefix
|
||
// declared and never registered is as fatal as a route registered and never
|
||
// declared. Asserting against the manifest rather than against a literal is
|
||
// what keeps the test true after a prefix is added.
|
||
assert.deepStrictEqual(
|
||
Object.keys(api.record.routes).sort(),
|
||
Object.keys(manifest.mounts).sort(),
|
||
)
|
||
for (const [tier, prefixes] of Object.entries(manifest.mounts)) {
|
||
assert.deepStrictEqual(Object.keys(api.record.routes[tier]).sort(), [...prefixes].sort())
|
||
}
|
||
})
|
||
|
||
test('all three tiers are mounted (R14)', () => {
|
||
const { api } = register()
|
||
|
||
// Not the assertion above restated. That one says the manifest and the code
|
||
// agree; this one says WHICH answer they agree on, so that deleting a tier from
|
||
// both halves at once still fails. R14 puts this module on all three from the
|
||
// start precisely so that a later phase adding a player surface does not have
|
||
// to move an address clients are already calling.
|
||
assert.deepStrictEqual(Object.keys(api.record.routes).sort(), ['admin', 'player', 'public'])
|
||
for (const tier of ['admin', 'player', 'public']) {
|
||
assert.deepStrictEqual(Object.keys(api.record.routes[tier]), ['/rust'])
|
||
}
|
||
})
|
||
|
||
test('every registered mount is a real express router', () => {
|
||
const { api } = register()
|
||
for (const byPrefix of Object.values(api.record.routes)) {
|
||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||
assert.strictEqual(typeof router, 'function', `${prefix} is not a router`)
|
||
assert.ok(router.stack, `${prefix} has no middleware stack`)
|
||
}
|
||
}
|
||
})
|
||
|
||
test('prefixes are one segment, lowercase, no parameters', () => {
|
||
// §2.4's rule, restated where a typo is cheap to find. Core enforces it, and a
|
||
// module that fails it does not mount at all.
|
||
for (const prefixes of Object.values(manifest.mounts)) {
|
||
for (const prefix of prefixes) {
|
||
assert.match(prefix, /^\/[a-z0-9][a-z0-9-]*$/, `illegal mount prefix ${prefix}`)
|
||
}
|
||
}
|
||
})
|
||
|
||
test('registration touches no database and awaits nothing', () => {
|
||
const ctx = fakeCtx()
|
||
register(ctx)
|
||
|
||
// §2.2's first rule. Core requires `app.js` with the pool pointed at a dead
|
||
// port in two build tools, so a query here would hang both — and the symptom is
|
||
// a build that never finishes rather than an error naming this module.
|
||
assert.deepStrictEqual(ctx.db.query.calls, [])
|
||
})
|
||
|
||
test('registers both lifecycle hooks', () => {
|
||
const { api } = register()
|
||
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||
})
|
||
|
||
test('the manifest declares what the loader requires', () => {
|
||
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
|
||
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
|
||
assert.ok(manifest.coreApi, 'coreApi is required — it is the version check')
|
||
// Declaring a schema without a purge is refused: a module that can create
|
||
// tables and cannot drop them leaves an operator with orphaned data.
|
||
if (manifest.schema) assert.ok(manifest.purge, 'a schema fragment requires a purge file')
|
||
// The chunk must be in a SUBDIRECTORY — the directory it sits in is what core
|
||
// serves, so an entry in the module root would publish the whole module.
|
||
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
|
||
})
|
||
|
||
test('the manifest declares no extension slot it does not fill', () => {
|
||
const { api } = register()
|
||
|
||
// §11.3 of the plan reads `extensions` as "declared, and held against reality
|
||
// by the loader". Only the first half is true: the loader checks that a named
|
||
// slot EXISTS (`registries.hasSlot`) and never checks that the module went on
|
||
// to fill it — `checkDeclared` covers `mounts` alone. So a declaration with
|
||
// nothing behind it loads cleanly and means nothing, which is exactly why this
|
||
// module does not write one until it has an extension to register.
|
||
//
|
||
// The other half of that correction: `admin.users.detail` is the ONLY server
|
||
// slot core declares. `site.footer.status` is a CLIENT slot and is registered
|
||
// from the chunk — naming it here would fail the load with
|
||
// `unknown extension slot "site.footer.status"`.
|
||
const declared = manifest.extensions || []
|
||
const filled = api.record.extensions.map((e) => e.slot)
|
||
assert.deepStrictEqual([...declared].sort(), [...filled].sort())
|
||
})
|
||
|
||
test('the Team provider is registered, whole, with the page core links to (phase 9)', () => {
|
||
const { api } = register()
|
||
const provider = api.record.teamProvider
|
||
|
||
// The three required methods, the optional fourth (D48's roster audience),
|
||
// and the fifth member, which is DATA: core substitutes `{externalId}` and
|
||
// nothing else, so the page cannot be nested under its server (D56).
|
||
for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders', 'projectRoster']) {
|
||
assert.strictEqual(typeof provider[name], 'function', `${name} must be a function`)
|
||
}
|
||
assert.strictEqual(provider.pageUrlTemplate, '/rust/clans/{externalId}')
|
||
})
|
||
|
||
test('nothing is registered that has nothing behind it yet', () => {
|
||
const { api } = register()
|
||
|
||
// The phase-1 statement, written down so that removing it is deliberate. A
|
||
// declared trigger nothing emits and a declared slot nothing fills are both
|
||
// surfaces an operator can configure and then wait on — worse than an absent
|
||
// one, because the absence is visible. Each of these arrives with the phase
|
||
// that has something real to put in it, and this assertion is what that phase
|
||
// deletes. Phase 9 deleted the Team provider's line; phase 10 the four
|
||
// engagement lines, and the announce leg and post hook it deliberately did
|
||
// NOT register (D62) moved into the assertions below. Phase 12 deleted the
|
||
// leases and option sources, and kept budgets here on purpose (D79): a lease
|
||
// spends none, and a dimension nothing spends is a dial that does nothing.
|
||
// Phase 13a deleted the budgets and actions lines, and registered each budget
|
||
// beside the verb that spends it (below). The announce leg is 13b's.
|
||
assert.deepStrictEqual(api.record.legs, [])
|
||
assert.strictEqual(api.record.hooks.post, undefined)
|
||
})
|
||
|
||
test('the world verbs are registered, and every budget has a verb that spends it (phase 13a)', () => {
|
||
const { api } = register()
|
||
|
||
const actions = api.record.eventActions
|
||
const budgets = api.record.eventBudgets
|
||
assert.deepStrictEqual(actions.map((a) => a.id).sort(), ['rust.prefab.place', 'rust.zone.open'])
|
||
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), ['rust.npcs', 'rust.prefabs', 'rust.zone.minutes'])
|
||
|
||
// D79/D89: no dimension without a verb that spends it. A crate step and an
|
||
// NPC step are priced on different dials, and a zone on its minutes.
|
||
const spent = new Set()
|
||
const place = actions.find((a) => a.id === 'rust.prefab.place')
|
||
for (const cost of [
|
||
place.cost({ prefab: 'crate.elite', count: 3 }),
|
||
place.cost({ prefab: 'npc.scientist', count: 2 }),
|
||
actions.find((a) => a.id === 'rust.zone.open').cost({ minutes: 90 }),
|
||
]) {
|
||
for (const id of Object.keys(cost)) spent.add(id)
|
||
}
|
||
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
|
||
|
||
for (const a of actions) {
|
||
assert.strictEqual(a.reversible, 'ledger')
|
||
assert.strictEqual(typeof a.revert, 'function')
|
||
assert.strictEqual(typeof a.reconcile, 'function')
|
||
// Every param source is one this module registers.
|
||
const sources = new Set(api.record.eventOptionSources.map((s) => s.id))
|
||
for (const p of a.params) if (p.source) assert.ok(sources.has(p.source), `${a.id}.${p.name} names ${p.source}`)
|
||
}
|
||
})
|
||
|
||
test('the leases and their option sources are registered, every source a lease reads (phase 12)', () => {
|
||
const { api } = register()
|
||
|
||
const leases = api.record.eventLeases
|
||
const sources = api.record.eventOptionSources
|
||
assert.deepStrictEqual(
|
||
leases.map((l) => l.id).sort(),
|
||
['rust.decay.scale', 'rust.group.permission', 'rust.population', 'rust.spawn.scalar'],
|
||
)
|
||
|
||
// D78: exactly the sources the leases' targets name, plus those the world
|
||
// verbs' params name (phase 13a) — none without a reader.
|
||
const read = new Set(leases.map((l) => l.target.source))
|
||
for (const a of api.record.eventActions) for (const p of a.params) if (p.source) read.add(p.source)
|
||
assert.deepStrictEqual([...read].sort(), sources.map((s) => s.id).sort())
|
||
|
||
for (const l of leases) {
|
||
// D73: every lease is targeted, because the target is what names the server.
|
||
assert.ok(l.target && l.target.label && l.target.example, `${l.id} has no target`)
|
||
for (const fn of ['read', 'apply', 'restore', 'inForce']) {
|
||
assert.strictEqual(typeof l[fn], 'function', `${l.id} has no ${fn}()`)
|
||
}
|
||
}
|
||
})
|
||
|
||
test('the engagement set is registered as one decision (phase 10, R7)', () => {
|
||
const { api } = register()
|
||
|
||
const triggers = api.record.triggers
|
||
const streams = api.record.streams
|
||
assert.ok(Array.isArray(triggers) && triggers.length > 0)
|
||
assert.ok(Array.isArray(api.record.audiences) && api.record.audiences.length === 3)
|
||
assert.ok(api.record.engagementSeeds && Array.isArray(api.record.engagementSeeds.ruleGroups))
|
||
|
||
// A stream is a toggle for a trigger; one with no trigger behind it is a
|
||
// toggle nothing can ever fire (D65, one namespace across both facets).
|
||
const triggerIds = new Set(triggers.map((t) => t.id))
|
||
for (const s of streams) assert.ok(triggerIds.has(s.id), `stream ${s.id} has no trigger`)
|
||
assert.deepStrictEqual(
|
||
streams.map((s) => s.id).sort(),
|
||
['rust.base.destroyed', 'rust.server.offline', 'rust.server.online', 'rust.wipe.started'],
|
||
)
|
||
})
|
||
|
||
test('the module’s protocol version agrees with the manifest it ships beside', () => {
|
||
const sidecar = require('../sidecarClient')
|
||
|
||
// The wire version is declared in three repos — here, `PROTOCOL_VERSION` in
|
||
// the sidecar, and `overlay.toml` in the plugin overlay — and nothing in one
|
||
// repo can check the other two. What CAN be checked is that this repo says one
|
||
// thing: the number the client sends is the number an operator sees on a
|
||
// freshly created server row, so a bump that edits one and not the other
|
||
// configures every new server against a version the client does not speak.
|
||
assert.strictEqual(typeof sidecar.PROTOCOL_VERSION, 'number')
|
||
assert.ok(sidecar.PROTOCOL_VERSION >= 1)
|
||
})
|
||
|
||
test('an identity capability is declared, and it is the module id (phase 5, D16)', () => {
|
||
// Core flattens every started module's capabilities into ONE list, so a client
|
||
// asking "is this module installed" needs a string only this module can
|
||
// declare. `servers` is not that string — it names a surface, and another
|
||
// module could name it too — which is the whole reason this one exists beside
|
||
// the five surface words.
|
||
//
|
||
// It is asserted against `manifest.id` rather than against the literal "rust"
|
||
// so that the two cannot drift: the day the id changes, the capability a
|
||
// client gates a whole navigation group on has to change with it.
|
||
assert.ok(
|
||
manifest.capabilities.includes(manifest.id),
|
||
`module.json must declare "${manifest.id}" as a capability — it is the only string a client can` +
|
||
' use to tell this module apart from any other, and the Android app gates its Rust rows on it',
|
||
)
|
||
})
|