THROWAWAY BRANCH — evidence for the Phase 1 contract, never merged. See
modules/uo/SPIKE.md and docs/website/MODULE_API.md Part 7.
The six public spawn-atlas routes now live in modules/uo/, reached only through
the ctx/register surface, with the client half loading as a prebuilt ESM chunk.
All three exit criteria met:
• zero internal-file imports from the module into core; the built chunk has
zero bare import specifiers and bundles no React
• routes.manifest.json AND routes.guards.json are byte-identical
• /uo/atlas renders from /modules/uo/entry.js under script-src 'self' with
zero CSP violation reports
729 core tests and 81 module tests pass. Verified end to end against the real
database: the schema fragment replays after core's, onBoot runs the atlas
refresh, and the six API URLs answer unchanged.
Two things the spike changed in the contract:
• ctx.express / ctx.validator. A module lives outside server/, so Node never
reaches server/node_modules and require('express') fails outright — the
server-side twin of the one-React rule, which §2.6 had only for the client.
• window.__rg.jsxRuntime, so a module can build with the automatic JSX
runtime its tooling already assumes rather than being forced to classic.
And it confirmed §6.1 empirically: regenerating the OpenAPI spec silently
deleted all 361 lines of the atlas paths with "Swagger-autogen: Success", while
the route manifest kept all six in the same run. That is exactly the
static-analysis-vs-runtime split the fragment merge exists to prevent.
Co-Authored-By: Claude <noreply@anthropic.com>
337 lines
12 KiB
JavaScript
337 lines
12 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,
|
|
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')
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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))
|
|
|
|
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`).
|
|
*/
|
|
const PARSER_VERSION = 2
|
|
|
|
/** 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,
|
|
}
|
|
})
|
|
|
|
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,
|
|
unresolvedPoints: unresolved,
|
|
},
|
|
source,
|
|
},
|
|
facets,
|
|
creatures,
|
|
regions,
|
|
landmarks,
|
|
champions,
|
|
points,
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
AtlasSourceError,
|
|
PARSER_VERSION,
|
|
readSources,
|
|
hashSources,
|
|
sameSources,
|
|
buildAtlas,
|
|
aggregateCreatures,
|
|
displayName,
|
|
}
|