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
Member

What & why

Protocol 3.0 §8.6 (docs/link/v3.md) — the dependency order 5 was sequenced behind, landing ahead of the marketplace so /site/market ships with real item names. Docs in docs #70.

Items on the wire carry a LabelNumber, not a name. The bridge has always sent it — char.profile.equipment.cliloc, reward titles as a cliloc number in string form, and one per marketplace listing — but the site had no table to resolve it against, so a character sheet could only render id 1023721 where the game renders "quarter staff". The number was never the missing piece; the table was.

⚠️ §8.6's recommendation was not implementable, and the reason is worth a reviewer's attention

v3.md recommended scripts/buildClilocs.js reading the client's Cliloc.enu into a committed db/data/clilocs.json. Both halves turned out to be wrong.

Every current client ships its cliloc files COMPRESSED. All eight Cliloc.* files in the client on this machine open with a DWORD whose high byte is 0x8E — the "Mythic" container. The plain layout (02 00 00 00 01 00, then {int32 number, byte flag, uint16 length, UTF-8}) is what those files looked like before that change, and parsing one as the other does not fail cleanly: it yields 19,508 "records" with only 1,722 distinct ids, ids ranging into the negatives, one 62 KB "string", and a truncation somewhere in the middle.

Two facts closed off the alternatives:

  • ServUO cannot read it either. Its bundled Ultima.StringList implements only the plain layout, so on a modern client VendorSearch.StringList is null and VendorSearch.GetItemName returns item.Name. The shard could not supply names on our behalf — the in-game Vendor Search gump has the same gap. (This also retires an open cost in §8.2: the mandatory "never call GetItemName in the sweep" costs us nothing we could otherwise have had.)
  • The committed artifact violates the Part C corrections. No committed snapshot of derived content; nothing EA-derived ever shipped. UO's strings are EA's, exactly as the creature sprites are.

the operator converts once, from their own client, and the site reads the result — the §6 spawn-atlas pattern: parse on boot from a configured path, hash-gated, output gitignored. A shard that never converts is fully supported; names render as ids exactly as before.

What landed

  • utils/clilocParse.js — pure parsers, fs-free so the suite runs in CI where there is no client. Accepts the plain binary layout and delimited text, sniffed by header rather than extension (operators name these things whatever they like). displayText() drops the ~1_val~ arguments the bridge never sends — it sends the id, never the property packet.
  • A compressed file is rejected BY NAME, not parsed into nonsense. This is the single most likely operator mistake and without the marker check the error names truncation, which is the wrong problem to hand someone.
  • utils/clilocSource.js — the fs layer. hashSource reports compressed so the admin panel can flag an unconverted file without parsing 5 MB on every status poll; without it, pointing at a client directory reports a perfectly readable file with pending drift — "ready to import" — and the operator only learns otherwise when the import fails. (I shipped that bug first and caught it on the live server; the check is four bytes of a buffer already in hand.)
  • model/shardClilocs — refresh/status/lookup. All-or-nothing replace: DELETE, not TRUNCATE (TRUNCATE is DDL in MariaDB and implicitly commits). Batched server-side resolution behind a capped cache; resolveMany never throws, because a cliloc lookup is decoration on someone's character sheet.
  • Deliberately no staged-approval flow, unlike the atlas. The atlas escalates a refresh that would remove a facet because a half-copied tree and a real map change are indistinguishable from inside the process. A cliloc file is one file with one hash, and a partial copy makes the parser fail on a truncated record — the ambiguity the atlas must escalate to a human is one this parser simply detects.
  • No public route. The table is never served as a table: 67k rows would dwarf any page using them, and the Android client consumes the same already-resolved JSON.
  • tools/cliloc-export/ — a small .NET console app driving UOFiddler's Ultima.dll. Loads it reflectively (UOFiddler is net10.0; a project reference fails at compile time with CS1705) and writes the plain records by hand, because StringList.SaveStringList looks exactly like the export path and re-compresses on save — its output is byte-identical to its input.

Two parser bugs found by building it, both now covered by tests

  1. Trimming a text line before splitting dropped 55,994 of 123,490 entries — while still reporting success. About half a cliloc table is empty strings for ids the client reserves, exported as 1005008<TAB>; trimming eats the trailing separator, leaving a bare number that then looks like a header row.
  2. Number('') is 0, not NaN, so a line starting with a separator imported as a bogus cliloc 0. Caught by a test I wrote for a different reason.

How it was tested

Verified against the real 123,490-entry client table and the live MariaDB, not just unit tests.

Import and lookup

