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
184 lines
6.7 KiB
JavaScript
184 lines
6.7 KiB
JavaScript
const { test } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const { aggregateCreatures, displayName, encodePoint } = require('../scripts/buildSpawnAtlas')
|
||
const { readPoint } = require('../scripts/importSpawnAtlas')
|
||
|
||
// buildSpawnAtlas is the fs/CLI shell, but its aggregation and its artifact
|
||
// encoding are pure and worth covering — the encoding especially, because it is
|
||
// mirrored by hand in importSpawnAtlas.readPoint() and a drift between the two
|
||
// would corrupt every imported point silently rather than failing loudly.
|
||
|
||
// ── aggregateCreatures ─────────────────────────────────────────────────────
|
||
|
||
const POINTS = [
|
||
{
|
||
facet: 'Felucca',
|
||
types: [
|
||
{ type: 'Lizardman', max: 3 },
|
||
{ type: 'Orc', max: 1 },
|
||
],
|
||
},
|
||
{ facet: 'Felucca', types: [{ type: 'Lizardman', max: 2 }] },
|
||
{ facet: 'Trammel', types: [{ type: 'lizardman', max: 5 }] },
|
||
]
|
||
|
||
test('aggregateCreatures: sums each type’s own max across points', () => {
|
||
const creatures = aggregateCreatures(POINTS)
|
||
const lizardman = creatures.find((c) => c.slug === 'lizardman')
|
||
// 3 + 2 + 5 — how many exist in the world at once.
|
||
assert.equal(lizardman.total, 10)
|
||
assert.equal(lizardman.points, 3)
|
||
})
|
||
|
||
test('aggregateCreatures: counts points per facet', () => {
|
||
const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
|
||
assert.deepEqual(lizardman.facets, { Felucca: 2, Trammel: 1 })
|
||
})
|
||
|
||
test('aggregateCreatures: differing case collapses into one creature', () => {
|
||
// "Lizardman" and "lizardman" are the same creature; the shard's spawn files
|
||
// are not consistent about case.
|
||
const creatures = aggregateCreatures(POINTS)
|
||
assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
|
||
})
|
||
|
||
test('aggregateCreatures: sorted by slug, and internal state is not leaked', () => {
|
||
const creatures = aggregateCreatures(POINTS)
|
||
assert.deepEqual(
|
||
creatures.map((c) => c.slug),
|
||
['lizardman', 'orc'],
|
||
)
|
||
// The spelling tally is a build detail and must not reach the artifact.
|
||
assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
|
||
})
|
||
|
||
test('aggregateCreatures: empty input yields no creatures', () => {
|
||
assert.deepEqual(aggregateCreatures([]), [])
|
||
})
|
||
|
||
// ── displayName ────────────────────────────────────────────────────────────
|
||
|
||
test('displayName: the most common spelling wins', () => {
|
||
assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
|
||
assert.equal(displayName(new Map([['Lizardman', 9], ['lizardman', 2]])), 'Lizardman')
|
||
})
|
||
|
||
test('displayName: ties break toward the capitalised spelling', () => {
|
||
// Otherwise the display name depends on file read order, which would produce
|
||
// spurious diffs in the committed artifact on an unrelated rebuild.
|
||
assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
|
||
assert.equal(displayName(new Map([['acidslug', 1], ['AcidSlug', 1]])), 'AcidSlug')
|
||
})
|
||
|
||
test('displayName: fully tied spellings fall back to alphabetical, not input order', () => {
|
||
const forward = displayName(new Map([['abc', 1], ['abd', 1]]))
|
||
const reverse = displayName(new Map([['abd', 1], ['abc', 1]]))
|
||
assert.equal(forward, reverse)
|
||
})
|
||
|
||
// ── encodePoint / readPoint round trip ─────────────────────────────────────
|
||
|
||
const FULL_POINT = {
|
||
facet: 'Trammel',
|
||
name: 'CovetousSpawner26',
|
||
x: 5412,
|
||
y: 1970,
|
||
width: 10,
|
||
height: 10,
|
||
range: 5,
|
||
maxCount: 3,
|
||
minDelay: 5,
|
||
maxDelay: 10,
|
||
todStart: 4,
|
||
todEnd: 8,
|
||
todMode: 1,
|
||
region: 'Covetous',
|
||
landmark: null,
|
||
types: [
|
||
{ type: 'Lizardman', max: 3 },
|
||
{ type: 'Orc', max: 1 },
|
||
],
|
||
}
|
||
|
||
test('encodePoint: drops facet and tuple-encodes types', () => {
|
||
const encoded = encodePoint(FULL_POINT)
|
||
assert.equal(Object.hasOwn(encoded, 'facet'), false)
|
||
assert.deepEqual(encoded.types, [
|
||
['Lizardman', 3],
|
||
['Orc', 1],
|
||
])
|
||
})
|
||
|
||
test('round trip: encodePoint → JSON → readPoint restores every field', () => {
|
||
// The artifact goes through JSON on disk, so round-trip through it here too.
|
||
const wire = JSON.parse(JSON.stringify(encodePoint(FULL_POINT)))
|
||
const decoded = readPoint(wire, 'Trammel')
|
||
for (const key of Object.keys(FULL_POINT)) {
|
||
if (key === 'types') continue
|
||
assert.deepEqual(decoded[key], FULL_POINT[key], `field "${key}" survived the round trip`)
|
||
}
|
||
assert.deepEqual(decoded.types, FULL_POINT.types)
|
||
})
|
||
|
||
test('round trip: omitted defaults come back as zeros, not undefined', () => {
|
||
// The build omits width/height/range/minDelay/maxDelay/tod* when they are 0,
|
||
// which is the majority of spawners. They must decode to 0 — a NULL would
|
||
// violate the NOT NULL columns.
|
||
const sparse = {
|
||
facet: 'Felucca',
|
||
name: 'Simple',
|
||
x: 100,
|
||
y: 200,
|
||
width: 0,
|
||
height: 0,
|
||
range: 0,
|
||
maxCount: 1,
|
||
minDelay: 0,
|
||
maxDelay: 0,
|
||
todStart: 0,
|
||
todEnd: 0,
|
||
todMode: 0,
|
||
region: null,
|
||
landmark: null,
|
||
types: [{ type: 'Orc', max: 1 }],
|
||
}
|
||
const encoded = JSON.parse(JSON.stringify(encodePoint(sparse)))
|
||
// Precondition: the build really did omit them.
|
||
assert.equal(Object.hasOwn(encoded, 'width'), false)
|
||
assert.equal(Object.hasOwn(encoded, 'todMode'), false)
|
||
|
||
const decoded = readPoint(encoded, 'Felucca')
|
||
for (const key of ['width', 'height', 'range', 'minDelay', 'maxDelay', 'todStart', 'todEnd', 'todMode']) {
|
||
assert.equal(decoded[key], 0, `${key} decodes to 0`)
|
||
}
|
||
})
|
||
|
||
test('round trip: label is recomputed, not stored', () => {
|
||
const encoded = encodePoint(FULL_POINT)
|
||
// Precondition: the build does not write it.
|
||
assert.equal(Object.hasOwn(encoded, 'label'), false)
|
||
|
||
assert.equal(readPoint(encoded, 'Trammel').label, 'Covetous')
|
||
assert.equal(readPoint({ ...encoded, region: undefined, landmark: 'Britain' }, 'T').label, 'Britain')
|
||
assert.equal(
|
||
readPoint({ ...encoded, region: undefined, landmark: undefined }, 'T').label,
|
||
'Wilderness',
|
||
)
|
||
})
|
||
|
||
test('readPoint: takes its facet from the shard file, not the record', () => {
|
||
const decoded = readPoint(encodePoint(FULL_POINT), 'Felucca')
|
||
assert.equal(decoded.facet, 'Felucca')
|
||
})
|
||
|
||
test('readPoint: tolerates already-decoded object types', () => {
|
||
// Defensive: an artifact written before tuple encoding still imports.
|
||
const decoded = readPoint({ x: 1, y: 2, types: [{ type: 'Orc', max: 2 }] }, 'Felucca')
|
||
assert.deepEqual(decoded.types, [{ type: 'Orc', max: 2 }])
|
||
})
|
||
|
||
test('readPoint: a record with no types decodes to an empty list', () => {
|
||
assert.deepEqual(readPoint({ x: 1, y: 2 }, 'Felucca').types, [])
|
||
})
|