Files
Module-uo/server/router/player/shard.controller.js
wtclaude 28f4b9afe2 feat(client): the whole client half (phase 3, slice 3)
The 35 files behind twelve public pages, seven admin views, two player views
and three core-page extensions, ported onto `window.__rg`. Every one of them
imports exactly the seven kit members plus `lib/format.js`, which is the
finding §2.7.1 predicted and this confirms.

`client/src/core.js` is the port mechanism, and unlike the server's it is a
plain read: `window.__rg` is published before any module chunk evaluates, so
there is no gap to defer around and a ported component keeps its ordinary
import shape. `client/src/api.js` rebuilds the UO namespaces over the request
primitive — same URLs, because §1.2 freezes the API surface.

SPA paths changed and API paths did not. `/site/shard` is `/uo/shard`, and the
admin paths lost their now-redundant `shard-` prefixes (`/admin/uo/ops`), a
clean break being the only moment that is free.

`shim/rg.js` becomes the single reader of the global, so the "core did not
publish its dependencies" message is reachable from whichever module the
bundler happens to touch first rather than from whichever one is imported
first — a guarantee that used to last until someone sorted the imports.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:00:25 -05:00

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 { 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
})
}
}
// 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 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 }