Full import (parse + replace) 663 ms
Hash-gated no-op (the normal boot) 14 ms
Cold resolve / warm resolve 4.2 ms / 0.015 ms
Parsed → stored 123,490 → 67,496 (55,994 blank dropped)
  • Binary and TSV imports converge: same 67,496 rows, identical key sets. (1,323 values differ by trailing whitespace only — label prefixes like "max = " — which displayText trims anyway; noted in CLILOCS.md as the reason the binary form is recommended.)
  • A file truncated to half its length is refusedTRUNCATED, "Truncated record header at byte 2486759 (74909 entries read)" — and the 67,496 rows already loaded are untouched. This is the guarantee that replaces the atlas's approval flow.
  • Real strings resolve: 1023721 → "quarter staff", 1015012 → "Greater Heal", 1060404 → "cold damage" (placeholder and its orphan % stripped), 1049644 → null (the string was nothing but arguments).

Boot, on a real server start — both paths confirmed in the log, neither blocking startup:

INFO  [shardClilocs] cliloc table refreshed {"file":".../clilocs.tsv","count":67496}
WARN  [shardClilocs] cliloc source unavailable (item names will show as ids)
      {"code":"COMPRESSED","reason":"This is a compressed (Mythic-format) cliloc file..."}

All three admin routes over HTTP with a real session: 401 unauthenticated; status reporting drift/count; import returning unchanged then imported under force; PUT path → the unconverted client directory correctly reporting code: "COMPRESSED", drift: null rather than "ready to import".

Suites629 server tests pass (22 new in clilocParse.test.js). Client builds clean. swagger-output.json, routes.manifest.json and routes.guards.json regenerated and committed.

Not covered by an automated test: the character sheet renders resolved names in presentational React with no DOM test harness in this repo (the client suite covers pure-logic modules only). It builds clean, but was not rendered against a live linked-player profile — that needs a logged-in player with a linked game account and a shard answering a profile RPC.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a Co-Authored-By trailer.

License

  • I agree that my contribution is licensed under this project's license (GNU GPL v3.0 or later), and I have the right to contribute it.

Update — shard-added and shard-edited items (second commit)

Review feedback: the table has to be updatable the way the spawn atlas is, because shards edit items and add new ones — those carry cliloc ids no stock client table has.

Reading exactly one converted file meant re-exporting 5 MB every time an operator added one item. That is friction enough that the table goes stale, which is the exact failure Part C was redesigned to avoid.

So it now mirrors spawnAtlasSource.readSources(): a base (the converted client table) plus every overlay under custom/, all re-read on every boot and hash-gated as a set. Later sources win, so an overlay both adds ids and overrides stock ones. Adding, editing or removing any overlay counts as drift.

<cliloc path>/
  clilocs.plain            ← base
  custom/
    01-uomysticmoon.tsv    ← shard additions + overrides

custom/ is the one convention here that is ours rather than the shard's, deliberately. 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, so 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 the atlas's ambiguity, and gets its 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. That is precisely what the atlas stages a facet removal for, so it is staged here too: status: "needsReview", nothing applied, missingSources reported by both the import and status(), accepted with {"approve": true}.

It is a flag rather than the atlas's approve/reject pair because the atlas stores a pending decision so that approving re-parses the tree; here nothing is stored, so re-reading at approval time is automatic. (This replaces the "why there is no staged-approval flow" reasoning in the original description — that argument held for a single file and does not survive multiple sources.)

Import and status also report a per-source breakdown, 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 another stock-table fixture:

  1. 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.
  2. CANDIDATE_NAMES omitted clilocs.plain — 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.

How the update was tested

Full walk against the live MariaDB and a real server boot: base-only import → overlay adding one id and overriding another (breakdown correct: added: 1, overrode: 1) → unchanged set as a no-op → edited overlay re-importing and withdrawing its override → vanished overlay refused with the table intactstatus reporting missingSourcesapprove applying it → a file-path configuration still finding overlays beside it. The needsReview warning was also confirmed on a real boot log.

Through the running server, all three resolve correctly:

1180001 (shard-added)  -> Runic Gateway Sigil (v2)
1023721 (overridden)   -> gnarled quarter staff
1015012 (stock, base)  -> Greater Heal

646 server tests pass (16 new in clilocSource.test.js driving real temp directories — which file wins, what a listing yields, what happens when one vanishes are exactly the behaviours a mock would define away; 3 new in clilocParse.test.js). Artifacts regenerated.

