feat(shard): resolve cliloc names for items and reward titles
Protocol 3.0 §8.6 (docs/link/v3.md), the dependency order 5 was sequenced
behind. Items on the wire carry a LabelNumber, not a name — the bridge has
always sent it (char.profile.equipment.cliloc, reward titles as a cliloc
number in string form, and one per marketplace listing) but the site had no
table to resolve it against, so a character sheet could only render
`id 1023721` where the game renders "quarter staff".
The number was never the missing piece. The table was.
Sourced from a file the operator converts once from their own client, at a
path from the `cliloc_client_path` setting falling back to UO_CLIENT_PATH.
Nothing client-derived is committed: UO's strings are EA's, exactly as the
creature sprites are. A shard with nothing configured is fully supported —
names render as ids, as they did before.
The conversion step is not avoidable, and that is the substantive finding
here: every current client ships its cliloc files COMPRESSED (first DWORD's
high byte 0x8E, the Mythic container), and ServUO's own bundled
Ultima.StringList cannot read that either — so VendorSearch.GetItemName is
already inert on such a shard and the plugin could not supply names instead.
v3.md's original "read the client's Cliloc.enu" recommendation was therefore
not implementable as written, and its committed db/data/clilocs.json artifact
also predates the Part C corrections (no committed derived snapshots, nothing
EA-derived shipped). Replaced with the spawn-atlas pattern: parse on boot from
an operator-configured path, hash-gated, output gitignored.
- utils/clilocParse.js — pure parsers, fs-free so the suite runs in CI.
Accepts the plain binary layout and delimited text, sniffed by header rather
than extension. Rejects a compressed file BY NAME: without that check the
plain parser reads it as ~19k records of negative ids and 60 KB "strings"
before dying mid-file, and the resulting error names the wrong problem.
displayText() drops the ~1_val~ arguments the bridge never sends.
- utils/clilocSource.js — the fs layer. hashSource reports `compressed` so the
admin panel can flag an unconverted file WITHOUT parsing 5 MB per poll;
otherwise pointing at a client directory reports a healthy file with pending
drift ("ready to import") and the operator only finds out on failure.
- model/shardClilocs — refresh/status/lookup. All-or-nothing replace (DELETE,
not TRUNCATE — TRUNCATE is DDL in MariaDB and implicitly commits). Batched
server-side resolution behind a capped cache; never throws, because a cliloc
lookup is decoration on a character sheet.
- Deliberately NO staged-approval flow, unlike the atlas: the atlas escalates
facet loss because a half-copied tree and a real map change are
indistinguishable from inside the process, whereas a partial cliloc copy
makes the parser fail on a truncated record. The ambiguity the atlas must
escalate is one this parser simply detects.
- No public route. The table is never served AS a table: 67k rows would dwarf
any page using them, and the Android client consumes the same resolved JSON.
Two parser bugs found by building it, both now covered by tests: trimming a
text line before splitting ate the trailing separator on empty-text entries
and silently dropped 55,994 of 123,490 while still reporting success; and
Number('') is 0, not NaN, so a line starting with a separator imported as a
bogus cliloc 0.
Verified against the real client table (123,490 entries) and the live MariaDB:
import 663 ms, hash-gated boot no-op 14 ms, cold resolve 4.2 ms / warm 0.015 ms.
Binary and TSV imports converge on the same 67,496 rows with identical keys
(blank entries — half the table — are dropped at import). A file truncated to
half its length is refused with TRUNCATED and leaves the previous table
serving. Boot logs verified for both the import and the compressed-file
warning; neither blocks startup. All three admin routes exercised over HTTP
with a real session. 629 server tests pass; client builds clean; swagger,
routes.manifest.json and routes.guards.json regenerated.
Not covered by an automated test: the character sheet renders resolved names
in presentational React with no DOM test harness in this repo, and was not
rendered against a live linked-player profile — that needs a logged-in player
with a linked game account and a shard answering a profile RPC.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
215
server/test/clilocParse.test.js
Normal file
215
server/test/clilocParse.test.js
Normal file
@@ -0,0 +1,215 @@
|
||||
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: null and undefined are empty, not "null"', () => {
|
||||
assert.equal(displayText(null), '')
|
||||
assert.equal(displayText(undefined), '')
|
||||
})
|
||||
Reference in New Issue
Block a user