Two defects the Phase 16b re-verify found in the released v1.2.1 bundle, both of which make a shipped feature unusable and neither of which any test saw. ## The aggregator discarded the UniqueId `shard_spawn_points.unique_id` was NULL on all 6,455 rows of a stock 57.4 tree. `listSpawners` filters `unique_id IS NOT NULL`, so `uo.options.spawners` was an empty dropdown -- and it is the ONLY option source for the Phase 12b object-property leases, so no `Spawner.MaxCount` / `MinDelay` / `MaxDelay` lease could be authored at all, with nothing on the form to say why. Every part of the path was already right except one line. The spawn files carry `<UniqueId>` (~6,374 of them), `parsePoints` returns it, the column exists and the insert passes `p.uniqueId || null`. `buildAtlas` rebuilds each point from an explicit field list and `uniqueId` was not on it -- the word appears nowhere in that file. `PARSER_VERSION = 4`'s own note says "a spawn point keeps its UniqueId, which is what a property lease targets", so the intent shipped as a comment while the code dropped the field one function later. `PARSER_VERSION` goes to 5 because the bump is the only thing that re-reads an already-imported tree: `sameSources` compares the tree's hashes, which have not changed -- only what is kept from them. Confirmed on the rig, where the boot after the fix logged `spawn atlas refreshed` on an unchanged tree and the manual import then correctly answered `unchanged`. ## A landmark option value named 23 places at once A stock tree has 558 landmarks under 320 distinct `facet/name` pairs. `Trammel/Entrance` is 23 different dungeons -- Blighted Grove, Covetous, Deceit, Despise, Destard and so on -- and `landmarkPoint` resolved with `.find()`, so 22 of the 23 were unreachable. An author who picked "Entrance - Destard" got Blighted Grove, and the run succeeded with no warning. The group was already the disambiguator: it was shown in the dropdown and left out of the value. The value is now `facet/group/name`, which is distinct across all 558. `landmarkPoint` tries that form first and keeps the two-part read as a fallback, because every event published before this fix stores `facet/name` and a published version is immutable -- refusing to parse those would break runs rather than correct them. The fallback keeps the old first-match behaviour deliberately: it is imprecise in exactly the way it always was, and silently relocating a live event's spawn point is worse than repeating a known imprecision. A three-part value whose group is gone REFUSES rather than falling back to the name, because it asked for one particular place. ## Verification On the released-artefact rig (installer -> bundle 2026.09.10 -> stock 57.4 tree -> protocol-7 sidecar -> core at main with this module): spawn points 6455 rows, 6364 with a unique_id (was 0) uo.options.spawners 100 options, and `?q=orc` searches them (was 0) uo.options.landmarks 558 options, 558 distinct values (was 320) suite 625 pass, 0 fail Each new test was confirmed to FAIL without its fix. The atlas one asserts the field on the AGGREGATOR's output rather than the parser's, which is the whole point of it -- and the test fixture had no `<UniqueId>` at all until now, which is exactly why a green suite said nothing. The landmark one asserts an INEQUALITY between two resolved points rather than a literal value string, so it survives another change of format as long as two options still address two places. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
517 lines
21 KiB
JavaScript
517 lines
21 KiB
JavaScript
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||
// every moved test: 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 the seam the
|
||
// contract actually promises.
|
||
|
||
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('../utils/spawnAtlasSource')
|
||
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
|
||
const { ctx } = require('./_setup')
|
||
const settings = ctx.settings
|
||
|
||
|
||
// ── 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 })
|
||
|
||
// Decoration, NESTED, because the real tree nests two deep in places
|
||
// (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`) and a flat read would index a
|
||
// fraction of it while looking like it worked.
|
||
fs.mkdirSync(path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper'), { recursive: true })
|
||
fs.writeFileSync(
|
||
path.join(root, 'Data', 'Decoration', 'top.cfg'),
|
||
'# a brazier\nBrazier 0x0E31\n100 100 0\n200 200 -5\n\nStatic 0x108F\n300 300 0\n',
|
||
'utf8',
|
||
)
|
||
fs.writeFileSync(
|
||
path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper', 'nested.cfg'),
|
||
'Brazier 0x0E31\n400 400 0\nLargeCrate 0x0E3C\n500 500 0\n',
|
||
'utf8',
|
||
)
|
||
|
||
for (const facet of facets) {
|
||
fs.writeFileSync(
|
||
path.join(root, 'Spawns', `${facet}.xml`),
|
||
`<Spawns>
|
||
<Points><Name>${facet}A</Name><UniqueId>uid-${facet}-A</UniqueId>
|
||
<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: a point keeps the UniqueId a property lease targets', () => {
|
||
// The field is asserted on the AGGREGATOR's output, not the parser's, which is
|
||
// the whole point of this test. `parsePoints` produced it from Phase 12b
|
||
// onwards and `PARSER_VERSION`'s own note said a point kept it, while the
|
||
// mapping in `buildAtlas` rebuilt each point from an explicit field list that
|
||
// omitted it — so `shard_spawn_points.unique_id` was NULL on every row, and
|
||
// `listSpawners`, whose WHERE is `unique_id IS NOT NULL`, answered empty. That
|
||
// left `uo.options.spawners` an empty dropdown and every Phase 12b
|
||
// object-property lease unauthorable. Found by the Phase 16b released-artefact
|
||
// walk, against a real tree whose files carry ~6,400 of these.
|
||
//
|
||
// The fixture above had no <UniqueId> at all until this test, which is exactly
|
||
// why a green suite said nothing about it.
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
const atlas = buildAtlas(root)
|
||
const named = atlas.points.find((p) => p.name === 'SosariaA')
|
||
assert.equal(named.uniqueId, 'uid-Sosaria-A')
|
||
// And a point whose file names none is absent rather than empty-string, so the
|
||
// DB layer's `unique_id IS NOT NULL AND <> ''` reads it the same way either way.
|
||
const unnamed = atlas.points.find((p) => p.name === 'SosariaB')
|
||
assert.ok(!unnamed.uniqueId)
|
||
})
|
||
|
||
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'])
|
||
})
|
||
|
||
// ── The decoration index (Phase 12a) ────────────────────────
|
||
|
||
test('decoration is read recursively and rolled up per type', () => {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decor-'))
|
||
try {
|
||
writeTree(root)
|
||
const atlas = buildAtlas(root)
|
||
|
||
// Sorted by type, and `uses` counts every header line across the whole tree
|
||
// — the nested file's Brazier is the second use of the same type, not a
|
||
// second type.
|
||
assert.deepEqual(atlas.decor, [
|
||
{ type: 'Brazier', itemId: 0x0e31, uses: 2 },
|
||
{ type: 'LargeCrate', itemId: 0x0e3c, uses: 1 },
|
||
{ type: 'Static', itemId: 0x108f, uses: 1 },
|
||
])
|
||
assert.equal(atlas.meta.counts.decor, 3)
|
||
|
||
// Every decoration file is fingerprinted like every other source, so an
|
||
// operator editing one is a tree change the boot path notices.
|
||
const labels = Object.keys(atlas.meta.source).filter((l) => l.startsWith('Data/Decoration/'))
|
||
assert.deepEqual(labels.sort(), ['Data/Decoration/Deep/Deeper/nested.cfg', 'Data/Decoration/top.cfg'])
|
||
} finally {
|
||
fs.rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('two spellings of one decoration type fold into one row', () => {
|
||
// The Phase 16 acceptance walk's blocking finding. Stock ServUO 57.4's own
|
||
// `Data/Decoration/` names four types under two casings each —
|
||
// CheckerBoard/Checkerboard, ChessBoard/Chessboard, MetalChest/Metalchest,
|
||
// SpinningWheelEastAddon/SpinningwheelEastAddon — and in every pair exactly one
|
||
// is a real class; the other is a mis-cased line the shard's own loader resolves
|
||
// anyway.
|
||
//
|
||
// A case-SENSITIVE Map keeps both. `shard_decor_types.type` is a PRIMARY KEY
|
||
// under MariaDB's default `..._ai_ci` collation, which folds case, so the second
|
||
// row raised `1062 Duplicate entry` and took the WHOLE atlas import transaction
|
||
// down with it. The blast radius is not decoration: with no atlas, EVERY option
|
||
// source answers empty and no world verb can be authored at all.
|
||
//
|
||
// Asserted on the count as well as the row, because the failure mode was two
|
||
// rows that a database — not this function — would later refuse.
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decorcase-'))
|
||
try {
|
||
writeTree(root)
|
||
fs.writeFileSync(
|
||
path.join(root, 'Data', 'Decoration', 'miscased.cfg'),
|
||
'checkerboard 0x0FA6\n600 600 0\nCheckerBoard 0x0FA6\n700 700 0\n',
|
||
)
|
||
const atlas = buildAtlas(root)
|
||
|
||
const boards = atlas.decor.filter((d) => d.type.toLowerCase() === 'checkerboard')
|
||
assert.equal(boards.length, 1, 'two casings of one type must not be two rows')
|
||
// First spelling seen wins, exactly as the first item id does. Which one
|
||
// survives is cosmetic — the shard resolves either.
|
||
assert.equal(boards[0].type, 'checkerboard')
|
||
assert.equal(boards[0].uses, 2, 'both lines still count as uses of the one type')
|
||
} finally {
|
||
fs.rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('a tree with no decoration at all still builds', () => {
|
||
// Optional, like the champion file. A shard that has stripped its decoration
|
||
// has a perfectly good atlas; the decoration verb simply has nothing to offer.
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-nodecor-'))
|
||
try {
|
||
writeTree(root)
|
||
fs.rmSync(path.join(root, 'Data', 'Decoration'), { recursive: true, force: true })
|
||
const atlas = buildAtlas(root)
|
||
assert.deepEqual(atlas.decor, [])
|
||
assert.equal(atlas.meta.counts.decor, 0)
|
||
} finally {
|
||
fs.rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|