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>
This commit is contained in:
2026-07-29 06:46:12 -05:00
parent b61a4d6721
commit bda031566a
11 changed files with 939 additions and 120 deletions

View File

@@ -243,27 +243,31 @@ const PLACEHOLDER_RE = /~\d+_[^~]*~/g
* `"[~1_stuff~]"` becomes `""` (correctly nothing — the whole string was the
* argument) and `"cold damage ~1_val~%"` becomes `"cold damage"`.
*
* The trailing `%` in that second example is only stripped BECAUSE a placeholder
* was removed — it is the unit belonging to the number we never had. Stripping
* `%` unconditionally would corrupt a string that legitimately ends in one.
* **Punctuation is only tidied when a placeholder was actually removed.** The
* trailing `%` above is the unit belonging to the number we never had, and the
* brackets in `[~1_stuff~]` only ever wrapped the argument — but a string with
* no placeholder has no such debris, and trimming it anyway corrupts real names.
* A shard's `"Runic Gateway Sigil (v2)"` came back as `"(v2"` while this was
* unconditional.
*
* Returns `''` when nothing survives, which callers treat as "no name" and fall
* back to the item id — better than showing a bracket.
*/
const DEBRIS = /^[\s\-–—,.;:%[\]()]+|[\s\-–—,.;:%[\]()]+$/g
function displayText(raw) {
if (raw == null) return ''
const source = String(raw)
const hadPlaceholder = PLACEHOLDER_RE.test(source)
PLACEHOLDER_RE.lastIndex = 0 // the regex is global; `test` advances it
const trailing = hadPlaceholder ? /[\s\-–—,.;:%[\]()]+$/ : /[\s\-–—,.;:[\]()]+$/
if (!hadPlaceholder) return source.replace(/\s+/g, ' ').trim()
return source
.replace(PLACEHOLDER_RE, ' ')
.replace(/\s+/g, ' ')
.replace(/\s+([,.;:!?])/g, '$1')
.replace(/^[\s\-–—,.;:[\]()]+/, '')
.replace(trailing, '')
.replace(DEBRIS, '')
.trim()
}

View File

