From 1629796235fb8eaa17c744223308513a5977a568 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 16:50:37 -0500 Subject: [PATCH] =?UTF-8?q?feat(houses):=20tier=20house=20visibility=20?= =?UTF-8?q?=E2=80=94=20public=20IDOC-only,=20staff=20full,=20player=20own?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per request, split the single public house registry into three role-scoped views: - Public /site/houses → only houses in DANGER (IDOC), by LOCATION (region + map/ coords). No owner, price, co-owners or decay detail. Renamed "Houses in danger"; kept live via the public house.decay feed. The full-registry deltas (house.update / house.remove — which carry owner/price) are REMOVED from the public SSE allowlist so they never reach the public channel. - Staff full registry → new /admin/houses (admin + moderator, RoleGate + MOD_PATHS) backed by GET /admin/shard/houses (modAccess), with owner/price/co-owners/decay and search, kept live on the admin SSE channel. - Player portal → "My houses" home-status section (own houses only, with decay/ IDOC status) via GET /player/shard/houses, scoped to the caller's linked accounts. Server tests green, client build clean, swagger regenerated. Co-Authored-By: Claude Opus 4.8 --- client/src/App.jsx | 9 ++ client/src/api/client.js | 2 + client/src/routes/admin/AdminLayout.jsx | 4 +- client/src/routes/admin/views/HousesAdmin.jsx | 119 ++++++++++++++ client/src/routes/player/PlayerCharacters.jsx | 45 +++++- client/src/routes/public/Houses.jsx | 145 +++++------------- server/src/router/v1/admin/admin.routes.js | 10 ++ .../router/v1/admin/shardOps.controller.js | 15 +- server/src/router/v1/player/player.routes.js | 9 ++ .../src/router/v1/player/shard.controller.js | 16 +- .../src/router/v1/public/shard.controller.js | 19 ++- server/src/utils/shardBroadcast.js | 8 +- server/swagger/swagger-output.json | 76 +++++++++ 13 files changed, 362 insertions(+), 115 deletions(-) create mode 100644 client/src/routes/admin/views/HousesAdmin.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index f803e27..ac320d1 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -47,6 +47,7 @@ import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import UserDetail from './routes/admin/views/UserDetail.jsx' import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx' +import HousesAdmin from './routes/admin/views/HousesAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx' @@ -144,6 +145,14 @@ export default function App() { } /> + + + + } + /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 9b46a73..8f36c2a 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -259,6 +259,7 @@ export const api = { vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`), char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`), sales: () => req('/admin/shard/sales'), + houses: () => req('/admin/shard/houses'), // full registry (admin/moderator) }, // ----- auth providers / SSO config (admin only) ----- @@ -322,6 +323,7 @@ export const api = { vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`), char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`), sales: () => req('/player/shard/sales'), + houses: () => req('/player/shard/houses'), // the caller's own houses createAccount: (account, password) => req('/player/shard/account', { method: 'POST', body: { account, password } }), }, diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 5294579..9e2204b 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -64,6 +64,7 @@ const NAV = [ items: [ { to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] }, { to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] }, + { to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] }, ], }, { @@ -97,6 +98,7 @@ const TITLES = { '/admin/hero': 'Hero Editor', '/admin/moderation': 'Moderation', '/admin/shard-ops': 'In-Game Ops', + '/admin/houses': 'House Registry', '/admin/settings': 'Site Settings', '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Web Bot Activity', @@ -143,7 +145,7 @@ export default function AdminLayout() { // Moderators only get the moderation section (Discord + in-game ops) + their // own account security. const isModerator = user?.role === 'moderator' - const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/account'] + const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/houses', '/admin/account'] const visible = (item) => { if (item.roles && !item.roles.includes(user?.role)) return false if (isModerator) return MOD_PATHS.includes(item.to) diff --git a/client/src/routes/admin/views/HousesAdmin.jsx b/client/src/routes/admin/views/HousesAdmin.jsx new file mode 100644 index 0000000..89eb787 --- /dev/null +++ b/client/src/routes/admin/views/HousesAdmin.jsx @@ -0,0 +1,119 @@ +import { useMemo, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { useAsync } from '../../../lib/useAsync.js' +import { useShardFeed } from '../../../lib/useShardFeed.js' +import { api } from '../../../api/client.js' + +// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and +// decay — everything the public board hides. Loaded from /admin/shard/houses, kept +// live from the admin SSE channel (house.update / house.remove). +const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay']) + +const DECAY_TONE = { + LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a', + Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5', +} + +function DecayBadge({ decay, isIdoc }) { + const label = isIdoc ? 'IDOC' : decay + if (!label) return null + const tone = DECAY_TONE[label] || 'var(--muted)' + return ( + + {label} + + ) +} + +function ownerLabel(h) { + return h.ownerName || h.ownerAcct || null +} + +function HouseRow({ h }) { + const owner = ownerLabel(h) + return ( +
+
+
+ + {h.name || 'An unnamed house'} + + +
+
+ {owner ? <>Owned by {owner} : 'No owner'} + {(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''} +
+
+ {h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''} +
+
+ {h.price != null && ( +
+
{Number(h.price).toLocaleString()}
+
placement value
+
+ )} +
+ ) +} + +export default function HousesAdmin() { + const { loading, error, data } = useAsync(() => api.admin.shard.houses()) + // Full registry deltas ride the admin SSE channel (never the public one). + const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 }) + const [q, setQ] = useState('') + + const board = useMemo(() => { + const map = new Map() + for (const h of data || []) if (h && h.serial) map.set(h.serial, h) + for (let i = events.length - 1; i >= 0; i -= 1) { + const ev = events[i] + if (!ev.serial) continue + if (ev.kind === 'house.update') { + map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct }) + } else if (ev.kind === 'house.remove') { + map.delete(ev.serial) + } else if (ev.kind === 'house.decay') { + const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y } + map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' }) + } + } + return [...map.values()] + }, [data, events]) + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase() + const rows = needle + ? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle))) + : board + return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || '')) + }, [board, q]) + + if (loading) return + if (error) return + + return ( +
+
+

+ {board.length.toLocaleString()} houses + {connected ? '● live' : '○ offline'} +

+ setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} /> +
+ {board.length === 0 ? ( +
+

No houses are being tracked right now.

+
+ ) : ( +
+ {filtered.map((h) => )} +
+ )} + {board.length > 0 && filtered.length === 0 && ( +

No houses match “{q}”.

+ )} +
+ ) +} diff --git a/client/src/routes/player/PlayerCharacters.jsx b/client/src/routes/player/PlayerCharacters.jsx index 27711cd..e8ddb77 100644 --- a/client/src/routes/player/PlayerCharacters.jsx +++ b/client/src/routes/player/PlayerCharacters.jsx @@ -1,14 +1,57 @@ import GameAccounts from '../../components/GameAccounts.jsx' import VendorSales from '../../components/VendorSales.jsx' +import { useAsync } from '../../lib/useAsync.js' import { api } from '../../api/client.js' // The logged-in player's characters. Shows the link prompt when no game account // is linked, otherwise their characters grouped by account (shared component), -// plus their own recent vendor sales. +// plus their own home status and recent vendor sales. + +const DECAY_TONE = { + LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a', + Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5', +} + +// The caller's own houses (home status). Only their own — never anyone else's. +function MyHouses() { + const { data } = useAsync(() => api.player.shard.houses(), []) + if (!data || data.length === 0) return null + return ( +
+
My houses
+
+ {data.map((h) => { + const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage) + const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)') + return ( +
+
+
{h.name || 'An unnamed house'}
+
+ {h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''} +
+
+ {label && ( + + {label} + + )} +
+ ) + })} +
+

