The base cliloc table now comes over the bridge. `clilocBridge.js` walks
`GET /cliloc` page by page and the model merges the `custom/` overlays over it —
overlays stay on disk because ServUO has no server-side notion of a custom
cliloc, so there is nothing on the shard to ask for.
**The shard wins whenever uo-link is configured and enabled**, with no mode
setting: there is no version of "which source?" an operator benefits from
answering. A file on disk remains the source only where there is no shard link,
plus a one-off explicit `path` — deprecated, not removed, and unchanged.
**Boot no longer imports on the bridge.** The file path could hash 5 MB locally
and skip in 14 ms; a shard round trip in the boot sequence would be spent
answering "no" on every restart but the one after a client patch — and patching a
client is an operator action, so importing became one. Admin → Shard → Import.
Whatever table is loaded keeps serving until then.
Three checks in the walk, each for a way a shard can hand back a table that looks
complete:
* only `cut: 'end'` finishes it — a short page can equally be a spent budget,
and a truncated table renders some items named and some not, which is exactly
what NO table looks like;
* the cursor must advance, or the walk stops rather than spinning;
* every page echoes the source's size and mtime, so a client patched mid-import
is refused outright rather than stitched from two files.
**The base is exempt from the vanished-source rule**, which is an upgrade detail
rather than a preference: an install that used the file pipeline carries its base
file's label in the stored fingerprint, and on the bridge that label is *supposed*
to disappear. Counting it as vanished would demand an approval for a change the
upgrade itself made. Overlays keep the rule in full.
**The protocol pin moves 7 → 8** — the third declaration site, and the one
nothing enforces. Phase 1 moved the sidecar and the overlay together because the
installer refuses a mismatched bundle; this one has to be moved by hand, in the
phase that first calls a protocol-8 route. The schema block above it is the
record of what forgetting costs: two phases of every REST call answered 409.
Verified against a live shard, sidecar and site: 12 pages, 67,496 rows imported
in 1.68 s, the operator's three-row overlay overriding stock strings on top of
it, and the next import correctly `unchanged`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
706 lines
26 KiB
JavaScript
706 lines
26 KiB
JavaScript
const db = require('./shardClilocs.db')
|
|
const { settings } = require('../../core')
|
|
const { displayText, parseCliloc } = require('../../utils/clilocParse')
|
|
const {
|
|
ClilocFormatError,
|
|
ClilocSourceError,
|
|
PARSER_VERSION,
|
|
hashSources,
|
|
sameSources,
|
|
missingSources,
|
|
missingOverlays,
|
|
readOverlays,
|
|
readCliloc,
|
|
} = require('../../utils/clilocSource')
|
|
const bridge = require('../../utils/clilocBridge')
|
|
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
|
|
const log = require('../../core').logger('shardClilocs')
|
|
|
|
// The cliloc table — UO's id → display-string map, refreshed from a file the
|
|
// operator converts once from their own client.
|
|
//
|
|
// Why the site holds this at all: items on the wire carry a `LabelNumber`, not a
|
|
// name. `char.profile.equipment` has always sent `cliloc`, and every marketplace
|
|
// listing sends one too. Without the table the UI can only print `id 1023721`
|
|
// where the game prints "quarter staff".
|
|
//
|
|
// Two rules govern the boot path, both inherited from the spawn atlas:
|
|
//
|
|
// 1. **It never blocks startup.** No configured path, an unreadable file, a
|
|
// wrong-format file, a database error — all caught and logged. The site
|
|
// comes up either way, serving whatever table it already had (or none, in
|
|
// which case the UI falls back to item ids exactly as it did before).
|
|
// 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.
|
|
//
|
|
// The table is built from a SET of sources — a base 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. Later sources win,
|
|
// so an overlay both adds ids the client never had and overrides stock ones.
|
|
//
|
|
// ── Where the base comes from (protocol 8, docs/link/v8.md §9) ─────────
|
|
//
|
|
// **The shard**, on any install with uo-link configured. It has the operator's
|
|
// client files already — a ServUO server cannot boot without them — and since
|
|
// phase 2 it has the decompressor too, so `GET /cliloc` returns the table and
|
|
// nobody installs UOFiddler or copies a 5 MB file anywhere.
|
|
//
|
|
// **A file on disk** otherwise. That is the pipeline this replaces, kept for
|
|
// installs with no shard link and for development, and deprecated rather than
|
|
// removed: an operator who has one keeps working, and an operator who has a shard
|
|
// never builds one. Passing an explicit `path` to `refresh()` still selects it,
|
|
// which is the escape hatch for "import from this file, this once".
|
|
//
|
|
// **Overlays are always the filesystem's**, either way. There is nothing on the
|
|
// shard to ask for: ServUO has no server-side notion of a custom cliloc, so the
|
|
// `custom/` directory is the only place those ids exist.
|
|
//
|
|
// ── What that changed about WHEN this runs ───────────────────────
|
|
//
|
|
// Boot no longer imports on the shard path. The file path could hash 5 MB locally
|
|
// on every restart and skip; the shard path would mean a sidecar round trip in the
|
|
// boot sequence, for a table that changes when an operator patches their client —
|
|
// an event they know about and we do not. So on the bridge, importing is an admin
|
|
// action (Admin → Shard), and boot leaves whatever is loaded serving.
|
|
//
|
|
// 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'
|
|
|
|
/**
|
|
* Where the converted cliloc file lives.
|
|
*
|
|
* The admin setting wins over the environment so an operator can repoint it
|
|
* without a redeploy, matching how the rest of the shard integration is
|
|
* admin-managed rather than env-configured. `UO_CLIENT_PATH` remains as the
|
|
* deploy-time default, since the path usually describes a mount the deployment
|
|
* sets up.
|
|
*/
|
|
async function getClientPath() {
|
|
try {
|
|
const configured = await settings.get(SETTING_KEY)
|
|
if (configured && String(configured).trim() !== '') return String(configured).trim()
|
|
} catch {
|
|
// Settings unavailable is not fatal — fall through to the env default.
|
|
}
|
|
const fromEnv = process.env.UO_CLIENT_PATH
|
|
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
|
|
}
|
|
|
|
async function setClientPath(value, updatedBy = null) {
|
|
const result = await settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
|
|
invalidate()
|
|
return result
|
|
}
|
|
|
|
// ── Refresh ────────────────────────────────────────────────────────────────
|
|
|
|
/** Was the loaded table built by THIS parser? */
|
|
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
|
|
|
/**
|
|
* Refresh the cliloc table from the configured file.
|
|
*
|
|
* Returns a result describing what happened rather than throwing, so the caller
|
|
* — including the boot path — can log it and move on:
|
|
*
|
|
* `skipped` no path configured
|
|
* `unavailable` path configured but missing / unreadable / not a cliloc file
|
|
* `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). `approve`
|
|
* additionally accepts a vanished source.
|
|
*
|
|
* Which SOURCE it reads is decided here and nowhere else: the shard when uo-link
|
|
* is configured and enabled, a file otherwise, and always a file when the caller
|
|
* named one.
|
|
*/
|
|
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
|
const override = String(pathOverride ?? '').trim()
|
|
|
|
if (override === '' && (await shardLinked())) {
|
|
return refreshFromShard({ force, approve })
|
|
}
|
|
|
|
return refreshFromFile({ force, approve, path: override })
|
|
}
|
|
|
|
/**
|
|
* Is there a shard to ask?
|
|
*
|
|
* Both halves matter. `baseUrl` alone is an install that has been configured and
|
|
* then switched off, and calling it would spend a 12 s timeout to learn what the
|
|
* row already says. Never throws: an unreadable config means "no shard", and the
|
|
* file path is a working answer.
|
|
*/
|
|
async function shardLinked() {
|
|
try {
|
|
const config = await uoLinkConfig.getSafe()
|
|
return Boolean(config?.enabled && config?.baseUrl)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge parsed sources in order, later winning.
|
|
*
|
|
* Shared by both paths, because the merge is the same question whichever end the
|
|
* base arrived from: what did each source contribute, and what did it override.
|
|
* The per-source breakdown is for the admin panel — 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.
|
|
*/
|
|
function mergeSources(groups) {
|
|
const merged = new Map()
|
|
const sources = []
|
|
|
|
for (const group of groups) {
|
|
let added = 0
|
|
let overrode = 0
|
|
|
|
for (const entry of group.entries) {
|
|
if (!Number.isInteger(entry.number)) continue
|
|
if (merged.has(entry.number)) overrode++
|
|
else added++
|
|
merged.set(entry.number, entry)
|
|
}
|
|
|
|
sources.push({
|
|
label: group.label,
|
|
kind: group.kind,
|
|
entries: group.entries.length,
|
|
added,
|
|
overrode,
|
|
})
|
|
}
|
|
|
|
return { entries: [...merged.values()], sources }
|
|
}
|
|
|
|
/** Overlay hashes as a `{ label: sha256 }` map, in merge order. */
|
|
function overlayHashes(files) {
|
|
const hashes = {}
|
|
for (const file of files) hashes[file.label] = file.sha256
|
|
return hashes
|
|
}
|
|
|
|
/** The overlay half of a stored fingerprint — everything under `custom/`. */
|
|
function onlyOverlays(hashes) {
|
|
if (!hashes) return null
|
|
const out = {}
|
|
for (const [label, sha] of Object.entries(hashes)) {
|
|
if (label.startsWith('custom/')) out[label] = sha
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Import with the shard as the base source.
|
|
*
|
|
* The gate is two-part, and neither part is something the shard can answer for
|
|
* us: has the client file changed (size/mtime/sha256, plus the shard's own
|
|
* `EXTRACTOR_VERSION`), and has any overlay beside the configured path changed.
|
|
* Either is drift; neither is the normal case.
|
|
*/
|
|
async function refreshFromShard({ force = false, approve = false } = {}) {
|
|
let fingerprint
|
|
|
|
try {
|
|
fingerprint = await bridge.fingerprint()
|
|
} catch (err) {
|
|
if (err instanceof bridge.ClilocBridgeError) {
|
|
return { status: 'unavailable', source: 'bridge', reason: err.message, code: err.code }
|
|
}
|
|
return { status: 'failed', source: 'bridge', reason: err.message }
|
|
}
|
|
|
|
const configured = await getClientPath()
|
|
const overlays = readOverlays(configured)
|
|
const hashes = overlayHashes(overlays.files)
|
|
|
|
const meta = await db.getMeta().catch(() => null)
|
|
|
|
if (
|
|
!force &&
|
|
bridge.sameSource(fingerprint, meta?.base) &&
|
|
sameSources(hashes, onlyOverlays(meta?.hashes)) &&
|
|
currentParser(meta)
|
|
) {
|
|
return {
|
|
status: 'unchanged',
|
|
source: 'bridge',
|
|
file: fingerprint.file,
|
|
count: meta.count ?? null,
|
|
customCount: overlays.files.length,
|
|
hashing: fingerprint.hashing,
|
|
}
|
|
}
|
|
|
|
// An overlay that was loaded last time and is not there now is refused rather
|
|
// than applied — an unmounted volume and a deliberate deletion look identical
|
|
// from here, and the wrong guess silently drops every name that file gave.
|
|
// The BASE is deliberately not part of this question: an install upgraded from
|
|
// the file pipeline is *supposed* to stop having one.
|
|
const gone = missingOverlays(hashes, meta?.hashes)
|
|
|
|
if (gone.length > 0 && !approve) {
|
|
return {
|
|
status: 'needsReview',
|
|
source: 'bridge',
|
|
reason: `${gone.length} previously-loaded cliloc overlay(s) are missing; the existing table is unchanged`,
|
|
missingSources: gone,
|
|
file: fingerprint.file,
|
|
}
|
|
}
|
|
|
|
let base
|
|
|
|
try {
|
|
base = await bridge.readCliloc({ lang: bridge.DEFAULT_LANGUAGE })
|
|
} catch (err) {
|
|
if (err instanceof bridge.ClilocBridgeError) {
|
|
return { status: 'unavailable', source: 'bridge', reason: err.message, code: err.code }
|
|
}
|
|
return { status: 'failed', source: 'bridge', reason: err.message }
|
|
}
|
|
|
|
const groups = [{ label: base.source.file, kind: 'shard', entries: base.entries }]
|
|
|
|
for (const file of overlays.files) {
|
|
try {
|
|
groups.push({ label: file.label, kind: 'custom', entries: parseCliloc(file.buffer) })
|
|
} catch (err) {
|
|
if (err instanceof ClilocFormatError) {
|
|
// Named, because "which of my six overlay files is malformed" is
|
|
// otherwise a guessing game.
|
|
return {
|
|
status: 'unavailable',
|
|
source: 'bridge',
|
|
reason: `${file.label}: ${err.message}`,
|
|
code: err.code,
|
|
}
|
|
}
|
|
return { status: 'failed', source: 'bridge', reason: err.message }
|
|
}
|
|
}
|
|
|
|
const merged = mergeSources(groups)
|
|
|
|
try {
|
|
const applied = await db.replaceAll(merged.entries, {
|
|
source: 'bridge',
|
|
base: fingerprint,
|
|
hashes,
|
|
parserVersion: PARSER_VERSION,
|
|
sources: merged.sources,
|
|
file: base.source.file,
|
|
bytes: fingerprint.size,
|
|
})
|
|
|
|
invalidate()
|
|
|
|
return {
|
|
status: 'imported',
|
|
source: 'bridge',
|
|
file: base.source.file,
|
|
count: applied.count,
|
|
parsed: merged.entries.length,
|
|
blank: applied.blank,
|
|
pages: base.source.pages,
|
|
sources: merged.sources,
|
|
// The shard says how many rows it holds; this is how many arrived. They
|
|
// agree, or the walk is wrong in a way no count on its own would show.
|
|
reported: base.source.reported,
|
|
received: base.source.received,
|
|
overlayProblem: overlays.problem ?? undefined,
|
|
acceptedMissing: gone.length > 0 ? gone : undefined,
|
|
}
|
|
} catch (err) {
|
|
return { status: 'failed', source: 'bridge', reason: err.message }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Import from a converted file on disk — the pre-protocol-8 pipeline, unchanged.
|
|
*
|
|
* Deprecated but supported: an install with no shard link has no other way to get
|
|
* a table, and development without a running ServUO is the same case.
|
|
*/
|
|
async function refreshFromFile({ 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()
|
|
if (configured === '') return { status: 'skipped', reason: 'no cliloc path configured' }
|
|
|
|
let fingerprint
|
|
try {
|
|
fingerprint = hashSources(configured)
|
|
} catch (err) {
|
|
if (err instanceof ClilocSourceError) {
|
|
return { status: 'unavailable', reason: err.message, code: err.code, path: configured }
|
|
}
|
|
return { status: 'failed', reason: err.message, path: configured }
|
|
}
|
|
|
|
const meta = await db.getMeta().catch(() => 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
|
|
try {
|
|
parsed = readCliloc(configured)
|
|
} catch (err) {
|
|
if (err instanceof ClilocFormatError || err instanceof ClilocSourceError) {
|
|
return { status: 'unavailable', reason: err.message, code: err.code, path: configured }
|
|
}
|
|
return { status: 'failed', reason: err.message, path: configured }
|
|
}
|
|
|
|
try {
|
|
// `source: 'file'` is what lets the NEXT refresh — and `status()` — tell a
|
|
// table built from a converted file from one built over the bridge. Without
|
|
// it an install that gains a shard link looks like it already imported.
|
|
const applied = await db.replaceAll(parsed.entries, { ...parsed.source, source: 'file' })
|
|
invalidate()
|
|
return {
|
|
status: 'imported',
|
|
path: configured,
|
|
file: parsed.source.file,
|
|
count: applied.count,
|
|
parsed: parsed.entries.length,
|
|
blank: applied.blank,
|
|
// 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 }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Boot hook. Best-effort by contract: it logs and returns, never throws, so a
|
|
* missing or malformed cliloc file can never stop the site coming up.
|
|
*
|
|
* **On the bridge it imports nothing**, deliberately. The file path can hash a
|
|
* local 5 MB file on every restart and skip in 14 ms; asking the shard would put
|
|
* a sidecar round trip in the boot sequence to answer a question whose answer is
|
|
* "no" every time except after a client patch — which is an operator action, and
|
|
* therefore something an operator can press a button for. Whatever table is
|
|
* loaded keeps serving, which is exactly what happens today when a restart finds
|
|
* nothing changed.
|
|
*/
|
|
async function refreshOnBoot() {
|
|
try {
|
|
if (await shardLinked()) {
|
|
log.info('cliloc table comes from the shard; import is admin-triggered (Admin → Shard)')
|
|
return { status: 'skipped', source: 'bridge', reason: 'the shard is the cliloc source' }
|
|
}
|
|
|
|
const result = await refresh()
|
|
switch (result.status) {
|
|
case 'imported':
|
|
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
|
|
// a cliloc file is in a supported state (the UI shows item ids), and the
|
|
// most common cause — pointing at the client's own compressed file —
|
|
// needs the reason spelled out rather than a stack trace.
|
|
log.warn('cliloc source unavailable (item names will show as ids)', {
|
|
reason: result.reason,
|
|
code: result.code,
|
|
path: result.path,
|
|
})
|
|
break
|
|
case 'failed':
|
|
log.warn('cliloc refresh failed', { reason: result.reason })
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
return result
|
|
} catch (err) {
|
|
log.warn('cliloc refresh errored', { error: err.message })
|
|
return { status: 'failed', reason: err.message }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Everything the admin panel needs to describe cliloc state.
|
|
*
|
|
* Two shapes, one per source, sharing every field a panel actually renders
|
|
* (`count`, `drift`, `problem`, `sources`, `missingSources`, `importedAt`). What
|
|
* differs is what `file` means and what a problem with it looks like: on the
|
|
* bridge it is the shard's own client file and the problems are transport ones,
|
|
* on disk it is a path an operator typed.
|
|
*/
|
|
async function status({ path: pathOverride = '' } = {}) {
|
|
if (pathOverride.trim() === '' && (await shardLinked())) return shardStatus()
|
|
|
|
const configured = pathOverride.trim() !== '' ? pathOverride.trim() : await getClientPath()
|
|
const meta = await db.getMeta().catch(() => null)
|
|
const loaded = await db.count().catch(() => 0)
|
|
|
|
let fileReadable = false
|
|
let file = null
|
|
let drift = null
|
|
let problem = null
|
|
let code = null
|
|
let sources = []
|
|
let missing = []
|
|
if (configured !== '') {
|
|
try {
|
|
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
|
|
// operator only finds out when the import fails. `drift` stays null
|
|
// because comparing hashes with an unusable file answers nothing.
|
|
if (fingerprint.compressed) {
|
|
problem =
|
|
'This is a compressed (Mythic-format) cliloc file, which the site cannot read. ' +
|
|
'Convert it to the plain format first — see docs/website/CLILOCS.md.'
|
|
code = 'COMPRESSED'
|
|
} else {
|
|
drift = !sameSources(fingerprint.hashes, meta?.hashes) || !currentParser(meta)
|
|
}
|
|
} catch (err) {
|
|
fileReadable = false
|
|
problem = err.message
|
|
code = err.code ?? null
|
|
}
|
|
}
|
|
|
|
return {
|
|
configured: configured !== '',
|
|
path: configured,
|
|
file,
|
|
fileReadable,
|
|
problem,
|
|
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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Status when the shard is the source.
|
|
*
|
|
* The one thing worth knowing here that the file path has no equivalent of:
|
|
* `hashing`. The shard reports a null `sha256` for a client file it has not
|
|
* hashed yet — hashing the 343 MB of art and animation it also serves cannot fit
|
|
* in a 10 s reply, so it happens on its own thread — and a null hash means "ask
|
|
* again", never "changed". Drift falls back to (size, mtime) meanwhile, which is
|
|
* the same gate the shard itself applies, so an operator is never blocked from
|
|
* importing by a hash that has not landed.
|
|
*/
|
|
async function shardStatus() {
|
|
const configured = await getClientPath()
|
|
const meta = await db.getMeta().catch(() => null)
|
|
const loaded = await db.count().catch(() => 0)
|
|
|
|
const overlays = readOverlays(configured)
|
|
const hashes = overlayHashes(overlays.files)
|
|
|
|
let fingerprint = null
|
|
let problem = overlays.problem ?? null
|
|
let code = null
|
|
|
|
try {
|
|
fingerprint = await bridge.fingerprint()
|
|
} catch (err) {
|
|
problem = err.message
|
|
code = err.code ?? null
|
|
}
|
|
|
|
const drift = fingerprint
|
|
? !bridge.sameSource(fingerprint, meta?.base) ||
|
|
!sameSources(hashes, onlyOverlays(meta?.hashes)) ||
|
|
!currentParser(meta)
|
|
: null
|
|
|
|
return {
|
|
source: 'bridge',
|
|
configured: true,
|
|
// The overlay directory, which is all the path setting still selects on this
|
|
// source. Reported so a panel can say where `custom/` is being read from.
|
|
path: configured,
|
|
file: fingerprint?.file ?? bridge.SOURCE_FILE,
|
|
fileReadable: Boolean(fingerprint),
|
|
problem,
|
|
code,
|
|
drift,
|
|
count: loaded,
|
|
shard: fingerprint
|
|
? {
|
|
size: fingerprint.size,
|
|
mtime: fingerprint.mtime,
|
|
sha256: fingerprint.sha256,
|
|
extractorVersion: fingerprint.extractorVersion,
|
|
hashing: fingerprint.hashing,
|
|
complete: fingerprint.complete,
|
|
}
|
|
: null,
|
|
sources: Object.keys(hashes),
|
|
loadedSources: meta?.sources ?? null,
|
|
missingSources: missingOverlays(hashes, meta?.hashes),
|
|
importedAt: meta?.importedAt ?? null,
|
|
sourceBytes: meta?.base?.size ?? meta?.bytes ?? null,
|
|
}
|
|
}
|
|
|
|
// ── Lookup ─────────────────────────────────────────────────────────────────
|
|
//
|
|
// Resolution happens SERVER-SIDE, not in the browser. Two reasons: the table is
|
|
// ~123k rows and shipping it to a client would dwarf every page that uses it,
|
|
// and the Android app consumes the same JSON and would otherwise need its own
|
|
// copy. Callers get names, not ids-plus-a-table.
|
|
|
|
// A small write-through cache in front of the table. Item ids repeat heavily —
|
|
// one page of listings is mostly the same few hundred clilocs, and a character
|
|
// sheet re-resolves the same gear on every view — so this turns the steady state
|
|
// into zero queries. Capped so a pathological caller cannot grow it without
|
|
// bound; on overflow it is dropped wholesale rather than evicted entry-by-entry,
|
|
// which is cheap and correct for a table that only changes on reimport.
|
|
const CACHE_MAX = 20000
|
|
let cache = new Map()
|
|
|
|
function invalidate() {
|
|
cache = new Map()
|
|
}
|
|
|
|
/**
|
|
* Resolve a batch of cliloc ids to display strings.
|
|
*
|
|
* Returns a `Map<number, string>` holding only the ids that resolved to
|
|
* something displayable — an id with no row, or one whose text is nothing but
|
|
* interpolated arguments we do not have, is simply absent. Callers fall back to
|
|
* whatever they had (the item id), so "missing" and "unnamed" collapse into one
|
|
* branch at the call site.
|
|
*
|
|
* Never throws: a cliloc lookup is decoration on someone's character sheet, and
|
|
* a database blip must not fail the sheet.
|
|
*/
|
|
async function resolveMany(numbers) {
|
|
const out = new Map()
|
|
if (!Array.isArray(numbers)) return out
|
|
|
|
const wanted = [...new Set(numbers.filter((n) => Number.isInteger(n) && n > 0))]
|
|
if (wanted.length === 0) return out
|
|
|
|
const missing = []
|
|
for (const number of wanted) {
|
|
if (cache.has(number)) {
|
|
const hit = cache.get(number)
|
|
if (hit !== '') out.set(number, hit)
|
|
} else {
|
|
missing.push(number)
|
|
}
|
|
}
|
|
|
|
if (missing.length > 0) {
|
|
try {
|
|
const rows = await db.lookup(missing)
|
|
const found = new Map(rows.map((r) => [Number(r.number), displayText(r.text)]))
|
|
if (cache.size + missing.length > CACHE_MAX) invalidate()
|
|
for (const number of missing) {
|
|
// Cache the miss too ('' meaning "no usable name"), so an id absent from
|
|
// the table does not re-query on every page view.
|
|
const text = found.get(number) ?? ''
|
|
cache.set(number, text)
|
|
if (text !== '') out.set(number, text)
|
|
}
|
|
} catch (err) {
|
|
log.warn('cliloc lookup failed', { message: err.message })
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
/** Single-id convenience. Returns `null` when there is no usable name. */
|
|
async function resolve(number) {
|
|
const found = await resolveMany([number])
|
|
return found.get(number) ?? null
|
|
}
|
|
|
|
module.exports = {
|
|
SETTING_KEY,
|
|
getClientPath,
|
|
setClientPath,
|
|
shardLinked,
|
|
refresh,
|
|
refreshOnBoot,
|
|
status,
|
|
resolveMany,
|
|
resolve,
|
|
invalidate,
|
|
}
|