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

@@ -189,6 +189,28 @@ const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
const clearGuilds = () => query('DELETE FROM shard_guilds')
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
// The guild an actor LEADS — matched on the current board (leader_serial or the
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
// is not modelled (the board carries only counts + leader), so we don't guess it.
const findGuildLedByActor = (serial, acct) =>
query(
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
WHERE leader_serial = ? OR (leader_acct IS NOT NULL AND leader_acct = ?)
LIMIT 1`,
[serial ?? null, acct ?? null],
)
// Guilds led by any of the given game accounts (admin: a user's linked accounts).
const listGuildsLedByAccounts = (accounts) =>
accounts.length === 0
? Promise.resolve([])
: query(
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
WHERE leader_acct IN (${accounts.map(() => '?').join(', ')})
ORDER BY name ASC`,
accounts,
)
// ── Governor board + term history (Protocol 2.0) ───────────────────────────
const GOV_COLS =
'city, governor_serial, governor_name, governor_acct, governor_web_id, elect_serial, elect_name, elect_acct, election_phase, candidates, auto_pick_at, payload, t, updated_at'
@@ -286,6 +308,8 @@ module.exports = {
removeGuild,
clearGuilds,
listGuilds,
findGuildLedByActor,
listGuildsLedByAccounts,
upsertGovernor,
listGovernors,
listGovernorshipsByAccounts,

View File

@@ -382,6 +382,22 @@ async function replaceGuilds(guilds) {
for (const ev of guilds || []) await upsertGuild(ev)
}
// The guild an actor leads (cross-link on the character sheet). Leadership only —
// see the db note; membership for rank-and-file isn't in the feed, so we return
// null rather than show a possibly-stale guess.
async function findGuildForActor({ serial, acct }) {
const rows = await db.findGuildLedByActor(serial ?? null, acct ?? null)
const g = rows[0]
if (!g) return null
return { id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, role: 'leader' }
}
// Guilds led by any of a user's linked accounts (admin user-detail cross-link).
async function listGuildsLedForAccounts(accounts) {
const rows = await db.listGuildsLedByAccounts(accounts)
return rows.map((g) => ({ id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, leaderName: g.leader_name }))
}
// ── Town governors (Protocol 2.0) ──────────────────────────────────────────
// Upsert a city's governance snapshot (city.update) AND capture term history.
// Term capture runs first (it reads the CURRENT open term to decide whether the
@@ -543,6 +559,8 @@ module.exports = {
clearGuilds,
listGuilds,
replaceGuilds,
findGuildForActor,
listGuildsLedForAccounts,
upsertGovernor,
listGovernors,
listGovernorshipsForAccounts,

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