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>
275 lines
12 KiB
JavaScript
275 lines
12 KiB
JavaScript
// ── Player: game-account linking + reads ───────────────────────────────────
|
|
//
|
|
// The player-facing surface for the uo-link integration. A logged-in player
|
|
// runs [link in game, gets a one-time code, and enters it here — the server
|
|
// confirms it with the sidecar (which permanently tags the game account with the
|
|
// website user id) and mirrors the link locally. Roster/vendor reads are
|
|
// ownership-checked against that mirror so a player can only see accounts they
|
|
// have linked. The sidecar token stays server-side throughout.
|
|
|
|
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')
|
|
|
|
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, 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 {
|
|
const guild = await shardState.findGuildForActor({ serial: profile.serial, acct: profile.acct })
|
|
if (guild) profile.guild = guild
|
|
if (profile.acct) {
|
|
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 })
|
|
}
|
|
return profile
|
|
}
|
|
|
|
// POST /player/shard/link — confirm an in-game link code.
|
|
async function link(req, res) {
|
|
const { code } = req.body
|
|
try {
|
|
const result = await uoLinkClient.confirmLink(code, req.user.id)
|
|
|
|
if (result.ok && result.data && result.data.kind === 'link.ok') {
|
|
const account = result.data.account
|
|
await shardLinks.link({ account, userId: req.user.id, charName: result.data.char || null })
|
|
await activity.log({ req, action: 'uoLink.account.link', detail: { account } })
|
|
log.info('player linked game account', { user: req.user.username, account })
|
|
return res.json({ linked: true, account })
|
|
}
|
|
|
|
// Sidecar reports bad/expired codes as 400 link.error or 404.
|
|
if (result.status === 400 || result.status === 404) {
|
|
return res.status(400).json({ message: 'That code is unknown or has expired. Run [link in game for a new one.' })
|
|
}
|
|
if (result.status === 503 || result.status === 0) {
|
|
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
|
}
|
|
return res.status(502).json({ message: 'Could not confirm the link with the shard.' })
|
|
} catch (err) {
|
|
log.error('player.shard.link', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /player/shard/accounts — the caller's linked game accounts.
|
|
async function listAccounts(req, res) {
|
|
try {
|
|
return res.json(await shardLinks.listForUser(req.user.id))
|
|
} catch (err) {
|
|
log.error('player.shard.listAccounts', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Admins may view any character's data; everyone else is limited to accounts
|
|
// they have personally linked. The same handlers back /player/shard (role
|
|
// `player`, never admin) and /admin/shard (staff), so this bypass only ever
|
|
// widens access for genuine admins.
|
|
const isAdmin = (req) => req.user && req.user.role === 'admin'
|
|
|
|
// Shared ownership gate + live round-trip for roster/vendors. `fetcher` is the
|
|
// uoLinkClient method to call with the account.
|
|
async function ownedRoundTrip(req, res, fetcher, label) {
|
|
const { account } = req.params
|
|
try {
|
|
const owns = isAdmin(req) || (await shardLinks.ownsAccount(account, req.user.id))
|
|
if (!owns) return res.status(403).json({ message: 'That account is not linked to your profile.' })
|
|
|
|
const result = await fetcher(account)
|
|
if (result.ok) return res.json(result.data)
|
|
if (result.status === 404) return res.status(404).json({ message: 'Not found.' })
|
|
if (result.status === 503 || result.status === 0) {
|
|
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
|
}
|
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
|
} catch (err) {
|
|
log.error(`player.shard.${label}`, err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /player/shard/roster/:account — characters on a linked account.
|
|
const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'roster')
|
|
|
|
// GET /player/shard/vendors/:account — player vendors on a linked account.
|
|
const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, 'vendors')
|
|
|
|
// GET /player/shard/char/:serial — a character sheet, but ONLY if the character's
|
|
// account is linked to the caller. The sidecar returns the owning account in the
|
|
// profile, which we check against the caller's links before returning anything.
|
|
async function getChar(req, res) {
|
|
const { serial } = req.params
|
|
if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid serial.' })
|
|
try {
|
|
const result = await uoLinkClient.getCharBySerial(serial)
|
|
if (result.ok) {
|
|
// Admins see any character; others only characters on an account they linked.
|
|
if (!isAdmin(req)) {
|
|
const acct = result.data && result.data.acct
|
|
const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false
|
|
if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' })
|
|
}
|
|
return res.json(await enrichCharProfile(result.data))
|
|
}
|
|
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
|
|
if (result.status === 503 || result.status === 0) {
|
|
return res.status(503).json({ message: 'The game server is restarting — try again shortly.' })
|
|
}
|
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
|
} catch (err) {
|
|
log.error('player.shard.getChar', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /player/shard/sales — recent player-vendor sales for the caller's linked
|
|
// accounts only (as seller/owner). Read from the site's own event log.
|
|
async function getSales(req, res) {
|
|
try {
|
|
const links = await shardLinks.listForUser(req.user.id)
|
|
const accounts = links.map((l) => l.account)
|
|
return res.json(await salesForAccounts(accounts))
|
|
} catch (err) {
|
|
log.error('player.shard.getSales', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /player/shard/houses — the caller's OWN houses (home status), scoped to
|
|
// their linked accounts. A player sees their own decay/IDOC standing; never
|
|
// anyone else's. Full detail is fine here — it's their property.
|
|
async function getHouses(req, res) {
|
|
try {
|
|
const links = await shardLinks.listForUser(req.user.id)
|
|
const accounts = links.map((l) => l.account)
|
|
return res.json(await shardState.listHousesForAccounts(accounts))
|
|
} catch (err) {
|
|
log.error('player.shard.getHouses', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// Map a failed uoLinkClient.createAccount result to a user-facing HTTP response.
|
|
// The password is never echoed anywhere; only the mapped reason is returned.
|
|
function mapCreateAccountError(res, result) {
|
|
const reason = (result.data && result.data.reason) || ''
|
|
switch (result.status) {
|
|
case 409:
|
|
return res.status(409).json({ message: 'That account name is already taken.' })
|
|
case 429:
|
|
return res.status(429).json({ message: 'The account limit for your network has been reached.' })
|
|
case 403:
|
|
return res.status(403).json({ message: 'Game-account signups are not available on this shard right now.' })
|
|
case 400:
|
|
return res.status(400).json({ message: reason || 'The account name or password was not accepted.' })
|
|
case 503:
|
|
case 0:
|
|
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
|
|
default:
|
|
return res.status(502).json({ message: 'Could not reach the shard to create the account.' })
|
|
}
|
|
}
|
|
|
|
// POST /player/shard/account — provision a GAME account for the signed-in website
|
|
// user and auto-link it (Protocol 2.0 hybrid). Used by self-serve signup and the
|
|
// invite-accept "create game account" step alike (both act as the signed-in user).
|
|
// actor + websiteUserId are stamped from the session; the browser IP (req.ip,
|
|
// trust-proxy configured) is forwarded for the shard's per-IP cap; the password is
|
|
// never logged. Gated by the game_account_signup setting AND the shard's own mode.
|
|
async function createGameAccount(req, res) {
|
|
const { account, password } = req.body
|
|
try {
|
|
if (!(await settings.isGameAccountSignupEnabled())) {
|
|
return res.status(403).json({ message: 'Game-account signup is not available right now.' })
|
|
}
|
|
const result = await uoLinkClient.createAccount({
|
|
actor: req.user.username,
|
|
account,
|
|
password,
|
|
websiteUserId: req.user.id,
|
|
ip: req.ip,
|
|
})
|
|
if (result.ok) {
|
|
// Mirror the link locally so the portal lists the account immediately.
|
|
await shardLinks.link({ account, userId: req.user.id })
|
|
await activity.log({ req, userId: req.user.id, action: 'shard.account.create', detail: { account } })
|
|
log.info('game account created', { account, userId: req.user.id, ip: req.ip })
|
|
return res.status(201).json({ account, linked: true })
|
|
}
|
|
return mapCreateAccountError(res, result)
|
|
} catch (err) {
|
|
log.error('player.shard.createGameAccount', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
module.exports = { link, listAccounts, roster, vendors, getChar, getSales, getHouses, createGameAccount }
|