diff --git a/client/src/App.jsx b/client/src/App.jsx
index 3b2e99a..66f2881 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -19,6 +19,9 @@ import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.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 WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
@@ -84,6 +87,9 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
+ } />
} />
} />
{/* CMS pages: top-level /:slug, matched only after the named routes
diff --git a/client/src/api/client.js b/client/src/api/client.js
index a5c36a3..6472abf 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -95,6 +95,13 @@ export const api = {
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
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
// fetch-only, so SSE subscribers build the URL from here. The admin stream
diff --git a/client/src/components/PlayersOnline.jsx b/client/src/components/PlayersOnline.jsx
new file mode 100644
index 0000000..2b2194a
--- /dev/null
+++ b/client/src/components/PlayersOnline.jsx
@@ -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 (
+
+
+
+ Players online
+
+
+ {loading ? '—' : total}
+
+
+
+ {error && (
+
+ Population is unavailable right now.
+
+ )}
+
+ {!loading && !error && (
+
+ {rows.length === 0 ? (
+
+ {total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
+
+ ) : (
+ rows.map((r) => (
+
+ {r.label}
+ {/* tabular figures keep the right-aligned counts in a clean column */}
+ {r.count}
+
+ ))
+ )}
+
+ )}
+
+ )
+}
diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx
index 65b59aa..eeef44b 100644
--- a/client/src/components/SiteHeader.jsx
+++ b/client/src/components/SiteHeader.jsx
@@ -13,6 +13,9 @@ const NAV = [
{ label: 'Wiki', to: '/wiki' },
{ label: 'Shard', to: '/site/shard' },
{ 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' },
]
diff --git a/client/src/data/cityCrests.js b/client/src/data/cityCrests.js
new file mode 100644
index 0000000..d9bff1b
--- /dev/null
+++ b/client/src/data/cityCrests.js
@@ -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) }
+}
diff --git a/client/src/data/regionBuckets.js b/client/src/data/regionBuckets.js
new file mode 100644
index 0000000..7d901af
--- /dev/null
+++ b/client/src/data/regionBuckets.js
@@ -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 }
+}
diff --git a/client/src/routes/public/Governors.jsx b/client/src/routes/public/Governors.jsx
new file mode 100644
index 0000000..bfdec52
--- /dev/null
+++ b/client/src/routes/public/Governors.jsx
@@ -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 (
+
+ {c.sigil}
+
+ )
+}
+
+// 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 (
+
+
+ {open && (
+
+ {loading &&
Loading…
}
+ {error &&
Could not load history.
}
+ {data && data.length === 0 && (
+
No recorded terms yet.
+ )}
+ {data && data.length > 0 && (
+
+ {data.map((t, i) => (
+ -
+
+ {t.governor?.name || 'Vacant'}
+
+
+ {fmtDate(t.startedAt)}{t.endedAt ? ` – ${fmtDate(t.endedAt)}` : ' – present'}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+}
+
+function CityCard({ c }) {
+ const phase = PHASE[c.electionPhase] || null
+ const gov = c.governor
+ return (
+
+
+
+
+
+
+ {crestFor(c.city).label || c.city}
+
+ {phase && (
+
+ {phase.label}
+
+ )}
+
+
+ {gov ? (
+ <>Governor {gov.name}>
+ ) : (
+ 'Seat vacant'
+ )}
+
+
+
+
+ {c.electionPhase && c.electionPhase !== 'none' && (
+
+ {c.candidates ? `${c.candidates} candidate${c.candidates === 1 ? '' : 's'}` : 'No candidates yet'}
+ {c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
+
+ )}
+
+
+
+ )
+}
+
+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 (
+
+
+
+
+
+
+ {connected ? 'Live' : 'Offline'}
+
+
+
+ {loading &&
}
+ {error &&
}
+
+ {!loading && !error && (
+ <>
+ {board.length === 0 ? (
+
+
+ City Loyalty governance is not enabled on this shard.
+
+
+ ) : (
+
+ {board.map((c) => )}
+
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/client/src/routes/public/Guilds.jsx b/client/src/routes/public/Guilds.jsx
new file mode 100644
index 0000000..a91b7ef
--- /dev/null
+++ b/client/src/routes/public/Guilds.jsx
@@ -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 —
+ return {leader.name}
+}
+
+function GuildRow({ g }) {
+ return (
+
+
+
+ {g.abbr && (
+
+ {g.abbr}
+
+ )}
+
+ {g.name || 'A guild'}
+
+
+ {g.alliance && (
+
+ {g.alliance}
+
+ )}
+
+
+
+ {g.online ?? 0}
+ / {g.members ?? 0}
+
+
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+
+
+
+ {connected ? 'Live' : 'Offline'}
+
+
+
+ {loading &&
}
+ {error &&
}
+
+ {!loading && !error && (
+ <>
+ {board.length === 0 ? (
+
+ No guilds are being tracked right now.
+
+ ) : (
+ <>
+ {joins.length > 0 && (
+
+
+ Recently joined
+
+
+ {joins.map((j) => (
+
+ {j.who.name}
+ joined
+ {j.abbr ? `[${j.abbr}] ` : ''}{j.name}
+
+ ))}
+
+
+ )}
+
+
+
+ {board.length} guilds · {totalMembers.toLocaleString()} members
+
+
setQ(e.target.value)}
+ placeholder="Search guilds…"
+ style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
+ />
+
+
+
+ {filtered.map((g) => )}
+
+ {filtered.length === 0 && (
+
No guilds match “{q}”.
+ )}
+ >
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/client/src/routes/public/Houses.jsx b/client/src/routes/public/Houses.jsx
new file mode 100644
index 0000000..223c9c4
--- /dev/null
+++ b/client/src/routes/public/Houses.jsx
@@ -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 (
+
+ {label}
+
+ )
+}
+
+// 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 (
+
+
+
+
+ {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 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 (
+
+
+
+
+
+
+ {connected ? 'Live' : 'Offline'}
+
+
+
+ {loading &&
}
+ {error &&
}
+
+ {!loading && !error && (
+ <>
+ {board.length === 0 ? (
+
+ No houses are being tracked right now.
+
+ ) : (
+ <>
+
+
+ {board.length.toLocaleString()} houses
+
+
setQ(e.target.value)}
+ placeholder="Search by owner, region…"
+ style={{ flex: 'none', width: 210, maxWidth: '55%', fontSize: '0.84rem' }}
+ />
+
+
+ {filtered.map((h) => )}
+
+ {filtered.length === 0 && (
+
No houses match “{q}”.
+ )}
+ >
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx
index 1f6ea5c..431df8b 100644
--- a/client/src/routes/public/Shard.jsx
+++ b/client/src/routes/public/Shard.jsx
@@ -7,6 +7,7 @@ import { useShardFeed } from '../../lib/useShardFeed.js'
import { describe } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import { api } from '../../api/client.js'
+import PlayersOnline from '../../components/PlayersOnline.jsx'
// ── Gold-supply sparkline ───────────────────────────────────────────────────
function Sparkline({ series }) {
@@ -108,12 +109,16 @@ export default function Shard() {
{/* Stat tiles */}
-
-
+
+ {/* Live players-online breakdown (total + region buckets) */}
+
+
{/* Staff online — linked staff accounts only, with location */}