feat(shard): resolve cliloc names for items and reward titles #115

Merged
whitlocktech merged 2 commits from feat/cliloc-table into edge 2026-07-29 11:58:56 +00:00
11 changed files with 939 additions and 120 deletions
Showing only changes of commit bda031566a - Show all commits

View File

@@ -739,7 +739,7 @@
{
"method": "POST",
"path": "/api/v1/admin/shard/clilocs/import",
"handlers": 4,
"handlers": 5,
"gates": [
"noindex",
"requireAuth",

View File

@@ -34,10 +34,12 @@ async function replaceAll(entries, meta) {
// identical content: the binary format carries the blanks explicitly and a
// text export may or may not, depending on the tool.
//
// Later duplicates win. The plain format permits a repeated id and the
// client's own loader resolves it the same way (its dictionary assignment
// overwrites), so collapsing here keeps the batch insert from failing on a
// primary-key collision for a file the game itself would load.
// Later duplicates win. Merging across sources already happened upstream in
// `readCliloc`, so in practice this collapses nothing — it is kept because
// the plain format permits a repeated id WITHIN one file and the client's
// own loader resolves it the same way (its dictionary assignment
// overwrites). Without it, a file the game itself would load happily would
// fail the batch insert on a primary-key collision.
const byNumber = new Map()
let blank = 0
for (const entry of entries) {

View File

@@ -5,7 +5,9 @@ const {
ClilocFormatError,
ClilocSourceError,
PARSER_VERSION,
hashSource,
hashSources,
sameSources,
missingSources,
readCliloc,
} = require('../../utils/clilocSource')
const log = require('../../utils/logger')('shardClilocs')
@@ -27,13 +29,24 @@ const log = require('../../utils/logger')('shardClilocs')
// 2. **Nothing client-derived is committed.** The table is built from the
// operator's own file at a configured path. The repo ships no strings.
//
// Unlike the atlas there is no staged-approval flow, and the difference is
// deliberate: the atlas stages a refresh that would REMOVE a facet because a
// half-copied tree and a real map change look identical from here. A cliloc file
// is a single file with a single hash, and the realistic corruption — a partial
// copy — makes the parser fail on a truncated record rather than yield a
// plausible-but-short table. The failure mode the atlas has to guess about is
// one this parser can simply detect.
// The table is built from a SET of sources — the converted client table plus
// every operator-maintained overlay beside it — because shards edit items and
// add new ones, and those carry cliloc ids no stock client table has. All of
// them are re-read on every boot and hash-gated together, so adding one custom
// item never means re-exporting a 5 MB client file. Later sources win.
//
// That set is also why this has the atlas's escalation, in a lighter form. A
// single corrupt file fails the parse loudly, but a source that has simply
// VANISHED parses perfectly and imports a table quietly missing everything it
// contributed — the same ambiguity (real change vs half-copied mount) the atlas
// stages a facet removal for. So a disappearing source is refused and reported
// rather than applied.
//
// It is lighter than the atlas's because it needs to be: the atlas stores a
// pending decision in its own table and adds approve/reject endpoints, whereas
// here the decision is a single boolean an admin passes to the import they were
// already going to run. Re-parsing at approval time — the property that makes
// the atlas store only the decision — is automatic when there is nothing stored.
const SETTING_KEY = 'cliloc_client_path'
@@ -76,13 +89,15 @@ const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
*
* `skipped` no path configured
* `unavailable` path configured but missing / unreadable / not a cliloc file
* `unchanged` source hash matches the loaded table; nothing parsed
* `unchanged` source hashes match the loaded table; nothing parsed
* `imported` parsed and applied
* `needsReview` a previously-present source has vanished; NOT applied
* `failed` parsed or applied and something went wrong
*
* `force` skips the hash check (an admin asking for a reimport).
* `force` skips the hash check (an admin asking for a reimport). `approve`
* additionally accepts a vanished source.
*/
async function refresh({ force = false, path: pathOverride = '' } = {}) {
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
// An explicit override wins outright — a one-off "use this file", which must
// not be silently overruled by the configured path the way an env default is.
const configured = pathOverride.trim() !== '' ? pathOverride.trim() : await getClientPath()
@@ -90,7 +105,7 @@ async function refresh({ force = false, path: pathOverride = '' } = {}) {
let fingerprint
try {
fingerprint = hashSource(configured)
fingerprint = hashSources(configured)
} catch (err) {
if (err instanceof ClilocSourceError) {
return { status: 'unavailable', reason: err.message, code: err.code, path: configured }
@@ -100,11 +115,31 @@ async function refresh({ force = false, path: pathOverride = '' } = {}) {
const meta = await db.getMeta().catch(() => null)
// Two things make a loaded table stale: the file changed, or the PARSER did.
// Only checking the file would strand an install whose client never patches on
// whatever an older build derived.
if (!force && meta?.sha256 === fingerprint.sha256 && currentParser(meta)) {
return { status: 'unchanged', path: configured, file: fingerprint.file, count: meta.count ?? null }
// Two things make a loaded table stale: any source changed, or the PARSER did.
// Only checking the sources would strand an install whose client never patches
// on whatever an older build derived.
if (!force && sameSources(fingerprint.hashes, meta?.hashes) && currentParser(meta)) {
return {
status: 'unchanged',
path: configured,
file: fingerprint.file,
count: meta.count ?? null,
customCount: fingerprint.customCount,
}
}
// A source that was there last import and is not there now is refused, not
// applied — an unmounted volume and a deliberate deletion look identical from
// here, and the wrong guess silently drops every name that file contributed.
const gone = missingSources(fingerprint.hashes, meta?.hashes)
if (gone.length > 0 && !approve) {
return {
status: 'needsReview',
reason: `${gone.length} previously-loaded cliloc source(s) are missing; the existing table is unchanged`,
missingSources: gone,
path: configured,
file: fingerprint.file,
}
}
let parsed
@@ -118,7 +153,7 @@ async function refresh({ force = false, path: pathOverride = '' } = {}) {
}
try {
const applied = await db.replaceAll(parsed.entries, { ...parsed.source, mtime: fingerprint.mtime })
const applied = await db.replaceAll(parsed.entries, parsed.source)
invalidate()
return {
status: 'imported',
@@ -127,7 +162,12 @@ async function refresh({ force = false, path: pathOverride = '' } = {}) {
count: applied.count,
parsed: parsed.entries.length,
blank: applied.blank,
duplicates: applied.duplicates,
// Per-source breakdown: how many entries each file contributed and how
// many of them overrode something already merged. An operator who adds an
// overlay wants to see it took effect, and "overrode: 0" on a file meant
// to re-label stock items says it did not.
sources: parsed.source.sources,
acceptedMissing: gone.length > 0 ? gone : undefined,
}
} catch (err) {
return { status: 'failed', reason: err.message, path: configured }
@@ -143,7 +183,18 @@ async function refreshOnBoot() {
const result = await refresh()
switch (result.status) {
case 'imported':
log.info('cliloc table refreshed', { file: result.file, count: result.count })
log.info('cliloc table refreshed', {
file: result.file,
count: result.count,
overlays: (result.sources || []).filter((s) => s.kind === 'custom').length,
})
break
case 'needsReview':
log.warn(
'cliloc refresh staged for admin review — a previously-loaded source is missing; ' +
'the existing table is unchanged',
{ missing: result.missingSources },
)
break
case 'unavailable':
// Deliberately a warning, not an error: an operator who has not supplied
@@ -180,11 +231,15 @@ async function status({ path: pathOverride = '' } = {}) {
let drift = null
let problem = null
let code = null
let sources = []
let missing = []
if (configured !== '') {
try {
const fingerprint = hashSource(configured)
const fingerprint = hashSources(configured)
fileReadable = true
file = fingerprint.file
sources = Object.keys(fingerprint.hashes)
missing = missingSources(fingerprint.hashes, meta?.hashes)
// A compressed file is readable but not importable, and the panel has to
// say so HERE — otherwise pointing at an unconverted client directory
// reports a healthy file with pending drift ("ready to import") and the
@@ -196,7 +251,7 @@ async function status({ path: pathOverride = '' } = {}) {
'Convert it to the plain format first — see docs/website/CLILOCS.md.'
code = 'COMPRESSED'
} else {
drift = meta?.sha256 !== fingerprint.sha256 || !currentParser(meta)
drift = !sameSources(fingerprint.hashes, meta?.hashes) || !currentParser(meta)
}
} catch (err) {
fileReadable = false
@@ -214,6 +269,12 @@ async function status({ path: pathOverride = '' } = {}) {
code,
drift,
count: loaded,
// Every source found now (base first, then overlays), what each contributed
// at the last import, and any that have since vanished — which is the state
// an import will refuse without `approve`.
sources,
loadedSources: meta?.sources ?? null,
missingSources: missing,
importedAt: meta?.importedAt ?? null,
sourceBytes: meta?.bytes ?? null,
}

View File

@@ -318,8 +318,8 @@ shardRouter.put(
shardRouter.get(
'/clilocs',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Cliloc table status: path, drift, entry count (admin only)'
// #swagger.description = 'Where the converted cliloc file is, whether it can be read, how many entries are loaded, and whether the file on disk has drifted from them. A shard with no cliloc file configured is a supported state — item names simply render as ids.'
// #swagger.summary = 'Cliloc table status: sources, drift, entry count (admin only)'
// #swagger.description = 'Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -329,21 +329,22 @@ shardRouter.get(
shardRouter.post(
'/clilocs/import',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Re-import the cliloc table from the converted file (admin only)'
// #swagger.description = 'Applies a client patch without a restart. `force` reimports even when the source hash matches what is loaded. A missing file — or the common mistake of pointing at the client\'s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.'
// #swagger.summary = 'Re-import the cliloc table from its source files (admin only)'
// #swagger.description = 'Applies a client patch, or a change to the shard\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\'s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the file is unchanged." } } } } } } */
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocRefreshResult" } } } } */
adminOnly,
body('force').optional().isBoolean(),
body('approve').optional().isBoolean(),
validate,
shardClilocs.importClilocs,
)
shardRouter.put(
'/clilocs/path',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Set the cliloc file the site reads from (admin only)'
// #swagger.description = 'Accepts either the converted file itself or a directory to search. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
// #swagger.summary = 'Set the cliloc source the site reads from (admin only)'
// #swagger.description = 'Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." } } } } } } */
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */

View File

@@ -33,17 +33,33 @@ async function getStatus(req, res) {
}
}
// POST /admin/shard/clilocs/import — reload after a client patch without a
// restart. `force` reimports even when the source hash matches what is loaded
// (the escape hatch for "the database is wrong but the file is not").
// POST /admin/shard/clilocs/import — reload after a client patch or a change to
// the shard's own overlay files, without a restart.
//
// `force` reimports even when the source hashes match what is loaded (the escape
// hatch for "the database is wrong but the files are not").
//
// `approve` accepts a refresh in which a previously-loaded source has VANISHED.
// That is refused by default because an unmounted volume and a deliberate
// deletion look identical from the server — the lighter cousin of the atlas's
// approve/reject flow, and the reason it can be a flag here rather than a
// pending table is that nothing is stored to approve: the import re-reads the
// files at approval time by construction.
async function importClilocs(req, res) {
try {
const force = !!req.body?.force
const result = await clilocs.refresh({ force })
const approve = !!req.body?.approve
const result = await clilocs.refresh({ force, approve })
await activity.log({
req,
action: 'shard.clilocs.import',
detail: { force, status: result.status, count: result.count ?? null },
detail: {
force,
approve,
status: result.status,
count: result.count ?? null,
missingSources: result.missingSources ?? result.acceptedMissing ?? null,
},
})
return res.json(result)
} catch (err) {

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

View File

@@ -4083,8 +4083,8 @@
"tags": [
"Admin · Shard"
],
"summary": "Cliloc table status: path, drift, entry count (admin only)",
"description": "Where the converted cliloc file is, whether it can be read, how many entries are loaded, and whether the file on disk has drifted from them. A shard with no cliloc file configured is a supported state — item names simply render as ids.",
"summary": "Cliloc table status: sources, drift, entry count (admin only)",
"description": "Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.",
"responses": {
"200": {
"description": "Cliloc status",
@@ -4125,8 +4125,8 @@
"tags": [
"Admin · Shard"
],
"summary": "Re-import the cliloc table from the converted file (admin only)",
"description": "Applies a client patch without a restart. `force` reimports even when the source hash matches what is loaded. A missing file — or the common mistake of pointing at the client\\'s own COMPRESSED Cliloc.enu — answers 200 with status \"unavailable\" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.",
"summary": "Re-import the cliloc table from its source files (admin only)",
"description": "Applies a client patch, or a change to the shard\\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\\'s own COMPRESSED Cliloc.enu — answers 200 with status \"unavailable\" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.",
"responses": {
"200": {
"description": "What happened",
@@ -4162,7 +4162,11 @@
"properties": {
"force": {
"type": "boolean",
"description": "Reimport even if the file is unchanged."
"description": "Reimport even if the sources are unchanged."
},
"approve": {
"type": "boolean",
"description": "Accept a refresh in which a previously-loaded source has vanished."
}
}
}
@@ -4176,8 +4180,8 @@
"tags": [
"Admin · Shard"
],
"summary": "Set the cliloc file the site reads from (admin only)",
"description": "Accepts either the converted file itself or a directory to search. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.",
"summary": "Set the cliloc source the site reads from (admin only)",
"description": "Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.",
"responses": {
"200": {
"description": "Cliloc status after the change",
@@ -20594,7 +20598,7 @@
},
"description": {
"type": "string",
"example": "True when the file's hash differs from the loaded table. NULL when the file could not be read or is not usable."
"example": "True when any source hash differs from the loaded table. NULL when the sources could not be read or are not usable."
},
"example": {
"type": "boolean",
@@ -20615,7 +20619,180 @@
},
"example": {
"type": "number",
"example": 123490
"example": 67496
}
}
},
"sources": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"description": {
"type": "string",
"example": "Every source found now, root-relative, base first then overlays in merge order."
},
"example": {
"type": "array",
"example": [
"clilocs.plain",
"custom/uomysticmoon.tsv"
],
"items": {
"type": "string"
}
}
}
},
"loadedSources": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "What each source contributed at the last import."
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"label": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "custom/uomysticmoon.tsv"
}
}
},
"kind": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"base",
"custom"
],
"items": {
"type": "string"
}
},
"example": {
"type": "string",
"example": "custom"
}
}
},
"entries": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 37
}
}
},
"added": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"description": {
"type": "string",
"example": "Ids this source introduced."
},
"example": {
"type": "number",
"example": 25
}
}
},
"overrode": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"description": {
"type": "string",
"example": "Ids it replaced from an earlier source."
},
"example": {
"type": "number",
"example": 12
}
}
}
}
}
}
}
}
},
"missingSources": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"description": {
"type": "string",
"example": "Sources loaded previously and now absent. An import refuses these without `approve`."
},
"example": {
"type": "array",
"example": [],
"items": {}
}
}
},
@@ -20685,12 +20862,17 @@
"unavailable",
"unchanged",
"imported",
"needsReview",
"failed"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "`needsReview` means a previously-loaded source has vanished and nothing was applied; re-run with `approve` to accept it."
},
"example": {
"type": "string",
"example": "imported"
@@ -20780,13 +20962,17 @@
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Entries stored (blank strings are dropped)."
},
"example": {
"type": "number",
"example": 123490
"example": 67496
}
}
},
"duplicates": {
"parsed": {
"type": "object",
"properties": {
"type": {
@@ -20799,11 +20985,166 @@
},
"description": {
"type": "string",
"example": "Repeated ids collapsed on import (last wins)."
"example": "Entries read across every source before blanks were dropped."
},
"example": {
"type": "number",
"example": 0
"example": 123527
}
}
},
"blank": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "number",
"example": 55994
}
}
},
"sources": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Per-source breakdown: what each file contributed and how much of it overrode an earlier source."
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"label": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"kind": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"base",
"custom"
],
"items": {
"type": "string"
}
}
}
},
"entries": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
}
}
},
"added": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
}
}
},
"overrode": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
}
}
}
}
}
}
}
}
},
"missingSources": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"nullable": {
"type": "boolean",
"example": true
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"description": {
"type": "string",
"example": "On `needsReview`: the sources that vanished. Nothing was applied."
}
}
},
"acceptedMissing": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"nullable": {
"type": "boolean",
"example": true
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"description": {
"type": "string",
"example": "On `imported` with `approve`: the vanished sources the admin accepted."
}
}
}