+ Keep an eye on the decay status — refresh a house in game before it reaches IDOC. +

+
+ ) +} + export default function PlayerCharacters() { return (
`/player/char/${serial}`} /> +
) diff --git a/client/src/routes/public/Houses.jsx b/client/src/routes/public/Houses.jsx index 223c9c4..246a72f 100644 --- a/client/src/routes/public/Houses.jsx +++ b/client/src/routes/public/Houses.jsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useMemo } from 'react' import PublicLayout from '../../components/PublicLayout.jsx' import PageHeader from '../../components/PageHeader.jsx' import { Loading, ErrorState } from '../../components/PageState.jsx' @@ -6,67 +6,30 @@ import { useAsync } from '../../lib/useAsync.js' import { useShardFeed } from '../../lib/useShardFeed.js' import { api } from '../../api/client.js' -// The house registry. Loaded from /public/shard/houses, kept live by merging -// house.update / house.remove deltas by serial. `price` is the placement value — -// NOT a for-sale flag (stock ServUO has none), and the UI labels it as such. -const HOUSE_KINDS = new Set(['house.update', 'house.remove']) - -// Decay level → colour, from healthiest to collapsed. -const DECAY_TONE = { - LikeNew: '#7fd0a4', - Slightly: '#a9cf8a', - Somewhat: '#d7c56a', - Fairly: '#e0a95f', - Greatly: '#d9736f', - IDOC: '#e05a5a', - Collapsed: '#8c96a5', -} - -function DecayBadge({ decay, isIdoc }) { - const label = isIdoc ? 'IDOC' : decay - if (!label) return null - const tone = DECAY_TONE[label] || 'var(--muted)' - return ( - - {label} - - ) -} - -// house.update carries owner as a flattened ownerName/ownerAcct on our shaped row. -function ownerLabel(h) { - return h.ownerName || h.ownerAcct || null -} +// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner, +// price, decay detail and the full registry are staff-only (admin Houses view). +// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a +// house entering IDOC appears, one leaving it drops off. +const HOUSE_KINDS = new Set(['house.decay']) function HouseRow({ h }) { - const owner = ownerLabel(h) return (
+