Replaces the committed-artifact design from the first commit. Two problems with it, both raised in review: **Facets are not a fixed list.** The first pass carried a hardcoded table of the six stock UO facets to reconcile the spelling drift between sources. That is wrong: a shard may add facets, replace them outright, or rename them when its maps are updated, and a built-in list quietly mishandles all three. Nothing in the atlas names a facet any more. The facet set is discovered from the tree — spawn records and region definitions are the authority — and the loose spellings in Data/Locations are matched against it by key and prefix. Custom facets get identical treatment; the tests use `Sosaria` and `Underdark` precisely so a stock-facet assumption cannot creep back in. **A snapshot goes stale.** Maps change over a server's life, so a build-once artifact silently drifts from the world players actually see. The tree is now the single source of truth and the atlas is re-derived on every boot. ## What that changed - **The committed artifact is gone** — 1.41 MB of generated JSON removed, along with `scripts/buildSpawnAtlas.js` and the whole encode/decode seam it needed (`encodePoint`/`readPoint`, the tuple encoding, the omitted-defaults scheme and their round-trip tests). Nothing to keep in sync, nothing to go stale. - **NEW `src/utils/spawnAtlasSource.js`** — the only thing that touches a ServUO tree; shared by the boot path and the CLI. Parsers stay pure and fs-free. - **NEW `src/model/shardAtlas/`** — `.db.js` (the one-transaction replace) and `.model.js` (the refresh decision). - **`scripts/importSpawnAtlas.js`** is now a thin CLI over the model: `--servuo`, `--force`, `--approve`, `--reject`, `--status`. `atlas:build` is gone; `atlas:import` remains. - Path comes from the `spawn_atlas_servuo_path` admin setting, falling back to `SERVUO_PATH`. The setting wins, matching how the rest of the shard integration is admin-managed rather than env-configured. ## Two contracts on the boot path **It never blocks startup.** No path, an unreadable mount, a malformed file, a database error — every one is caught and logged, and the site comes up serving whatever atlas it already had. Verified by booting the real server with no path, a broken path, and a good path. **A facet disappearing is never applied automatically.** Losing a facet is the signature of a half-copied or mid-update tree as much as of a real map change, and boot cannot tell them apart. The refresh is staged in `shard_atlas_pending` for an admin to approve or reject, and startup continues regardless. Additions and every other change apply immediately, since none of them can destroy something an operator would miss. Only the decision is stored, not the parsed world: a few KB of source hashes and the facet diff. Approving re-parses, so what gets applied matches the tree at approval time rather than at boot. A rejection is remembered against those exact hashes, so a declined refresh does not re-prompt on every restart — changing the tree changes the hashes and asks again. Hash-gated, so the common case (restart, maps unchanged) reads and hashes the tree (~120 ms) and writes nothing. A real change costs a ~400 ms parse. The admin approve/reject UI is part of the second PR, with the rest of the routes and pages. Until then the CLI covers it. ## Verification - **564 server tests pass**, 28 new in `spawnAtlas.source.test.js` covering the custom-facet build, the spelling reconciliation, hash gating, and every branch of the refresh decision — including that `refreshOnBoot` survives a database that throws on every call. - End-to-end against the local MariaDB and the real ServUO tree: 6,455 points, 800 creatures, 23,927 point/type rows, 387 regions, 558 landmarks, 25 altars, 83.2% of points resolved to a place name. - The facet gate exercised against a real tree copy with `malas.xml` removed: staged rather than applied, atlas untouched with all 293 Malas points intact, reject then stays quiet on re-run, approve applies and drops the facet. - Booted the real server under all three source conditions; none blocked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
564 lines
22 KiB
JavaScript
564 lines
22 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)
|
|
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 <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')
|
|
})
|