feat(events): the five world verbs an author sees (Phase 12a)
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

`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
This commit is contained in:
2026-09-07 01:52:15 -05:00
parent c11c130438
commit 89be9d6a4e
11 changed files with 1484 additions and 19 deletions

View File

@@ -14,6 +14,7 @@ const {
buildFacetIndex,
resolveFacetName,
slugify,
parseDecoration,
decodeEntities,
} = require('../utils/spawnAtlasParse')
@@ -599,3 +600,49 @@ test('parsePoints: DelayInSec decides the unit, and both come out in seconds', (
assert.equal(seconds.minDelay, 5)
assert.equal(seconds.maxDelay, 10)
})
// ── parseDecoration (Phase 12a) ───────────────────────────────
test('parseDecoration: reads the type off each header and ignores the placements', () => {
const rows = parseDecoration(`# switch
Static 0x108F
5552 1864 11
5399 1875 17
# crate
LargeCrate 0x0E3C
5408 607 45
`)
assert.deepEqual(rows, [
{ type: 'Static', itemId: 0x108f },
{ type: 'LargeCrate', itemId: 0x0e3c },
])
})
test('parseDecoration: a parenthesised property list is not part of the type', () => {
// These are the shard's own decoration details — which way a door faces, what
// hue a banner is — and an event author is choosing neither. Only the class
// name is, because that is what the plugin constructs from.
assert.deepEqual(parseDecoration('AnkhNorth 0x0004 (Hue=0x47E)'), [
{ type: 'AnkhNorth', itemId: 4 },
])
assert.deepEqual(parseDecoration('ArmsAndWeaponsPrimer 0x0FEF (Name=a life of travel)'), [
{ type: 'ArmsAndWeaponsPrimer', itemId: 0x0fef },
])
})
test('parseDecoration: a negative z on a placement line is not mistaken for a type', () => {
// The real trap in this format: a coordinate line starts with a digit OR a
// minus, so "not a comment" is not the test. A z of -12 is ordinary in every
// dungeon file in the tree.
assert.deepEqual(parseDecoration(`Static 0x07A4
5558 1826 -12
-5 -5 -5
`), [{ type: 'Static', itemId: 0x07a4 }])
})
test('parseDecoration: empty, comment-only and absent input all yield nothing', () => {
assert.deepEqual(parseDecoration(''), [])
assert.deepEqual(parseDecoration(null), [])
assert.deepEqual(parseDecoration('# nothing but a comment\n\n'), [])
})