## What & why Protocol 3.0 **§8.6** ([`docs/link/v3.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/v3.md)) — the dependency order 5 was sequenced behind, landing ahead of the marketplace so `/site/market` ships with real item names. Docs in docs #70. Items on the wire carry a `LabelNumber`, **not a name**. The bridge has always sent it — `char.profile.equipment.cliloc`, reward titles as a cliloc number in string form, and one per marketplace listing — but the site had no table to resolve it against, so a character sheet could only render `id 1023721` where the game renders **"quarter staff"**. The number was never the missing piece; the table was. ### ⚠️ §8.6's recommendation was not implementable, and the reason is worth a reviewer's attention v3.md recommended `scripts/buildClilocs.js` reading the client's `Cliloc.enu` into a committed `db/data/clilocs.json`. Both halves turned out to be wrong. **Every current client ships its cliloc files COMPRESSED.** All eight `Cliloc.*` files in the client on this machine open with a DWORD whose high byte is `0x8E` — the "Mythic" container. The plain layout (`02 00 00 00 01 00`, then `{int32 number, byte flag, uint16 length, UTF-8}`) is what those files looked like *before* that change, and parsing one as the other **does not fail cleanly**: it yields 19,508 "records" with only 1,722 distinct ids, ids ranging into the negatives, one 62 KB "string", and a truncation somewhere in the middle. Two facts closed off the alternatives: - **ServUO cannot read it either.** Its bundled `Ultima.StringList` implements only the plain layout, so on a modern client `VendorSearch.StringList` is null and `VendorSearch.GetItemName` returns `item.Name`. The shard could not supply names on our behalf — **the in-game Vendor Search gump has the same gap.** (This also retires an open cost in §8.2: the mandatory "never call `GetItemName` in the sweep" costs us nothing we could otherwise have had.) - **The committed artifact violates the Part C corrections.** No committed snapshot of derived content; nothing EA-derived ever shipped. UO's strings are EA's, exactly as the creature sprites are. ⇒ **the operator converts once, from their own client, and the site reads the result** — the §6 spawn-atlas pattern: parse on boot from a configured path, hash-gated, output gitignored. A shard that never converts is fully supported; names render as ids exactly as before. ### What landed - **`utils/clilocParse.js`** — pure parsers, fs-free so the suite runs in CI where there is no client. Accepts the plain binary layout *and* delimited text, **sniffed by header rather than extension** (operators name these things whatever they like). `displayText()` drops the `~1_val~` arguments the bridge never sends — it sends the id, never the property packet. - **A compressed file is rejected BY NAME**, not parsed into nonsense. This is the single most likely operator mistake and without the marker check the error names *truncation*, which is the wrong problem to hand someone. - **`utils/clilocSource.js`** — the fs layer. `hashSource` reports `compressed` so the admin panel can flag an unconverted file **without parsing 5 MB on every status poll**; without it, pointing at a client directory reports a perfectly readable file with pending drift — *"ready to import"* — and the operator only learns otherwise when the import fails. (I shipped that bug first and caught it on the live server; the check is four bytes of a buffer already in hand.) - **`model/shardClilocs`** — refresh/status/lookup. All-or-nothing replace: **`DELETE`, not `TRUNCATE`** (TRUNCATE is DDL in MariaDB and implicitly commits). Batched server-side resolution behind a capped cache; `resolveMany` never throws, because a cliloc lookup is decoration on someone's character sheet. - **Deliberately no staged-approval flow, unlike the atlas.** The atlas escalates a refresh that would remove a facet because a half-copied tree and a real map change are indistinguishable from inside the process. A cliloc file is one file with one hash, and a partial copy makes the parser fail on a truncated record — **the ambiguity the atlas must escalate to a human is one this parser simply detects.** - **No public route.** The table is never served *as* a table: 67k rows would dwarf any page using them, and the Android client consumes the same already-resolved JSON. - **`tools/cliloc-export/`** — a small .NET console app driving UOFiddler's `Ultima.dll`. Loads it **reflectively** (UOFiddler is net10.0; a project reference fails at compile time with CS1705) and writes the plain records **by hand**, because `StringList.SaveStringList` looks exactly like the export path and *re-compresses on save* — its output is byte-identical to its input. ### Two parser bugs found by building it, both now covered by tests 1. **Trimming a text line before splitting dropped 55,994 of 123,490 entries — while still reporting success.** About half a cliloc table is empty strings for ids the client reserves, exported as `1005008<TAB>`; trimming eats the trailing separator, leaving a bare number that then looks like a header row. 2. **`Number('')` is `0`, not `NaN`**, so a line starting with a separator imported as a bogus cliloc 0. Caught by a test I wrote for a different reason. ## How it was tested Verified against the **real 123,490-entry client table** and the **live MariaDB**, not just unit tests. **Import and lookup** | | | |---|---| | Full import (parse + replace) | **663 ms** | | Hash-gated no-op (the normal boot) | **14 ms** | | Cold resolve / warm resolve | **4.2 ms** / **0.015 ms** | | Parsed → stored | 123,490 → **67,496** (55,994 blank dropped) | - **Binary and TSV imports converge**: same 67,496 rows, identical key sets. (1,323 values differ by trailing whitespace only — label prefixes like `"max = "` — which `displayText` trims anyway; noted in CLILOCS.md as the reason the binary form is recommended.) - **A file truncated to half its length is refused** — `TRUNCATED`, `"Truncated record header at byte 2486759 (74909 entries read)"` — and the 67,496 rows already loaded are untouched. This is the guarantee that replaces the atlas's approval flow. - Real strings resolve: `1023721 → "quarter staff"`, `1015012 → "Greater Heal"`, `1060404 → "cold damage"` (placeholder and its orphan `%` stripped), `1049644 → null` (the string was nothing but arguments). **Boot, on a real server start** — both paths confirmed in the log, neither blocking startup: ``` INFO [shardClilocs] cliloc table refreshed {"file":".../clilocs.tsv","count":67496} WARN [shardClilocs] cliloc source unavailable (item names will show as ids) {"code":"COMPRESSED","reason":"This is a compressed (Mythic-format) cliloc file..."} ``` **All three admin routes over HTTP** with a real session: `401` unauthenticated; status reporting `drift`/`count`; import returning `unchanged` then `imported` under `force`; `PUT path` → the unconverted client directory correctly reporting `code: "COMPRESSED"`, `drift: null` rather than "ready to import". **Suites** — `629 server tests pass` (22 new in `clilocParse.test.js`). Client builds clean. `swagger-output.json`, `routes.manifest.json` and `routes.guards.json` regenerated and committed. **Not covered by an automated test:** the character sheet renders resolved names in presentational React with no DOM test harness in this repo (the client suite covers pure-logic modules only). It builds clean, but was not rendered against a live linked-player profile — that needs a logged-in player with a linked game account and a shard answering a profile RPC. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it. --- ## Update — shard-added and shard-edited items (second commit) Review feedback: the table has to be updatable the way the spawn atlas is, because **shards edit items and add new ones** — those carry cliloc ids no stock client table has. Reading exactly one converted file meant re-exporting 5 MB every time an operator added one item. That is friction enough that the table goes stale, which is the exact failure Part C was redesigned to avoid. So it now mirrors `spawnAtlasSource.readSources()`: a **base** (the converted client table) plus every overlay under `custom/`, all re-read on every boot and **hash-gated as a set**. Later sources win, so an overlay both *adds* ids and *overrides* stock ones. Adding, editing or removing any overlay counts as drift. ``` <cliloc path>/ clilocs.plain ← base custom/ 01-uomysticmoon.tsv ← shard additions + overrides ``` **`custom/` is the one convention here that is ours rather than the shard's, deliberately.** 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, so 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 the atlas's ambiguity, and gets its 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. That is precisely what the atlas stages a facet removal for, so it is staged here too: `status: "needsReview"`, nothing applied, `missingSources` reported by both the import and `status()`, accepted with `{"approve": true}`. It is a **flag** rather than the atlas's approve/reject pair because the atlas stores a pending decision *so that approving re-parses the tree*; here nothing is stored, so re-reading at approval time is automatic. (This replaces the "why there is no staged-approval flow" reasoning in the original description — that argument held for a single file and does not survive multiple sources.) Import and status also report a per-source breakdown, 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 another stock-table fixture: 1. **`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. 2. **`CANDIDATE_NAMES` omitted `clilocs.plain`** — 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`. ### How the update was tested Full walk against the live MariaDB and a real server boot: base-only import → overlay adding one id and overriding another (breakdown correct: `added: 1, overrode: 1`) → unchanged set as a no-op → edited overlay re-importing and withdrawing its override → **vanished overlay refused with the table intact** → `status` reporting `missingSources` → `approve` applying it → a file-path configuration still finding overlays beside it. The `needsReview` warning was also confirmed on a real boot log. Through the running server, all three resolve correctly: ``` 1180001 (shard-added) -> Runic Gateway Sigil (v2) 1023721 (overridden) -> gnarled quarter staff 1015012 (stock, base) -> Greater Heal ``` **646 server tests pass** (16 new in `clilocSource.test.js` driving real temp directories — which file wins, what a listing yields, what happens when one vanishes are exactly the behaviours a mock would define away; 3 new in `clilocParse.test.js`). Artifacts regenerated.
wtclaude added 1 commit 2026-07-29 09:23:12 +00:00
Protocol 3.0 §8.6 (docs/link/v3.md), the dependency order 5 was sequenced
behind. Items on the wire carry a LabelNumber, not a name — the bridge has
always sent it (char.profile.equipment.cliloc, reward titles as a cliloc
number in string form, and one per marketplace listing) but the site had no
table to resolve it against, so a character sheet could only render
`id 1023721` where the game renders "quarter staff".

