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

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