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

@@ -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."
}
}
}