test(server): port core's UO suite onto the ctx harness
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 8m47s

22 test files moved from core, plus the two that were split out of files core
keeps. 351 tests pass.

One change runs through every moved test, and it is the boundary rather than a
chore: core internals can no longer be stubbed by requiring them, because there
are none to require. `../utils/db` and `../model/settings` do not exist here.
What a test controls instead is the ctx core would have handed over, installed
once by test/_setup.js -- which is a better seam anyway, since it is exactly the
surface the contract promises and nothing wider.

The ctx _setup installs is deliberately unfrozen. Core freezes what it hands a
module and entry.test.js still asserts against a frozen one; but a test that
needs settings.get to return a path has to be able to say so.

Two tests changed SHAPE, and that is the boundary too. fromShardEvent used to
assert through publish() into pushDevices and a captured fetch -- which
endpoints were hit, how many requests went out. None of that is this module's
any more: publish is ctx.push.publish, and the device registry and the relay are
behind it. Reaching for them from here would be reaching past ctx. What remains
is what the module owns and is the part worth guarding: a game account resolves
to a website user, a personal target that resolves to nobody is dropped rather
than published, and a sensitive kind never reaches publish at all.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:07:15 -05:00
committed by Claude
parent 740a677f92
commit 6b99d7e220
28 changed files with 4692 additions and 24 deletions

View File

@@ -0,0 +1,601 @@
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&apos;s Legacy"), "Mondain's Legacy")
assert.equal(decodeEntities('a &amp; b'), 'a & b')
assert.equal(decodeEntities('&lt;tag&gt;'), '<tag>')
assert.equal(decodeEntities('&#65;&#x42;'), '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&apos;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)
})