@@ -1,15 +1,32 @@
// Cliloc table — the filesystem layer.
//
// `clilocParse.js` holds the pure parsers; this module is the only thing that
// touches the converted cliloc file on disk, and it is shared by both callers:
// touches cliloc files on disk, and it is shared by both callers:
//
// - the server, which refreshes the table on boot (`shardClilocs.model.js`)
// - the admin panel, which can force a reimport without a restart
//
// The file is the OPERATOR'S, produced once from their own UO client (see
// docs/website/CLILOCS.md). Nothing derived from it is committed: the repo holds
// no string table, exactly as it holds no map snapshot and no artwork. That rule
// is why this module reads a configured path instead of a path inside the repo.
// The files are the OPERATOR'S (see docs/website/CLILOCS.md). Nothing derived
// from them is committed: the repo holds no string table, exactly as it holds no
// map snapshot and no artwork. That rule is why this module reads a configured
// path instead of a path inside the repo.
//
// ── Why this reads a SET of files, not one ────────────────────────────────
//
// Shards edit items and add new ones. Those carry cliloc ids that a stock client
// table does not have — and forcing a 5 MB client re-export every time an
// operator adds one item would be miserable enough that the table would simply
// go stale, which is the exact failure the spawn atlas was redesigned to avoid.
//
// So this mirrors `spawnAtlasSource.readSources()`: a BASE table (the converted
// client file) plus every operator-maintained OVERLAY beside it, all re-read on
// every boot and hash-gated as a SET. Adding, editing or removing any overlay
// counts as drift and re-imports. Later sources win, so an overlay both adds new
// ids and overrides stock ones.
//
// Measured on a real shard: the script tree references 16,434 cliloc ids and only
// 37 are absent from the stock client table. Tens of entries against a 67k base
// is what makes the overlay the right shape rather than a second full table.
//
// Reading and hashing ~5 MB costs a few milliseconds and a full parse ~50 ms, so
// the boot path hashes first and only parses when something actually changed.
@@ -21,7 +38,7 @@ const path = require('path')
const { ClilocFormatError, PARSER_VERSION, parseCliloc, isCompressedCliloc } = require('./clilocParse')
/**
* Filenames looked for when the configured path is a DIRECTORY.
* Filenames looked for as the BASE table when the configured path is a directory.
*
* Ordered by how specific they are: an explicitly converted file wins over
* something that merely sits in a client folder, so an operator who dropped a
@@ -35,12 +52,29 @@ const { ClilocFormatError, PARSER_VERSION, parseCliloc, isCompressedCliloc } = r
const CANDIDATE_NAMES = [
'clilocs.tsv',
'clilocs.csv',
'clilocs.plain',
'cliloc.plain',
'cliloc.plain.enu',
'cliloc.enu.plain',
'clilocs.txt',
'cliloc.enu',
]
/**
* Where shard-specific additions and overrides live: a `custom/` directory
* beside the base table.
*
* ServUO has **no server-side convention** for custom clilocs — they live in the
* patched client file a shard distributes to its players, and nothing in the
* tree declares them. There is therefore nothing to discover, and this is the
* one place in the cliloc pipeline that is a convention we chose rather than one
* the shard already has. It is a directory rather than a single file so an
* operator can keep additions grouped however they like (per system, per patch)
* without the site caring.
*/
const CUSTOM_DIR = 'custom'
const CUSTOM_EXTENSIONS = ['.tsv', '.csv', '.txt', '.enu', '.plain']
class ClilocSourceError extends Error {
constructor(message, code) {
super(message)
@@ -54,13 +88,16 @@ function sha256(buffer) {
}
/**
* Resolve the configured path to an actual file.
* Resolve the configured path to `{ root, base }`.
*
* Accepts either a direct file path or a directory to search, because operators
* reasonably supply both — "here is the file" and "here is the folder I put it
* in" are equally natural answers to the admin panel's prompt.
* in" are equally natural answers to the admin panel's prompt. When it is a
* file, `root` is the directory CONTAINING it, so overlays work either way: an
* operator who pointed at a file should not have to re-point at its folder just
* to add a `custom/` directory next to it.
*/
function resolveFile(configured) {
function resolveBase(configured) {
if (!configured || String(configured).trim() === '') {
throw new ClilocSourceError('No cliloc path configured', 'NO_PATH')
}
@@ -73,7 +110,7 @@ function resolveFile(configured) {
throw new ClilocSourceError(`Cliloc path does not exist: ${target}`, 'NOT_FOUND')
}
if (stat.isFile()) return target
if (stat.isFile()) return { root: path.dirname(target), base: target }
if (!stat.isDirectory()) {
throw new ClilocSourceError(`Cliloc path is neither a file nor a directory: ${target}`, 'NOT_FOUND')
@@ -89,7 +126,7 @@ function resolveFile(configured) {
const byLower = new Map(listing.map((name) => [name.toLowerCase(), name]))
for (const candidate of CANDIDATE_NAMES) {
const actual = byLower.get(candidate)
if (actual) return path.join(target, actual)
if (actual) return { root: target, base: path.join(target, actual) }
}
throw new ClilocSourceError(
@@ -98,12 +135,68 @@ function resolveFile(configured) {
)
}
/** Overlay files under `<root>/custom/`, sorted so precedence is deterministic. */
function listCustom(root) {
const dir = path.join(root, CUSTOM_DIR)
let listing
try {
listing = fs.readdirSync(dir, { withFileTypes: true })
} catch (err) {
// No overlay directory is the normal case, not an error.
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
throw new ClilocSourceError(`Cliloc overlay directory is not readable: ${dir}`, 'UNREADABLE')
}
return listing
.filter((e) => e.isFile() && CUSTOM_EXTENSIONS.includes(path.extname(e.name).toLowerCase()))
.map((e) => e.name)
.sort()
.map((name) => path.join(dir, name))
}
function readFileOrThrow(file) {
try {
return fs.readFileSync(file)
} catch {
throw new ClilocSourceError(`Cliloc file is not readable: ${file}`, 'UNREADABLE')
}
}
/**
* A fingerprint of the source file: `{ file, sha256, bytes, mtime, compressed }`.
* Read every cliloc source under the configured path.
*
* The boot path compares the hash against what was last imported and skips the
* parse entirely when it matches — the normal case on every restart that did not
* follow a client patch.
* Returns `{ root, files: [{ label, kind, file, buffer, sha256, bytes, compressed }] }`
* with the base first and overlays after, in the order they must be merged.
*
* Labels are root-relative and forward-slashed so a hash map compares equal
* across platforms — the same directory read on Windows and Linux must produce
* the same fingerprint, or every boot would look like a change. (The same
* reasoning, and the same bug, as `spawnAtlasSource.readSources`.)
*/
function readSources(configured) {
const { root, base } = resolveBase(configured)
const describe = (file, kind) => {
const buffer = readFileOrThrow(file)
return {
label: path.relative(root, file).split(path.sep).join('/'),
kind,
file,
buffer,
sha256: sha256(buffer),
bytes: buffer.length,
compressed: isCompressedCliloc(buffer),
}
}
const files = [describe(base, 'base')]
for (const overlay of listCustom(root)) files.push(describe(overlay, 'custom'))
return { root, files }
}
/**
* A fingerprint of every source: `{ "<label>": "<sha256>" }`, plus the base's
* details for the admin panel.
*
* `compressed` is reported here rather than left to the parse because the admin
* panel calls this and NOT `readCliloc` (parsing 5 MB on every status poll would
@@ -112,62 +205,96 @@ function resolveFile(configured) {
* import" — and the operator only learns otherwise when the import fails. The
* check is four bytes of a buffer already in hand.
*/
function hashSource(configured) {
const file = resolveFile(configured)
let buffer
try {
buffer = fs.readFileSync(file)
} catch {
throw new ClilocSourceError(`Cliloc file is not readable: ${file}`, 'UNREADABLE')
}
let mtime = null
try {
mtime = fs.statSync(file).mtime.toISOString()
} catch {
// A missing mtime is cosmetic (it is only shown in the admin panel).
}
function hashSources(configured) {
const { root, files } = readSources(configured)
const hashes = {}
for (const file of files) hashes[file.label] = file.sha256
const base = files[0]
return {
file,
sha256: sha256(buffer),
bytes: buffer.length,
mtime,
compressed: isCompressedCliloc(buffer),
root,
hashes,
file: base.file,
bytes: base.bytes,
compressed: files.some((f) => f.compressed),
customCount: files.length - 1,
}
}
/** True when two source fingerprints describe the same file. */
function sameSource(a, b) {
return !!a && !!b && a.sha256 === b.sha256
/** True when two source fingerprints describe the same set of files. */
function sameSources(a, b) {
if (!a || !b) return false
const aKeys = Object.keys(a).sort()
const bKeys = Object.keys(b).sort()
if (aKeys.length !== bKeys.length) return false
return aKeys.every((key, i) => key === bKeys[i] && a[key] === b[key])
}
/**
* Read and parse the configured cliloc file.
* Labels present in `loaded` that are absent from `current`.
*
* This is the multi-source hazard that a single file did not have. One corrupt
* file fails the parse loudly, but a source that has simply VANISHED — an
* unmounted volume, a half-copied deploy — parses perfectly and imports a table
* quietly missing everything that file contributed. That is the same ambiguity
* the spawn atlas escalates for a disappearing facet, so it is escalated here
* too rather than applied.
*/
function missingSources(current, loaded) {
if (!loaded) return []
return Object.keys(loaded).filter((label) => !Object.hasOwn(current, label))
}
/**
* Read and parse every source, merged into one entry list.
*
* Later sources win: the base client table first, then each overlay in sorted
* order, so an overlay both ADDS ids the client never had and OVERRIDES stock
* ones the shard has re-purposed.
*
* Returns `{ entries, source }`. Throws `ClilocSourceError` for anything about
* the path and `ClilocFormatError` for anything about the contents — the two are
* different problems for an operator (wrong place vs wrong file), and the admin
* panel says which.
* the paths and `ClilocFormatError` for anything about the contents — different
* problems for an operator (wrong place vs wrong file), and the admin panel says
* which. A format error names the file it came from, because "which of my six
* overlay files is malformed" is otherwise a guessing game.
*/
function readCliloc(configured) {
const file = resolveFile(configured)
const { root, files } = readSources(configured)
let buffer
try {
buffer = fs.readFileSync(file)
} catch {
throw new ClilocSourceError(`Cliloc file is not readable: ${file}`, 'UNREADABLE')
const merged = new Map()
const perSource = []
for (const file of files) {
let entries
try {
entries = parseCliloc(file.buffer)
} catch (err) {
if (err instanceof ClilocFormatError) {
throw new ClilocFormatError(`${file.label}: ${err.message}`, err.code)
}
throw err
}
let added = 0
let overrode = 0
for (const entry of entries) {
if (!Number.isInteger(entry.number)) continue
if (merged.has(entry.number)) overrode++
else added++
merged.set(entry.number, entry)
}
perSource.push({ label: file.label, kind: file.kind, entries: entries.length, added, overrode })
}
const entries = parseCliloc(buffer)
return {
entries,
entries: [...merged.values()],
source: {
file,
sha256: sha256(buffer),
bytes: buffer.length,
root,
file: files[0].file,
sha256: files[0].sha256,
bytes: files[0].bytes,
hashes: Object.fromEntries(files.map((f) => [f.label, f.sha256])),
parserVersion: PARSER_VERSION,
count: entries.length,
sources: perSource,
},
}
}
@@ -177,8 +304,13 @@ module.exports = {
ClilocSourceError,
PARSER_VERSION,
CANDIDATE_NAMES,
resolveFile,
hashSource,
sameSource,
CUSTOM_DIR,
CUSTOM_EXTENSIONS,
resolveBase,
listCustom,
readSources,
hashSources,
sameSources,
missingSources,
readCliloc,
}