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:
@@ -183,6 +183,7 @@ export const api = {
|
||||
sales: () => req(`/admin/users/${id}/shard/sales`),
|
||||
houses: () => req(`/admin/users/${id}/shard/houses`),
|
||||
online: () => req(`/admin/users/${id}/shard/online`),
|
||||
standing: () => req(`/admin/users/${id}/shard/standing`),
|
||||
}),
|
||||
|
||||
// ----- moderation dashboard (admin + moderator) -----
|
||||
|
||||
@@ -10,6 +10,38 @@ import ShardAccountActions from './ShardAccountActions.jsx'
|
||||
|
||||
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||
|
||||
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
|
||||
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
|
||||
// literal string. Without a cliloc table on the site we can only show literals, so
|
||||
// numeric reward entries are skipped rather than shown as a raw number. Returns a
|
||||
// de-duped list of human-readable title chips.
|
||||
function displayTitles(titles) {
|
||||
if (!titles) return []
|
||||
const out = []
|
||||
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||
if (titles.skill) out.push(titles.skill)
|
||||
const reward = Array.isArray(titles.reward) ? titles.reward : []
|
||||
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
||||
// Prefer the selected reward title; fall back to the first literal one.
|
||||
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
|
||||
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
|
||||
return [...new Set(out.filter(Boolean))]
|
||||
}
|
||||
|
||||
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
|
||||
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatTile({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
||||
@@ -64,6 +96,21 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
||||
</div>
|
||||
|
||||
{/* Titles + standing (guild led / governorship) — all optional */}
|
||||
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
|
||||
{char.governorOf && char.governorOf.map((city) => (
|
||||
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
|
||||
))}
|
||||
{char.guild && (
|
||||
<TitleChip tone="var(--accent)">
|
||||
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
|
||||
</TitleChip>
|
||||
)}
|
||||
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Staff moderation for this character's account (self-gates to staff). */}
|
||||
{moderation && char.acct && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
|
||||
|
||||
@@ -57,6 +57,33 @@ function OnlineNow({ scope }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Shard "standing": city governorships held and guilds led by this user's
|
||||
// accounts (both reliable current-state lookups). Renders nothing when empty.
|
||||
function Standing({ scope }) {
|
||||
const { data } = useAsync(() => scope.standing(), [scope])
|
||||
if (!data) return null
|
||||
const govs = data.governorOf || []
|
||||
const guilds = data.guildsLed || []
|
||||
if (govs.length === 0 && guilds.length === 0) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Standing</SectionTitle>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{govs.map((g) => (
|
||||
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
|
||||
Governor of {g.city}
|
||||
</span>
|
||||
))}
|
||||
{guilds.map((g) => (
|
||||
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
|
||||
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Houses owned by the user's accounts, IDOC first (flagged).
|
||||
function Houses({ scope }) {
|
||||
const { data } = useAsync(() => scope.houses(), [scope])
|
||||
@@ -82,10 +109,12 @@ function Houses({ scope }) {
|
||||
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')}
|
||||
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
|
||||
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
||||
{h.stage ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.stage}</div> : null}
|
||||
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
|
||||
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
|
||||
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
||||
</div>
|
||||
</li>
|
||||
@@ -102,6 +131,7 @@ function ShardSections({ scope }) {
|
||||
<CharacterStats scope={scope} />
|
||||
<SectionTitle>Linked accounts & characters</SectionTitle>
|
||||
<GameAccounts scope={scope} readOnly moderation charTo={(serial) => `/admin/characters/${serial}`} />
|
||||
<Standing scope={scope} />
|
||||
<OnlineNow scope={scope} />
|
||||
<Houses scope={scope} />
|
||||
<VendorSales fetchSales={scope.sales} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1255,6 +1255,18 @@ adminRouter.get(
|
||||
validate,
|
||||
usersShard.getOnline,
|
||||
)
|
||||
adminRouter.get(
|
||||
'/users/:id/shard/standing',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s 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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7091,6 +7091,63 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}/shard/standing": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Users"
|
||||
],
|
||||
"summary": "A user’s shard standing — governorships held and guilds led (admin only)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "User id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Standing { governorOf, guildsLed }",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/uo-link/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
Reference in New Issue
Block a user