Files
website/server/test/clilocParse.test.js
wtclaude bda031566a feat(shard): read clilocs from a source SET so shard items get names
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>
2026-07-29 06:46:12 -05:00

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), '')
})