feat(shard): Protocol 2.0 cross-links — titles, guild, governor, houses

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>
This commit is contained in:
2026-07-17 12:45:15 -05:00
parent e9aa19a83d
commit 2957708bab
9 changed files with 229 additions and 3 deletions

View File

@@ -1255,6 +1255,18 @@ adminRouter.get(
validate,
usersShard.getOnline,
)
adminRouter.get(
'/users/:id/shard/standing',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A users shard standing — governorships held and guilds led (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getStanding,
)
// ── uo-link sidecar control (admin only) ──────────────────────────────────
// Connection config (base/ws URL + token + protocol + enabled) and the town

View File

@@ -83,4 +83,22 @@ async function getOnline(req, res) {
}
}
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline }
// GET /admin/users/:id/shard/standing — the user's shard "standing" cross-links:
// city governorships they currently hold and guilds they lead. Both are reliable
// current-state lookups on the user's linked accounts.
async function getStanding(req, res) {
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
const [governorOf, guildsLed] = await Promise.all([
shardState.listGovernorshipsForAccounts(ctx.accounts),
shardState.listGuildsLedForAccounts(ctx.accounts),
])
return res.json({ governorOf, guildsLed })
} catch (err) {
log.error('getStanding', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding }

View File

@@ -9,6 +9,7 @@
const uoLinkClient = require('../../../utils/uoLinkClient')
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
const shardState = require('../../../model/shardState/shardState.model')
const { salesForAccounts } = require('../../../utils/shardSales')
const activity = require('../../../model/activity/activity.model')
@@ -16,6 +17,24 @@ const log = require('../../../utils/logger')('player-shard')
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
// Decorate a char.profile with cross-links from our own board data: the guild the
// character leads and any city governorship on its account. 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)
}
} 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
@@ -102,7 +121,7 @@ async function getChar(req, res) {
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(result.data)
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) {