THROWAWAY BRANCH — evidence for the Phase 1 contract, never merged. See
modules/uo/SPIKE.md and docs/website/MODULE_API.md Part 7.
The six public spawn-atlas routes now live in modules/uo/, reached only through
the ctx/register surface, with the client half loading as a prebuilt ESM chunk.
All three exit criteria met:
• zero internal-file imports from the module into core; the built chunk has
zero bare import specifiers and bundles no React
• routes.manifest.json AND routes.guards.json are byte-identical
• /uo/atlas renders from /modules/uo/entry.js under script-src 'self' with
zero CSP violation reports
729 core tests and 81 module tests pass. Verified end to end against the real
database: the schema fragment replays after core's, onBoot runs the atlas
refresh, and the six API URLs answer unchanged.
Two things the spike changed in the contract:
• ctx.express / ctx.validator. A module lives outside server/, so Node never
reaches server/node_modules and require('express') fails outright — the
server-side twin of the one-React rule, which §2.6 had only for the client.
• window.__rg.jsxRuntime, so a module can build with the automatic JSX
runtime its tooling already assumes rather than being forced to classic.
And it confirmed §6.1 empirically: regenerating the OpenAPI spec silently
deleted all 361 lines of the atlas paths with "Swagger-autogen: Success", while
the route manifest kept all six in the same run. That is exactly the
static-analysis-vs-runtime split the fragment merge exists to prevent.
Co-Authored-By: Claude <noreply@anthropic.com>
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('../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)
|
|
})
|