feat(shard): Protocol 2.0 boards UI — guilds, governors, houses, players-online
Phase 2: the public UI for the four new boards, following the ChampSpawns live pattern (snapshot via useAsync + merge SSE deltas with useShardFeed). - Players Online widget (components/PlayersOnline.jsx): total + region breakdown rolled up into display buckets (data/regionBuckets.js — the one place to retune the grouping); live via presence.online. Placed on the Shard page, replacing the static players-online stat tile. - Guilds (/site/guilds): searchable board of rosters/alliances/leaders with a "recently joined" strip from guild.join. - Governors (/site/governors): one card per city with a placeholder crest (data/cityCrests.js — swap for real art without touching components), election phase badge + autoPickAt countdown, and an on-demand "past governors" term history (the look-back reads the ledger captured in Phase 1). Clean empty state when City Loyalty isn't enabled. - Houses (/site/houses): searchable registry with decay badges; price labelled "placement value", not a for-sale flag. - API client methods + nav links (Guilds / Governors / Houses). Client build clean (240 modules). Refs .plans/protocol2-integration.md (Phase 2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,9 @@ import Status from './routes/public/Status.jsx'
|
|||||||
import Shard from './routes/public/Shard.jsx'
|
import Shard from './routes/public/Shard.jsx'
|
||||||
import ShardActivity from './routes/public/ShardActivity.jsx'
|
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||||
import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
||||||
|
import Guilds from './routes/public/Guilds.jsx'
|
||||||
|
import Governors from './routes/public/Governors.jsx'
|
||||||
|
import Houses from './routes/public/Houses.jsx'
|
||||||
import Wiki from './routes/wiki/Wiki.jsx'
|
import Wiki from './routes/wiki/Wiki.jsx'
|
||||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||||
import CmsPage from './routes/public/CmsPage.jsx'
|
import CmsPage from './routes/public/CmsPage.jsx'
|
||||||
@@ -84,6 +87,9 @@ export default function App() {
|
|||||||
<Route path="/site/shard" element={<Shard />} />
|
<Route path="/site/shard" element={<Shard />} />
|
||||||
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||||
<Route path="/site/champs" element={<ChampSpawns />} />
|
<Route path="/site/champs" element={<ChampSpawns />} />
|
||||||
|
<Route path="/site/guilds" element={<Guilds />} />
|
||||||
|
<Route path="/site/governors" element={<Governors />} />
|
||||||
|
<Route path="/site/houses" element={<Houses />} />
|
||||||
<Route path="/wiki" element={<Wiki />} />
|
<Route path="/wiki" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ export const api = {
|
|||||||
online: () => req('/public/shard/online'),
|
online: () => req('/public/shard/online'),
|
||||||
idoc: () => req('/public/shard/idoc'),
|
idoc: () => req('/public/shard/idoc'),
|
||||||
champs: () => req('/public/shard/champs'),
|
champs: () => req('/public/shard/champs'),
|
||||||
|
// Protocol 2.0 boards.
|
||||||
|
guilds: () => req('/public/shard/guilds'),
|
||||||
|
governors: () => req('/public/shard/governors'),
|
||||||
|
governorHistory: (city, limit) =>
|
||||||
|
req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`),
|
||||||
|
presence: () => req('/public/shard/presence'),
|
||||||
|
houses: () => req('/public/shard/houses'),
|
||||||
},
|
},
|
||||||
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
||||||
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
||||||
|
|||||||
84
client/src/components/PlayersOnline.jsx
Normal file
84
client/src/components/PlayersOnline.jsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useAsync } from '../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../lib/useShardFeed.js'
|
||||||
|
import { bucketize } from '../data/regionBuckets.js'
|
||||||
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
|
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
|
||||||
|
// then keeps the total + region breakdown current from the presence.online SSE
|
||||||
|
// kind. The raw byRegion map is rolled up into display buckets (see
|
||||||
|
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
|
||||||
|
const PRESENCE_KINDS = new Set(['presence.online'])
|
||||||
|
|
||||||
|
export default function PlayersOnline() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.presence())
|
||||||
|
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
|
||||||
|
|
||||||
|
// The freshest snapshot wins: the newest buffered presence.online event, else
|
||||||
|
// the initial fetch.
|
||||||
|
const snapshot = events[0] || data
|
||||||
|
|
||||||
|
const { total, rows } = useMemo(() => {
|
||||||
|
const count = Number(snapshot?.count) || 0
|
||||||
|
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
|
||||||
|
return { total: count, rows: bucketRows }
|
||||||
|
}, [snapshot])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 20 }}>
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Players online
|
||||||
|
</span>
|
||||||
|
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
|
||||||
|
{loading ? '—' : total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
|
||||||
|
Population is unavailable right now.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
|
||||||
|
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
rows.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{r.label}</span>
|
||||||
|
{/* tabular figures keep the right-aligned counts in a clean column */}
|
||||||
|
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ const NAV = [
|
|||||||
{ label: 'Wiki', to: '/wiki' },
|
{ label: 'Wiki', to: '/wiki' },
|
||||||
{ label: 'Shard', to: '/site/shard' },
|
{ label: 'Shard', to: '/site/shard' },
|
||||||
{ label: 'Champions', to: '/site/champs' },
|
{ label: 'Champions', to: '/site/champs' },
|
||||||
|
{ label: 'Guilds', to: '/site/guilds' },
|
||||||
|
{ label: 'Governors', to: '/site/governors' },
|
||||||
|
{ label: 'Houses', to: '/site/houses' },
|
||||||
{ label: 'About', to: '/site/about' },
|
{ label: 'About', to: '/site/about' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
31
client/src/data/cityCrests.js
Normal file
31
client/src/data/cityCrests.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
|
||||||
|
// emoji sigil + a ring colour — enough to make the Governors board and the
|
||||||
|
// governor badge read as distinct "crests" today, swappable for real artwork
|
||||||
|
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
|
||||||
|
// public path) onto an entry and update CityCrest to prefer it.
|
||||||
|
//
|
||||||
|
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
|
||||||
|
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
|
||||||
|
|
||||||
|
export const CITY_CRESTS = {
|
||||||
|
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
|
||||||
|
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
|
||||||
|
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
|
||||||
|
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
|
||||||
|
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
|
||||||
|
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
|
||||||
|
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
|
||||||
|
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
|
||||||
|
|
||||||
|
// Look up a crest by the raw city key, tolerating spacing variants
|
||||||
|
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
|
||||||
|
export function crestFor(city) {
|
||||||
|
if (!city) return FALLBACK
|
||||||
|
const key = String(city).replace(/\s+/g, '')
|
||||||
|
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
|
||||||
|
if (crest) return crest
|
||||||
|
return { ...FALLBACK, label: String(city) }
|
||||||
|
}
|
||||||
62
client/src/data/regionBuckets.js
Normal file
62
client/src/data/regionBuckets.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
|
||||||
|
// regions) up into a handful of labelled display buckets for the "Players Online"
|
||||||
|
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
|
||||||
|
// membership) and the widget follows. Anything not matched lands in "Wilderness"
|
||||||
|
// so the bucket counts always reconcile to the true total.
|
||||||
|
|
||||||
|
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
|
||||||
|
// membership. First matching bucket wins; the last bucket is the catch-all.
|
||||||
|
export const BUCKETS = [
|
||||||
|
{
|
||||||
|
id: 'britain',
|
||||||
|
label: 'Britain',
|
||||||
|
// Passthrough for the capital + its immediate surrounds.
|
||||||
|
match: (r) => /^britain/i.test(r),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'towns',
|
||||||
|
label: 'Towns',
|
||||||
|
// The other named cities/towns.
|
||||||
|
match: (r) =>
|
||||||
|
/^(moonglow|minoc|trinsic|jhelom|yew|skara ?brae|magincia|new ?magincia|vesper|nujelm|cove|ocllo|serpent'?s? hold|wind|delucia|papua)/i.test(
|
||||||
|
r,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dungeons',
|
||||||
|
label: 'Dungeons',
|
||||||
|
match: (r) =>
|
||||||
|
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
|
||||||
|
r,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'housing',
|
||||||
|
label: 'Housing',
|
||||||
|
// House regions expose themselves as named house/townhouse regions.
|
||||||
|
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wilderness',
|
||||||
|
label: 'Wilderness',
|
||||||
|
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
|
||||||
|
match: () => true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
|
||||||
|
// order, dropping empty buckets, with the summed total also returned.
|
||||||
|
export function bucketize(byRegion = {}) {
|
||||||
|
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
|
||||||
|
let total = 0
|
||||||
|
for (const [region, n] of Object.entries(byRegion || {})) {
|
||||||
|
const count = Number(n) || 0
|
||||||
|
total += count
|
||||||
|
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
|
||||||
|
totals.set(bucket.id, totals.get(bucket.id) + count)
|
||||||
|
}
|
||||||
|
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
|
||||||
|
(r) => r.count > 0,
|
||||||
|
)
|
||||||
|
return { rows, total }
|
||||||
|
}
|
||||||
186
client/src/routes/public/Governors.jsx
Normal file
186
client/src/routes/public/Governors.jsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { crestFor } from '../../data/cityCrests.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// The town-governor board (City Loyalty). Loaded from /public/shard/governors,
|
||||||
|
// kept live by merging city.update deltas by city. Empty on shards without the
|
||||||
|
// City Loyalty system. Each city card links to its term history (look-back).
|
||||||
|
const GOV_KINDS = new Set(['city.update'])
|
||||||
|
|
||||||
|
const PHASE = {
|
||||||
|
none: null,
|
||||||
|
nominate: { label: 'Nominations open', color: '#7f8fd0' },
|
||||||
|
vote: { label: 'Voting', color: '#e6c26a' },
|
||||||
|
pending: { label: 'Result pending', color: '#c9a24b' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt).
|
||||||
|
function until(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const ms = new Date(iso).getTime() - Date.now()
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||||
|
const mins = Math.round(ms / 60000)
|
||||||
|
if (mins < 60) return `in ${mins}m`
|
||||||
|
const hrs = Math.round(mins / 60)
|
||||||
|
if (hrs < 24) return `in ${hrs}h`
|
||||||
|
return `in ${Math.round(hrs / 24)}d`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(ms) {
|
||||||
|
if (ms == null) return ''
|
||||||
|
return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function CityCrest({ city, size = 44 }) {
|
||||||
|
const c = crestFor(city)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
flex: 'none', width: size, height: size, borderRadius: '50%',
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: size * 0.5, background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: `2px solid ${c.color}`, boxShadow: `0 0 10px ${c.color}22`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.sigil}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsible term history for one city, fetched on demand from the ledger.
|
||||||
|
function TermHistory({ city }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const { loading, error, data } = useAsync(
|
||||||
|
() => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)),
|
||||||
|
[open, city],
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sans"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: '0.76rem' }}
|
||||||
|
>
|
||||||
|
{open ? 'Hide past governors' : 'Past governors →'}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{loading && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Loading…</p>}
|
||||||
|
{error && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Could not load history.</p>}
|
||||||
|
{data && data.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>No recorded terms yet.</p>
|
||||||
|
)}
|
||||||
|
{data && data.length > 0 && (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||||
|
{data.map((t, i) => (
|
||||||
|
<li key={i} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{t.governor?.name || 'Vacant'}
|
||||||
|
</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.72rem' }}>
|
||||||
|
{fmtDate(t.startedAt)}{t.endedAt ? ` – ${fmtDate(t.endedAt)}` : ' – present'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CityCard({ c }) {
|
||||||
|
const phase = PHASE[c.electionPhase] || null
|
||||||
|
const gov = c.governor
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 18 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<CityCrest city={c.city} />
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<strong className="display" style={{ fontSize: '1.05rem', color: 'var(--head)' }}>
|
||||||
|
{crestFor(c.city).label || c.city}
|
||||||
|
</strong>
|
||||||
|
{phase && (
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.66rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: phase.color, border: `1px solid ${phase.color}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||||
|
{phase.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sans" style={{ marginTop: 3, fontSize: '0.9rem', color: gov ? 'var(--ink)' : 'var(--muted)' }}>
|
||||||
|
{gov ? (
|
||||||
|
<>Governor <strong style={{ color: 'var(--head)' }}>{gov.name}</strong></>
|
||||||
|
) : (
|
||||||
|
'Seat vacant'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{c.electionPhase && c.electionPhase !== 'none' && (
|
||||||
|
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
|
||||||
|
{c.candidates ? `${c.candidates} candidate${c.candidates === 1 ? '' : 's'}` : 'No candidates yet'}
|
||||||
|
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TermHistory city={c.city} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Governors() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.governors())
|
||||||
|
const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 })
|
||||||
|
|
||||||
|
const board = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const c of data || []) if (c && c.city) map.set(c.city, c)
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev)
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || ''))
|
||||||
|
}, [data, events])
|
||||||
|
|
||||||
|
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="Governors of Britannia" lead="Who rules each city, and where the next election stands." />
|
||||||
|
<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'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the governor board right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{board.length === 0 ? (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>
|
||||||
|
City Loyalty governance is not enabled on this shard.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<div className="grid-2" style={{ gap: 12 }}>
|
||||||
|
{board.map((c) => <CityCard key={c.city} c={c} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
client/src/routes/public/Guilds.jsx
Normal file
169
client/src/routes/public/Guilds.jsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
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'
|
||||||
|
|
||||||
|
// The guild board. Loaded once from /public/shard/guilds, then kept live by
|
||||||
|
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
|
||||||
|
// joined" strip on top of the board.
|
||||||
|
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
|
||||||
|
|
||||||
|
function Leader({ leader }) {
|
||||||
|
if (!leader || !leader.name) return <span className="dim">—</span>
|
||||||
|
return <span>{leader.name}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuildRow({ g }) {
|
||||||
|
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: 'baseline', gap: 8, minWidth: 0 }}>
|
||||||
|
{g.abbr && (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
flex: 'none',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
border: '1px solid rgba(201,162,75,0.4)',
|
||||||
|
borderRadius: 5,
|
||||||
|
padding: '1px 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.abbr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<strong
|
||||||
|
className="display"
|
||||||
|
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{g.name || 'A guild'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
{g.alliance && (
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||||
|
{g.alliance}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
|
||||||
|
<span className="dim"> / {g.members ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||||
|
<Leader leader={g.leader} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Guilds() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.guilds())
|
||||||
|
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
|
||||||
|
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
|
||||||
|
const board = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
|
||||||
|
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
}, [data, events])
|
||||||
|
|
||||||
|
// Recent joins strip (newest first, deduped, capped).
|
||||||
|
const joins = useMemo(
|
||||||
|
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
|
||||||
|
[events],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const needle = q.trim().toLowerCase()
|
||||||
|
const rows = needle
|
||||||
|
? board.filter((g) =>
|
||||||
|
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
|
||||||
|
)
|
||||||
|
: board
|
||||||
|
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||||
|
}, [board, q])
|
||||||
|
|
||||||
|
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
|
||||||
|
|
||||||
|
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="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
|
||||||
|
<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'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the guild board right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{board.length === 0 ? (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{joins.length > 0 && (
|
||||||
|
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||||
|
Recently joined
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||||
|
{joins.map((j) => (
|
||||||
|
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||||
|
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
|
||||||
|
<span className="dim"> joined </span>
|
||||||
|
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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} guilds · {totalMembers.toLocaleString()} members
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="input sans"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Search guilds…"
|
||||||
|
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match “{q}”.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
client/src/routes/public/Houses.jsx
Normal file
156
client/src/routes/public/Houses.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
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'
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Houses() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.houses())
|
||||||
|
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
|
||||||
|
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.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) {
|
||||||
|
map.delete(ev.serial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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])
|
||||||
|
|
||||||
|
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." />
|
||||||
|
<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'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the house registry 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useShardFeed } from '../../lib/useShardFeed.js'
|
|||||||
import { describe } from '../../lib/shardEvents.js'
|
import { describe } from '../../lib/shardEvents.js'
|
||||||
import { ago } from '../../lib/format.js'
|
import { ago } from '../../lib/format.js'
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
|
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
||||||
|
|
||||||
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||||
function Sparkline({ series }) {
|
function Sparkline({ series }) {
|
||||||
@@ -108,12 +109,16 @@ export default function Shard() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Stat tiles */}
|
{/* Stat tiles */}
|
||||||
<section className="grid-3" style={{ gap: 14, marginBottom: 24 }}>
|
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
|
||||||
<Stat value={status?.onlineCount ?? '—'} label="Players online" />
|
|
||||||
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
||||||
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Live players-online breakdown (total + region buckets) */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<PlayersOnline />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Staff online — linked staff accounts only, with location */}
|
{/* Staff online — linked staff accounts only, with location */}
|
||||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user