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
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user