feat(houses): tier house visibility — public IDOC-only, staff full, player own

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 16:50:37 -05:00
parent a165c90c62
commit 1629796235
13 changed files with 362 additions and 115 deletions

View File

@@ -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() {
</RoleGate>
}
/>
<Route
path="houses"
element={
<RoleGate roles={['admin', 'moderator']}>
<HousesAdmin />
</RoleGate>
}
/>
<Route path="characters" element={<AdminCharacters />} />
<Route path="characters/:serial" element={<AdminCharacter />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} />

View File

@@ -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 } }),
},

View File

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

View File

@@ -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 (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
{label}
</span>
)
}
function ownerLabel(h) {
return h.ownerName || h.ownerAcct || null
}
function HouseRow({ h }) {
const owner = ownerLabel(h)
return (
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.name || 'An unnamed house'}
</strong>
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
</div>
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
</div>
</div>
{h.price != null && (
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
</div>
)}
</div>
)
}
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 <Loading />
if (error) return <ErrorState message="Could not load the house registry." />
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
{board.length.toLocaleString()} houses
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
</p>
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
</div>
{board.length === 0 ? (
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
)}
{board.length > 0 && filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match {q}.</p>
)}
</section>
)
}

View File

@@ -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 (
<section style={{ marginTop: 30 }}>
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => {
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
return (
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
</div>
</div>
{label && (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
{label}
</span>
)}
</div>
)
})}
</div>
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
Keep an eye on the decay status refresh a house in game before it reaches IDOC.
</p>
</section>
)
}
export default function PlayerCharacters() {
return (
<div>
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
<MyHouses />
<VendorSales fetchSales={api.player.shard.sales} />
</div>
)

View File

@@ -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 (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.04em', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
{label}
</span>
)
}
// 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 (
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<span
aria-hidden="true"
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.name || 'An unnamed house'}
</strong>
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.region || 'The wilderness'}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
</div>
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
</div>
</div>
{h.price != null && (
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>
{Number(h.price).toLocaleString()}
</div>
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>
placement value
</div>
</div>
)}
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
IDOC
</span>
</div>
)
}
@@ -74,43 +37,29 @@ function HouseRow({ h }) {
export default function Houses() {
const { loading, error, data } = useAsync(() => api.shard.houses())
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
const [q, setQ] = useState('')
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
// adds/updates the row; anything else (refreshed, collapsed) drops it.
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.kind === 'house.update' && ev.serial) {
// Live house.update events arrive in the sidecar's shape (owner is an
// actor object); normalize to the flattened shape the row renders.
map.set(ev.serial, {
...ev,
ownerName: ev.owner?.name ?? ev.ownerName,
ownerAcct: ev.owner?.acct ?? ev.ownerAcct,
})
} else if (ev.kind === 'house.remove' && ev.serial) {
if (ev.kind !== 'house.decay' || !ev.serial) continue
if (String(ev.to).toUpperCase() === 'IDOC') {
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
} else {
map.delete(ev.serial)
}
}
return [...map.values()]
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
}, [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])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Houses" lead="The house registry — owners, sizes and standing across Britannia." />
<PageHeader eyebrow="Live" title="Houses in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
@@ -118,37 +67,23 @@ export default function Houses() {
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the house registry right now." />}
{error && <ErrorState message="Could not load the houses board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
</section>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
{board.length.toLocaleString()} houses
</p>
<input
className="input sans"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search by owner, region…"
style={{ flex: 'none', width: 210, maxWidth: '55%', fontSize: '0.84rem' }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
{filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match {q}.</p>
)}
</>
)}
</>
board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are collapsing right now.</p>
</section>
) : (
<>
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
{board.length} in danger
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
</>
)
)}
</div>
</PublicLayout>

View File

@@ -288,6 +288,16 @@ adminRouter.get(
modAccess,
shardOps.listAudit,
)
adminRouter.get(
'/shard/houses',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)'
// #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
modAccess,
shardOps.listHouses,
)
// ── Image uploads (screenshots/gallery) ───────────────────────────────
const UPLOAD_DIR =

View File

