Shards edit items and add new ones, and those carry cliloc ids no stock client
table has. Reading exactly one converted file meant an operator had to
re-export 5 MB every time they added one item — friction enough that the table
would simply go stale, which is the failure the spawn atlas was redesigned to
avoid in the first place.
So this mirrors spawnAtlasSource.readSources(): a BASE (the converted client
table) plus every operator-maintained overlay under `custom/`, all re-read on
every boot and hash-gated as a SET. Later sources win, so an overlay both adds
ids the client never had and overrides stock ones the shard re-purposed.
Adding, editing or removing any overlay counts as drift.
`custom/` is the one convention here that is ours rather than the shard's, and
deliberately so: ServUO has no server-side notion of a custom cliloc — they
live in the patched client a shard distributes, and nothing in the tree
declares them. There is nothing to discover. (An operator who does patch their
client cliloc needs no overlay: convert the patched file and the edits are in
the base.) Scale, measured on the live shard: its script tree references 16,434
cliloc ids and only 37 are absent from stock — tens against a 67k base, which
is why this is an overlay and not a second table.
The set brings back a hazard a single file did not have, and it gets the
atlas's answer. A corrupt source fails the parse loudly, but a source that has
VANISHED parses perfectly and imports a table quietly missing everything it
contributed — an unmounted volume is indistinguishable from a deliberate
deletion. So it is staged, not applied (`needsReview`), reported by both the
import and status(), and accepted with `{approve:true}`. That is a flag rather
than the atlas's approve/reject pair because the atlas stores a pending
decision SO THAT approving re-parses; here nothing is stored, so re-reading at
approval time is automatic.
Also reports a per-source breakdown (entries/added/overrode) on import and in
status, which is how an operator confirms an overlay took effect — "overrode: 0"
on a file meant to re-label stock items says it did not.
Two bugs this surfaced, both found by running a shard-style overlay rather than
by another stock-table fixture:
- displayText tidied punctuation unconditionally, so a custom
"Runic Gateway Sigil (v2)" rendered as "(v2". Stripping leftover brackets is
right after a placeholder is removed and wrong otherwise — the same condition
the `%` rule already had.
- CANDIDATE_NAMES did not include `clilocs.plain`, which is the exact filename
CLILOCS.md and the export tool's README tell operators to write. Pointing at
the directory they were told to create failed with NO_FILE.
Verified end to end against the live MariaDB and a real server boot: base-only
import, overlay adding one id and overriding another (per-source breakdown
correct), unchanged set as a no-op, an edited overlay re-importing and
withdrawing its override, a vanished overlay refused with the table intact,
status reporting missingSources, approve applying it, and a file-path
configuration still finding overlays beside it. All three resolve correctly
through the running server: shard-added, overridden and stock. 646 server tests
pass (16 new in clilocSource.test.js, 3 new in clilocParse.test.js); swagger,
routes.manifest.json and routes.guards.json regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
195 lines
8.2 KiB
JavaScript
195 lines
8.2 KiB
JavaScript
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const os = require('node:os')
|
|
const path = require('node:path')
|
|
|
|
const {
|
|
ClilocSourceError,
|
|
CUSTOM_DIR,
|
|
resolveBase,
|
|
listCustom,
|
|
readSources,
|
|
hashSources,
|
|
sameSources,
|
|
missingSources,
|
|
readCliloc,
|
|
} = require('../src/utils/clilocSource')
|
|
|
|
// The fs layer, exercised against real temp directories rather than mocks —
|
|
// the behaviours that matter here (which file wins, what a directory listing
|
|
// yields, what happens when one vanishes) are precisely the ones a mock would
|
|
// define away.
|
|
|
|
function tmpdir() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cliloc-'))
|
|
return dir
|
|
}
|
|
|
|
const tsv = (entries) => entries.map(([n, t]) => `${n}\t${t}`).join('\n') + '\n'
|
|
|
|
function write(dir, name, contents) {
|
|
const file = path.join(dir, name)
|
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
fs.writeFileSync(file, contents)
|
|
return file
|
|
}
|
|
|
|
// ── Resolution ─────────────────────────────────────────────────────────────
|
|
|
|
test('resolveBase: a directory picks the most specific candidate', () => {
|
|
const dir = tmpdir()
|
|
// A converted file sitting next to the client's own compressed one must win —
|
|
// otherwise pointing at a client folder finds the file that will be rejected.
|
|
write(dir, 'cliloc.enu', 'ignored')
|
|
write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
|
|
assert.equal(path.basename(resolveBase(dir).base), 'clilocs.tsv')
|
|
})
|
|
|
|
test('resolveBase: a file path roots overlays at its DIRECTORY', () => {
|
|
// An operator who pointed at a file should not have to re-point at its folder
|
|
// just to add a custom/ directory beside it.
|
|
const dir = tmpdir()
|
|
const file = write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
|
assert.deepEqual(resolveBase(file), { root: dir, base: file })
|
|
})
|
|
|
|
test('resolveBase: a missing path and an empty path are different errors', () => {
|
|
assert.throws(() => resolveBase(''), (err) => err.code === 'NO_PATH')
|
|
assert.throws(() => resolveBase(path.join(tmpdir(), 'nope')), (err) => err.code === 'NOT_FOUND')
|
|
})
|
|
|
|
test('resolveBase: a directory with no cliloc file names what it looked for', () => {
|
|
assert.throws(() => resolveBase(tmpdir()), (err) => {
|
|
assert.ok(err instanceof ClilocSourceError)
|
|
assert.equal(err.code, 'NO_FILE')
|
|
assert.match(err.message, /clilocs\.tsv/)
|
|
return true
|
|
})
|
|
})
|
|
|
|
// ── Overlays ───────────────────────────────────────────────────────────────
|
|
|
|
test('listCustom: no overlay directory is normal, not an error', () => {
|
|
const dir = tmpdir()
|
|
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
|
assert.deepEqual(listCustom(dir), [])
|
|
})
|
|
|
|
test('listCustom: sorted, and only recognised extensions', () => {
|
|
const dir = tmpdir()
|
|
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
|
write(dir, `${CUSTOM_DIR}/b.tsv`, tsv([[2, 'b']]))
|
|
write(dir, `${CUSTOM_DIR}/a.csv`, tsv([[3, 'c']]))
|
|
write(dir, `${CUSTOM_DIR}/notes.md`, 'ignore me')
|
|
assert.deepEqual(listCustom(dir).map((f) => path.basename(f)), ['a.csv', 'b.tsv'])
|
|
})
|
|
|
|
test('readSources: base first, then overlays, with root-relative labels', () => {
|
|
const dir = tmpdir()
|
|
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
|
|
const { files } = readSources(dir)
|
|
// Forward-slashed so the same directory read on Windows and Linux fingerprints
|
|
// identically — otherwise every boot on one of them looks like a change.
|
|
assert.deepEqual(files.map((f) => [f.label, f.kind]), [
|
|
['clilocs.tsv', 'base'],
|
|
['custom/shard.tsv', 'custom'],
|
|
])
|
|
})
|
|
|
|
// ── Merging ────────────────────────────────────────────────────────────────
|
|
|
|
test('readCliloc: an overlay ADDS ids the base never had', () => {
|
|
// The whole point: shards add items, and those carry cliloc ids no stock
|
|
// client table has.
|
|
const dir = tmpdir()
|
|
write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
|
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[1180001, 'Runic Gateway Sigil']]))
|
|
const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
|
|
assert.equal(byNumber.get(1023721), 'quarter staff')
|
|
assert.equal(byNumber.get(1180001), 'Runic Gateway Sigil')
|
|
})
|
|
|
|
test('readCliloc: an overlay OVERRIDES a stock id', () => {
|
|
const dir = tmpdir()
|
|
write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
|
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[1023721, 'gnarled staff of testing']]))
|
|
const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
|
|
assert.equal(byNumber.get(1023721), 'gnarled staff of testing')
|
|
})
|
|
|
|
test('readCliloc: later overlays beat earlier ones, deterministically', () => {
|
|
const dir = tmpdir()
|
|
write(dir, 'clilocs.tsv', tsv([[7, 'base']]))
|
|
write(dir, `${CUSTOM_DIR}/01-first.tsv`, tsv([[7, 'first']]))
|
|
write(dir, `${CUSTOM_DIR}/02-second.tsv`, tsv([[7, 'second']]))
|
|
const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
|
|
assert.equal(byNumber.get(7), 'second')
|
|
})
|
|
|
|
test('readCliloc: reports what each source contributed', () => {
|
|
// An operator who adds an overlay wants to see it took effect; "overrode: 0"
|
|
// on a file meant to re-label stock items says it did not.
|
|
const dir = tmpdir()
|
|
write(dir, 'clilocs.tsv', tsv([[1, 'a'], [2, 'b']]))
|
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'B!'], [3, 'c']]))
|
|
const { source } = readCliloc(dir)
|
|
assert.deepEqual(source.sources, [
|
|
{ label: 'clilocs.tsv', kind: 'base', entries: 2, added: 2, overrode: 0 },
|
|
{ label: 'custom/shard.tsv', kind: 'custom', entries: 2, added: 1, overrode: 1 },
|
|
])
|
|
})
|
|
|
|
test('readCliloc: a malformed overlay names the file it came from', () => {
|
|
// "Which of my six overlay files is broken" is otherwise a guessing game.
|
|
const dir = tmpdir()
|
|
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
|
write(dir, `${CUSTOM_DIR}/broken.tsv`, 'no separators here\nnor here\n')
|
|
assert.throws(() => readCliloc(dir), (err) => {
|
|
assert.equal(err.code, 'EMPTY')
|
|
assert.match(err.message, /^custom\/broken\.tsv: /)
|
|
return true
|
|
})
|
|
})
|
|
|
|
test('readCliloc: a compressed BASE is still rejected by name', () => {
|
|
const dir = tmpdir()
|
|
write(dir, 'cliloc.enu', Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(32, 0x41)]))
|
|
assert.throws(() => readCliloc(dir), (err) => err.code === 'COMPRESSED')
|
|
})
|
|
|
|
// ── Drift over the SET ─────────────────────────────────────────────────────
|
|
|
|
test('hashSources: fingerprints every source, and counts the overlays', () => {
|
|
const dir = tmpdir()
|
|
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
|
|
const fp = hashSources(dir)
|
|
assert.deepEqual(Object.keys(fp.hashes).sort(), ['clilocs.tsv', 'custom/shard.tsv'])
|
|
assert.equal(fp.customCount, 1)
|
|
})
|
|
|
|
test('sameSources: adding or editing an overlay counts as drift', () => {
|
|
const dir = tmpdir()
|
|
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
|
const before = hashSources(dir).hashes
|
|
|
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
|
|
const added = hashSources(dir).hashes
|
|
assert.equal(sameSources(before, added), false, 'a new overlay is drift')
|
|
|
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b changed']]))
|
|
const edited = hashSources(dir).hashes
|
|
assert.equal(sameSources(added, edited), false, 'an edited overlay is drift')
|
|
assert.equal(sameSources(edited, hashSources(dir).hashes), true, 'an untouched set is not')
|
|
})
|
|
|
|
test('missingSources: a vanished source is detected, an added one is not "missing"', () => {
|
|
const loaded = { 'clilocs.tsv': 'aaa', 'custom/shard.tsv': 'bbb' }
|
|
assert.deepEqual(missingSources({ 'clilocs.tsv': 'aaa' }, loaded), ['custom/shard.tsv'])
|
|
assert.deepEqual(missingSources({ ...loaded, 'custom/new.tsv': 'ccc' }, loaded), [])
|
|
// Nothing loaded yet (a first import) is not a vanished source.
|
|
assert.deepEqual(missingSources({ 'clilocs.tsv': 'aaa' }, null), [])
|
|
})
|