Files
Module-uo/server/test/entry.test.js
wtclaude 89be9d6a4e
All checks were successful
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 30s
PR Checks / frozen-manifest (pull_request) Successful in 43s
feat(events): the five world verbs an author sees (Phase 12a)
`uo.creature.spawn`, `uo.boss.spawn`, `uo.npc.place`, `uo.gate.open` and
`uo.decor.place`, over protocol 7's one command family. Five actions because
five is what an author has; one `perform`/`revert`/`reconcile` because on the
wire they are one thing.

Five new budget dimensions -- `uo.creatures`, `uo.bosses`, `uo.npcs`,
`uo.decor`, `uo.gate.minutes` -- all declared by THIS MODULE (org lead,
2026-09-07). Core meters whatever dimensions a module declares and holds no UO
knowledge, which is the whole of what MODULE_API means by game-agnostic. A gate
is priced in minutes rather than in gates: one standing all day and twelve
standing five minutes each are not the same imposition on a world.

`reconcile()` ASKS the shard, and is the one place in this file that must not
use `reconcileByBootId`. A crier line lives in shard memory, so a changed
`bootId` IS proof it is gone; a spawned creature is in the world SAVE and
survives the restart the stamp would report it lost by. Anything `world.owned`
does not list is gone -- safe only because the shard's registry and the objects
it describes are written by the same save.

Teardown reports `gone` as success and `refused` as failed. A creature a player
killed is the point of having spawned it, and a run that ended `incomplete`
because its event worked would be a report nobody could read. `refused` means
the shard denies this run ever owned the serial, so nothing will delete it
through this path and the row must land unresolved with a reason.

The atlas gains a decoration index, parsed from the shard's own
`Data/Decoration/**/*.cfg` -- 120 files, read RECURSIVELY because the real tree
nests two deep and a flat read would index a fraction of it while looking like
it worked. 313 distinct types. The decor verb resolves through it rather than
passing a type name through, which keeps the verb to this shard's own decoration
vocabulary AND fetches the item id: `Static` alone accounts for 5031 placements
under 1992 different graphics, so a bare type name places the wrong thing.
`PARSER_VERSION` -> 3, so an already-imported tree is re-read.

Two things the build found in code that had already shipped:

`uo.options.creatures` answered with the atlas SLUG -- unique, stable, and not
something the shard can build, because a creature is constructed from a ServUO
class name and `orc-brute` is not one. The atlas's `name` is the raw type token
from the spawn files, so the fix was to stop discarding the half that works.
Safe to change because Phase 12a is the source's first consumer; the file said
so when it shipped.

`uo.npc.place` could not be performed from its own required params. Both ends
refuse an oracle with neither a greeting nor a line, but both fields were
optional -- so a cross-field rule sat where no authoring form could render it.
The greeting is now `required`, which says the same thing in the contract
itself. Caught by the existing dry-run sweep, which is a better argument for
that test than anything written about it when it shipped.

605 tests pass. `swagger-fragment.json` is stale on `edge` already and this
phase adds no route, so it is left alone.

Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 01:52:15 -05:00

192 lines
8.1 KiB
JavaScript

