Protocol 3.0 order 3 (Part C), first of two website PRs. This half is the data
pipeline only — parsers, the build/import CLI, and the tables. No routes and no
client, so nothing is user-visible yet; the API and pages follow in PR 2.
Part C is website-only: no plugin, no sidecar, no new event kinds, no wire
change.
## Parsing
`src/utils/spawnAtlasParse.js` is pure and fs-free so CI covers it with no
ServUO tree. Zero new dependencies — `Regions.xml` genuinely nests, so it gets a
small hand-rolled subset tokenizer rather than a new XML package. The 10.5 MB of
`Spawns/*.xml` never touches it: those records are flat and get a streaming
regex sweep instead.
The high-value transform is point-in-rect placement — highest region priority
wins, ties break to the smaller rect, then a nearest-landmark fallback within
200 tiles, else "Wilderness". That is what turns "lizardman at 5411,1234" into
"Despise, Felucca", and it resolves 83.2% of points (5,369 of 6,455).
Three things the real data forced, none of which were in the design:
- **Only 6 facets, not 13.** `Eodon.xml`, `GravewaterLake.xml` and the other
named-area files carry TerMur/Trammel points, so the facet comes from each
record's own `<Map>` and the artifact shards 6 ways.
- **Facet names disagree across sources.** `Data/Locations/*.xml` spells them
`Ter Mur` and `Tokuno Islands`; `<Map>` and `<Facet name>` say `TerMur` and
`Tokuno`. Unreconciled this is silent — the landmark fallback simply never
fires on those facets and every unregioned spawn there reads "Wilderness".
- **Spawn type tokens carry XmlSpawner directives**: `Fairy,{RND,4,8}`,
`alchemist/z/-50`, `Agralem/Name/Agralem`. Taken literally these invent
creatures that do not exist AND split real ones in two, since `Fairy` and
`Fairy,{RND,4,8}` slug apart. 71 of 845 entries were affected; stripping at
the first `/` or `,` leaves 800 clean ones.
## Artifact
`npm run atlas:build -- --servuo <path>` writes `db/data/spawnAtlas.*.json`:
6 facet shards + a compact index + a small indented `meta`. 1.41 MB committed,
down from 4.40 MB by dropping `facet` per record, omitting defaulted fields, and
tuple-encoding the ~24,000 type entries. `encodePoint()` and the importer's
`readPoint()` are exact inverses and are round-tripped in tests.
Display spelling is chosen deterministically (most common, ties to the
capitalised form) because the spawn files are inconsistent about case and the
name would otherwise depend on file read order — a spurious diff on every
unrelated rebuild.
## Import
`npm run atlas:import` needs no ServUO tree, which is the whole reason build and
import are separate: the container has the artifact but not the tree. It
reloads all six tables in one transaction (DELETE, not TRUNCATE, which is DDL
and would implicitly commit), so a failed import leaves the previous atlas
intact.
## No artwork, by design
The repo ships no creature art and no extraction tooling. Sprites live in the
operator's own client `.mul`/`.uop` files and are theirs, not ours to
redistribute. `shard_spawn_creatures.art` is nullable and NULL on every fresh
import; an operator who wants art extracts it themselves, drops it under
`server/uploads/atlas/` (already gitignored) and maps slugs in a gitignored
`spawnAtlas.art.json`. Text-only is the normal, fully supported state.
## Verification
- **544 server tests pass**, 57 new across `spawnAtlas.parse.test.js` (the
`:OBJ=` split, directive stripping, nested-region priority inheritance,
half-open rects, the facet reconciliation, tokenizer edge cases) and
`spawnAtlas.build.test.js` (aggregation, deterministic naming, and the
encode/decode round trip).
- Built and imported for real against the local MariaDB and the ServUO tree at
`C:\Users\colby\Desktop\ServUO`: 6,455 points, 800 creatures, 23,927
point/type rows, 387 regions, 558 landmarks, 25 champion altars.
- "Where does a lizardman spawn?" answers Shrines / Isamu-Jima / Yew across
Felucca, Trammel and Tokuno.
No routes changed, so the OpenAPI spec and route manifest are untouched.
---
- [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
503 lines
19 KiB
JavaScript
503 lines
19 KiB
JavaScript
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const {
|
|
parseXml,
|
|
parseObjects2,
|
|
parsePoints,
|
|
parseRegions,
|
|
parseLocations,
|
|
parseChampions,
|
|
buildPlacementIndex,
|
|
resolveRegion,
|
|
normalizeFacet,
|
|
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 ─────────────────────────────────────────────────
|
|
|
|
test('normalizeFacet: reconciles the Locations spellings with <Map>', () => {
|
|
// Left unreconciled, every unregioned Ter Mur and Tokuno spawn silently
|
|
// resolves to "Wilderness" because the landmark bucket is keyed differently.
|
|
assert.equal(normalizeFacet('Ter Mur'), 'TerMur')
|
|
assert.equal(normalizeFacet('TerMur'), 'TerMur')
|
|
assert.equal(normalizeFacet('Tokuno Islands'), 'Tokuno')
|
|
assert.equal(normalizeFacet('Tokuno'), 'Tokuno')
|
|
assert.equal(normalizeFacet('felucca'), 'Felucca')
|
|
})
|
|
|
|
test('normalizeFacet: an unknown facet passes through instead of vanishing', () => {
|
|
assert.equal(normalizeFacet('CustomShardFacet'), 'CustomShardFacet')
|
|
assert.equal(normalizeFacet(''), '')
|
|
assert.equal(normalizeFacet(null), '')
|
|
})
|
|
|
|
test('parseLocations and parsePoints agree on facet after normalisation', () => {
|
|
const landmarks = parseLocations(
|
|
'<places><parent name="Ter Mur"><parent name="Holy City">' +
|
|
'<child name="Bank" x="1000" y="1000" z="0" /></parent></parent></places>',
|
|
)
|
|
const points = parsePoints(
|
|
'<Spawns><Points><Name>a</Name><Map>TerMur</Map><X>1000</X><Y>1000</Y></Points></Spawns>',
|
|
)
|
|
assert.equal(landmarks[0].facet, points[0].facet)
|
|
})
|
|
|
|
// ── 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')
|
|
})
|