Files
Module-uo/server/utils/spawnAtlasSource.js
wtclaude 8def6e19f4
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 58s
PR Checks / server-tests (pull_request) Successful in 8m27s
fix(events): the atlas import, and a teardown that was a no-op (Phase 16a)
Two defects the acceptance walk found in shipped code, both invisible to the
suites that were green on either side of them.

**The spawn atlas cannot import on a stock ServUO tree.** `spawnAtlasSource.js`
dedupes decoration types with a case-SENSITIVE `Map`, but `shard_decor_types.type`
is a PRIMARY KEY under MariaDB's default `..._ai_ci` collation, which folds case.
Stock 57.4's own `Data/Decoration/` names four types under two spellings each
(CheckerBoard/Checkerboard, ChessBoard/Chessboard, MetalChest/Metalchest,
SpinningWheelEastAddon/SpinningwheelEastAddon), and in every pair exactly one is a
real class. The second row raised `1062 Duplicate entry` and took the WHOLE import
transaction down. The blast radius is not decoration: with no atlas, EVERY option
source answers empty and no Phase 12 world verb can be authored at all.

The shard end already knew — `BridgeWorld.cs` resolves a decor type with
`FindTypeByName(name, ignoreCase: true)` and its comment says the atlas and the
decoration files disagree about casing. Folding here is the two ends agreeing.

**Teardown of every world verb was a no-op that reported success.** `revertOwned`
forwarded core's `idempotencyKey` as the despawn's OWN key — and core's key is the
step's, the one `placeOwned` spawned under. `BridgeIdempotency` keys on the key
alone, so the despawn was taken for a repeat and answered with the SPAWN's stored
reply; `OnDespawn` never ran. Core read `ok` with no `refused` and marked every
row `reverted` while the shard still held every object.

Measured on the rig: ledger `world | reverted | 21`, shard `world.owned` 21 alive
with `pruned: 0`, and the identical despawn re-sent with a fresh key removed all
21. It affected all five world verbs, so an invasion's creatures, boss, oracle,
gate and decoration stayed in the world for ever while the console reported a
clean teardown.

`MODULE_API.md` says what that key is for and it is not this: it identifies a
dispatch core never learned the outcome of, so the module can ask about it. No key
is needed on a despawn — a repeat answers `gone`, which both ends already treat as
success — and dropping it also makes the documented empty-`resources` case work,
since no serials means "everything this run owns". The parameter is removed from
`despawnWorld`'s signature rather than left optional.

Both fixes are verified end to end against a real ServUO + sidecar + website rig:
the import now yields 309 decor types (was failing at 313 with 4 collisions),
6,455 spawn points, 800 creatures, 558 landmarks; and a full four-phase run's
teardown left the shard owning 0 objects.

Each new test was confirmed to FAIL without its fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 08:27:50 -05:00

416 lines
15 KiB
JavaScript

