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
745 lines
28 KiB
JavaScript
745 lines
28 KiB
JavaScript
// Spawn atlas parsers — pure functions over strings, no `fs`, no dependencies.
|
|
//
|
|
// These back the CLI build script (`scripts/buildSpawnAtlas.js`), which is the
|
|
// only thing that reads a ServUO tree. Keeping every parser pure and fs-free is
|
|
// what lets the test suite cover them in CI, where no ServUO tree exists: the
|
|
// tests hand these functions literal XML strings.
|
|
//
|
|
// Four source shapes, two very different parsing strategies:
|
|
//
|
|
// Spawns/*.xml ~10.5 MB across 13 files, FLAT <Points> records
|
|
// → streaming regex, never a DOM. See parsePoints().
|
|
// Data/Regions.xml 129 KB, genuinely nested <region> inside <region>
|
|
// Data/Locations/*.xml nested <parent>/<child>
|
|
// Config/ChampionSpawns.xml 4.8 KB, <spawn>/<location>
|
|
// → the small recursive tokenizer below.
|
|
//
|
|
// The server has zero XML dependencies and this adds none. The tokenizer is
|
|
// deliberately a *subset* parser: it handles the constructs these four files
|
|
// actually use (elements, attributes, self-closing tags, comments, the XML
|
|
// declaration, CDATA, the five predefined entities plus numeric refs) and
|
|
// nothing else. It is not a general-purpose XML parser and must not be reused
|
|
// as one — no namespaces, no DTDs, no entity declarations.
|
|
|
|
// ── Entities ───────────────────────────────────────────────────────────────
|
|
|
|
const NAMED_ENTITIES = {
|
|
amp: '&',
|
|
lt: '<',
|
|
gt: '>',
|
|
quot: '"',
|
|
apos: "'",
|
|
}
|
|
|
|
// Region and location names carry apostrophes ("Mondain's Legacy", "Wrong's
|
|
// Level 3"), so entity decoding is load-bearing here, not decorative.
|
|
function decodeEntities(text) {
|
|
if (!text.includes('&')) return text
|
|
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body) => {
|
|
if (body[0] === '#') {
|
|
const code =
|
|
body[1] === 'x' || body[1] === 'X'
|
|
? Number.parseInt(body.slice(2), 16)
|
|
: Number.parseInt(body.slice(1), 10)
|
|
return Number.isFinite(code) ? String.fromCodePoint(code) : match
|
|
}
|
|
const named = NAMED_ENTITIES[body.toLowerCase()]
|
|
return named === undefined ? match : named
|
|
})
|
|
}
|
|
|
|
// ── The tokenizer ──────────────────────────────────────────────────────────
|
|
|
|
const ATTR_RE = /([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g
|
|
|
|
function parseAttrs(source) {
|
|
const attrs = {}
|
|
ATTR_RE.lastIndex = 0
|
|
let match
|
|
while ((match = ATTR_RE.exec(source)) !== null) {
|
|
const raw = match[3] !== undefined ? match[3] : match[4]
|
|
attrs[match[1]] = decodeEntities(raw)
|
|
}
|
|
return attrs
|
|
}
|
|
|
|
/**
|
|
* Parse a small nested XML document into `{ name, attrs, children, text }`.
|
|
*
|
|
* Intended for Regions.xml / Locations / ChampionSpawns.xml only — never for
|
|
* the multi-megabyte Spawns files. Returns the root element, or `null` for a
|
|
* document with no elements.
|
|
*
|
|
* Mismatched or stray closing tags are ignored rather than thrown on: these are
|
|
* hand-maintained shard config files, and one malformed region should degrade
|
|
* to a missing region, not abort a build that is otherwise fine.
|
|
*/
|
|
function parseXml(source) {
|
|
const text = String(source)
|
|
const root = { name: '#document', attrs: {}, children: [], text: '' }
|
|
const stack = [root]
|
|
let i = 0
|
|
|
|
while (i < text.length) {
|
|
const lt = text.indexOf('<', i)
|
|
if (lt === -1) {
|
|
appendText(stack[stack.length - 1], text.slice(i))
|
|
break
|
|
}
|
|
if (lt > i) appendText(stack[stack.length - 1], text.slice(i, lt))
|
|
|
|
// Comment, declaration/DOCTYPE, or CDATA — skipped wholesale.
|
|
if (text.startsWith('<!--', lt)) {
|
|
const end = text.indexOf('-->', lt + 4)
|
|
i = end === -1 ? text.length : end + 3
|
|
continue
|
|
}
|
|
if (text.startsWith('<![CDATA[', lt)) {
|
|
const end = text.indexOf(']]>', lt + 9)
|
|
const stop = end === -1 ? text.length : end
|
|
appendRawText(stack[stack.length - 1], text.slice(lt + 9, stop))
|
|
i = end === -1 ? text.length : end + 3
|
|
continue
|
|
}
|
|
if (text.startsWith('<?', lt)) {
|
|
const end = text.indexOf('?>', lt + 2)
|
|
i = end === -1 ? text.length : end + 2
|
|
continue
|
|
}
|
|
if (text.startsWith('<!', lt)) {
|
|
const end = text.indexOf('>', lt + 2)
|
|
i = end === -1 ? text.length : end + 1
|
|
continue
|
|
}
|
|
|
|
const gt = findTagEnd(text, lt)
|
|
if (gt === -1) {
|
|
// Unterminated tag: nothing sane is left to read.
|
|
break
|
|
}
|
|
const inner = text.slice(lt + 1, gt)
|
|
|
|
if (inner[0] === '/') {
|
|
const name = inner.slice(1).trim()
|
|
// Pop to the nearest matching open element. If there is no match the tag
|
|
// is stray and we drop it rather than unwinding the whole stack.
|
|
for (let depth = stack.length - 1; depth > 0; depth -= 1) {
|
|
if (stack[depth].name === name) {
|
|
stack.length = depth
|
|
break
|
|
}
|
|
}
|
|
i = gt + 1
|
|
continue
|
|
}
|
|
|
|
const selfClosing = inner.endsWith('/')
|
|
const body = selfClosing ? inner.slice(0, -1) : inner
|
|
const space = body.search(/\s/)
|
|
const name = (space === -1 ? body : body.slice(0, space)).trim()
|
|
const node = {
|
|
name,
|
|
attrs: space === -1 ? {} : parseAttrs(body.slice(space)),
|
|
children: [],
|
|
text: '',
|
|
}
|
|
stack[stack.length - 1].children.push(node)
|
|
if (!selfClosing) stack.push(node)
|
|
i = gt + 1
|
|
}
|
|
|
|
return root.children.length > 0 ? root.children[0] : null
|
|
}
|
|
|
|
// `>` inside a quoted attribute value must not end the tag.
|
|
function findTagEnd(text, from) {
|
|
let quote = null
|
|
for (let i = from + 1; i < text.length; i += 1) {
|
|
const ch = text[i]
|
|
if (quote) {
|
|
if (ch === quote) quote = null
|
|
} else if (ch === '"' || ch === "'") {
|
|
quote = ch
|
|
} else if (ch === '>') {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
function appendText(node, chunk) {
|
|
if (chunk.trim() === '') return
|
|
appendRawText(node, decodeEntities(chunk))
|
|
}
|
|
|
|
function appendRawText(node, chunk) {
|
|
node.text = node.text ? `${node.text}${chunk}` : chunk
|
|
}
|
|
|
|
function childrenNamed(node, name) {
|
|
if (!node || !node.children) return []
|
|
return node.children.filter((child) => child.name === name)
|
|
}
|
|
|
|
// ── Facet names ────────────────────────────────────────────────────────────
|
|
//
|
|
// Facets are NOT a fixed list. A shard may add facets, replace them wholesale,
|
|
// or rename them when its maps are updated, so nothing here may name Felucca,
|
|
// Trammel or any other stock facet. The facet set is whatever the shard's own
|
|
// files say it is, discovered at parse time.
|
|
//
|
|
// The complication is that the sources disagree about spelling for the SAME
|
|
// facet and nothing in the files reconciles them: `Spawns/*.xml` `<Map>` and
|
|
// `Regions.xml` `<Facet name>` say `TerMur`, while `Data/Locations/*.xml` spells
|
|
// it `Ter Mur` and calls Tokuno `Tokuno Islands`. Left unreconciled this fails
|
|
// silently — the landmark bucket is keyed differently from the points looking it
|
|
// up, so the fallback never fires and every unregioned spawn on those facets
|
|
// reads "Wilderness".
|
|
//
|
|
// Reconciliation is therefore done by MATCHING, not by a lookup table:
|
|
// `facetKey()` collapses spelling differences, and `resolveFacetName()` matches
|
|
// a loosely-spelled name against the canonical set discovered from the shard's
|
|
// own data. A facet nobody else mentions keeps its own name rather than being
|
|
// dropped.
|
|
|
|
/**
|
|
* Collapse a facet name to a comparison key: lowercase, alphanumerics only.
|
|
* `TerMur`, `Ter Mur` and `ter-mur` all key alike.
|
|
*/
|
|
function facetKey(value) {
|
|
return String(value ?? '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '')
|
|
}
|
|
|
|
/**
|
|
* Build a key → canonical-spelling lookup from the authoritative facet names.
|
|
*
|
|
* The authority is what the spawn records and region definitions actually say,
|
|
* since those are the names the atlas keys everything on. Later names do not
|
|
* overwrite earlier ones, so the first source wins consistently.
|
|
*/
|
|
function buildFacetIndex(names) {
|
|
const index = new Map()
|
|
for (const name of names) {
|
|
const key = facetKey(name)
|
|
if (key !== '' && !index.has(key)) index.set(key, String(name).trim())
|
|
}
|
|
return index
|
|
}
|
|
|
|
/**
|
|
* Resolve a loosely-spelled facet name against the discovered canonical set.
|
|
*
|
|
* Tried in order: exact key match (`Ter Mur` → `TerMur`), then a prefix match in
|
|
* either direction (`Tokuno Islands` → `Tokuno`), longest candidate first so a
|
|
* more specific facet wins over a shorter one that merely prefixes it.
|
|
*
|
|
* A name matching nothing is returned trimmed rather than dropped — on a shard
|
|
* with a custom facet that is a real facet the atlas simply has no spawns for
|
|
* yet, and inventing a match would be worse than leaving it alone.
|
|
*/
|
|
function resolveFacetName(value, index) {
|
|
const raw = String(value ?? '').trim()
|
|
const key = facetKey(raw)
|
|
if (key === '') return ''
|
|
if (index.has(key)) return index.get(key)
|
|
|
|
let best = null
|
|
for (const [candidateKey, canonical] of index) {
|
|
if (!key.startsWith(candidateKey) && !candidateKey.startsWith(key)) continue
|
|
if (best === null || candidateKey.length > facetKey(best).length) best = canonical
|
|
}
|
|
return best ?? raw
|
|
}
|
|
|
|
// ── Small coercions ────────────────────────────────────────────────────────
|
|
|
|
function toInt(value, fallback = 0) {
|
|
const n = Number.parseInt(value, 10)
|
|
return Number.isFinite(n) ? n : fallback
|
|
}
|
|
|
|
function toBool(value) {
|
|
return String(value).trim().toLowerCase() === 'true'
|
|
}
|
|
|
|
/**
|
|
* URL-safe slug used as the creature primary key and in `/atlas/:slug`.
|
|
* Spawn type tokens are C# class names, so they are already ASCII-ish; this
|
|
* mainly lowercases and collapses punctuation.
|
|
*/
|
|
function slugify(value) {
|
|
return String(value)
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
}
|
|
|
|
// ── Objects2 ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse a `<Objects2>` value into `[{ type, max }]`.
|
|
*
|
|
* The format is one or more segments joined by `:OBJ=`, each segment being
|
|
* `Type:MX=n:SB=0:RT=0:...` — the type is the token before the first `:`, and
|
|
* every following token is a `KEY=value` pair. Verified against trammel.xml,
|
|
* where a single point carries six types:
|
|
*
|
|
* Giantserpent:MX=1:...:OBJ=Giantspider:MX=1:...:OBJ=Boar:MX=1:...
|
|
*
|
|
* Splitting on `:` alone would shred this, which is why the `:OBJ=` split comes
|
|
* first. `MX` is that type's own max count and is what the atlas displays;
|
|
* every other flag (spawn/trigger/refractory bookkeeping) is dropped.
|
|
*
|
|
* The type token itself may carry XmlSpawner directives appended to the class
|
|
* name — property assignments after `/` and an amount/argument list after `,`:
|
|
*
|
|
* Agralem/Name/Agralem alchemist/z/-50 Fairy,{RND,4,8}
|
|
* GargishRefugee/hue/34532 greatape,true GargishRouser,1
|
|
*
|
|
* Taken literally these produce creatures that do not exist ("alchemist/z/-50")
|
|
* AND split real ones in two, because `Fairy` and `Fairy,{RND,4,8}` slug apart —
|
|
* 71 of 845 entries were affected before this was stripped. Only the leading
|
|
* class name identifies the creature, so everything from the first `/` or `,`
|
|
* is dropped.
|
|
*/
|
|
/** Reduce an XmlSpawner type token to the bare class name. */
|
|
function stripSpawnerDirectives(token) {
|
|
const cut = String(token).search(/[/,]/)
|
|
return (cut === -1 ? String(token) : String(token).slice(0, cut)).trim()
|
|
}
|
|
|
|
function parseObjects2(value) {
|
|
const source = String(value ?? '').trim()
|
|
if (source === '') return []
|
|
|
|
return source
|
|
.split(':OBJ=')
|
|
.map((segment) => {
|
|
const tokens = segment.split(':')
|
|
const type = stripSpawnerDirectives(tokens.shift() ?? '')
|
|
if (type === '') return null
|
|
let max = 1
|
|
for (const token of tokens) {
|
|
const eq = token.indexOf('=')
|
|
if (eq === -1) continue
|
|
if (token.slice(0, eq).trim().toUpperCase() === 'MX') {
|
|
max = toInt(token.slice(eq + 1), 1)
|
|
}
|
|
}
|
|
return { type, max }
|
|
})
|
|
.filter((entry) => entry !== null)
|
|
}
|
|
|
|
// ── Spawns/*.xml ───────────────────────────────────────────────────────────
|
|
|
|
const POINT_RE = /<Points>([\s\S]*?)<\/Points>/g
|
|
|
|
function tagValue(block, name) {
|
|
const match = block.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))
|
|
return match ? decodeEntities(match[1]).trim() : ''
|
|
}
|
|
|
|
/**
|
|
* Parse a `Spawns/<facet>.xml` file into spawn point records.
|
|
*
|
|
* Deliberately regex/streaming and NOT `parseXml` — these files total ~10.5 MB
|
|
* and putting them through a DOM builder would allocate a node per element for
|
|
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
|
|
* flat, so a per-record regex sweep is both correct and cheap.
|
|
*
|
|
* Only the fields the site can actually use are kept. Everything to do with
|
|
* triggering, refractory windows, proximity, sequential spawning and sounds is
|
|
* dropped here rather than downstream, which is what keeps the parsed atlas
|
|
* small.
|
|
*
|
|
* **`UniqueId` was on that list until Phase 12b and is now kept**, because a
|
|
* property lease has to name one particular spawner and this is the only name
|
|
* for one that exists off-shard. The line that justified dropping it cited a
|
|
* committed artifact; there is no committed artifact — `spawnAtlasSource.js`
|
|
* says so in its own header ("nothing is precomputed and committed") — so the
|
|
* only real cost was ~37 bytes a row in a table, and it bought a dropdown.
|
|
*
|
|
* NOTE: the facet comes from each record's own `<Map>`, never from the file
|
|
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
|
|
* carry TerMur/Trammel points, so there are 13 files but only 6 facets.
|
|
*/
|
|
/**
|
|
* A spawner's respawn window, in seconds.
|
|
*
|
|
* `DelayInSec` decides the unit of `MinDelay`/`MaxDelay`; absent (older files)
|
|
* it is false, which is minutes — the same default XmlSpawner assumes.
|
|
*/
|
|
function delaySeconds(block) {
|
|
const scale = toBool(tagValue(block, 'DelayInSec')) ? 1 : 60
|
|
return {
|
|
minDelay: toInt(tagValue(block, 'MinDelay')) * scale,
|
|
maxDelay: toInt(tagValue(block, 'MaxDelay')) * scale,
|
|
}
|
|
}
|
|
|
|
function parsePoints(source) {
|
|
const text = String(source)
|
|
const points = []
|
|
POINT_RE.lastIndex = 0
|
|
let match
|
|
|
|
while ((match = POINT_RE.exec(text)) !== null) {
|
|
const block = match[1]
|
|
// Reported exactly as written. `<Map>` is the authority the rest of the
|
|
// atlas keys on, so it is never rewritten.
|
|
const facet = tagValue(block, 'Map')
|
|
if (facet === '') continue
|
|
|
|
points.push({
|
|
name: tagValue(block, 'Name'),
|
|
// **Kept from Phase 12b, having been discarded since the atlas shipped.**
|
|
// It is `XmlSpawner.UniqueId` — the shard writes it into the spawn files
|
|
// and carries it on the live spawner — so it is the ONE way an authoring
|
|
// form can name a particular spawner without the shard being up. A serial
|
|
// cannot do that job: serials are assigned when the world is built and
|
|
// nothing off-shard knows them, which is why a property lease that could
|
|
// only be addressed by serial could have no dropdown at all.
|
|
uniqueId: tagValue(block, 'UniqueId'),
|
|
facet,
|
|
x: toInt(tagValue(block, 'X')),
|
|
y: toInt(tagValue(block, 'Y')),
|
|
width: toInt(tagValue(block, 'Width')),
|
|
height: toInt(tagValue(block, 'Height')),
|
|
range: toInt(tagValue(block, 'Range')),
|
|
maxCount: toInt(tagValue(block, 'MaxCount')),
|
|
// Normalised to SECONDS here, because the unit is per-record. XmlSpawner
|
|
// writes minutes by default and switches to seconds only when a spawner's
|
|
// delay does not divide into whole minutes, flagging that with
|
|
// `DelayInSec` (XmlSpawner2.cs:7462-7480, read back at :6345-6358). Taken
|
|
// literally the two are indistinguishable — a `5` means five minutes on
|
|
// one spawner and five seconds on the next — so a consumer that assumed
|
|
// either unit would be wrong about the other. Stock ServUO 57.4 has ~30
|
|
// second-flagged spawners, few enough to look like noise and quietly
|
|
// mislabel.
|
|
...delaySeconds(block),
|
|
// Time-of-day gating: TODMode 0 means "always", in which case the start
|
|
// and end values are meaningless and the site must not render them.
|
|
todStart: toInt(tagValue(block, 'TODStart')),
|
|
todEnd: toInt(tagValue(block, 'TODEnd')),
|
|
todMode: toInt(tagValue(block, 'TODMode')),
|
|
// A spawner switched off in-world spawns nothing; the build filters these
|
|
// out so the atlas describes what actually appears, not what is merely
|
|
// configured. Parsed here so the decision stays in the build script.
|
|
running: toBool(tagValue(block, 'IsRunning')),
|
|
types: parseObjects2(tagValue(block, 'Objects2')),
|
|
})
|
|
}
|
|
|
|
return points
|
|
}
|
|
|
|
// ── Data/Regions.xml ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Flatten `Data/Regions.xml` into `[{ facet, name, type, priority, parent, rects }]`.
|
|
*
|
|
* Regions nest: a `<region>` may contain further `<region>` elements, and the
|
|
* inner ones frequently omit `name` and `priority` (`<region type="CrystalField">`
|
|
* inside "Prism of Light"). Unnamed regions are skipped — they cannot label a
|
|
* spawn point — but their children are still walked, and a child that omits
|
|
* `priority` inherits its parent's rather than defaulting to 0, which would
|
|
* quietly sort it below every top-level region.
|
|
*/
|
|
function parseRegions(source) {
|
|
const root = parseXml(source)
|
|
const regions = []
|
|
if (!root) return regions
|
|
|
|
for (const facetNode of childrenNamed(root, 'Facet')) {
|
|
const facet = (facetNode.attrs.name || '').trim()
|
|
if (facet === '') continue
|
|
walkRegions(facetNode, facet, null, 0, regions)
|
|
}
|
|
return regions
|
|
}
|
|
|
|
function walkRegions(node, facet, parentName, parentPriority, out) {
|
|
for (const regionNode of childrenNamed(node, 'region')) {
|
|
const name = regionNode.attrs.name || ''
|
|
const priority = Object.hasOwn(regionNode.attrs, 'priority')
|
|
? toInt(regionNode.attrs.priority, parentPriority)
|
|
: parentPriority
|
|
|
|
if (name !== '') {
|
|
const rects = childrenNamed(regionNode, 'rect').map((rect) => ({
|
|
x: toInt(rect.attrs.x),
|
|
y: toInt(rect.attrs.y),
|
|
width: toInt(rect.attrs.width),
|
|
height: toInt(rect.attrs.height),
|
|
}))
|
|
// A named region with no rects (some exist purely to carry music or a
|
|
// `go` point) can never contain anything, so it is not worth indexing.
|
|
if (rects.length > 0) {
|
|
out.push({
|
|
facet,
|
|
name,
|
|
type: regionNode.attrs.type || '',
|
|
priority,
|
|
parent: parentName,
|
|
rects,
|
|
})
|
|
}
|
|
}
|
|
|
|
walkRegions(regionNode, facet, name === '' ? parentName : name, priority, out)
|
|
}
|
|
}
|
|
|
|
// ── Data/Locations/*.xml ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Flatten a `Data/Locations/<facet>.xml` into landmark points.
|
|
*
|
|
* The file nests `<parent>` arbitrarily deep and puts coordinates only on
|
|
* `<child>`: Trammel → Dungeons → Covetous → "Level 1". The outermost parent is
|
|
* the facet itself and is dropped from `path`; `group` is the innermost
|
|
* enclosing parent ("Covetous"), which is the label worth showing — "Covetous"
|
|
* reads better than "Level 1" when naming where a spawn is.
|
|
*/
|
|
function parseLocations(source, facetHint = '') {
|
|
const root = parseXml(source)
|
|
const landmarks = []
|
|
if (!root) return landmarks
|
|
|
|
for (const top of childrenNamed(root, 'parent')) {
|
|
// The file name (`Data/Locations/termur.xml`) is the more reliable signal
|
|
// and is preferred over the display label inside the file, which is where
|
|
// the `Ter Mur` / `Tokuno Islands` drift lives. Both are carried so the
|
|
// build can fall back to matching the label if the file name resolves to
|
|
// nothing — a shard may well name its files differently from its facets.
|
|
landmarks.push(
|
|
...collectLocations(top, facetHint || top.attrs.name || '', top.attrs.name || ''),
|
|
)
|
|
}
|
|
return landmarks
|
|
}
|
|
|
|
function collectLocations(top, facet, label) {
|
|
const out = []
|
|
walkLocations(top, facet, [], out)
|
|
for (const landmark of out) landmark.facetLabel = label
|
|
return out
|
|
}
|
|
|
|
function walkLocations(node, facet, path, out) {
|
|
for (const child of childrenNamed(node, 'child')) {
|
|
const name = child.attrs.name || ''
|
|
if (name === '') continue
|
|
out.push({
|
|
facet,
|
|
name,
|
|
group: path.length > 0 ? path[path.length - 1] : name,
|
|
path: [...path],
|
|
x: toInt(child.attrs.x),
|
|
y: toInt(child.attrs.y),
|
|
z: toInt(child.attrs.z),
|
|
})
|
|
}
|
|
for (const parent of childrenNamed(node, 'parent')) {
|
|
const name = parent.attrs.name || ''
|
|
walkLocations(parent, facet, name === '' ? path : [...path, name], out)
|
|
}
|
|
}
|
|
|
|
// ── Config/ChampionSpawns.xml ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse `Config/ChampionSpawns.xml` into champion altar records.
|
|
*
|
|
* This is the shard's *configured* champion roster — which altars exist, where,
|
|
* and which type each is pinned to. It is static content and distinct from the
|
|
* live `champ.update` feed the bridge already carries: this says "there is an
|
|
* Unholy Terror altar in Deceit", the feed says "it is on level 3 right now".
|
|
*
|
|
* A spawn with no `type` is randomised on every activation, which the site must
|
|
* render as "random" rather than as an empty type.
|
|
*/
|
|
/**
|
|
* Item types a shard uses as decoration, from one `Data/Decoration/*.cfg`.
|
|
*
|
|
* The format is a header line naming a type and an item id, optionally followed
|
|
* by a parenthesised property list, and then one `x y z` line per placement:
|
|
*
|
|
* ```
|
|
* # switch
|
|
* Static 0x108F
|
|
* 5552 1864 11
|
|
* ```
|
|
*
|
|
* Only the header matters here. The properties are decoration-authoring details
|
|
* (`Hue=`, `Facing=`, `Name=`) and the coordinates are where the SHARD put its
|
|
* own scenery, neither of which an event author is choosing — they pick a type
|
|
* and a place of their own.
|
|
*
|
|
* Returns one entry per header line, not per distinct type: the same type
|
|
* appears under many item ids (a `BarredMetalDoor` for each facing), and how
|
|
* often a shard reaches for something is worth keeping. `spawnAtlasSource`
|
|
* aggregates.
|
|
*/
|
|
function parseDecoration(source) {
|
|
const out = []
|
|
if (!source) return out
|
|
|
|
for (const raw of String(source).split(/\r?\n/)) {
|
|
const line = raw.trim()
|
|
|
|
// A coordinate line starts with a digit or a minus (z is often negative),
|
|
// so the type test is not merely "not a comment".
|
|
if (line === '' || line.startsWith('#')) continue
|
|
|
|
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s+0x([0-9A-Fa-f]+)/.exec(line)
|
|
if (!match) continue
|
|
|
|
out.push({ type: match[1], itemId: parseInt(match[2], 16) })
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
function parseChampions(source) {
|
|
const root = parseXml(source)
|
|
const champions = []
|
|
if (!root) return champions
|
|
|
|
for (const spawnNode of childrenNamed(root, 'spawn')) {
|
|
const location = childrenNamed(spawnNode, 'location')[0]
|
|
const attrs = location ? location.attrs : {}
|
|
champions.push({
|
|
name: spawnNode.attrs.name || '',
|
|
group: spawnNode.attrs.group || '',
|
|
type: spawnNode.attrs.type || '',
|
|
randomType: !spawnNode.attrs.type,
|
|
facet: (attrs.map || '').trim(),
|
|
x: toInt(attrs.x),
|
|
y: toInt(attrs.y),
|
|
z: toInt(attrs.z),
|
|
radius: toInt(attrs.radius),
|
|
})
|
|
}
|
|
return champions
|
|
}
|
|
|
|
// ── Placement ──────────────────────────────────────────────────────────────
|
|
|
|
const DEFAULT_LANDMARK_RADIUS = 200
|
|
|
|
function inRect(x, y, rect) {
|
|
return (
|
|
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
|
|
)
|
|
}
|
|
|
|
function rectArea(rect) {
|
|
return Math.max(1, rect.width) * Math.max(1, rect.height)
|
|
}
|
|
|
|
/**
|
|
* Group parsed regions and landmarks by facet once, so the per-point resolve
|
|
* below is a scan of one facet instead of the whole world. With ~6,500 points
|
|
* and a few thousand rects this stays comfortably sub-second; there is no need
|
|
* for a spatial index and none is worth the complexity.
|
|
*/
|
|
function buildPlacementIndex(regions, landmarks) {
|
|
const byFacet = new Map()
|
|
// Keyed on facetKey(), not the raw name, so two spellings of one facet cannot
|
|
// land in separate buckets — the failure that silently emptied the landmark
|
|
// bucket for Ter Mur and Tokuno.
|
|
const facet = (name) => {
|
|
const key = facetKey(name)
|
|
if (!byFacet.has(key)) byFacet.set(key, { regions: [], landmarks: [] })
|
|
return byFacet.get(key)
|
|
}
|
|
for (const region of regions) facet(region.facet).regions.push(region)
|
|
for (const landmark of landmarks) facet(landmark.facet).landmarks.push(landmark)
|
|
return byFacet
|
|
}
|
|
|
|
/**
|
|
* Turn a raw coordinate into a human place name.
|
|
*
|
|
* This is the transform the whole atlas exists for: it is what makes a row read
|
|
* "Lizardman — Despise, Felucca" instead of "Lizardman — 5411, 1234".
|
|
*
|
|
* Resolution order:
|
|
* 1. The highest-`priority` named region whose rect contains the point. Ties
|
|
* break toward the SMALLEST rect, so a specific room inside a dungeon wins
|
|
* over the dungeon-wide rect it sits in.
|
|
* 2. Otherwise the nearest landmark within `landmarkRadius` tiles, labelled by
|
|
* its group ("Covetous"), not the individual marker ("Level 1").
|
|
* 3. Otherwise "Wilderness". The radius cap is what keeps step 3 reachable —
|
|
* without it the nearest landmark is always *some* landmark, however far,
|
|
* and open countryside would get labelled with a dungeon on the far side
|
|
* of the map.
|
|
*/
|
|
function resolveRegion(x, y, facetName, index, options = {}) {
|
|
const radius = options.landmarkRadius ?? DEFAULT_LANDMARK_RADIUS
|
|
const bucket = index.get(facetKey(facetName))
|
|
const result = { region: null, landmark: null, label: 'Wilderness' }
|
|
if (!bucket) return result
|
|
|
|
let best = null
|
|
let bestPriority = -Infinity
|
|
let bestArea = Infinity
|
|
for (const region of bucket.regions) {
|
|
for (const rect of region.rects) {
|
|
if (!inRect(x, y, rect)) continue
|
|
const area = rectArea(rect)
|
|
if (region.priority > bestPriority || (region.priority === bestPriority && area < bestArea)) {
|
|
best = region
|
|
bestPriority = region.priority
|
|
bestArea = area
|
|
}
|
|
}
|
|
}
|
|
if (best) {
|
|
result.region = best.name
|
|
result.label = best.name
|
|
return result
|
|
}
|
|
|
|
let nearest = null
|
|
let nearestDistance = Infinity
|
|
const limit = radius * radius
|
|
for (const landmark of bucket.landmarks) {
|
|
const dx = landmark.x - x
|
|
const dy = landmark.y - y
|
|
const distance = dx * dx + dy * dy
|
|
if (distance < nearestDistance) {
|
|
nearest = landmark
|
|
nearestDistance = distance
|
|
}
|
|
}
|
|
if (nearest && nearestDistance <= limit) {
|
|
result.landmark = nearest.group || nearest.name
|
|
result.label = result.landmark
|
|
}
|
|
return result
|
|
}
|
|
|
|
module.exports = {
|
|
parseXml,
|
|
parseObjects2,
|
|
parsePoints,
|
|
parseRegions,
|
|
parseLocations,
|
|
parseChampions,
|
|
parseDecoration,
|
|
buildPlacementIndex,
|
|
resolveRegion,
|
|
facetKey,
|
|
buildFacetIndex,
|
|
resolveFacetName,
|
|
slugify,
|
|
decodeEntities,
|
|
DEFAULT_LANDMARK_RADIUS,
|
|
}
|