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