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

@@ -548,6 +548,48 @@ function walkLocations(node, facet, path, out) {
* A spawn with no `type` is randomised on every activation, which the site must
* render as "random" rather than as an empty type.
*/
/**
* Item types a shard uses as decoration, from one `Data/Decoration/*.cfg`.
*
* The format is a header line naming a type and an item id, optionally followed
* by a parenthesised property list, and then one `x y z` line per placement:
*
* ```
* # switch
* Static 0x108F
* 5552 1864 11
* ```
*
* Only the header matters here. The properties are decoration-authoring details
* (`Hue=`, `Facing=`, `Name=`) and the coordinates are where the SHARD put its
* own scenery, neither of which an event author is choosing — they pick a type
* and a place of their own.
*
* Returns one entry per header line, not per distinct type: the same type
* appears under many item ids (a `BarredMetalDoor` for each facing), and how
* often a shard reaches for something is worth keeping. `spawnAtlasSource`
* aggregates.
*/
function parseDecoration(source) {
const out = []
if (!source) return out
for (const raw of String(source).split(/\r?\n/)) {
const line = raw.trim()
// A coordinate line starts with a digit or a minus (z is often negative),
// so the type test is not merely "not a comment".
if (line === '' || line.startsWith('#')) continue
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s+0x([0-9A-Fa-f]+)/.exec(line)
if (!match) continue
out.push({ type: match[1], itemId: parseInt(match[2], 16) })
}
return out
}
function parseChampions(source) {
const root = parseXml(source)
const champions = []
@@ -675,6 +717,7 @@ module.exports = {
parseRegions,
parseLocations,
parseChampions,
parseDecoration,
buildPlacementIndex,
resolveRegion,
facetKey,

View File

@@ -23,6 +23,7 @@ const {
parseRegions,
parseLocations,
parseChampions,
parseDecoration,
buildPlacementIndex,
buildFacetIndex,
resolveFacetName,
@@ -35,6 +36,7 @@ const REGIONS_FILE = path.join('Data', 'Regions.xml')
const LOCATIONS_DIR = path.join('Data', 'Locations')
const SPAWNS_DIR = 'Spawns'
const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml')
const DECORATION_DIR = path.join('Data', 'Decoration')
class AtlasSourceError extends Error {
constructor(message, code) {
@@ -62,6 +64,33 @@ function listXml(dir) {
}
}
/**
* Every `.cfg` under `dir`, recursively, tree-relative and forward-slashed.
*
* Recursive because `Data/Decoration` nests two deep in places
* (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`, `Old/Britannia`) and a flat read
* would silently index a third of what the shard actually has — the failure
* mode being a dropdown that is quietly missing whole expansions rather than an
* error anyone would notice.
*/
function listCfgTree(dir, prefix = '') {
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
throw err
}
const out = []
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name
if (entry.isDirectory()) out.push(...listCfgTree(path.join(dir, entry.name), rel))
else if (entry.name.toLowerCase().endsWith('.cfg')) out.push(rel)
}
return out
}
function readIfPresent(file) {
try {
return fs.readFileSync(file, 'utf8')
@@ -111,6 +140,12 @@ function readSources(root) {
push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE))
// Optional, like the champion file: a shard that has stripped its decoration still
// has a usable atlas, it just cannot offer the decoration verb anything to place.
for (const rel of listCfgTree(path.join(root, DECORATION_DIR))) {
push(`Data/Decoration/${rel}`, path.join(root, DECORATION_DIR, rel))
}
return { files }
}
@@ -141,7 +176,7 @@ function hashSources(root) {
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
* seconds in the source, decided by `DelayInSec`).
*/
const PARSER_VERSION = 2
const PARSER_VERSION = 3
/** True when two source fingerprints describe the same tree. */
function sameSources(a, b) {
@@ -294,6 +329,25 @@ function buildAtlas(root, options = {}) {
}
})
// Decoration: what this shard already calls scenery, which is what makes the
// authoring dropdown the operator's own vocabulary rather than our taste.
const decorUses = new Map()
for (const file of files) {
if (!file.label.startsWith('Data/Decoration/')) continue
for (const entry of parseDecoration(file.text)) {
const seen = decorUses.get(entry.type)
if (seen) {
seen.uses += 1
continue
}
// The FIRST item id wins, and it is only a preview: a type appears under
// as many ids as it has facings or variants, and picking one arbitrarily
// is honest in a way that picking "the most used" would not be.
decorUses.set(entry.type, { type: entry.type, itemId: entry.itemId, uses: 1 })
}
}
const decor = [...decorUses.values()].sort((a, b) => a.type.localeCompare(b.type))
const creatures = aggregateCreatures(points)
const facets = [...new Set(points.map((point) => point.facet))].sort()
const unresolved = points.filter((point) => !point.region && !point.landmark).length
@@ -311,6 +365,7 @@ function buildAtlas(root, options = {}) {
regions: regions.length,
landmarks: landmarks.length,
champions: champions.length,
decor: decor.length,
unresolvedPoints: unresolved,
},
source,
@@ -321,6 +376,7 @@ function buildAtlas(root, options = {}) {
landmarks,
champions,
points,
decor,
}
}

View File

@@ -289,6 +289,36 @@ const closeParticipation = ({ runId, idempotencyKey }) =>
body: { idempotencyKey },
})
// ── The world verbs (protocol 7) ───────────────────────────────
//
// One endpoint for five author-facing verbs. `what` is the discriminator, and the
// per-verb fields ride alongside it: `type`/`name`/`hue`/`spread` for creatures and
// decoration, the three multipliers for a boss, `greeting`/`lines` for an oracle,
// `target`/`holdMs` for a gate.
//
// The shard registers every serial it places against the run and persists that
// registry, which is what makes `despawnWorld` below safe to point at a list of
// serials: it can only delete what the run actually owns.
const spawnWorld = (body) => call('/world', { method: 'POST', body })
// What the run still owns. A GET, unlike the participation snapshot: it carries no
// idempotency key and the shard answers it in one pass. An unknown run answers with an
// empty hand rather than a 404 — "owns nothing" and "never heard of it" are the same
// fact once the registry is the only record, and they stay the same fact across a
// restart, because the registry is written by the same world save as the objects it
// describes.
const ownedWorld = ({ runId }) => call(`/world/${encodeURIComponent(runId)}`)
// Give back what the run owns. No `serials` means everything, which is the call
// teardown makes. The reply splits three ways: `removed` was deleted, `gone` was
// already absent (a player killed it — an ordinary success), and `refused` was never
// this run's to delete.
const despawnWorld = ({ runId, serials, idempotencyKey }) =>
call(`/world/${encodeURIComponent(runId)}/despawn`, {
method: 'POST',
body: { serials, idempotencyKey },
})
// ── Help-page (support) queue commands (§6) ────────────────────────────────
const respondPage = (pageId, { message, close }) =>
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
@@ -328,6 +358,9 @@ module.exports = {
openParticipation,
snapshotParticipation,
closeParticipation,
spawnWorld,
ownedWorld,
despawnWorld,
adminKick,
adminBan,
adminUnban,