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>
Per request, split the single public house registry into three role-scoped views:
- Public /site/houses → only houses in DANGER (IDOC), by LOCATION (region + map/
coords). No owner, price, co-owners or decay detail. Renamed "Houses in danger";
kept live via the public house.decay feed. The full-registry deltas
(house.update / house.remove — which carry owner/price) are REMOVED from the
public SSE allowlist so they never reach the public channel.
- Staff full registry → new /admin/houses (admin + moderator, RoleGate + MOD_PATHS)
backed by GET /admin/shard/houses (modAccess), with owner/price/co-owners/decay
and search, kept live on the admin SSE channel.
- Player portal → "My houses" home-status section (own houses only, with decay/
IDOC status) via GET /player/shard/houses, scoped to the caller's linked accounts.
Server tests green, client build clean, swagger regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 5: the account-provisioning backend — link-only stays, plus hybrid
self-signup, an admin email-invite tool, and site-side unlink.
- uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the
shard (hashed there) and never stored/logged; the end-user browser IP is passed
for the shard's per-IP cap; actor is stamped server-side.
- Hybrid signup: POST /player/shard/account provisions a game account (its own
username + password) for the signed-in user and mirrors the link locally. Gated
by the new game_account_signup setting AND the shard's own mode (mapped 403/409/
429/400/503). Serves both self-serve signup and the invite-accept game step.
- Email invites: user_invites table (sha256 token hash, single-use, expiring);
invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) +
mailer.sendInvite (falls back to returning the accept link if email is off);
public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the
user at the invite's preset role and logs them in, bypassing the registration
gate. Accept is race-safe (atomic single-use; rolls back the user if it loses).
- Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local
mirror drop; account.unlinked ingest reconciles the mirror when a player runs
[unlink in game. account.audit / account.unlinked are logged (admin channel
only — never on the public SSE allowlist).
Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest
reconcile/visibility. Full suite 193/193; swagger regenerated.
Refs .plans/protocol2-integration.md (Phase 5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3: surface the new board data on existing character/user pages.
- Character sheet: render the char.profile titles block (fame/karma + skill +
selected reward title; numeric clilocs skipped since the site has no cliloc
table yet), plus "Guildmaster" and "Governor of <city>" chips.
- Char profile enrichment (player/admin /shard/char/:serial, one shared path):
attach guild + governorOf from our own boards. Guild is LEADERSHIP-ONLY — it's
verifiable from current board state, whereas guessing membership from stale
guild.join events risks showing a wrong guild, so we return null instead.
- Admin user detail (/admin/users/:id): new "Standing" section (governorships
held + guilds led) via GET /users/:id/shard/standing; Houses rows now show the
registry fields (decay level, placement price, co-owner/friend counts) already
returned by listHousesForAccounts.
Server 179/179, client build clean, swagger regenerated.
Refs .plans/protocol2-integration.md (Phase 3).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a "View" action beside Edit in the users table that opens a dedicated,
read-only page showing everything the uo-link shard knows about a user,
scoped to their linked game accounts: character rosters, currently-online
characters, houses (IDOC-first), and recent vendor sales.
Backend (admin-only, under the existing /users adminOnly gate):
- GET /admin/users/:id — single sanitized user (page is deep-linkable)
- GET /admin/users/:id/shard/{accounts,sales,houses,online}
- shardState: listHousesByAccounts / listOnlineByAccounts (+ model shapers)
- Extract salesForAccounts into utils/shardSales; reuse in player getSales
- Live rosters reuse the existing admin-bypass /admin/shard/* endpoints,
so no new routes for roster/vendors/char
Frontend:
- UserDetail page reusing CharacterStats / GameAccounts / VendorSales
- GameAccounts gains a readOnly prop (drops link form + self-voice copy)
- api.admin.getUser + api.admin.userShard(id) scope; route + layout title
Tests: adminUserShard.test.js (404, account scoping, empty accounts,
salesForAccounts cap/filter). Full server suite 164 pass; client builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
Public "Online now" now lists only players whose game account is linked
to a STAFF website user (admin/editor/moderator) — linked players are no
longer exposed publicly with their name and location. listOnlineLinked
joins through to users and filters on role; the section is relabeled
"Staff online".
Character/roster/vendor reads gain an admin bypass: admins may view any
character's data, while players (and editor/moderator staff) stay limited
to accounts they have personally linked. The bypass lives in the shared
player controller and only ever widens access for genuine admins.
Also finalizes the uo-link character/vendor front end (player + admin
character sheets, VendorSales component, ShardChar removed) and
regenerates swagger-output.json.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
Ties an in-game account to a website user and gates reads on ownership.
- schema: shard_account_links (account PK → user_id, char_name, linked_at;
FK users ON DELETE CASCADE) — the site-side mirror of the sidecar's
authoritative link.
- model/shardLinks: upsert/list/ownership-check/getByAccount/unlink.
- player/shard.controller.js:
- POST /player/shard/link — confirm a one-time [link code via
uoLinkClient.confirmLink(code, req.user.id); on link.ok mirror the link and
activity.log it; bad/expired codes → 400, shard down → 503.
- GET /player/shard/accounts — the caller's linked accounts.
- GET /player/shard/roster/:account and /vendors/:account — live round-trips,
ownership-checked against the mirror (403 otherwise), 503 on shard restart.
- player.routes.js: mounted under the existing requireRole('player') gate with
express-validator guards + #swagger annotations; new "Player · Shard" tag and
ShardLinkRequest/ShardLinkResult/ShardLink schemas; spec regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3