View File

@@ -1172,8 +1172,35 @@ const doc = {
fileReadable: { type: 'boolean', example: true },
problem: { type: 'string', nullable: true, description: 'Why the file cannot be used, when it cannot. Set (with code COMPRESSED) for a readable-but-unconverted client file.', example: null },
code: { type: 'string', nullable: true, description: 'Machine-readable cause of `problem`.', enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED'] },
drift: { type: 'boolean', nullable: true, description: 'True when the file\'s hash differs from the loaded table. NULL when the file could not be read or is not usable.', example: false },
count: { type: 'integer', description: 'Entries currently loaded.', example: 123490 },
drift: { type: 'boolean', nullable: true, description: 'True when any source hash differs from the loaded table. NULL when the sources could not be read or are not usable.', example: false },
count: { type: 'integer', description: 'Entries currently loaded.', example: 67496 },
sources: {
type: 'array',
items: { type: 'string' },
description: 'Every source found now, root-relative, base first then overlays in merge order.',
example: ['clilocs.plain', 'custom/uomysticmoon.tsv'],
},
loadedSources: {
type: 'array',
nullable: true,
description: 'What each source contributed at the last import.',
items: {
type: 'object',
properties: {
label: { type: 'string', example: 'custom/uomysticmoon.tsv' },
kind: { type: 'string', enum: ['base', 'custom'], example: 'custom' },
entries: { type: 'integer', example: 37 },
added: { type: 'integer', description: 'Ids this source introduced.', example: 25 },
overrode: { type: 'integer', description: 'Ids it replaced from an earlier source.', example: 12 },
},
},
},
missingSources: {
type: 'array',
items: { type: 'string' },
description: 'Sources loaded previously and now absent. An import refuses these without `approve`.',
example: [],
},
importedAt: { type: 'string', format: 'date-time', nullable: true },
sourceBytes: { type: 'integer', nullable: true, example: 4973525 },
},
@@ -1185,7 +1212,8 @@ const doc = {
properties: {
status: {
type: 'string',
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'failed'],
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed'],
description: '`needsReview` means a previously-loaded source has vanished and nothing was applied; re-run with `approve` to accept it.',
example: 'imported',
},
reason: { type: 'string', nullable: true },
@@ -1197,8 +1225,36 @@ const doc = {
},
path: { type: 'string', nullable: true },
file: { type: 'string', nullable: true },
count: { type: 'integer', nullable: true, example: 123490 },
duplicates: { type: 'integer', nullable: true, description: 'Repeated ids collapsed on import (last wins).', example: 0 },
count: { type: 'integer', nullable: true, description: 'Entries stored (blank strings are dropped).', example: 67496 },
parsed: { type: 'integer', nullable: true, description: 'Entries read across every source before blanks were dropped.', example: 123527 },
blank: { type: 'integer', nullable: true, example: 55994 },
sources: {
type: 'array',
nullable: true,
description: 'Per-source breakdown: what each file contributed and how much of it overrode an earlier source.',
items: {
type: 'object',
properties: {
label: { type: 'string' },
kind: { type: 'string', enum: ['base', 'custom'] },
entries: { type: 'integer' },
added: { type: 'integer' },
overrode: { type: 'integer' },
},
},
},
missingSources: {
type: 'array',
nullable: true,
items: { type: 'string' },
description: 'On `needsReview`: the sources that vanished. Nothing was applied.',
},
acceptedMissing: {
type: 'array',
nullable: true,
items: { type: 'string' },
description: 'On `imported` with `approve`: the vanished sources the admin accepted.',
},
},
},
ShardLinkRequest: {

View File

@@ -209,6 +209,18 @@ test('displayText: ordinary names pass through untouched', () => {
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), '')

View File

@@ -0,0 +1,194 @@
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), [])
})