diff --git a/client/src/api/client.js b/client/src/api/client.js index 6472abf..c515752 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -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) ----- diff --git a/client/src/components/CharacterSheet.jsx b/client/src/components/CharacterSheet.jsx index 72f247d..967bee0 100644 --- a/client/src/components/CharacterSheet.jsx +++ b/client/src/components/CharacterSheet.jsx @@ -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 ( + + {children} + + ) +} + function StatTile({ value, label }) { return (
@@ -64,6 +96,21 @@ export default function CharacterSheet({ char, moderation = false }) { {char.serial}
+ {/* Titles + standing (guild led / governorship) — all optional */} + {(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && ( +
+ {char.governorOf && char.governorOf.map((city) => ( + Governor of {city} + ))} + {char.guild && ( + + Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name} + + )} + {displayTitles(char.titles).map((t) => {t})} +
+ )} + {/* Staff moderation for this character's account (self-gates to staff). */} {moderation && char.acct && (
diff --git a/client/src/routes/admin/views/UserDetail.jsx b/client/src/routes/admin/views/UserDetail.jsx index 5ca2f0e..7bc5a31 100644 --- a/client/src/routes/admin/views/UserDetail.jsx +++ b/client/src/routes/admin/views/UserDetail.jsx @@ -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 ( +
+ Standing +
+ {govs.map((g) => ( + + Governor of {g.city} + + ))} + {guilds.map((g) => ( + + Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name} + + ))} +
+
+ ) +} + // 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` : ''}
- {h.stage ?
{h.stage}
: null} + {(h.decay || h.stage) ?
{h.decay || h.stage}
: null} + {h.price != null ?
{Number(h.price).toLocaleString()} gp
: null} {h.lastRefreshed ?
refreshed {ago(h.lastRefreshed)}
: null}
@@ -102,6 +131,7 @@ function ShardSections({ scope }) { Linked accounts & characters `/admin/characters/${serial}`} /> + diff --git a/server/src/model/shardState/shardState.db.js b/server/src/model/shardState/shardState.db.js index 1f7ecff..2a21774 100644 --- a/server/src/model/shardState/shardState.db.js +++ b/server/src/model/shardState/shardState.db.js @@ -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, diff --git a/server/src/model/shardState/shardState.model.js b/server/src/model/shardState/shardState.model.js index f851e93..9a544a0 100644 --- a/server/src/model/shardState/shardState.model.js +++ b/server/src/model/shardState/shardState.model.js @@ -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, diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index a423d15..ed7878f 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -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 diff --git a/server/src/router/v1/admin/usersShard.controller.js b/server/src/router/v1/admin/usersShard.controller.js index d9e42a3..fd3c5a1 100644 --- a/server/src/router/v1/admin/usersShard.controller.js +++ b/server/src/router/v1/admin/usersShard.controller.js @@ -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 } diff --git a/server/src/router/v1/player/shard.controller.js b/server/src/router/v1/player/shard.controller.js index cef8929..76927d7 100644 --- a/server/src/router/v1/player/shard.controller.js +++ b/server/src/router/v1/player/shard.controller.js @@ -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) { diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 1c3a7c8..fa3fbee 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -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": [