Two defects the acceptance walk found in shipped code, both invisible to the suites that were green on either side of them. **The spawn atlas cannot import on a stock ServUO tree.** `spawnAtlasSource.js` dedupes decoration types with a case-SENSITIVE `Map`, but `shard_decor_types.type` is a PRIMARY KEY under MariaDB's default `..._ai_ci` collation, which folds case. Stock 57.4's own `Data/Decoration/` names four types under two spellings each (CheckerBoard/Checkerboard, ChessBoard/Chessboard, MetalChest/Metalchest, SpinningWheelEastAddon/SpinningwheelEastAddon), and in every pair exactly one is a real class. The second row raised `1062 Duplicate entry` and took the WHOLE import transaction down. The blast radius is not decoration: with no atlas, EVERY option source answers empty and no Phase 12 world verb can be authored at all. The shard end already knew — `BridgeWorld.cs` resolves a decor type with `FindTypeByName(name, ignoreCase: true)` and its comment says the atlas and the decoration files disagree about casing. Folding here is the two ends agreeing. **Teardown of every world verb was a no-op that reported success.** `revertOwned` forwarded core's `idempotencyKey` as the despawn's OWN key — and core's key is the step's, the one `placeOwned` spawned under. `BridgeIdempotency` keys on the key alone, so the despawn was taken for a repeat and answered with the SPAWN's stored reply; `OnDespawn` never ran. Core read `ok` with no `refused` and marked every row `reverted` while the shard still held every object. Measured on the rig: ledger `world | reverted | 21`, shard `world.owned` 21 alive with `pruned: 0`, and the identical despawn re-sent with a fresh key removed all 21. It affected all five world verbs, so an invasion's creatures, boss, oracle, gate and decoration stayed in the world for ever while the console reported a clean teardown. `MODULE_API.md` says what that key is for and it is not this: it identifies a dispatch core never learned the outcome of, so the module can ask about it. No key is needed on a despawn — a repeat answers `gone`, which both ends already treat as success — and dropping it also makes the documented empty-`resources` case work, since no serials means "everything this run owns". The parameter is removed from `despawnWorld`'s signature rather than left optional. Both fixes are verified end to end against a real ServUO + sidecar + website rig: the import now yields 309 decor types (was failing at 313 with 4 collisions), 6,455 spawn points, 800 creatures, 558 landmarks; and a full four-phase run's teardown left the shard owning 0 objects. Each new test was confirmed to FAIL without its fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
493 lines
20 KiB
JavaScript
493 lines
20 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><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'])
|
||
})
|
||
|
||
// ── 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 })
|
||
}
|
||
})
|