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>
228 lines
11 KiB
JavaScript
228 lines
11 KiB
JavaScript
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const {
|
|
ClilocFormatError,
|
|
parseCliloc,
|
|
parseClilocBinary,
|
|
parseClilocText,
|
|
isCompressedCliloc,
|
|
displayText,
|
|
isPlaceholderOnly,
|
|
} = require('../src/utils/clilocParse')
|
|
|
|
// These parsers are pure and fs-free precisely so this suite can run in CI,
|
|
// where there is no UO client and no converted cliloc file. Every fixture below
|
|
// is built from the real layout, and the strings are verbatim entries from a
|
|
// real Cliloc.enu (123,490 entries) rather than invented ones.
|
|
|
|
// ── Fixture builders ───────────────────────────────────────────────────────
|
|
|
|
/** Build a plain-format cliloc buffer: 6-byte header, then records. */
|
|
function buildBinary(entries, { header1 = 2, header2 = 1 } = {}) {
|
|
const parts = [Buffer.alloc(6)]
|
|
parts[0].writeInt32LE(header1, 0)
|
|
parts[0].writeUInt16LE(header2, 4)
|
|
for (const e of entries) {
|
|
const text = Buffer.from(e.text, 'utf8')
|
|
const head = Buffer.alloc(7)
|
|
head.writeInt32LE(e.number, 0)
|
|
head.writeUInt8(e.flag ?? 0, 4)
|
|
head.writeUInt16LE(text.length, 5)
|
|
parts.push(head, text)
|
|
}
|
|
return Buffer.concat(parts)
|
|
}
|
|
|
|
// ── Binary ─────────────────────────────────────────────────────────────────
|
|
|
|
test('parseClilocBinary: reads a plain-format table', () => {
|
|
const buf = buildBinary([
|
|
{ number: 1015012, text: 'Greater Heal' },
|
|
{ number: 1023721, text: 'quarter staff' },
|
|
{ number: 1025913, flag: 1, text: 'bonnet' },
|
|
])
|
|
assert.deepEqual(parseClilocBinary(buf), [
|
|
{ number: 1015012, flag: 0, text: 'Greater Heal' },
|
|
{ number: 1023721, flag: 0, text: 'quarter staff' },
|
|
{ number: 1025913, flag: 1, text: 'bonnet' },
|
|
])
|
|
})
|
|
|
|
test('parseClilocBinary: length is UNSIGNED 16-bit', () => {
|
|
// ServUO's own SDK reads this field into a signed short, which turns any
|
|
// string over 32 KB into a negative length. Real tables top out around 12 KB
|
|
// so nothing is broken today, but the field is written unsigned and reading it
|
|
// that way costs nothing.
|
|
const text = 'x'.repeat(40000)
|
|
const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text }]))
|
|
assert.equal(entry.text.length, 40000)
|
|
})
|
|
|
|
test('parseClilocBinary: multi-byte UTF-8 survives (length is in BYTES)', () => {
|
|
const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text: 'Ilshenar — Ver Lor Reg' }]))
|
|
assert.equal(entry.text, 'Ilshenar — Ver Lor Reg')
|
|
})
|
|
|
|
test('parseClilocBinary: a truncated record body throws rather than importing short', () => {
|
|
// The realistic corruption is a half-copied file. It must fail loudly: a
|
|
// silently short table renders as "some items named, some not", which is
|
|
// indistinguishable from having no table at all.
|
|
const buf = buildBinary([{ number: 1023721, text: 'quarter staff' }])
|
|
const truncated = buf.subarray(0, buf.length - 4)
|
|
assert.throws(() => parseClilocBinary(truncated), (err) => {
|
|
assert.ok(err instanceof ClilocFormatError)
|
|
assert.equal(err.code, 'TRUNCATED')
|
|
return true
|
|
})
|
|
})
|
|
|
|
test('parseClilocBinary: a truncated record HEADER throws too', () => {
|
|
const buf = Buffer.concat([buildBinary([{ number: 1023721, text: 'quarter staff' }]), Buffer.alloc(3)])
|
|
assert.throws(() => parseClilocBinary(buf), (err) => err.code === 'TRUNCATED')
|
|
})
|
|
|
|
test('parseClilocBinary: an empty table (header only) is valid', () => {
|
|
assert.deepEqual(parseClilocBinary(buildBinary([])), [])
|
|
})
|
|
|
|
// ── Compressed detection ───────────────────────────────────────────────────
|
|
|
|
test('isCompressedCliloc: recognises the Mythic marker', () => {
|
|
// Every cliloc the client ships opens with a DWORD whose high byte is 0x8E.
|
|
// Real first bytes of Cliloc.enu (e8 79 67 8e) and Cliloc.deu (99 5d 26 8e).
|
|
assert.equal(isCompressedCliloc(Buffer.from([0xe8, 0x79, 0x67, 0x8e])), true)
|
|
assert.equal(isCompressedCliloc(Buffer.from([0x99, 0x5d, 0x26, 0x8e])), true)
|
|
assert.equal(isCompressedCliloc(buildBinary([])), false)
|
|
})
|
|
|
|
test('parseCliloc: a compressed file is rejected by NAME, not parsed into nonsense', () => {
|
|
// This is the whole reason the marker check exists. Without it the plain
|
|
// parser reads compressed bytes as ~19k records of negative ids and 60 KB
|
|
// "strings" before dying somewhere in the middle — and the resulting error
|
|
// names truncation, which is the wrong problem to hand an operator.
|
|
const compressed = Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(64, 0x41)])
|
|
assert.throws(() => parseCliloc(compressed), (err) => {
|
|
assert.equal(err.code, 'COMPRESSED')
|
|
assert.match(err.message, /CLILOCS\.md/)
|
|
return true
|
|
})
|
|
})
|
|
|
|
// ── Text ───────────────────────────────────────────────────────────────────
|
|
|
|
test('parseClilocText: tab-delimited, skipping a header row', () => {
|
|
const entries = parseClilocText('number\ttext\n1023721\tquarter staff\n1015012\tGreater Heal\n')
|
|
assert.deepEqual(entries, [
|
|
{ number: 1023721, flag: 0, text: 'quarter staff' },
|
|
{ number: 1015012, flag: 0, text: 'Greater Heal' },
|
|
])
|
|
})
|
|
|
|
test('parseClilocText: splits on the FIRST separator only', () => {
|
|
// Cliloc text is full of commas. Splitting on all of them would truncate every
|
|
// such entry at its first one.
|
|
const [entry] = parseClilocText('1044000,a scroll of magery, unfinished\n')
|
|
assert.equal(entry.text, 'a scroll of magery, unfinished')
|
|
})
|
|
|
|
test('parseClilocText: unwraps quoted CSV fields and doubled quotes', () => {
|
|
const [entry] = parseClilocText('1023721,"a ""quarter"" staff, plain"\n')
|
|
assert.equal(entry.text, 'a "quarter" staff, plain')
|
|
})
|
|
|
|
test('parseClilocText: reads an optional flag column', () => {
|
|
const [entry] = parseClilocText('1025913\t1\tbonnet\n')
|
|
assert.deepEqual(entry, { number: 1025913, flag: 1, text: 'bonnet' })
|
|
})
|
|
|
|
test('parseClilocText: text that is itself a number stays the text', () => {
|
|
// `number,text` where text is "100" is indistinguishable from `number,flag`
|
|
// with an empty text. Keeping it as the text is the safer miss — the other way
|
|
// silently deletes a real entry.
|
|
const [entry] = parseClilocText('1000000,100\n')
|
|
assert.equal(entry.text, '100')
|
|
})
|
|
|
|
test('parseClilocText: blank lines and # comments are ignored', () => {
|
|
const entries = parseClilocText('# exported by hand\n\n1023721\tquarter staff\n\n')
|
|
assert.equal(entries.length, 1)
|
|
})
|
|
|
|
test('parseClilocText: a file with no entries is an error, not an empty table', () => {
|
|
assert.throws(() => parseClilocText('nothing here\nnor here\n'), (err) => err.code === 'EMPTY')
|
|
})
|
|
|
|
test('parseClilocText: an empty leading field is skipped, not imported as id 0', () => {
|
|
// `Number('')` is 0, not NaN, so a line that merely starts with a separator
|
|
// would otherwise become a bogus cliloc 0.
|
|
assert.throws(() => parseClilocText('\tstray text\n,another\n'), (err) => err.code === 'EMPTY')
|
|
})
|
|
|
|
test('parseClilocText: keeps entries whose text is EMPTY', () => {
|
|
// About half of a real table is empty strings (unused ids). They must survive
|
|
// parsing — the import layer decides whether to store them, and both input
|
|
// formats have to agree on what the file contained.
|
|
const entries = parseClilocText('1005008\t\n1023721\tquarter staff\n')
|
|
assert.equal(entries.length, 2)
|
|
assert.deepEqual(entries[0], { number: 1005008, flag: 0, text: '' })
|
|
})
|
|
|
|
// ── Sniffing ───────────────────────────────────────────────────────────────
|
|
|
|
test('parseCliloc: sniffs binary vs text from the header, not the extension', () => {
|
|
assert.equal(parseCliloc(buildBinary([{ number: 1023721, text: 'quarter staff' }]))[0].text, 'quarter staff')
|
|
assert.equal(parseCliloc(Buffer.from('1023721\tquarter staff\n'))[0].text, 'quarter staff')
|
|
})
|
|
|
|
test('parseCliloc: a binary-looking header that is not 2/1 falls through to text', () => {
|
|
// The recoverable guess: a mis-sniffed text file says "no entries found",
|
|
// while a mis-sniffed binary yields plausible nonsense.
|
|
assert.throws(() => parseCliloc(Buffer.from([9, 0, 0, 0, 9, 0, 65, 66])), (err) => err.code === 'EMPTY')
|
|
})
|
|
|
|
// ── Display ────────────────────────────────────────────────────────────────
|
|
|
|
test('displayText: drops interpolated arguments we never receive', () => {
|
|
// The bridge sends a cliloc id, never the property packet that carries the
|
|
// arguments, so a name containing them has to be reduced to what is knowable.
|
|
assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
|
|
assert.equal(displayText('~1_NAME~ the ~2_TITLE~'), 'the')
|
|
})
|
|
|
|
test('displayText: a string that is nothing but arguments resolves to nothing', () => {
|
|
assert.equal(displayText('[~1_stuff~]'), '')
|
|
assert.equal(isPlaceholderOnly('[~1_stuff~]'), true)
|
|
assert.equal(isPlaceholderOnly('quarter staff'), false)
|
|
})
|
|
|
|
test('displayText: a trailing % is only stripped when a placeholder was removed', () => {
|
|
// "cold damage ~1_val~%" loses its % because that % was the unit belonging to
|
|
// the number we never had. A string that genuinely ends in one keeps it.
|
|
assert.equal(displayText('50%'), '50%')
|
|
assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
|
|
})
|
|
|
|
test('displayText: ordinary names pass through untouched', () => {
|
|
assert.equal(displayText('quarter staff'), 'quarter staff')
|
|
assert.equal(displayText('a scroll of magery, unfinished'), 'a scroll of magery, unfinished')
|
|
assert.equal(displayText(' spiked collar '), 'spiked collar')
|
|
})
|
|
|
|
test('displayText: punctuation is only tidied when a placeholder was removed', () => {
|
|
// A shard's custom "Runic Gateway Sigil (v2)" came back as "(v2" while the
|
|
// bracket trim was unconditional. A string with no placeholder has no debris
|
|
// to clean, so it is left alone apart from whitespace.
|
|
assert.equal(displayText('Runic Gateway Sigil (v2)'), 'Runic Gateway Sigil (v2)')
|
|
assert.equal(displayText('scroll of power - greater'), 'scroll of power - greater')
|
|
assert.equal(displayText('[Companion] Great Dane'), '[Companion] Great Dane')
|
|
// …but the debris a placeholder leaves behind is still cleaned.
|
|
assert.equal(displayText('[~1_stuff~]'), '')
|
|
assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
|
|
})
|
|
|
|
test('displayText: null and undefined are empty, not "null"', () => {
|
|
assert.equal(displayText(null), '')
|
|
assert.equal(displayText(undefined), '')
|
|
})
|