Five targeted leases over two planes, the item grant, the world save, and the atlas work the spawner dropdown needed. FIVE LEASES, ONE FACTORY `uo.spawner.maxcount`, `.mindelay`, `.maxdelay`, `.running` and `uo.seasonal.status`. The four callables differ only in which key they name, so they are built rather than repeated: five copies would be five chances for one of them to forget the drift check, which is the one thing §F says a lease must not be allowed to skip. It is `MaxCount`, not the `Amount` EVENTS_PLAN.md named -- there is no such property on ServUO 57.4. `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries SECONDS: the spawn files' own `DelayInSec` flag proves both units are in use on a real tree, and a unit that cannot express five seconds cannot express this shard's own data. The seasonal lease is a THREE-value enum over EIGHT events. §G called `GetEntry(type).Status` "a nine-value enum" and had it backwards: `EventStatus` has three values and it is `EventType` that has nine entries. Eight rather than nine because `TreasuresOfTokuno` is excluded -- `IsActive()` reads its own `DropEra` rather than `Status`, so leasing it would apply cleanly, read back, restore cleanly and do nothing at all. Two behaviours worth the review. `inForce()` reads the frame's `holds` rather than a row's `held` flag, because a catalog walk can enumerate the keys but never the holds on a targeted one. And a target that VANISHED mid-run is a SUCCESSFUL restore: there is nothing to give back, and reporting it failed would leave a ledger row unresolved for ever over an object that is gone -- 12a's `gone` in the lease plane's vocabulary. THE GRANT NAMES A RUN, NEVER A RECIPIENT LIST Core has the participants in `event_run_participants`, but a module cannot read core's tables -- so the alternative was a new core surface handing them over. Not needed: the shard has held the run's ledger since it opened, keyed by the same serials core stores as `member_key`. And the grant is RETRYABLE. §G called it un-retryable because a lost acknowledgement and a grant that never applied were the same event, which is exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9. Protocol 6's idempotency key closes it. `uo.rewards` counts ITEMS rather than grants: 500 gold to forty people and a candle to forty people are not the same imposition. THE ATLAS KEEPS UniqueId AGAIN, AND THE SPAWNER SOURCE SEARCHES The parser has read `<UniqueId>` and thrown it away since the atlas shipped, on a line citing a committed artifact -- there is no committed artifact, as `spawnAtlasSource.js` says in its own header. It is the ONLY name for one particular spawner that exists off the shard, so a property lease could not have had a dropdown without it. `PARSER_VERSION` -> 4 so an unchanged tree is re-read. `uo.options.spawners` is the first searchable source and the first that had to be: 6,707 spawn points against `MAX_OPTIONS`' 2,000, so a flat list would drop two thirds of the world and say nothing about which two thirds. ONE DEFECT IN ALREADY-MERGED CODE, AND IT WOULD HAVE BROKEN EVERYTHING The protocol pin never left 5. `uo_link_config.protocol` reaches the sidecar as `X-UOLink-Version` on every REST call and an exact mismatch is a 409, so from Phase 11a onward every sidecar call on a real deployment would have been refused -- the whole event plane dead, loudly, for a reason nobody would look here for. 11a took the wire to 6 and 12a to 7; neither moved the pin, in either of the two places this repo declares it. It survived both because both live walks set the column by hand while standing the rig up, which is exactly what makes a migration nobody runs invisible. All three sites go to 7. The test that guards them is worth understanding before trusting it: `schemaFragment.test.js` asserts the three declarations agree WITH EACH OTHER -- a real check they once failed -- but all three being equally stale passes it, and nothing in this repo can anchor it to the wire. Recorded in the model's own header so the next reader knows. CHECKS `npm test`: 620 pass, 0 fail (was 605). `check:imports` and `check:externals` clean; the client builds and its 42 tests pass. `check:swagger` reports the fragment stale -- it is ALREADY stale on `edge` (verified by stashing this branch's changes and re-running) and this phase adds no route, so it is left alone rather than regenerated inside an unrelated change. Two bugs the new tests caught in this branch's own code before it left: `counted()` returns `.count` and the grant read `.value`, so every grant went out with `amount: undefined` and the non-stackable guard never fired; and `optionalInt`'s `ok` was ignored, so a bad hue passed silently instead of refusing. Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
654 lines
26 KiB
JavaScript
654 lines
26 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,
|
|
parseDecoration,
|
|
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 }])
|
|
// **The UniqueId is KEPT from Phase 12b**, having been dropped since the atlas
|
|
// shipped. It is `XmlSpawner.UniqueId` — carried in the spawn files and on the
|
|
// live spawner — so it is the only name for one particular spawner that exists
|
|
// off the shard, and a property lease targets by it. A serial cannot do that
|
|
// job: serials are assigned when the world is built and nothing here knows one.
|
|
assert.equal(covetous.uniqueId, '001a34e5-0efa-46de-9c93-b6a163d96370')
|
|
// The rest of the dropped fields still are. Triggering, refractory windows,
|
|
// proximity and sounds are what the site has no use for.
|
|
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)
|
|
})
|
|
|
|
// ── parseDecoration (Phase 12a) ───────────────────────────────
|
|
|
|
test('parseDecoration: reads the type off each header and ignores the placements', () => {
|
|
const rows = parseDecoration(`# switch
|
|
Static 0x108F
|
|
5552 1864 11
|
|
5399 1875 17
|
|
|
|
# crate
|
|
LargeCrate 0x0E3C
|
|
5408 607 45
|
|
`)
|
|
assert.deepEqual(rows, [
|
|
{ type: 'Static', itemId: 0x108f },
|
|
{ type: 'LargeCrate', itemId: 0x0e3c },
|
|
])
|
|
})
|
|
|
|
test('parseDecoration: a parenthesised property list is not part of the type', () => {
|
|
// These are the shard's own decoration details — which way a door faces, what
|
|
// hue a banner is — and an event author is choosing neither. Only the class
|
|
// name is, because that is what the plugin constructs from.
|
|
assert.deepEqual(parseDecoration('AnkhNorth 0x0004 (Hue=0x47E)'), [
|
|
{ type: 'AnkhNorth', itemId: 4 },
|
|
])
|
|
assert.deepEqual(parseDecoration('ArmsAndWeaponsPrimer 0x0FEF (Name=a life of travel)'), [
|
|
{ type: 'ArmsAndWeaponsPrimer', itemId: 0x0fef },
|
|
])
|
|
})
|
|
|
|
test('parseDecoration: a negative z on a placement line is not mistaken for a type', () => {
|
|
// The real trap in this format: a coordinate line starts with a digit OR a
|
|
// minus, so "not a comment" is not the test. A z of -12 is ordinary in every
|
|
// dungeon file in the tree.
|
|
assert.deepEqual(parseDecoration(`Static 0x07A4
|
|
5558 1826 -12
|
|
-5 -5 -5
|
|
`), [{ type: 'Static', itemId: 0x07a4 }])
|
|
})
|
|
|
|
test('parseDecoration: empty, comment-only and absent input all yield nothing', () => {
|
|
assert.deepEqual(parseDecoration(''), [])
|
|
assert.deepEqual(parseDecoration(null), [])
|
|
assert.deepEqual(parseDecoration('# nothing but a comment\n\n'), [])
|
|
})
|