Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data pipeline; this makes it reachable — six public routes, five admin ones, two public pages and an admin panel. Still website-only: no plugin, no sidecar, no new event kinds, no wire change. The API sits at /api/v1/public/atlas, not under /public/shard. Nothing here touches the sidecar, so the pages stay complete while the shard is down, and a /shard prefix would imply a dependency the atlas does not have. Unlike /shard/* it IS site-mode gated, like /posts and /wiki: a bestiary is site content. Every route carries requireFeature('atlas') and projects its response. The atlas feature declares no sensitive fields, so the projection is a no-op today — the call is there because v3.md 3.6.1's rule is that the FIRST field needing a gate should be covered by construction rather than by a retrofit. Two bugs the UI surfaced, both fixed here: Respawn delays were stored in the wrong unit, sometimes. XmlSpawner writes MinDelay/MaxDelay in minutes and switches to seconds only when a delay does not divide into whole minutes, flagging it per record with DelayInSec. A `5` means five minutes on one spawner and five seconds on the next, both plausible, and the pipeline stored the raw number. 170 of 6,455 stock spawners are second flagged. The parser normalises to seconds; the API and UI carry seconds. That exposed the hash gate as a trap. "Has the tree changed?" is the wrong question on its own: an install whose maps never change would have kept serving the old readings forever, because the only thing compared was the tree. PARSER_VERSION is now stored beside the source hashes and a mismatch counts as drift, so any future parse correction lands on the next boot. Also renamed the detail route's spawn-point array to `spawners` — it was `points`, which is the COUNT on the search route, so one key meant a number in one place and an array in the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
397 lines
16 KiB
JavaScript
397 lines
16 KiB
JavaScript
process.env.DB_HOST = '127.0.0.1'
|
||
process.env.DB_PORT = '59999'
|
||
|
||
const fs = require('fs')
|
||
const os = require('os')
|
||
const path = require('path')
|
||
|
||
const { test, after, beforeEach } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const {
|
||
AtlasSourceError,
|
||
aggregateCreatures,
|
||
displayName,
|
||
sameSources,
|
||
hashSources,
|
||
buildAtlas,
|
||
PARSER_VERSION,
|
||
} = require('../src/utils/spawnAtlasSource')
|
||
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
|
||
const atlasDb = require('../src/model/shardAtlas/shardAtlas.db')
|
||
const settings = require('../src/model/settings/settings.model')
|
||
const db = require('../src/utils/db')
|
||
|
||
after(() => db.close())
|
||
|
||
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
|
||
//
|
||
// Deliberately uses facets that do NOT exist in stock UO. The atlas must not
|
||
// contain a built-in facet list anywhere: a shard may add facets, replace them
|
||
// outright, or rename them when its maps are updated, and everything has to keep
|
||
// working with no code change.
|
||
|
||
function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {}) {
|
||
fs.mkdirSync(path.join(root, 'Spawns'), { recursive: true })
|
||
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
|
||
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
|
||
|
||
for (const facet of facets) {
|
||
fs.writeFileSync(
|
||
path.join(root, 'Spawns', `${facet}.xml`),
|
||
`<Spawns>
|
||
<Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
|
||
<MaxCount>3</MaxCount><IsRunning>True</IsRunning>
|
||
<Objects2>Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0</Objects2></Points>
|
||
<Points><Name>${facet}B</Name><Map>${facet}</Map><X>9000</X><Y>9000</Y>
|
||
<MaxCount>1</MaxCount><IsRunning>True</IsRunning>
|
||
<Objects2>lizardman:MX=2:SB=0</Objects2></Points>
|
||
<Points><Name>${facet}Off</Name><Map>${facet}</Map><X>1</X><Y>1</Y>
|
||
<MaxCount>1</MaxCount><IsRunning>False</IsRunning>
|
||
<Objects2>Ghost:MX=1</Objects2></Points>
|
||
</Spawns>`,
|
||
'utf8',
|
||
)
|
||
// The location file names its facet differently from <Map>, the real
|
||
// `Ter Mur` / `Tokuno Islands` drift.
|
||
fs.writeFileSync(
|
||
path.join(root, 'Data', 'Locations', `${facet.toLowerCase()}.xml`),
|
||
`<places><parent name="${facet} Isles"><parent name="Deep Cave">
|
||
<child name="Level 1" x="9010" y="9010" z="0" /></parent></parent></places>`,
|
||
'utf8',
|
||
)
|
||
}
|
||
|
||
fs.writeFileSync(
|
||
path.join(root, 'Data', 'Regions.xml'),
|
||
`<ServerRegions>${facets
|
||
.map(
|
||
(facet) => `<Facet name="${facet}">
|
||
<region type="TownRegion" priority="10" name="${facet} City">
|
||
<rect x="1000" y="1000" width="500" height="500" />
|
||
</region></Facet>`,
|
||
)
|
||
.join('')}</ServerRegions>`,
|
||
'utf8',
|
||
)
|
||
|
||
if (includeChampions) {
|
||
fs.writeFileSync(
|
||
path.join(root, 'Config', 'ChampionSpawns.xml'),
|
||
`<championSystem><spawn name="Deep" group="G" type="Terror">
|
||
<location x="1100" y="1100" z="0" map="${facets[0]}" radius="40" />
|
||
</spawn></championSystem>`,
|
||
'utf8',
|
||
)
|
||
}
|
||
}
|
||
|
||
function tempTree(options) {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-test-'))
|
||
writeTree(root, options)
|
||
return root
|
||
}
|
||
|
||
// ── buildAtlas against a custom-facet tree ─────────────────────────────────
|
||
|
||
test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
|
||
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||
const atlas = buildAtlas(root)
|
||
assert.deepEqual(atlas.facets, ['Sosaria', 'Underdark'])
|
||
assert.equal(atlas.meta.counts.facets, 2)
|
||
})
|
||
|
||
test('buildAtlas: reconciles a location file that spells the facet differently', () => {
|
||
// "Sosaria Isles" inside the file vs <Map>Sosaria</Map> — the same drift that
|
||
// silently emptied the Ter Mur / Tokuno landmark buckets.
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
const atlas = buildAtlas(root)
|
||
assert.deepEqual([...new Set(atlas.landmarks.map((l) => l.facet))], ['Sosaria'])
|
||
// And the fallback actually fires, rather than the point reading Wilderness.
|
||
const far = atlas.points.find((p) => p.name === 'SosariaB')
|
||
assert.equal(far.landmark, 'Deep Cave')
|
||
assert.equal(far.label, 'Deep Cave')
|
||
})
|
||
|
||
test('buildAtlas: resolves a contained point to its region', () => {
|
||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||
const inCity = atlas.points.find((p) => p.name === 'SosariaA')
|
||
assert.equal(inCity.region, 'Sosaria City')
|
||
assert.equal(inCity.label, 'Sosaria City')
|
||
})
|
||
|
||
test('buildAtlas: drops spawners that are switched off in-world', () => {
|
||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||
assert.equal(atlas.points.some((p) => p.name === 'SosariaOff'), false)
|
||
assert.equal(atlas.meta.counts.pointsDisabled, 1)
|
||
})
|
||
|
||
test('buildAtlas: a champion altar resolves through the same placement index', () => {
|
||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||
assert.equal(atlas.champions[0].label, 'Sosaria City')
|
||
assert.equal(atlas.champions[0].facet, 'Sosaria')
|
||
})
|
||
|
||
test('buildAtlas: a tree with no champion file still builds', () => {
|
||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'], includeChampions: false }))
|
||
assert.deepEqual(atlas.champions, [])
|
||
})
|
||
|
||
test('buildAtlas: missing path and empty path raise typed errors', () => {
|
||
assert.throws(() => buildAtlas(''), (err) => err instanceof AtlasSourceError && err.code === 'NO_PATH')
|
||
assert.throws(
|
||
() => buildAtlas(path.join(os.tmpdir(), 'definitely-not-a-servuo-tree-xyz')),
|
||
(err) => err instanceof AtlasSourceError && err.code === 'NOT_FOUND',
|
||
)
|
||
})
|
||
|
||
test('buildAtlas: a directory with no spawn files raises rather than building empty', () => {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-empty-'))
|
||
fs.mkdirSync(path.join(root, 'Data'), { recursive: true })
|
||
fs.writeFileSync(path.join(root, 'Data', 'Regions.xml'), '<ServerRegions/>', 'utf8')
|
||
assert.throws(() => buildAtlas(root), (err) => err.code === 'NO_SPAWNS')
|
||
})
|
||
|
||
// ── Hashing ────────────────────────────────────────────────────────────────
|
||
|
||
test('hashSources: stable across reads, changes when a file changes', () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
const first = hashSources(root)
|
||
assert.ok(sameSources(first, hashSources(root)))
|
||
|
||
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- edit -->', 'utf8')
|
||
assert.equal(sameSources(first, hashSources(root)), false)
|
||
})
|
||
|
||
test('sameSources: a missing or extra file is a difference', () => {
|
||
assert.equal(sameSources({ a: '1' }, { a: '1', b: '2' }), false)
|
||
assert.equal(sameSources({ a: '1' }, { a: '2' }), false)
|
||
assert.equal(sameSources({ a: '1' }, { a: '1' }), true)
|
||
assert.equal(sameSources(null, { a: '1' }), false)
|
||
assert.equal(sameSources({ a: '1' }, null), false)
|
||
})
|
||
|
||
// ── Aggregation ────────────────────────────────────────────────────────────
|
||
|
||
const POINTS = [
|
||
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 3 }, { type: 'Orc', max: 1 }] },
|
||
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 2 }] },
|
||
{ facet: 'Underdark', types: [{ type: 'lizardman', max: 5 }] },
|
||
]
|
||
|
||
test('aggregateCreatures: sums each type’s own max and counts per facet', () => {
|
||
const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
|
||
assert.equal(lizardman.total, 10)
|
||
assert.equal(lizardman.points, 3)
|
||
assert.deepEqual(lizardman.facets, { Sosaria: 2, Underdark: 1 })
|
||
})
|
||
|
||
test('aggregateCreatures: differing case collapses to one creature', () => {
|
||
const creatures = aggregateCreatures(POINTS)
|
||
assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
|
||
assert.deepEqual(creatures.map((c) => c.slug), ['lizardman', 'orc'])
|
||
assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
|
||
})
|
||
|
||
test('displayName: most common wins, ties break to the capitalised form', () => {
|
||
assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
|
||
assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
|
||
// Deterministic regardless of insertion order — a committed artifact is gone,
|
||
// but a spurious diff in the DB on every restart would be just as wrong.
|
||
assert.equal(
|
||
displayName(new Map([['abc', 1], ['abd', 1]])),
|
||
displayName(new Map([['abd', 1], ['abc', 1]])),
|
||
)
|
||
})
|
||
|
||
test('pointTypeRows: collapses a repeated type to the larger max', () => {
|
||
// The primary key is (point_id, slug), so a duplicate would otherwise fail the
|
||
// insert and take the whole transaction with it.
|
||
const rows = shardAtlas.pointTypeRows([
|
||
{ types: [{ type: 'Orc', max: 1 }, { type: 'orc', max: 4 }, { type: 'Rat', max: 2 }] },
|
||
])
|
||
assert.deepEqual(rows.sort(), [[1, 'orc', 4], [1, 'rat', 2]].sort())
|
||
})
|
||
|
||
test('pointTypeRows: point ids are 1-based and line up with insert order', () => {
|
||
const rows = shardAtlas.pointTypeRows([
|
||
{ types: [{ type: 'A', max: 1 }] },
|
||
{ types: [{ type: 'B', max: 1 }] },
|
||
])
|
||
assert.deepEqual(rows, [[1, 'a', 1], [2, 'b', 1]])
|
||
})
|
||
|
||
// ── The refresh decision ───────────────────────────────────────────────────
|
||
//
|
||
// The boot path's two contracts: it never blocks startup, and it never applies a
|
||
// facet removal on its own.
|
||
|
||
let applied
|
||
let pendingRow
|
||
let facetsInDb
|
||
let metaRow
|
||
|
||
beforeEach(() => {
|
||
applied = null
|
||
pendingRow = null
|
||
facetsInDb = []
|
||
metaRow = null
|
||
atlasDb.replaceAtlas = async (atlas) => {
|
||
applied = atlas
|
||
return { points: atlas.points.length, creatures: atlas.creatures.length }
|
||
}
|
||
atlasDb.getMeta = async () => metaRow
|
||
atlasDb.getFacets = async () => facetsInDb
|
||
atlasDb.getPending = async () => pendingRow
|
||
atlasDb.setPending = async (payload, status) => {
|
||
pendingRow = { ...payload, status }
|
||
}
|
||
atlasDb.clearPending = async () => {
|
||
pendingRow = null
|
||
}
|
||
settings.get = async () => ''
|
||
process.env.SERVUO_PATH = ''
|
||
})
|
||
|
||
test('refresh: no configured path is skipped, not an error', async () => {
|
||
const result = await shardAtlas.refresh()
|
||
assert.equal(result.status, 'skipped')
|
||
})
|
||
|
||
test('refresh: an unreadable tree reports unavailable rather than throwing', async () => {
|
||
const result = await shardAtlas.refresh({ path: path.join(os.tmpdir(), 'no-such-tree-abc') })
|
||
assert.equal(result.status, 'unavailable')
|
||
assert.equal(result.code, 'NOT_FOUND')
|
||
})
|
||
|
||
test('refresh: a fresh database imports', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
const result = await shardAtlas.refresh({ path: root })
|
||
assert.equal(result.status, 'imported')
|
||
assert.ok(applied)
|
||
assert.deepEqual(result.addedFacets, ['Sosaria'])
|
||
})
|
||
|
||
test('refresh: an unchanged tree parses nothing and writes nothing', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
metaRow = buildAtlas(root).meta
|
||
const result = await shardAtlas.refresh({ path: root })
|
||
assert.equal(result.status, 'unchanged')
|
||
assert.equal(applied, null)
|
||
})
|
||
|
||
// The hash gate alone would strand an install whose maps never change on
|
||
// whatever an older build derived: a corrected parse would ship and never reach
|
||
// the data, because the only thing compared is the tree.
|
||
test('refresh: an unchanged tree is REIMPORTED when the parser has moved on', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
metaRow = { ...buildAtlas(root).meta, parserVersion: PARSER_VERSION - 1 }
|
||
const result = await shardAtlas.refresh({ path: root })
|
||
assert.equal(result.status, 'imported')
|
||
assert.ok(applied)
|
||
})
|
||
|
||
test('refresh: an atlas imported before parser versions existed is stale', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
const meta = buildAtlas(root).meta
|
||
delete meta.parserVersion
|
||
metaRow = meta
|
||
const result = await shardAtlas.refresh({ path: root })
|
||
assert.equal(result.status, 'imported')
|
||
})
|
||
|
||
test('refresh: --force reimports an unchanged tree', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
metaRow = buildAtlas(root).meta
|
||
const result = await shardAtlas.refresh({ path: root, force: true })
|
||
assert.equal(result.status, 'imported')
|
||
assert.ok(applied)
|
||
})
|
||
|
||
test('refresh: a NEW facet applies straight away', async () => {
|
||
// Additions cannot destroy anything an operator would miss.
|
||
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||
facetsInDb = ['Sosaria']
|
||
const result = await shardAtlas.refresh({ path: root })
|
||
assert.equal(result.status, 'imported')
|
||
assert.deepEqual(result.addedFacets, ['Underdark'])
|
||
})
|
||
|
||
test('refresh: a REMOVED facet is staged, not applied', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
facetsInDb = ['Sosaria', 'Underdark']
|
||
const result = await shardAtlas.refresh({ path: root })
|
||
assert.equal(result.status, 'needsReview')
|
||
assert.deepEqual(result.removedFacets, ['Underdark'])
|
||
// The critical part: the existing atlas was left alone.
|
||
assert.equal(applied, null)
|
||
assert.equal(pendingRow.status, 'pending')
|
||
})
|
||
|
||
test('refresh: approving applies the removal', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
facetsInDb = ['Sosaria', 'Underdark']
|
||
await shardAtlas.refresh({ path: root })
|
||
assert.equal(applied, null)
|
||
|
||
const result = await shardAtlas.approvePending({ path: root })
|
||
assert.equal(result.status, 'imported')
|
||
assert.ok(applied)
|
||
assert.deepEqual(result.removedFacets, ['Underdark'])
|
||
})
|
||
|
||
test('refresh: a rejected refresh does not re-prompt while the tree is unchanged', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
facetsInDb = ['Sosaria', 'Underdark']
|
||
await shardAtlas.refresh({ path: root })
|
||
await shardAtlas.rejectPending()
|
||
assert.equal(pendingRow.status, 'rejected')
|
||
|
||
const again = await shardAtlas.refresh({ path: root })
|
||
assert.equal(again.status, 'unchanged')
|
||
assert.equal(applied, null)
|
||
})
|
||
|
||
test('refresh: changing the tree asks again after a rejection', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
facetsInDb = ['Sosaria', 'Underdark']
|
||
await shardAtlas.refresh({ path: root })
|
||
await shardAtlas.rejectPending()
|
||
|
||
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- changed -->', 'utf8')
|
||
const again = await shardAtlas.refresh({ path: root })
|
||
assert.equal(again.status, 'needsReview')
|
||
})
|
||
|
||
test('refreshOnBoot: never throws, whatever goes wrong', async () => {
|
||
atlasDb.getMeta = async () => {
|
||
throw new Error('database is on fire')
|
||
}
|
||
atlasDb.getFacets = async () => {
|
||
throw new Error('still on fire')
|
||
}
|
||
atlasDb.replaceAtlas = async () => {
|
||
throw new Error('and the import too')
|
||
}
|
||
process.env.SERVUO_PATH = tempTree({ facets: ['Sosaria'] })
|
||
|
||
const result = await shardAtlas.refreshOnBoot()
|
||
assert.equal(result.status, 'failed')
|
||
})
|
||
|
||
test('refreshOnBoot: a missing tree is survivable, not fatal', async () => {
|
||
process.env.SERVUO_PATH = path.join(os.tmpdir(), 'nope-not-here-xyz')
|
||
const result = await shardAtlas.refreshOnBoot()
|
||
assert.equal(result.status, 'unavailable')
|
||
})
|
||
|
||
test('refresh: an explicit path overrides the configured one', async () => {
|
||
const configured = tempTree({ facets: ['Configured'] })
|
||
const override = tempTree({ facets: ['Override'] })
|
||
settings.get = async () => configured
|
||
|
||
const result = await shardAtlas.refresh({ path: override })
|
||
assert.equal(result.status, 'imported')
|
||
assert.deepEqual(result.addedFacets, ['Override'])
|
||
})
|