The data half of the extraction: 8 model directories, 13 utils, the shard
stream catalog and the 27-table schema fragment with its purge.
server/core.js is what makes the port a one-line import change per file rather
than a signature change per function. Ported code requires its dependencies at
file scope -- `const { query } = require('../../core')` -- which runs before
register() has been called and before any ctx exists. So every member is a
stable function that resolves ctx when CALLED, and nothing may be destructured
off ctx at init either, because core is free to hand over a getter.
Two helpers are vendored rather than taken from ctx, and the line between them
is the point. utils/excerpt.js is core's deriveExcerpt -- nine lines of pure
text handling. Core's sanitiser next to it was NOT copied: a second copy of a
security control diverges silently the moment either is fixed. announceLinks.js
vendors legError and articleUrl the same way, but baseUrl could not be: core's
reads APP_BASE_URL, and §2.7 forbids a module reading core's environment, so it
comes off ctx.site.baseUrl.
The schema fragment is core's 27 shard_*/uo_link_* statements, verbs CREATE,
ALTER and UPDATE only, every CREATE TABLE guarded. Two of its tables carry a
foreign key INTO users, which is allowed and is why the replay order matters --
core's schema is in place before this runs. The reverse never occurs and must
not: it would make core unable to boot without a module installed.
One real port bug caught by the integration run, not by tests: the atlas art
map resolved `../../../db/data`, which pointed at core's tree when this file
lived there and points outside server/ now. A path that happens to resolve is
exactly what survives a green suite, because the absent-file branch returns {}
and looks like the normal case.
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,
|
|
}
|