// Spawn atlas — the filesystem layer over a ServUO tree.
//
// `spawnAtlasParse.js` holds the pure parsers; this module is the only thing
// that touches a ServUO tree on disk, and it is shared by both callers:
//
// - the server, which refreshes the atlas on boot (`shardAtlas.model.js`)
// - the CLI (`scripts/importSpawnAtlas.js`)
//
// The shard's own files are the single source of truth. Nothing is precomputed
// and committed, because a shard's maps change over its lifetime — facets get
// added, replaced or renamed — and a snapshot in the repo would silently go
// stale against the world players actually see.
//
// Reading and hashing the whole tree costs ~120 ms and a full parse ~400 ms, so
// the boot path hashes first and only parses when something actually changed.
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const {
parsePoints,
parseRegions,
parseLocations,
parseChampions,
parseDecoration,
buildPlacementIndex,
buildFacetIndex,
resolveFacetName,
resolveRegion,
facetKey,
slugify,
} = require('./spawnAtlasParse')
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) {
super(message)
this.name = 'AtlasSourceError'
this.code = code
}
}
// ── Reading ────────────────────────────────────────────────────────────────
function sha256(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
}
function listXml(dir) {
try {
return fs
.readdirSync(dir)
.filter((name) => name.toLowerCase().endsWith('.xml'))
.sort()
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
throw err
}
}
/**
* 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')
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null
throw err
}
}
/**
* Read every atlas source file under `root`.
*
* Returns `{ files: [{ label, text, sha256, bytes }] }`, labels being
* tree-relative and forward-slashed so a hash map compares equal across
* platforms — the same tree read on Windows and Linux must produce the same
* fingerprint or every boot would look like a change.
*/
function readSources(root) {
if (!root || String(root).trim() === '') {
throw new AtlasSourceError('No ServUO path configured', 'NO_PATH')
}
if (!fs.existsSync(root)) {
throw new AtlasSourceError(`ServUO path does not exist: ${root}`, 'NOT_FOUND')
}
const files = []
const push = (label, file) => {
const text = readIfPresent(file)
if (text === null) return false
files.push({ label, text, sha256: sha256(text), bytes: Buffer.byteLength(text, 'utf8') })
return true
}
if (!push('Data/Regions.xml', path.join(root, REGIONS_FILE))) {
throw new AtlasSourceError(`Missing required file: ${REGIONS_FILE}`, 'NO_REGIONS')
}
for (const name of listXml(path.join(root, LOCATIONS_DIR))) {
push(`Data/Locations/${name}`, path.join(root, LOCATIONS_DIR, name))
}
const spawnFiles = listXml(path.join(root, SPAWNS_DIR))
if (spawnFiles.length === 0) {
throw new AtlasSourceError(`No spawn files found in ${SPAWNS_DIR}`, 'NO_SPAWNS')
}
for (const name of spawnFiles) push(`Spawns/${name}`, path.join(root, SPAWNS_DIR, name))
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 }
}
/**
* A fingerprint of the tree: `{ "<label>": "<sha256>" }`.
*
* The boot path compares this against what was last imported and skips the
* parse entirely when it matches, which is the normal case on every restart
* that did not follow a map update.
*/
function hashSources(root) {
const { files } = readSources(root)
const hashes = {}
for (const file of files) hashes[file.label] = file.sha256
return hashes
}
/**
* Bumped whenever the parser produces DIFFERENT data from IDENTICAL source
* files — a fixed misreading, a new field, a changed unit.
*
* Without it the hash gate is a trap: an install whose tree has not changed
* would keep serving what an older parser derived, indefinitely, because the
* only thing the boot path compares is the tree. The version is stored beside
* the source hashes and a mismatch counts as drift, so a deploy that corrects
* the parse actually reaches the data.
*
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
* seconds in the source, decided by `DelayInSec`).
* 3 — the decoration index, from `Data/Decoration/**\/*.cfg`.
* 4 — a spawn point keeps its `UniqueId`, which is what a property lease
* targets (Phase 12b). The bump is what re-reads a tree the boot path
* would otherwise skip on an unchanged hash — the source files have not
* changed, only what is kept from them.
*/
const PARSER_VERSION = 4
/** True when two source fingerprints describe the same tree. */
function sameSources(a, b) {
if (!a || !b) return false
const aKeys = Object.keys(a).sort()
const bKeys = Object.keys(b).sort()
if (aKeys.length !== bKeys.length) return false
return aKeys.every((key, i) => key === bKeys[i] && a[key] === b[key])
}
// ── Aggregation ────────────────────────────────────────────────────────────
/**
* Choose one display spelling for a creature.
*
* Spawn files are not consistent about case — the same creature is `Lizardman`
* in one file and `lizardman` in another. Slugging collapses them correctly, but
* the display name would otherwise depend on file read order. Most frequent
* spelling wins; ties break toward more capitals, then alphabetically.
*/
function displayName(spellings) {
const capitals = (value) => (value.match(/[A-Z]/g) || []).length
return [...spellings.entries()].sort((a, b) => {
if (b[1] !== a[1]) return b[1] - a[1]
const caps = capitals(b[0]) - capitals(a[0])
if (caps !== 0) return caps
return a[0].localeCompare(b[0])
})[0][0]
}
/**
* Roll spawn points up into per-type creature rows.
*
* `total` is the sum of each type's own max across every point that spawns it —
* how many of this creature the world holds at once. `facets` is a per-facet
* point count, so "where does this live" answers without touching the points.
*/
function aggregateCreatures(points) {
const creatures = new Map()
for (const point of points) {
for (const entry of point.types) {
const slug = slugify(entry.type)
if (slug === '') continue
let creature = creatures.get(slug)
if (!creature) {
creature = { slug, name: '', total: 0, points: 0, facets: {}, spellings: new Map() }
creatures.set(slug, creature)
}
creature.total += entry.max
creature.points += 1
creature.facets[point.facet] = (creature.facets[point.facet] || 0) + 1
creature.spellings.set(entry.type, (creature.spellings.get(entry.type) || 0) + 1)
}
}
return [...creatures.values()]
.map(({ spellings, ...creature }) => ({ ...creature, name: displayName(spellings) }))
.sort((a, b) => a.slug.localeCompare(b.slug))
}
// ── Build ──────────────────────────────────────────────────────────────────
/**
* Parse a ServUO tree into the full atlas.
*
* Pure with respect to the database — it reads files and returns data; nothing
* here writes. `shardAtlas.model.js` decides what to do with the result.
*/
function buildAtlas(root, options = {}) {
const { files } = readSources(root)
const byLabel = new Map(files.map((file) => [file.label, file]))
const source = {}
for (const file of files) source[file.label] = { bytes: file.bytes, sha256: file.sha256 }
const regions = parseRegions(byLabel.get('Data/Regions.xml').text)
const rawLandmarks = []
for (const file of files) {
if (!file.label.startsWith('Data/Locations/')) continue
const basename = path.basename(file.label, '.xml')
rawLandmarks.push(...parseLocations(file.text, basename))
}
const rawPoints = []
for (const file of files) {
if (!file.label.startsWith('Spawns/')) continue
rawPoints.push(...parsePoints(file.text))
}
// The facet set is whatever THIS tree declares — never a built-in list. A
// shard may add facets, replace them outright, or rename them when its maps
// are updated, and the atlas has to follow without a code change. Spawn
// records and region definitions are the authority, because those are the
// names everything else is keyed on.
const facetIndex = buildFacetIndex([
...rawPoints.map((point) => point.facet),
...regions.map((region) => region.facet),
])
// Landmark facets are then matched against that set, which is what absorbs the
// `Ter Mur` / `Tokuno Islands` spelling drift between Locations and <Map>.
const landmarks = rawLandmarks.map(({ facetLabel, ...landmark }) => {
const fromFile = resolveFacetName(landmark.facet, facetIndex)
const matchedFile = facetIndex.has(facetKey(fromFile))
const resolved = matchedFile ? fromFile : resolveFacetName(facetLabel, facetIndex)
return { ...landmark, facet: resolved || landmark.facet }
})
const placement = buildPlacementIndex(regions, landmarks)
const resolveOpts = options.landmarkRadius ? { landmarkRadius: options.landmarkRadius } : {}
const disabled = rawPoints.filter((point) => !point.running).length
const points = rawPoints
// A spawner switched off in-world produces nothing; advertising it would be
// a straight lie to a player planning a hunt.
.filter((point) => point.running)
// A spawner with no types is a placeholder — nothing to show.
.filter((point) => point.types.length > 0)
.map((point) => {
const place = resolveRegion(point.x, point.y, point.facet, placement, resolveOpts)
return {
name: point.name,
facet: point.facet,
x: point.x,
y: point.y,
width: point.width,
height: point.height,
range: point.range,
maxCount: point.maxCount,
minDelay: point.minDelay,
maxDelay: point.maxDelay,
todStart: point.todStart,
todEnd: point.todEnd,
todMode: point.todMode,
region: place.region,
landmark: place.landmark,
label: place.label,
types: point.types,
}
})
const championsFile = byLabel.get('Config/ChampionSpawns.xml')
const champions = (championsFile ? parseChampions(championsFile.text) : []).map((champ) => {
const facet = resolveFacetName(champ.facet, facetIndex) || champ.facet
return {
...champ,
facet,
slug: slugify(`${facet}-${champ.name}`),
label: resolveRegion(champ.x, champ.y, facet, placement, resolveOpts).label,
}
})
// Decoration: what this shard already calls scenery, which is what makes the
// authoring dropdown the operator's own vocabulary rather than our taste.
//
// **Keyed case-INSENSITIVELY, because the decoration files disagree with
// themselves about casing.** Stock 57.4 names four types under two spellings
// each — `CheckerBoard`/`Checkerboard`, `ChessBoard`/`Chessboard`,
// `MetalChest`/`Metalchest`, `SpinningWheelEastAddon`/`SpinningwheelEastAddon`
// — and in every pair exactly one is a real class, the other a mis-cased line
// the shard's own loader resolves anyway. A case-sensitive Map keeps both, and
// then `shard_decor_types.type` (a PRIMARY KEY under MariaDB's default
// `..._ai_ci` collation, which folds case) rejects the second row and takes the
// WHOLE import transaction down with it. That is not a decoration bug: with no
// atlas, every option source answers empty and no world verb can be authored at
// all. The shard end of this feature already knew — `BridgeWorld.cs` resolves a
// decor type with `FindTypeByName(name, ignoreCase: true)` and says why — so
// folding here is the two ends agreeing rather than a new rule.
//
// The first spelling seen wins, exactly as the first item id does. Either
// spelling resolves on the shard, so which one survives is cosmetic.
const decorUses = new Map()
for (const file of files) {
if (!file.label.startsWith('Data/Decoration/')) continue
for (const entry of parseDecoration(file.text)) {
const key = entry.type.toLowerCase()
const seen = decorUses.get(key)
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(key, { 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
return {
meta: {
generatedAt: new Date().toISOString(),
parserVersion: PARSER_VERSION,
landmarkRadius: options.landmarkRadius ?? undefined,
counts: {
facets: facets.length,
points: points.length,
pointsDisabled: disabled,
creatures: creatures.length,
regions: regions.length,
landmarks: landmarks.length,
champions: champions.length,
decor: decor.length,
unresolvedPoints: unresolved,
},
source,
},
facets,
creatures,
regions,
landmarks,
champions,
points,
decor,
}
}
module.exports = {
AtlasSourceError,
PARSER_VERSION,
readSources,
hashSources,
sameSources,
buildAtlas,
aggregateCreatures,
displayName,
}