@@ -155,4 +155,17 @@ async function listAudit(req, res) {
}
}
module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit }
// GET /admin/shard/houses — the FULL house registry (owner, price, co-owners,
// decay), staff-only (modAccess). The public /public/shard/houses shows only IDOC
// houses with location; this is the complete board, kept live for staff on the
// admin SSE channel (house.update / house.remove).
async function listHouses(req, res) {
try {
return res.json(await shardState.listHouses())
} catch (err) {
log.error('shardOps.listHouses', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit, listHouses }

View File

@@ -222,5 +222,14 @@ playerRouter.get(
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
shard.getSales,
)
playerRouter.get(
'/shard/houses',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'The callers own houses (home status)'
// #swagger.description = 'Houses owned by the callers linked accounts, with decay/IDOC status. Only the callers own houses — never anyone elses.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The callers houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getHouses,
)
module.exports = playerRouter

View File

@@ -148,6 +148,20 @@ async function getSales(req, res) {
}
}
// GET /player/shard/houses — the caller's OWN houses (home status), scoped to
// their linked accounts. A player sees their own decay/IDOC standing; never
// anyone else's. Full detail is fine here — it's their property.
async function getHouses(req, res) {
try {
const links = await shardLinks.listForUser(req.user.id)
const accounts = links.map((l) => l.account)
return res.json(await shardState.listHousesForAccounts(accounts))
} catch (err) {
log.error('player.shard.getHouses', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Map a failed uoLinkClient.createAccount result to a user-facing HTTP response.
// The password is never echoed anywhere; only the mapped reason is returned.
function mapCreateAccountError(res, result) {
@@ -202,4 +216,4 @@ async function createGameAccount(req, res) {
}
}
module.exports = { link, listAccounts, roster, vendors, getChar, getSales, createGameAccount }
module.exports = { link, listAccounts, roster, vendors, getChar, getSales, getHouses, createGameAccount }

View File

@@ -148,11 +148,24 @@ async function getPresence(req, res) {
}
}
// GET /public/shard/houses — the house registry (every house we've seen via
// house.update). Live via house.update / house.remove on the public SSE stream.
// GET /public/shard/houses — PUBLIC view: only houses in danger (IDOC), and only
// their location (name + region + map/coords). Owner, price, co-owners and decay
// detail are staff-only (see admin GET /admin/shard/houses). Live via house.decay
// on the public SSE stream. This is the "where are the falling houses" board.
async function getHouses(req, res) {
try {
return res.json(await shardState.listHouses())
const idoc = await shardState.listIdoc()
const publicHouses = idoc.map((h) => ({
serial: h.serial,
name: h.name,
region: h.region,
map: h.map,
x: h.x,
y: h.y,
z: h.z,
isIdoc: true,
}))
return res.json(publicHouses)
} catch (err) {
log.error('shard.getHouses', err)
return res.status(500).json({ message: 'Internal Server Error' })

View File

@@ -36,15 +36,17 @@ const PUBLIC_KINDS = new Set([
// Champion-spawn board deltas — the public Champions page renders these live.
'champ.update',
'champ.remove',
// Protocol 2.0 boards — all public, rendered live on their respective pages.
// Protocol 2.0 boards — public, rendered live on their respective pages.
'guild.update',
'guild.remove',
'guild.join',
'city.update',
'presence.online',
'region.enter',
'house.update',
'house.remove',
// NOTE: house.update / house.remove (the full registry — owner, price, co-owners)
// are deliberately NOT public. The public Houses page shows only IDOC houses (via
// house.decay, which is public above) with location only; the full registry is
// staff-only and rides the admin SSE channel. See public/shard.controller getHouses.
])
// Open response streams per channel.

View File

@@ -2876,6 +2876,41 @@
]
}
},
"/api/v1/admin/shard/houses": {
"get": {
"tags": [
"Admin · Shard"
],
"summary": "Full house registry — owner, price, decay (admin/moderator)",
"description": "The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.",
"responses": {
"200": {
"description": "Houses, ordered by name",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ShardHouse"
}
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/dashboard": {
"get": {
"tags": [
@@ -8721,6 +8756,47 @@
}
]
}
},
"/api/v1/player/shard/houses": {
"get": {
"tags": [
"Player · Shard"
],
"summary": "The callers own houses (home status)",
"description": "Houses owned by the callers linked accounts, with decay/IDOC status. Only the callers own houses — never anyone elses.",
"responses": {
"200": {
"description": "The callers houses",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ShardHouse"
}
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
}
},
"components": {