Five targeted leases over two planes, the item grant, the world save, and the atlas work the spawner dropdown needed. FIVE LEASES, ONE FACTORY `uo.spawner.maxcount`, `.mindelay`, `.maxdelay`, `.running` and `uo.seasonal.status`. The four callables differ only in which key they name, so they are built rather than repeated: five copies would be five chances for one of them to forget the drift check, which is the one thing §F says a lease must not be allowed to skip. It is `MaxCount`, not the `Amount` EVENTS_PLAN.md named -- there is no such property on ServUO 57.4. `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries SECONDS: the spawn files' own `DelayInSec` flag proves both units are in use on a real tree, and a unit that cannot express five seconds cannot express this shard's own data. The seasonal lease is a THREE-value enum over EIGHT events. §G called `GetEntry(type).Status` "a nine-value enum" and had it backwards: `EventStatus` has three values and it is `EventType` that has nine entries. Eight rather than nine because `TreasuresOfTokuno` is excluded -- `IsActive()` reads its own `DropEra` rather than `Status`, so leasing it would apply cleanly, read back, restore cleanly and do nothing at all. Two behaviours worth the review. `inForce()` reads the frame's `holds` rather than a row's `held` flag, because a catalog walk can enumerate the keys but never the holds on a targeted one. And a target that VANISHED mid-run is a SUCCESSFUL restore: there is nothing to give back, and reporting it failed would leave a ledger row unresolved for ever over an object that is gone -- 12a's `gone` in the lease plane's vocabulary. THE GRANT NAMES A RUN, NEVER A RECIPIENT LIST Core has the participants in `event_run_participants`, but a module cannot read core's tables -- so the alternative was a new core surface handing them over. Not needed: the shard has held the run's ledger since it opened, keyed by the same serials core stores as `member_key`. And the grant is RETRYABLE. §G called it un-retryable because a lost acknowledgement and a grant that never applied were the same event, which is exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9. Protocol 6's idempotency key closes it. `uo.rewards` counts ITEMS rather than grants: 500 gold to forty people and a candle to forty people are not the same imposition. THE ATLAS KEEPS UniqueId AGAIN, AND THE SPAWNER SOURCE SEARCHES The parser has read `<UniqueId>` and thrown it away since the atlas shipped, on a line citing a committed artifact -- there is no committed artifact, as `spawnAtlasSource.js` says in its own header. It is the ONLY name for one particular spawner that exists off the shard, so a property lease could not have had a dropdown without it. `PARSER_VERSION` -> 4 so an unchanged tree is re-read. `uo.options.spawners` is the first searchable source and the first that had to be: 6,707 spawn points against `MAX_OPTIONS`' 2,000, so a flat list would drop two thirds of the world and say nothing about which two thirds. ONE DEFECT IN ALREADY-MERGED CODE, AND IT WOULD HAVE BROKEN EVERYTHING The protocol pin never left 5. `uo_link_config.protocol` reaches the sidecar as `X-UOLink-Version` on every REST call and an exact mismatch is a 409, so from Phase 11a onward every sidecar call on a real deployment would have been refused -- the whole event plane dead, loudly, for a reason nobody would look here for. 11a took the wire to 6 and 12a to 7; neither moved the pin, in either of the two places this repo declares it. It survived both because both live walks set the column by hand while standing the rig up, which is exactly what makes a migration nobody runs invisible. All three sites go to 7. The test that guards them is worth understanding before trusting it: `schemaFragment.test.js` asserts the three declarations agree WITH EACH OTHER -- a real check they once failed -- but all three being equally stale passes it, and nothing in this repo can anchor it to the wire. Recorded in the model's own header so the next reader knows. CHECKS `npm test`: 620 pass, 0 fail (was 605). `check:imports` and `check:externals` clean; the client builds and its 42 tests pass. `check:swagger` reports the fragment stale -- it is ALREADY stale on `edge` (verified by stashing this branch's changes and re-running) and this phase adds no route, so it is left alone rather than regenerated inside an unrelated change. Two bugs the new tests caught in this branch's own code before it left: `counted()` returns `.count` and the grant read `.value`, so every grant went out with `amount: undefined` and the non-stackable guard never fired; and `optionalInt`'s `ok` was ignored, so a bad hue passed silently instead of refusing. Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
398 lines
14 KiB
JavaScript
398 lines
14 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.
|
|
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
|
|
|
|
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,
|
|
}
|