Files
Module-uo/server/router/player/shard.controller.js
wtclaude f335531538
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 8m23s
feat(assets): item pictures on the marketplace and the character sheet (Phase 5)
Both places this site already knew an item's (ItemID, hue) and could only print
it as text now show the picture, hued the way the client would draw it. The
shard does the hueing: whether a hue repaints every pixel or only the grey ones
is a flag in `tiledata.mul`, which a browser has no way to read.

**Ingest warms; the route only serves** (org lead, 2026-09-11). A page never
waits on the shard and never causes a fetch -- it renders what is stored and
leaves out what is not, which is the state every install was in before this
phase. Fetching happens behind that, on a timer, from the keys the site's own
rows name. The alternative, fetching on first request, was rejected on one
number: the shard's asset plane serves ONE request at a time, so a URL that
fetched would let any visitor walk 49,152 ids times 3,000 hues through that slot
and park an operator's own import behind it.

The wanted set is DERIVED (`SELECT DISTINCT item_id, hue`) rather than queued, so
it is self-healing: a restart loses nothing, and a key stops being wanted the
moment the vendor row naming it is deleted. The in-memory hint set on top is only
for the character sheet, which is fetched live from the shard and stored nowhere
-- nothing on disk would ever name those keys.

Staleness without a manifest (§7): every row records the shard's `catalog` id, a
hash of the files that decide its bytes. A client patch changes it and a restart
does not, so "is this out of date?" is a per-row question -- and pictures nobody
looks at any more are simply never re-fetched, which is why this is lazy rather
than a sweep. `shard_asset_meta` is deliberately NOT written here: it is the body
catalogue's singleton, and a warm pass touching it would tell the body import
that a client it never looked at is unchanged.

A key the shard has no art for writes no row at all. An empty row would make the
key held and it would never be asked again -- including after the operator
patches in the graphic that was missing.

`assets.sources` now reports which families an overlay serves, so an overlay
older than phase 5 is one reported state with a sentence naming the fix, instead
of a refusal per pass forever with no picture ever appearing.

688 server tests pass (14 new); client builds; the frozen manifest regenerates
with one added route, all documented, no core URL moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-11 06:13:08 -05:00

299 lines
13 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 itemArt = require('../../model/shardAssets/shardItemArt.model')
const { activity } = require('../../core')
const gameSignup = require('../../utils/gameSignup')
const { salesForAccounts } = require('../../utils/shardSales')
const log = require('../../core').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
})
}
}
/**
* Attach a picture to each equipped item (docs/link/v8.md §5, §11 — phase 5).
*
* The equipment list is the other place on this site that carries (itemId, hue),
* and unlike the marketplace it is LIVE: the profile is fetched from the shard per
* request and stored nowhere, so there is no table a warm pass could derive these
* keys from. That is what `notice` is for, and `decorate` does both — it fills in
* every picture we already hold and remembers the ones we do not, so a character
* whose sheet renders without art once renders with it a few minutes later.
*
* It never asks the shard. §17.11: the page serves what is stored and the warm
* pass does the fetching, because a route that fetched would let any visitor drive
* the shard's single-slot asset plane from a URL.
*/
async function resolveProfileArt(profile) {
const equipment = Array.isArray(profile?.equipment) ? profile.equipment : []
if (equipment.length === 0) return
await itemArt.decorate(equipment)
}
// 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)
await resolveProfileArt(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 gameSignup.isEnabled())) {
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 }