Add a "View" action beside Edit in the users table that opens a dedicated,
read-only page showing everything the uo-link shard knows about a user,
scoped to their linked game accounts: character rosters, currently-online
characters, houses (IDOC-first), and recent vendor sales.
Backend (admin-only, under the existing /users adminOnly gate):
- GET /admin/users/:id — single sanitized user (page is deep-linkable)
- GET /admin/users/:id/shard/{accounts,sales,houses,online}
- shardState: listHousesByAccounts / listOnlineByAccounts (+ model shapers)
- Extract salesForAccounts into utils/shardSales; reuse in player getSales
- Live rosters reuse the existing admin-bypass /admin/shard/* endpoints,
so no new routes for roster/vendors/char
Frontend:
- UserDetail page reusing CharacterStats / GameAccounts / VendorSales
- GameAccounts gains a readOnly prop (drops link form + self-voice copy)
- api.admin.getUser + api.admin.userShard(id) scope; route + layout title
Tests: adminUserShard.test.js (404, account scoping, empty accounts,
salesForAccounts cap/filter). Full server suite 164 pass; client builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
190 lines
4.8 KiB
JavaScript
190 lines
4.8 KiB
JavaScript
// Live shard state derived from the WS feed: who is online, the gold-supply
|
|
// series, and per-house decay stage. The ingest dispatcher calls the write
|
|
// methods; the public read endpoints call the list/count methods. Writes take
|
|
// camelCase semantic objects and map to the snake_case columns; only the keys
|
|
// present are written (so a char.vitals refresh doesn't clobber login fields).
|
|
|
|
const db = require('./shardState.db')
|
|
|
|
const MAX_ECONOMY = 1000
|
|
|
|
// Map a camelCase online descriptor to DB columns, dropping undefined keys so a
|
|
// partial refresh only touches the fields it carries.
|
|
function onlineFields(data) {
|
|
const map = {
|
|
name: data.name,
|
|
acct: data.acct,
|
|
web_id: data.webId,
|
|
map: data.map,
|
|
x: data.x,
|
|
y: data.y,
|
|
z: data.z,
|
|
hits: data.hits,
|
|
hits_max: data.hitsMax,
|
|
mana: data.mana,
|
|
mana_max: data.manaMax,
|
|
stam: data.stam,
|
|
stam_max: data.stamMax,
|
|
str: data.str,
|
|
dex: data.dex,
|
|
int: data.int,
|
|
}
|
|
const fields = {}
|
|
for (const [k, v] of Object.entries(map)) if (v !== undefined) fields[k] = v
|
|
return fields
|
|
}
|
|
|
|
// Upsert an online player (mob.login) or refresh their vitals (char.vitals).
|
|
async function upsertOnline(data) {
|
|
if (!data || !data.serial) return
|
|
await db.upsertOnline(data.serial, onlineFields(data))
|
|
}
|
|
|
|
const setOffline = (serial) => db.removeOnline(serial)
|
|
const clearOnline = () => db.clearOnline()
|
|
const onlineCount = () => db.countOnline()
|
|
|
|
function shapeOnline(r) {
|
|
return {
|
|
serial: r.serial,
|
|
name: r.name,
|
|
acct: r.acct,
|
|
webId: r.web_id,
|
|
map: r.map,
|
|
x: r.x,
|
|
y: r.y,
|
|
z: r.z,
|
|
hits: r.hits,
|
|
hitsMax: r.hits_max,
|
|
mana: r.mana,
|
|
manaMax: r.mana_max,
|
|
stam: r.stam,
|
|
stamMax: r.stam_max,
|
|
str: r.str,
|
|
dex: r.dex,
|
|
int: r.int,
|
|
updatedAt: r.updated_at,
|
|
}
|
|
}
|
|
|
|
// Only players whose account is linked to a website user (opt-in visibility).
|
|
async function listOnlineLinked() {
|
|
const rows = await db.listOnlineLinked()
|
|
return rows.map(shapeOnline)
|
|
}
|
|
|
|
async function listOnline() {
|
|
const rows = await db.listOnline()
|
|
return rows.map((r) => ({
|
|
serial: r.serial,
|
|
name: r.name,
|
|
acct: r.acct,
|
|
webId: r.web_id,
|
|
map: r.map,
|
|
x: r.x,
|
|
y: r.y,
|
|
z: r.z,
|
|
hits: r.hits,
|
|
hitsMax: r.hits_max,
|
|
mana: r.mana,
|
|
manaMax: r.mana_max,
|
|
stam: r.stam,
|
|
stamMax: r.stam_max,
|
|
str: r.str,
|
|
dex: r.dex,
|
|
int: r.int,
|
|
updatedAt: r.updated_at,
|
|
}))
|
|
}
|
|
|
|
// Append a gold-supply sample (economy.supply).
|
|
async function addEconomySample({ accounts, gold, t }) {
|
|
await db.insertEconomy({ accounts, gold, t })
|
|
}
|
|
|
|
async function listEconomy(limit = 100) {
|
|
const n = Math.min(Math.max(Number(limit) || 100, 1), MAX_ECONOMY)
|
|
const rows = await db.listEconomy(n)
|
|
// Return oldest → newest for charting.
|
|
return rows
|
|
.map((r) => ({ accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t }))
|
|
.reverse()
|
|
}
|
|
|
|
async function latestEconomy() {
|
|
const r = await db.latestEconomy()
|
|
return r ? { accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t } : null
|
|
}
|
|
|
|
// Upsert a house's decay stage (house.decay). is_idoc is derived from the stage.
|
|
async function upsertHouse(data) {
|
|
if (!data || !data.serial) return
|
|
const fields = {
|
|
stage: data.stage ?? null,
|
|
map: data.map ?? null,
|
|
x: data.x ?? null,
|
|
y: data.y ?? null,
|
|
z: data.z ?? null,
|
|
region: data.region ?? null,
|
|
name: data.name ?? null,
|
|
owner_serial: data.ownerSerial ?? null,
|
|
owner_acct: data.ownerAcct ?? null,
|
|
built_on: data.builtOn ? new Date(data.builtOn) : null,
|
|
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
|
|
is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0,
|
|
}
|
|
await db.upsertHouse(data.serial, fields)
|
|
}
|
|
|
|
function shapeHouse(r) {
|
|
return {
|
|
serial: r.serial,
|
|
stage: r.stage,
|
|
map: r.map,
|
|
x: r.x,
|
|
y: r.y,
|
|
z: r.z,
|
|
region: r.region,
|
|
name: r.name,
|
|
ownerSerial: r.owner_serial,
|
|
ownerAcct: r.owner_acct,
|
|
builtOn: r.built_on,
|
|
lastRefreshed: r.last_refreshed,
|
|
isIdoc: Boolean(r.is_idoc),
|
|
updatedAt: r.updated_at,
|
|
}
|
|
}
|
|
|
|
async function listIdoc() {
|
|
const rows = await db.listIdocHouses()
|
|
return rows.map(shapeHouse)
|
|
}
|
|
|
|
// Houses owned by the given game accounts (admin: a user's linked accounts).
|
|
async function listHousesForAccounts(accounts) {
|
|
const rows = await db.listHousesByAccounts(accounts)
|
|
return rows.map(shapeHouse)
|
|
}
|
|
|
|
// Online players on the given game accounts (admin: a user's linked accounts).
|
|
async function listOnlineForAccounts(accounts) {
|
|
const rows = await db.listOnlineByAccounts(accounts)
|
|
return rows.map(shapeOnline)
|
|
}
|
|
|
|
module.exports = {
|
|
upsertOnline,
|
|
setOffline,
|
|
clearOnline,
|
|
onlineCount,
|
|
listOnline,
|
|
listOnlineLinked,
|
|
listOnlineForAccounts,
|
|
addEconomySample,
|
|
listEconomy,
|
|
latestEconomy,
|
|
upsertHouse,
|
|
listIdoc,
|
|
listHousesForAccounts,
|
|
}
|