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

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>
This commit is contained in:
2026-07-29 04:21:38 -05:00
parent 1e1a3d67c3
commit b61a4d6721
19 changed files with 2182 additions and 13 deletions

View File

@@ -10,6 +10,7 @@
const uoLinkClient = require('../../../utils/uoLinkClient')
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
const shardState = require('../../../model/shardState/shardState.model')
const shardClilocs = require('../../../model/shardClilocs/shardClilocs.model')
const settings = require('../../../model/settings/settings.model')
const { salesForAccounts } = require('../../../utils/shardSales')
const activity = require('../../../model/activity/activity.model')
@@ -18,9 +19,62 @@ const log = require('../../../utils/logger')('player-shard')
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
/**
* Resolve the cliloc ids on a profile into display names.
*
* Items on the wire carry a `LabelNumber`, not a name — `BridgeProfile.WriteItem`
* sends `cliloc` on every equipment entry and `name` only for the minority of
* items a player has renamed. Reward titles are the same shape: the shard sends
* a cliloc number as a string, which the sheet previously had to SKIP because it
* had no way to turn it into words.
*
* Resolution happens here rather than in the browser because the table is ~123k
* rows: shipping it to render a dozen names would dwarf the page, and the
* Android client consumes this same JSON and would otherwise need its own copy.
*
* A shard with no cliloc table configured resolves nothing and the sheet renders
* ids exactly as it did before — this is decoration, and it is applied in the
* same best-effort block as the guild/governor cross-links.
*/
async function resolveProfileClilocs(profile) {
const wanted = []
const equipment = Array.isArray(profile.equipment) ? profile.equipment : []
for (const item of equipment) {
if (Number.isInteger(item?.cliloc)) wanted.push(item.cliloc)
}
// Reward titles arrive as strings that may be either a literal ("Knight of
// Trinsic") or a cliloc number in string form. Only the numeric ones need us.
const reward = Array.isArray(profile.titles?.reward) ? profile.titles.reward : []
const rewardNumbers = reward.map((r) => (/^\d+$/.test(String(r)) ? Number(r) : null))
for (const n of rewardNumbers) if (n !== null) wanted.push(n)
if (wanted.length === 0) return
const names = await shardClilocs.resolveMany(wanted)
if (names.size === 0) return
for (const item of equipment) {
// A player-given name always wins over the type name: an item called "Bob's
// lucky axe" should not be relabelled "hatchet".
if (item?.name) continue
const resolved = names.get(item?.cliloc)
if (resolved) item.clilocName = resolved
}
if (rewardNumbers.some((n) => n !== null)) {
profile.titles.rewardResolved = reward.map((raw, i) => {
const n = rewardNumbers[i]
return n === null ? String(raw) : names.get(n) ?? null
})
}
}
// Decorate a char.profile with cross-links from our own board data: the guild the
// character leads and any city governorship on its account. Best-effort — a
// failure here never fails the profile (it's a nicety, not the sheet).
// character leads and any city governorship on its account, plus resolved cliloc
// names. Best-effort — a failure here never fails the profile (it's a nicety,
// not the sheet).
async function enrichCharProfile(profile) {
if (!profile) return profile
try {
@@ -30,6 +84,7 @@ async function enrichCharProfile(profile) {
const govs = await shardState.listGovernorshipsForAccounts([profile.acct])
if (govs.length) profile.governorOf = govs.map((g) => g.city)
}
await resolveProfileClilocs(profile)
} catch (err) {
log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message })
}