// The entry point's contract with core (MODULE_API.md §2.2).
//
// Slice 0 registers nothing, so there is very little behaviour to assert — and
// the rules that DO apply are the ones that would otherwise be discovered on an
// operator's install: registering synchronously, never awaiting, never touching
// a database, never mutating what it was handed. Those hold for every slice
// after this one too, which is why they are tested against the entry point
// rather than against whatever it happens to register today.
const test = require('node:test')
const assert = require('node:assert')
const register = require('../index')
const { fakeCtx, fakeApi } = require('./_fakes')
test('exports a single register function', () => {
assert.strictEqual(typeof register, 'function')
})
test('registers synchronously and returns nothing to await', () => {
const result = register(fakeCtx(), fakeApi())
// Not `assert.strictEqual(result, undefined)` alone: a module that returned a
// promise would be a module whose registration core silently never waits for.
assert.ok(!result || typeof result.then !== 'function', 'register() must not return a thenable')
})
test('touches no database at registration time', () => {
const ctx = fakeCtx()
register(ctx, fakeApi())
assert.deepStrictEqual(ctx.db.query.calls, [], 'register() queried the database')
})
test('registers exactly what module.json declares', () => {
// The loader compares these two in BOTH directions and rejects a mismatch
// either way, so a prefix registered without being declared and a prefix
// declared without being registered are both module-breaking. Asserting
// against the manifest rather than a literal list means the test cannot drift
// from the file core actually reads.
const api = fakeApi()
register(fakeCtx(), api)
const manifest = require('../../module.json')
for (const tier of ['public', 'admin', 'player']) {
assert.deepStrictEqual(
Object.keys(api.record.routes[tier]).sort(),
[...manifest.mounts[tier]].sort(),
`${tier} mounts disagree with module.json`,
)
for (const router of Object.values(api.record.routes[tier])) {
assert.strictEqual(typeof router, 'function', `${tier} router is not a router`)
}
}
assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions)
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['towncrier'])
// The event contract (MODULE_API 1.10.0, EVENTS_PLAN.md Phase 9). Asserted
// here rather than only in the actions' own suite because registration is the
// half that can silently not happen: a declaration file nothing calls is a
// deployment whose event authors simply never see the verbs, with no error
// anywhere.
assert.deepStrictEqual(
api.record.eventActions.map((a) => a.id).sort(),
[
'uo.boss.spawn',
'uo.broadcast',
'uo.creature.spawn',
'uo.decor.place',
'uo.gate.open',
'uo.news.post',
'uo.npc.place',
'uo.participation.collect',
'uo.participation.open',
'uo.towncrier.post',
],
)
// Phase 12a's five are all the MODULE's dimensions, never core's (org lead,
// 2026-09-07): core meters whatever a module declares and knows nothing about
// Ultima Online. Asserted as an ordered list because the order is the order
// an author meets them in a cap meter.
assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), [
'uo.broadcasts',
'uo.creatures',
'uo.bosses',
'uo.npcs',
'uo.decor',
'uo.gate.minutes',
])
// Phase 11b. One key, because ServUO has almost no others: of the 158 non-Bridge
// `Config.Get` call sites in `Scripts/`, roughly eight are read live, and a lease
// on any of the rest applies cleanly and does nothing.
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), ['uo.playercaps.skillcap'])
assert.deepStrictEqual(
api.record.eventOptionSources.map((s) => s.id).sort(),
[
'uo.options.creatures',
'uo.options.decor',
'uo.options.landmarks',
'uo.options.regions',
],
)
assert.ok(api.record.streams.length > 0)
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
})
test('every registered stream is namespaced or grandfathered', () => {
// Core rejects a stream id that carries neither this module's prefix nor a
// §6.5 grandfathered name. The seven legacy ids are stored in
// `notification_subs` and read by a shipped Android client, so they are
// allowlisted rather than renamed — but a NEW id must be namespaced, and this
// is where that is caught before an install refuses to load the module.
// Copied from core's loader (LEGACY_STREAM_IDS), deliberately rather than
// imported — this repo has no dependency on core's source, and a copy that
// drifts is caught by the module failing to load, which is the failure this
// test exists to move earlier.
const GRANDFATHERED = new Set([
'server.status', 'idoc.warning', 'champ.start', 'governor.election',
'vendor.sale', 'house.idoc', 'account.login',
])
const api = fakeApi()
register(fakeCtx(), api)
for (const s of api.record.streams) {
assert.ok(
s.id.startsWith('uo.') || GRANDFATHERED.has(s.id),
`stream "${s.id}" is neither namespaced "uo." nor grandfathered`,
)
assert.ok(s.label && s.description, `stream "${s.id}" is missing its wire shape`)
assert.strictEqual(typeof s.personal, 'boolean')
assert.strictEqual(typeof s.requiresLinkedAccount, 'boolean')
}
})
test('registers a Team provider with all three methods', () => {
// Core requires all three: a provider that could list Teams but not their
// members would leave core holding Teams it can never populate, which is not
// the same as a call that fails. Asserted here so a refactor that drops one
// fails in this suite rather than at load on an operator's install.
const api = fakeApi()
register(fakeCtx(), api)
const provider = api.record.teamProvider
assert.ok(provider, 'a UO guild is a Team; something has to answer for them')
for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
assert.strictEqual(typeof provider[method], 'function', `${method} is missing`)
}
})
test('registration does not call the provider, or touch the database', async () => {
// register() runs while core's app.js is still being required, with the pool
// pointed at a dead port — routeManifest.js and swagger.js both depend on that.
// Registration is a CLAIM; core does not ask anything until it reconciles,
// which is after onBoot.
const ctx = fakeCtx()
let queried = false
const frozen = Object.freeze({ ...ctx, db: Object.freeze({ query: async () => { queried = true; return [] } }) })
const api = fakeApi()
register(frozen, api)
assert.equal(queried, false, 'a query at registration time would hang the manifest and the spec build')
})
test('takes a frozen ctx and does not try to write to it', () => {
const ctx = fakeCtx()
assert.ok(Object.isFrozen(ctx))
// Core freezes one level deep; a module that assigned to ctx would throw here
// in strict mode and fail silently outside it. Either way it must not.
assert.doesNotThrow(() => register(ctx, fakeApi()))
})
test('logs through ctx.log, never through console', () => {
const ctx = fakeCtx()
register(ctx, fakeApi())
assert.strictEqual(ctx.logs.length, 1, 'expected exactly one logger to be taken')
const { log } = ctx.logs[0]
assert.strictEqual(log.info.calls.length, 1)
assert.strictEqual(log.info.calls[0][0], 'registered')
})
test('carries no hidden state between calls', () => {
// Core calls register() exactly once, and the `once()` guard that enforces
// that lives in core's `api` — not here. What this asserts is the module's
// own half of it: registering into a second `api` produces the same result as
// the first, so nothing is memoised at file scope where a re-register would
// silently do less than it appears to.
const first = fakeApi()
const second = fakeApi()
register(fakeCtx(), first)
register(fakeCtx(), second)
assert.deepStrictEqual(second.record, first.record)
})