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:
2026-07-17 12:33:57 -05:00
parent 080478c4a1
commit e9aa19a83d
10 changed files with 711 additions and 2 deletions

View 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) }
}

View 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 }
}