The number was never the missing piece. The table was.

Sourced from a file the operator converts once from their own client, at a
path from the `cliloc_client_path` setting falling back to UO_CLIENT_PATH.
Nothing client-derived is committed: UO's strings are EA's, exactly as the
creature sprites are. A shard with nothing configured is fully supported —
names render as ids, as they did before.

The conversion step is not avoidable, and that is the substantive finding
here: every current client ships its cliloc files COMPRESSED (first DWORD's
high byte 0x8E, the Mythic container), and ServUO's own bundled
Ultima.StringList cannot read that either — so VendorSearch.GetItemName is
already inert on such a shard and the plugin could not supply names instead.
v3.md's original "read the client's Cliloc.enu" recommendation was therefore
not implementable as written, and its committed db/data/clilocs.json artifact
also predates the Part C corrections (no committed derived snapshots, nothing
EA-derived shipped). Replaced with the spawn-atlas pattern: parse on boot from
an operator-configured path, hash-gated, output gitignored.

- utils/clilocParse.js — pure parsers, fs-free so the suite runs in CI.
  Accepts the plain binary layout and delimited text, sniffed by header rather
  than extension. Rejects a compressed file BY NAME: without that check the
  plain parser reads it as ~19k records of negative ids and 60 KB "strings"
  before dying mid-file, and the resulting error names the wrong problem.
  displayText() drops the ~1_val~ arguments the bridge never sends.
