const { test } = require('node:test') const assert = require('node:assert/strict') const { parseXml, parseObjects2, parsePoints, parseRegions, parseLocations, parseChampions, buildPlacementIndex, resolveRegion, facetKey, buildFacetIndex, resolveFacetName, slugify, decodeEntities, } = require('../src/utils/spawnAtlasParse') // These parsers are pure and fs-free precisely so this suite can run in CI, // where there is no ServUO tree. Every fixture below is a literal excerpt of a // real shard file, trimmed — not invented shapes. // ── parseObjects2 ────────────────────────────────────────────────────────── test('parseObjects2: single type', () => { const types = parseObjects2('Jacob:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1') assert.deepEqual(types, [{ type: 'Jacob', max: 1 }]) }) test('parseObjects2: splits six types on :OBJ= and keeps each MX', () => { // Verbatim from trammel.xml — the case that a naive split(':') destroys. const raw = 'Gazer:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' + ':OBJ=Giantspider:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' + ':OBJ=Harpy:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' + ':OBJ=Headlessone:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' + ':OBJ=Lizardman:MX=3:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' + ':OBJ=Mongbat:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' const types = parseObjects2(raw) assert.equal(types.length, 6) assert.deepEqual( types.map((t) => t.type), ['Gazer', 'Giantspider', 'Harpy', 'Headlessone', 'Lizardman', 'Mongbat'], ) // MX is per type, not per spawner: the lizardman entry carries 3. assert.equal(types.find((t) => t.type === 'Lizardman').max, 3) assert.equal(types.find((t) => t.type === 'Gazer').max, 1) }) test('parseObjects2: empty and whitespace values yield no types', () => { assert.deepEqual(parseObjects2(''), []) assert.deepEqual(parseObjects2(' '), []) assert.deepEqual(parseObjects2(null), []) assert.deepEqual(parseObjects2(undefined), []) }) test('parseObjects2: strips XmlSpawner property directives after "/"', () => { // Left in place these become creatures that do not exist. assert.deepEqual(parseObjects2('Agralem/Name/Agralem:MX=1'), [{ type: 'Agralem', max: 1 }]) assert.deepEqual(parseObjects2('alchemist/z/-50:MX=1'), [{ type: 'alchemist', max: 1 }]) assert.deepEqual(parseObjects2('GargishRefugee/hue/34532'), [ { type: 'GargishRefugee', max: 1 }, ]) }) test('parseObjects2: strips argument lists after ","', () => { assert.deepEqual(parseObjects2('Fairy,{RND,4,8}:MX=1'), [{ type: 'Fairy', max: 1 }]) assert.deepEqual(parseObjects2('GargishRouser,1'), [{ type: 'GargishRouser', max: 1 }]) assert.deepEqual(parseObjects2('greatape,true'), [{ type: 'greatape', max: 1 }]) }) test('parseObjects2: a directive-laden token slugs the same as the bare one', () => { // The bug this closes: `Fairy` and `Fairy,{RND,4,8}` slugged apart and showed // as two different creatures on the same page. const bare = parseObjects2('Fairy:MX=1')[0] const decorated = parseObjects2('Fairy,{RND,4,8}:MX=1')[0] assert.equal(slugify(decorated.type), slugify(bare.type)) }) test('parseObjects2: strips a long EQUIP directive chain containing "<" and ">"', () => { const raw = 'xmlquestnpc/UNEQUIP,Innertorso/UNEQUIP,MiddleTorso/EQUIP//blessed/true/name/lord blackthorne/z/:MX=1' assert.deepEqual(parseObjects2(raw), [{ type: 'xmlquestnpc', max: 1 }]) }) test('parseObjects2: a token that is only a directive yields nothing', () => { assert.deepEqual(parseObjects2('/Name/Foo:MX=1'), []) assert.deepEqual(parseObjects2(',1:MX=1'), []) }) test('parseObjects2: a type with no MX token defaults to 1', () => { assert.deepEqual(parseObjects2('Orc'), [{ type: 'Orc', max: 1 }]) assert.deepEqual(parseObjects2('Orc:SB=0:RT=0'), [{ type: 'Orc', max: 1 }]) }) // ── parsePoints ──────────────────────────────────────────────────────────── const POINTS_XML = ` CovetousSpawner26 001a34e5-0efa-46de-9c93-b6a163d96370 Trammel 5412 1970 10 10 5 3 5 10 500 0 0 0 True Lizardman:MX=3:SB=0 Disabled Felucca 100 200 1 False Orc:MX=1 ` test('parsePoints: reads the kept fields and drops the rest', () => { const points = parsePoints(POINTS_XML) assert.equal(points.length, 2) const covetous = points[0] assert.equal(covetous.name, 'CovetousSpawner26') assert.equal(covetous.facet, 'Trammel') assert.equal(covetous.x, 5412) assert.equal(covetous.y, 1970) assert.equal(covetous.width, 10) assert.equal(covetous.range, 5) assert.equal(covetous.maxCount, 3) assert.equal(covetous.minDelay, 5) assert.equal(covetous.maxDelay, 10) assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }]) // Dropped fields must not survive into the artifact — this is what keeps it // under 1 MB. assert.equal(covetous.uniqueId, undefined) assert.equal(covetous.proximityTriggerSound, undefined) }) test('parsePoints: IsRunning is parsed so the build can drop dead spawners', () => { const points = parsePoints(POINTS_XML) assert.equal(points[0].running, true) assert.equal(points[1].running, false) }) test('parsePoints: facet comes from , never the file name', () => { // Eodon.xml holds TerMur points; a file-name assumption would mislabel every // one of them. const points = parsePoints( 'aTerMur12', ) assert.equal(points[0].facet, 'TerMur') }) test('parsePoints: a record with no is skipped rather than misfiled', () => { const points = parsePoints('a12') assert.deepEqual(points, []) }) test('parsePoints: empty document yields no points', () => { assert.deepEqual(parsePoints(''), []) assert.deepEqual(parsePoints(''), []) }) // ── parseRegions ─────────────────────────────────────────────────────────── const REGIONS_XML = ` ` test('parseRegions: flattens nested regions and collects rects', () => { const regions = parseRegions(REGIONS_XML) const byName = new Map(regions.map((r) => [r.name, r])) assert.ok(byName.has('Moongates')) assert.ok(byName.has('Prism of Light')) assert.equal(byName.get('Moongates').rects.length, 2) assert.deepEqual(byName.get('Moongates').rects[0], { x: 1330, y: 1991, width: 13, height: 13, }) assert.equal(byName.get('Moongates').facet, 'Felucca') assert.equal(byName.get('Moongates').type, 'GuardedRegion') }) test('parseRegions: a nested child records its parent', () => { const regions = parseRegions(REGIONS_XML) const crystal = regions.find((r) => r.name === 'Crystal Field') assert.ok(crystal, 'the nested named region should be indexed') assert.equal(crystal.parent, 'Prism of Light') assert.equal(crystal.facet, 'Felucca') }) test('parseRegions: a child with no priority inherits its parent', () => { // Defaulting to 0 instead would sort this specific room below every // top-level region that contains it. const crystal = parseRegions(REGIONS_XML).find((r) => r.name === 'Crystal Field') assert.equal(crystal.priority, 50) }) test('parseRegions: unnamed regions are skipped but still walked', () => { const regions = parseRegions(REGIONS_XML) // IcyRiver has a type but no name — it cannot label anything. assert.equal(regions.some((r) => r.type === 'IcyRiver'), false) }) test('parseRegions: a named region with no rects is not indexed', () => { // It can never contain a point, so indexing it only costs scan time. assert.equal(parseRegions(REGIONS_XML).some((r) => r.name === 'Music Only'), false) }) // ── parseLocations ───────────────────────────────────────────────────────── const LOCATIONS_XML = ` ` test('parseLocations: flattens to points carrying their group', () => { const landmarks = parseLocations(LOCATIONS_XML) assert.equal(landmarks.length, 3) const level1 = landmarks.find((l) => l.name === 'Level 1') assert.equal(level1.x, 5456) assert.equal(level1.y, 1863) assert.equal(level1.z, 0) assert.equal(level1.facet, 'Trammel') // "Covetous" is the useful label, not "Level 1". assert.equal(level1.group, 'Covetous') // The facet-level parent is dropped from the path. assert.deepEqual(level1.path, ['Dungeons', 'Covetous']) }) // ── Facet canonicalisation ───────────────────────────────────────────────── // Facets are NOT a fixed list — a shard may add, replace or rename them when its // maps are updated, so nothing may hardcode the stock six. Reconciliation is by // matching against whatever the shard's own files declare. test('facetKey: collapses spelling differences to one key', () => { assert.equal(facetKey('Ter Mur'), facetKey('TerMur')) assert.equal(facetKey('ter-mur'), facetKey('TerMur')) assert.equal(facetKey('Felucca'), 'felucca') assert.equal(facetKey(''), '') assert.equal(facetKey(null), '') }) test('facetKey: distinct facets keep distinct keys', () => { assert.notEqual(facetKey('Felucca'), facetKey('Trammel')) }) test('resolveFacetName: matches a loose spelling to the discovered canonical', () => { // The canonical set comes from the shard's own spawn/region data, not a table. const index = buildFacetIndex(['TerMur', 'Tokuno', 'Felucca']) assert.equal(resolveFacetName('Ter Mur', index), 'TerMur') assert.equal(resolveFacetName('Tokuno Islands', index), 'Tokuno') assert.equal(resolveFacetName('felucca', index), 'Felucca') }) test('resolveFacetName: works for facets that do not exist in stock UO', () => { // The whole point: a shard running its own maps gets the same treatment as // the stock ones, with no entry anywhere naming them. const index = buildFacetIndex(['Sosaria', 'The Underdark']) assert.equal(resolveFacetName('sosaria', index), 'Sosaria') assert.equal(resolveFacetName('The Underdark', index), 'The Underdark') assert.equal(resolveFacetName('the-underdark', index), 'The Underdark') // Same shape as the real `Tokuno Islands` → `Tokuno` case. assert.equal(resolveFacetName('Sosaria Isles', index), 'Sosaria') }) test('resolveFacetName: a merely similar name is NOT forced to match', () => { // "Underdark Isles" is not a prefix of "The Underdark" in either direction. // Keeping its own name is right — a wrong match would silently file a real // custom facet's landmarks under the wrong facet. const index = buildFacetIndex(['The Underdark']) assert.equal(resolveFacetName('Underdark Isles', index), 'Underdark Isles') }) test('resolveFacetName: prefers the longer match when several could prefix', () => { const index = buildFacetIndex(['Tokuno', 'TokunoDeep']) assert.equal(resolveFacetName('TokunoDeep Reaches', index), 'TokunoDeep') }) test('resolveFacetName: an unmatched facet keeps its own name', () => { // Inventing a match would be worse than leaving a real custom facet alone. const index = buildFacetIndex(['Felucca']) assert.equal(resolveFacetName('Ilshenar', index), 'Ilshenar') assert.equal(resolveFacetName('', index), '') assert.equal(resolveFacetName(null, index), '') }) test('buildFacetIndex: first spelling wins and is stable', () => { const index = buildFacetIndex(['TerMur', 'Ter Mur', 'ter-mur']) assert.equal(index.size, 1) assert.equal(resolveFacetName('Ter Mur', index), 'TerMur') }) test('parsePoints and parseRegions report facet names verbatim', () => { // and are the authority; they are never rewritten. const points = parsePoints( 'aSosaria12', ) assert.equal(points[0].facet, 'Sosaria') const regions = parseRegions( '' + '', ) assert.equal(regions[0].facet, 'Sosaria') }) test('placement index buckets two spellings of one facet together', () => { // This is the bug the key exists to prevent: unreconciled, the landmark bucket // is keyed apart from the points looking it up, the fallback never fires, and // every unregioned spawn on that facet silently reads "Wilderness". const index = buildPlacementIndex( [], [{ facet: 'Ter Mur', name: 'Bank', group: 'Holy City', path: [], x: 1000, y: 1000, z: 0 }], ) assert.equal(resolveRegion(1000, 1000, 'TerMur', index).landmark, 'Holy City') }) // ── parseChampions ───────────────────────────────────────────────────────── const CHAMPIONS_XML = ` ` test('parseChampions: reads altar name, type and location', () => { const champs = parseChampions(CHAMPIONS_XML) assert.equal(champs.length, 2) assert.deepEqual(champs[0], { name: 'Deceit', group: 'FelDungeons', type: 'UnholyTerror', randomType: false, facet: 'Felucca', x: 5178, y: 708, z: 20, radius: 60, }) }) test('parseChampions: a spawn with no type is flagged random, not blank', () => { const champs = parseChampions(CHAMPIONS_XML) assert.equal(champs[1].randomType, true) assert.equal(champs[1].type, '') }) // ── resolveRegion ────────────────────────────────────────────────────────── function fixtureIndex() { const regions = [ { facet: 'Felucca', name: 'Britain', type: 'TownRegion', priority: 10, parent: null, rects: [{ x: 1000, y: 1000, width: 500, height: 500 }], }, { facet: 'Felucca', name: 'Britain Bank', type: 'TownRegion', priority: 50, parent: 'Britain', rects: [{ x: 1400, y: 1400, width: 20, height: 20 }], }, { facet: 'Felucca', name: 'Wide Low Priority', type: 'TownRegion', priority: 10, parent: null, rects: [{ x: 1000, y: 1000, width: 2000, height: 2000 }], }, ] const landmarks = [ { facet: 'Felucca', name: 'Level 1', group: 'Covetous', path: [], x: 5000, y: 5000, z: 0 }, { facet: 'Felucca', name: 'Far Away', group: 'Vesper', path: [], x: 9000, y: 9000, z: 0 }, ] return buildPlacementIndex(regions, landmarks) } test('resolveRegion: a contained point takes the region name', () => { const result = resolveRegion(1100, 1100, 'Felucca', fixtureIndex()) assert.equal(result.region, 'Britain') assert.equal(result.label, 'Britain') assert.equal(result.landmark, null) }) test('resolveRegion: higher priority wins over a containing region', () => { const result = resolveRegion(1410, 1410, 'Felucca', fixtureIndex()) assert.equal(result.region, 'Britain Bank') }) test('resolveRegion: equal priority breaks toward the smaller rect', () => { // Both "Britain" (500x500) and "Wide Low Priority" (2000x2000) contain this // point at priority 10; the specific one must win. const result = resolveRegion(1200, 1200, 'Felucca', fixtureIndex()) assert.equal(result.region, 'Britain') }) test('resolveRegion: rects are half-open — the far edge is outside', () => { const index = fixtureIndex() // Britain spans x 1000..1499. 1499 is in, 1500 belongs to the next region. assert.equal(resolveRegion(1499, 1499, 'Felucca', index).region, 'Britain') assert.equal(resolveRegion(1500, 1500, 'Felucca', index).region, 'Wide Low Priority') }) test('resolveRegion: falls back to the nearest landmark group', () => { const result = resolveRegion(5050, 5050, 'Felucca', fixtureIndex()) assert.equal(result.region, null) assert.equal(result.landmark, 'Covetous') assert.equal(result.label, 'Covetous') }) test('resolveRegion: a landmark beyond the radius yields Wilderness', () => { // Without the radius cap the nearest landmark is always *some* landmark, and // open countryside would get labelled with a dungeon across the map. const result = resolveRegion(7000, 7000, 'Felucca', fixtureIndex()) assert.equal(result.landmark, null) assert.equal(result.label, 'Wilderness') }) test('resolveRegion: the radius is configurable', () => { const wide = resolveRegion(7000, 7000, 'Felucca', fixtureIndex(), { landmarkRadius: 5000 }) assert.equal(wide.label, 'Covetous') }) test('resolveRegion: an unknown facet degrades to Wilderness, not a throw', () => { const result = resolveRegion(1100, 1100, 'Malas', fixtureIndex()) assert.equal(result.label, 'Wilderness') assert.equal(result.region, null) }) test('resolveRegion: does not leak across facets', () => { const index = buildPlacementIndex( [ { facet: 'Trammel', name: 'Britain', type: 'TownRegion', priority: 10, parent: null, rects: [{ x: 1000, y: 1000, width: 500, height: 500 }], }, ], [], ) assert.equal(resolveRegion(1100, 1100, 'Trammel', index).region, 'Britain') assert.equal(resolveRegion(1100, 1100, 'Felucca', index).region, null) }) // ── Tokenizer edge cases ─────────────────────────────────────────────────── test('parseXml: skips comments, declarations and DOCTYPE', () => { const root = parseXml( '', ) assert.equal(root.name, 'r') assert.equal(root.children.length, 1) assert.equal(root.children[0].name, 'a') assert.equal(root.children[0].attrs.x, '1') }) test('parseXml: a ">" inside an attribute value does not end the tag', () => { const root = parseXml('') assert.equal(root.children[0].attrs.name, '1 > 0') assert.equal(root.children[0].attrs.b, '2') }) test('parseXml: single-quoted attributes are read', () => { const root = parseXml("") assert.equal(root.children[0].attrs.name, 'Mondain') }) test('parseXml: a stray closing tag is ignored, not fatal', () => { // Hand-maintained shard config: one malformed element should degrade to a // missing element, not abort an otherwise good build. const root = parseXml('') assert.equal(root.name, 'r') assert.deepEqual(root.children.map((n) => n.name), ['a', 'c']) }) test('parseXml: empty or element-free input yields null', () => { assert.equal(parseXml(''), null) assert.equal(parseXml(''), null) }) test('decodeEntities: named, numeric and hex refs', () => { assert.equal(decodeEntities("Mondain's Legacy"), "Mondain's Legacy") assert.equal(decodeEntities('a & b'), 'a & b') assert.equal(decodeEntities('<tag>'), '') assert.equal(decodeEntities('AB'), 'AB') // An unknown entity is left alone rather than silently eaten. assert.equal(decodeEntities('&nosuch;'), '&nosuch;') }) test('parseXml: decodes entities in attribute values', () => { const root = parseXml('') assert.equal(root.children[0].attrs.name, "Mondain's Legacy") }) // ── slugify ──────────────────────────────────────────────────────────────── test('slugify: produces URL-safe keys', () => { assert.equal(slugify('Lizardman'), 'lizardman') assert.equal(slugify('Giant Spider'), 'giant-spider') assert.equal(slugify("Mondain's Legacy"), 'mondain-s-legacy') assert.equal(slugify(' Orc '), 'orc') })