// ── 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 }