Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data pipeline; this makes it reachable — six public routes, five admin ones, two public pages and an admin panel. Still website-only: no plugin, no sidecar, no new event kinds, no wire change. The API sits at /api/v1/public/atlas, not under /public/shard. Nothing here touches the sidecar, so the pages stay complete while the shard is down, and a /shard prefix would imply a dependency the atlas does not have. Unlike /shard/* it IS site-mode gated, like /posts and /wiki: a bestiary is site content. Every route carries requireFeature('atlas') and projects its response. The atlas feature declares no sensitive fields, so the projection is a no-op today — the call is there because v3.md 3.6.1's rule is that the FIRST field needing a gate should be covered by construction rather than by a retrofit. Two bugs the UI surfaced, both fixed here: Respawn delays were stored in the wrong unit, sometimes. XmlSpawner writes MinDelay/MaxDelay in minutes and switches to seconds only when a delay does not divide into whole minutes, flagging it per record with DelayInSec. A `5` means five minutes on one spawner and five seconds on the next, both plausible, and the pipeline stored the raw number. 170 of 6,455 stock spawners are second flagged. The parser normalises to seconds; the API and UI carry seconds. That exposed the hash gate as a trap. "Has the tree changed?" is the wrong question on its own: an install whose maps never change would have kept serving the old readings forever, because the only thing compared was the tree. PARSER_VERSION is now stored beside the source hashes and a mismatch counts as drift, so any future parse correction lands on the next boot. Also renamed the detail route's spawn-point array to `spawners` — it was `points`, which is the COUNT on the search route, so one key meant a number in one place and an array in the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
602 lines
23 KiB
JavaScript
602 lines
23 KiB
JavaScript
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/<robe/loottype/blessed' +
|
|
'/itemid/8259>/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 = `<Spawns>
|
|
<Points>
|
|
<Name>CovetousSpawner26</Name>
|
|
<UniqueId>001a34e5-0efa-46de-9c93-b6a163d96370</UniqueId>
|
|
<Map>Trammel</Map>
|
|
<X>5412</X>
|
|
<Y>1970</Y>
|
|
<Width>10</Width>
|
|
<Height>10</Height>
|
|
<Range>5</Range>
|
|
<MaxCount>3</MaxCount>
|
|
<MinDelay>5</MinDelay>
|
|
<MaxDelay>10</MaxDelay>
|
|
<ProximityTriggerSound>500</ProximityTriggerSound>
|
|
<TODStart>0</TODStart>
|
|
<TODEnd>0</TODEnd>
|
|
<TODMode>0</TODMode>
|
|
<IsRunning>True</IsRunning>
|
|
<Objects2>Lizardman:MX=3:SB=0</Objects2>
|
|
</Points>
|
|
<Points>
|
|
<Name>Disabled</Name>
|
|
<Map>Felucca</Map>
|
|
<X>100</X>
|
|
<Y>200</Y>
|
|
<MaxCount>1</MaxCount>
|
|
<IsRunning>False</IsRunning>
|
|
<Objects2>Orc:MX=1</Objects2>
|
|
</Points>
|
|
</Spawns>`
|
|
|
|
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)
|
|
// Delays are normalised to seconds; this record carries no DelayInSec, which
|
|
// means minutes.
|
|
assert.equal(covetous.minDelay, 300)
|
|
assert.equal(covetous.maxDelay, 600)
|
|
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 <Map>, never the file name', () => {
|
|
// Eodon.xml holds TerMur points; a file-name assumption would mislabel every
|
|
// one of them.
|
|
const points = parsePoints(
|
|
'<Spawns><Points><Name>a</Name><Map>TerMur</Map><X>1</X><Y>2</Y></Points></Spawns>',
|
|
)
|
|
assert.equal(points[0].facet, 'TerMur')
|
|
})
|
|
|
|
test('parsePoints: a record with no <Map> is skipped rather than misfiled', () => {
|
|
const points = parsePoints('<Spawns><Points><Name>a</Name><X>1</X><Y>2</Y></Points></Spawns>')
|
|
assert.deepEqual(points, [])
|
|
})
|
|
|
|
test('parsePoints: empty document yields no points', () => {
|
|
assert.deepEqual(parsePoints('<Spawns></Spawns>'), [])
|
|
assert.deepEqual(parsePoints(''), [])
|
|
})
|
|
|
|
// ── parseRegions ───────────────────────────────────────────────────────────
|
|
|
|
const REGIONS_XML = `<?xml version="1.0" encoding="utf-8"?>
|
|
<ServerRegions>
|
|
<Facet name="Felucca">
|
|
<region type="GuardedRegion" priority="50" name="Moongates">
|
|
<!-- britain -->
|
|
<rect x="1330" y="1991" width="13" height="13" />
|
|
<rect x="761" y="741" width="19" height="21" />
|
|
</region>
|
|
<region type="MondainRegion" priority="50" name="Prism of Light">
|
|
<rect x="6400" y="0" width="221" height="255" />
|
|
<go x="6474" y="188" z="0" />
|
|
<music name="Dungeon9" />
|
|
<region type="CrystalField" name="Crystal Field">
|
|
<rect x="6506" y="83" width="7" height="7" />
|
|
<zrange min="-4" />
|
|
</region>
|
|
<region type="IcyRiver">
|
|
<rect x="6576" y="73" width="10" height="31" />
|
|
</region>
|
|
</region>
|
|
<region type="TownRegion" priority="10" name="Music Only">
|
|
<music name="Britain" />
|
|
</region>
|
|
</Facet>
|
|
</ServerRegions>`
|
|
|
|
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 = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
|
|
<places>
|
|
<parent name="Trammel">
|
|
<parent name="Dungeons">
|
|
<parent name="Covetous">
|
|
<child name="Entrance" x="2499" y="919" z="0" />
|
|
<child name="Level 1" x="5456" y="1863" z="0" />
|
|
</parent>
|
|
<parent name="Despise">
|
|
<child name="Level 3" x="5407" y="859" z="45" />
|
|
</parent>
|
|
</parent>
|
|
</parent>
|
|
</places>`
|
|
|
|
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', () => {
|
|
// <Map> and <Facet name> are the authority; they are never rewritten.
|
|
const points = parsePoints(
|
|
'<Spawns><Points><Name>a</Name><Map>Sosaria</Map><X>1</X><Y>2</Y></Points></Spawns>',
|
|
)
|
|
assert.equal(points[0].facet, 'Sosaria')
|
|
const regions = parseRegions(
|
|
'<ServerRegions><Facet name="Sosaria"><region name="Town" priority="1">' +
|
|
'<rect x="0" y="0" width="10" height="10"/></region></Facet></ServerRegions>',
|
|
)
|
|
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<championSystem>
|
|
<!-- comment describing the schema -->
|
|
<spawn name="Deceit" group="FelDungeons" type="UnholyTerror">
|
|
<location x="5178" y="708" z="20" map="Felucca" radius="60" />
|
|
</spawn>
|
|
<spawn name="Wandering" group="FelDungeons">
|
|
<location x="100" y="200" z="0" map="Felucca" radius="40" />
|
|
</spawn>
|
|
</championSystem>`
|
|
|
|
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(
|
|
'<?xml version="1.0"?><!DOCTYPE r><r><!-- <fake a="b"/> --><a x="1"/></r>',
|
|
)
|
|
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('<r><a name="1 > 0" b="2"/></r>')
|
|
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("<r><a name='Mondain' /></r>")
|
|
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('<r><a/></b><c/></r>')
|
|
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('<!-- only a comment -->'), 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>'), '<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('<r><region name="Mondain's Legacy" /></r>')
|
|
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')
|
|
})
|
|
|
|
// ── Respawn delays: the unit is per record ──────────────────────────────────
|
|
// XmlSpawner writes minutes by default and switches to seconds only when a
|
|
// delay does not divide into whole minutes, flagged by DelayInSec. `5` therefore
|
|
// means five MINUTES on one spawner and five SECONDS on the next, and a reader
|
|
// assuming either unit is wrong about the other — silently, since both are
|
|
// plausible respawn times.
|
|
const DELAY_XML = `<Spawns>
|
|
<Points>
|
|
<Name>Minutes</Name>
|
|
<Map>Sosaria</Map>
|
|
<X>1</X><Y>1</Y>
|
|
<MinDelay>5</MinDelay>
|
|
<MaxDelay>10</MaxDelay>
|
|
<IsRunning>True</IsRunning>
|
|
<Objects2>Orc:MX=1</Objects2>
|
|
</Points>
|
|
<Points>
|
|
<Name>Seconds</Name>
|
|
<Map>Sosaria</Map>
|
|
<X>2</X><Y>2</Y>
|
|
<DelayInSec>True</DelayInSec>
|
|
<MinDelay>5</MinDelay>
|
|
<MaxDelay>10</MaxDelay>
|
|
<IsRunning>True</IsRunning>
|
|
<Objects2>Orc:MX=1</Objects2>
|
|
</Points>
|
|
</Spawns>`
|
|
|
|
test('parsePoints: DelayInSec decides the unit, and both come out in seconds', () => {
|
|
const [minutes, seconds] = parsePoints(DELAY_XML)
|
|
assert.equal(minutes.minDelay, 300)
|
|
assert.equal(minutes.maxDelay, 600)
|
|
assert.equal(seconds.minDelay, 5)
|
|
assert.equal(seconds.maxDelay, 10)
|
|
})
|