- utils/clilocSource.js — the fs layer. hashSource reports `compressed` so the
  admin panel can flag an unconverted file WITHOUT parsing 5 MB per poll;
  otherwise pointing at a client directory reports a healthy file with pending
  drift ("ready to import") and the operator only finds out on failure.
- model/shardClilocs — refresh/status/lookup. All-or-nothing replace (DELETE,
  not TRUNCATE — TRUNCATE is DDL in MariaDB and implicitly commits). Batched
  server-side resolution behind a capped cache; never throws, because a cliloc
  lookup is decoration on a character sheet.
- Deliberately NO staged-approval flow, unlike the atlas: the atlas escalates
  facet loss because a half-copied tree and a real map change are
  indistinguishable from inside the process, whereas a partial cliloc copy
  makes the parser fail on a truncated record. The ambiguity the atlas must
  escalate is one this parser simply detects.
- No public route. The table is never served AS a table: 67k rows would dwarf
  any page using them, and the Android client consumes the same resolved JSON.

Two parser bugs found by building it, both now covered by tests: trimming a
text line before splitting ate the trailing separator on empty-text entries
and silently dropped 55,994 of 123,490 while still reporting success; and
Number('') is 0, not NaN, so a line starting with a separator imported as a
bogus cliloc 0.

Verified against the real client table (123,490 entries) and the live MariaDB:
import 663 ms, hash-gated boot no-op 14 ms, cold resolve 4.2 ms / warm 0.015 ms.
Binary and TSV imports converge on the same 67,496 rows with identical keys
(blank entries — half the table — are dropped at import). A file truncated to
half its length is refused with TRUNCATED and leaves the previous table
serving. Boot logs verified for both the import and the compressed-file
warning; neither blocks startup. All three admin routes exercised over HTTP
with a real session. 629 server tests pass; client builds clean; swagger,
routes.manifest.json and routes.guards.json regenerated.

Not covered by an automated test: the character sheet renders resolved names
in presentational React with no DOM test harness in this repo, and was not
rendered against a live linked-player profile — that needs a logged-in player
with a linked game account and a shard answering a profile RPC.

Co-Authored-By: Claude <noreply@anthropic.com>
wtclaude added 1 commit 2026-07-29 11:46:20 +00:00
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>
whitlocktech approved these changes 2026-07-29 11:58:48 +00:00
whitlocktech merged commit 8da658f223 into edge 2026-07-29 11:58:56 +00:00
whitlocktech deleted branch feat/cliloc-table 2026-07-29 11:58:57 +00:00
Sign in to join this conversation.
No description provided.