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 ( + + ) +} + +// 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 */}