diff --git a/client/src/App.jsx b/client/src/App.jsx
index 6310104..5ce1173 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -18,18 +18,6 @@ import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
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 Rules from './routes/public/Rules.jsx'
-import Atlas from './routes/public/Atlas.jsx'
-import AtlasCreature from './routes/public/AtlasCreature.jsx'
-import Leaderboards from './routes/public/Leaderboards.jsx'
-import Market from './routes/public/Market.jsx'
-import MarketVendor from './routes/public/MarketVendor.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
@@ -49,17 +37,10 @@ import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
-import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
-import ShardVisibility from './routes/admin/views/ShardVisibility.jsx'
-import SpawnAtlasAdmin from './routes/admin/views/SpawnAtlas.jsx'
-import ShardOps from './routes/admin/views/ShardOps.jsx'
-import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
-import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
-import HousesAdmin from './routes/admin/views/HousesAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
@@ -71,9 +52,7 @@ import PlayerRegister from './routes/player/PlayerRegister.jsx'
import ForgotPassword from './routes/player/ForgotPassword.jsx'
import ResetPassword from './routes/player/ResetPassword.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
-import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
-import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
-import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
+import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
@@ -110,18 +89,6 @@ export default function App() {
} />
} />
} />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
} />
} />
{/* Installed modules' public pages, namespaced `//…` — the
@@ -198,27 +165,6 @@ export default function App() {
} />
} />
} />
- } />
- } />
- } />
-
-
-
- }
- />
-
-
-
- }
- />
- } />
- } />
} />
} />
} />
@@ -253,8 +199,11 @@ export default function App() {
}
>
- } />
- } />
+ {/* The portal index resolves to the first nav row this viewer can
+ reach rather than naming a page: `PlayerCharacters` was a UO
+ page and left with the client half (MODULE_SYSTEM.md §2.7.1).
+ With the UO module installed that is still Characters. */}
+ } />
} />
} />
{/* Installed modules' player-portal pages, at /player//…. This
diff --git a/client/src/components/CharacterSheet.jsx b/client/src/components/CharacterSheet.jsx
deleted file mode 100644
index 58c8fb4..0000000
--- a/client/src/components/CharacterSheet.jsx
+++ /dev/null
@@ -1,293 +0,0 @@
-// Reusable character-sheet renderer for the char.profile shape returned by
-// /public/shard/char/:serial. Presentational only — the parent handles loading
-// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
-//
-// `moderation` opts in the in-game kick/ban controls for the character's account;
-// they self-gate to staff (ShardAccountActions), so passing it from a page a
-// player can reach is safe.
-
-import ShardAccountActions from './ShardAccountActions.jsx'
-
-const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
-
-// What to call an equipped item.
-//
-// Items on the wire carry a `LabelNumber`, not a name, so this used to be able
-// to show nothing but the layer and `id 12345`. The server now resolves the
-// cliloc against its own table and attaches `clilocName` (see
-// docs/website/CLILOCS.md); a shard with no cliloc file configured sends none,
-// and the layer fallback below is exactly what the sheet did before.
-//
-// A player-given `name` outranks the resolved type name — "Bob's lucky axe"
-// should not be relabelled "hatchet" — and the server applies the same
-// precedence, so this only re-states it for a profile that arrived with both.
-const itemName = (it) => it.name || it.clilocName || it.layer || 'Item'
-
-// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
-// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
-// literal string.
-//
-// `rewardResolved` is the server's parallel array with the numeric entries turned
-// into words (null where the cliloc table had nothing, or is not configured at
-// all). Prefer it, and keep the literal-only path as the fallback for a profile
-// served before the cliloc table existed — a numeric entry with no resolution is
-// still skipped rather than shown as a raw number.
-function displayTitles(titles) {
- if (!titles) return []
- const out = []
- if (titles.fameKarma) out.push(titles.fameKarma)
- if (titles.skill) out.push(titles.skill)
- const raw = Array.isArray(titles.reward) ? titles.reward : []
- const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null
- const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r)))
- const sel = typeof titles.selected === 'number' ? titles.selected : -1
- // Prefer the selected reward title; fall back to the first one that resolved.
- // The `??` matters: a selected title whose cliloc did not resolve must fall
- // through to the fallback rather than suppress the chip entirely.
- const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean)
- if (candidate) out.push(String(candidate))
- return [...new Set(out.filter(Boolean))]
-}
-
-// The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system
-// the character actually holds a score in. Systems at zero are omitted by the
-// shard, so an empty list means "this character has earned nothing anywhere",
-// which is a normal state for a new character and renders as nothing at all.
-//
-// `nameString` may be null when the system's name is a cliloc; fall back to
-// humanising the PointsType key, exactly as the leaderboards page does. `rank` is
-// absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and
-// "unranked" are different, so the chip only appears when it was actually sent.
-const humanisePoints = (key) =>
- String(key || '')
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
- .replace(/^./, (c) => c.toUpperCase())
-
-function PointsRow({ entry }) {
- const label = entry.nameString || humanisePoints(entry.system)
- const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0
- const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0
-
- return (
-
- {/* Only systems with a real cap get a bar; an uncapped score has nothing to
- be a fraction of, and a full-width bar would imply completion. */}
- {max > 0 && (
-
- {equipment.map((it) => {
- const label = itemName(it)
- const layer = it.layer || 'Item'
- // The layer only earns its own line once the headline is a real
- // name; when it IS the headline, repeating it is just noise.
- const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null]
- return (
-
- )
-}
diff --git a/client/src/components/CharacterStats.jsx b/client/src/components/CharacterStats.jsx
deleted file mode 100644
index 12142ed..0000000
--- a/client/src/components/CharacterStats.jsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { useEffect, useState } from 'react'
-
-// A small stat-tile row for a "My Characters" page: total characters, how many
-// are online right now, and how many game accounts are linked. `scope` is the
-// shard api object (admin or player self-service). Renders nothing until an
-// account is linked, so the empty/link-prompt state below it stands alone.
-//
-// It fetches the same rosters GameAccounts loads; for a personal page that's at
-// most a couple of extra live round-trips, and keeps this presentational bit
-// decoupled from GameAccounts' per-account roster loading.
-
-function Tile({ value, label }) {
- return (
-
-
{value}
-
- {label}
-
-
- )
-}
-
-// Fold the settled roster results into totals. `complete` is false when any
-// account's roster failed (a partial result — shown as a dash rather than a
-// misleadingly low count).
-function summarizeRosters(rosters) {
- let chars = 0
- let online = 0
- let complete = true
- for (const r of rosters) {
- if (r.status !== 'fulfilled') {
- complete = false
- continue
- }
- const cs = r.value.chars || []
- chars += cs.length
- online += cs.filter((c) => c.online).length
- }
- return { chars, online, complete }
-}
-
-export default function CharacterStats({ scope }) {
- const [stats, setStats] = useState(null)
-
- useEffect(() => {
- let cancelled = false
- ;(async () => {
- try {
- const accounts = await scope.accounts()
- const linked = accounts.length
- if (linked === 0) {
- if (!cancelled) setStats({ linked: 0 })
- return
- }
- // Roster is a live round-trip and can be unavailable (503); tolerate a
- // partial result so a restarting shard doesn't blank the whole row.
- const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
- if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) })
- } catch {
- if (!cancelled) setStats({ error: true })
- }
- })()
- return () => { cancelled = true }
- }, [scope])
-
- // Hidden until we know an account is linked (or while first loading).
- if (!stats || stats.error || stats.linked === 0) return null
-
- // Counts depend on live rosters; show a dash if none came back.
- const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
-
- return (
-
-
-
-
-
- )
-}
diff --git a/client/src/components/CreateGameAccountForm.jsx b/client/src/components/CreateGameAccountForm.jsx
deleted file mode 100644
index 7b045f6..0000000
--- a/client/src/components/CreateGameAccountForm.jsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { useState } from 'react'
-
-// Reusable "create a game account" form (its own username + password — the game
-// client credentials, distinct from the website login). Calls `submit(account,
-// password)` which should POST /player/shard/account; on success calls onCreated.
-// Used by the player portal (self-serve) and the invite-accept page alike.
-export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
- const [account, setAccount] = useState('')
- const [password, setPassword] = useState('')
- const [busy, setBusy] = useState(false)
- const [msg, setMsg] = useState('')
- const [error, setError] = useState('')
-
- async function onSubmit(e) {
- e.preventDefault()
- setMsg(''); setError('')
- if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
- return setError('Account name must be 3–30 letters, numbers, . _ or -.')
- }
- if (password.length < 8) return setError('Password must be at least 8 characters.')
- setBusy(true)
- try {
- await submit(account, password)
- setMsg(`Game account “${account}” created and linked.`)
- setAccount(''); setPassword('')
- if (onCreated) await onCreated()
- } catch (err) {
- if (err.status === 409) setError('That account name is already taken.')
- else if (err.status === 429) setError('The account limit for your network has been reached.')
- else if (err.status === 403) setError('Game-account signup is not available right now.')
- else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
- else setError(err.message || 'Could not create the account right now.')
- } finally {
- setBusy(false)
- }
- }
-
- return (
-
- )
-}
diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx
deleted file mode 100644
index ef9d2ed..0000000
--- a/client/src/components/GameAccounts.jsx
+++ /dev/null
@@ -1,231 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
-import { Loading, ErrorState } from './PageState.jsx'
-import ShardAccountActions from './ShardAccountActions.jsx'
-import CreateGameAccountForm from './CreateGameAccountForm.jsx'
-import { api } from '../api/client.js'
-
-// Shared game-account linking + character roster, used by both the player portal
-// (/player) and the staff account page (/admin/account). `scope` is the api
-// object with { link, accounts, roster } (player or admin self-service); `charTo`
-// maps a serial to the route for that character's sheet. `readOnly` drops the
-// link forms and self-voice copy for the admin case where staff view *another*
-// user's accounts (no `scope.link`) at /admin/users/:id.
-
-function LinkForm({ scope, onLinked, compact }) {
- const [code, setCode] = useState('')
- const [busy, setBusy] = useState(false)
- const [msg, setMsg] = useState('')
- const [error, setError] = useState('')
-
- async function submit(e) {
- e.preventDefault()
- setMsg(''); setError('')
- if (!code.trim()) return
- setBusy(true)
- try {
- const { account } = await scope.link(code.trim())
- setMsg(`Linked ${account}.`)
- setCode('')
- await onLinked()
- } catch (err) {
- setError(err.message || 'Could not link that code.')
- } finally {
- setBusy(false)
- }
- }
-
- return (
-
- )
-}
-
-function AccountRoster({ scope, account, charTo }) {
- const [roster, setRoster] = useState(null)
- const [error, setError] = useState('')
- const [unavailable, setUnavailable] = useState(false)
-
- const load = useCallback(async () => {
- setError(''); setUnavailable(false)
- try {
- setRoster(await scope.roster(account))
- } catch (err) {
- if (err.status === 503) setUnavailable(true)
- else setError(err.message || 'Could not load this account.')
- }
- }, [scope, account])
- useEffect(() => { load() }, [load])
-
- if (unavailable) {
- return (
-
-
The game server is restarting — try again shortly.
- {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/ShardAccountActions.jsx b/client/src/components/ShardAccountActions.jsx
deleted file mode 100644
index 3db37c2..0000000
--- a/client/src/components/ShardAccountActions.jsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { useState } from 'react'
-import { useAuth } from '../contexts/AuthContext.jsx'
-import { api } from '../api/client.js'
-
-// Compact in-game moderation controls (kick / ban / unban) scoped to a single
-// game account. Reused wherever a linked account or character is shown to staff:
-// the admin user-detail account list and the character sheet. Self-gates on role
-// (admin/moderator) so it is safe to render inside components that players also
-// see — a player never gets the controls, and the API enforces the same gate.
-//
-// `actor` is stamped server-side from the session; nothing here sends it. Kick is
-// reversible (they reconnect) so it acts immediately; Ban reveals an inline
-// confirm with an optional duration + reason before it fires.
-export default function ShardAccountActions({ account, style }) {
- const { user } = useAuth()
- const [busy, setBusy] = useState('')
- const [ok, setOk] = useState('')
- const [err, setErr] = useState('')
- const [banOpen, setBanOpen] = useState(false)
- const [durationSec, setDurationSec] = useState('')
- const [reason, setReason] = useState('')
-
- // Only staff who can actually use the write plane see the controls.
- if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
-
- async function run(label, fn, done) {
- setBusy(label); setOk(''); setErr('')
- try {
- const r = await fn()
- setOk(done(r))
- } catch (e) {
- setErr(e.message || 'Action failed.')
- } finally {
- setBusy('')
- }
- }
-
- const kick = () =>
- run('kick', () => api.admin.shardOps.kick({ account }), (r) => {
- const n = r && r.sessions != null ? r.sessions : null
- const plural = n === 1 ? '' : 's'
- const sessions = n != null ? ` (${n} session${plural})` : ''
- return `Kicked${sessions}.`
- })
- const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
- const ban = () =>
- run('ban', () =>
- api.admin.shardOps.ban({
- account,
- durationSec: durationSec === '' ? undefined : Number(durationSec),
- reason: reason.trim() || undefined,
- }),
- () => {
- setBanOpen(false)
- const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
- return `Banned${when}.`
- })
-
- const btn = { fontSize: '0.72rem', padding: '4px 10px' }
-
- return (
-
-
-
-
-
- {ok && {ok}}
- {err && {err}}
-
-
- {banOpen && (
-
-
-
-
-
- )}
-
- )
-}
diff --git a/client/src/components/ShardStatusLink.jsx b/client/src/components/ShardStatusLink.jsx
deleted file mode 100644
index ffaea4c..0000000
--- a/client/src/components/ShardStatusLink.jsx
+++ /dev/null
@@ -1,24 +0,0 @@
-// ── Core's fill for the `site.footer.status` extension slot ────────────────
-//
-// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the contract is
-// MODULE_API.md §3.7.
-//
-// This is the whole of what used to be four lines inline in SiteFooter.jsx, and
-// it is a file now for one reason: `/site/shard` is a UO page, so the link goes
-// when the client half goes, and core should be deleting a registration rather
-// than editing its footer under extraction pressure.
-//
-// Note what core kept and what it handed over. Core owns the position in the row
-// and the separator around it, and passes `linkStyle` so the row stays visually
-// one row. The label, the destination, and the decision to render at all are
-// this file's — which is exactly the division a module inherits.
-
-import { Link } from 'react-router-dom'
-
-export default function ShardStatusLink({ linkStyle }) {
- return (
-
- Shard Status
-
- )
-}
diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx
index 3cc52bf..ecffa08 100644
--- a/client/src/components/SiteHeader.jsx
+++ b/client/src/components/SiteHeader.jsx
@@ -13,13 +13,12 @@ import { useFeatureGate } from '../modules/features.jsx'
// One consistent top nav for the whole public site. Every page gets the same
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
//
-// Entries carrying a `feature` are surfaces an admin can disable or gate to a
-// higher audience (Admin -> Shard Visibility). They are hidden when this viewer
-// can't reach them, so we never render a link that would 403. The gate itself is
-// server-side; this is only about not advertising a dead end. Which module
-// answers for a given flag is the registry's business now, not this file's —
-// core registers `useShardFlags` for the ten below and Phase 3 hands them over
-// (modules/featureGate.js).
+// A row may carry a `feature`, naming a surface an installed module can disable
+// or gate to a higher audience; it is hidden when this viewer cannot reach it,
+// so we never render a link that would 403. No CORE row carries one today — the
+// nine that did were UO and left with the client half in slice 3 — but the gate
+// is not dead code: a module's rows join this list and bring their own flags,
+// resolved by the module that registered them (modules/featureGate.js).
//
// Exported because Admin -> Navigation edits this list. It stays declared here,
// with this component as its owner: the editor may only relabel, reorder and
@@ -33,15 +32,6 @@ export const NAV = [
{ label: 'Five on Friday', to: '/site/five-on-friday' },
{ label: 'Newsletter', to: '/site/newsletter' },
{ label: 'Wiki', to: '/wiki' },
- { label: 'Shard', to: '/site/shard', feature: 'status' },
- { label: 'Champions', to: '/site/champs', feature: 'champs' },
- { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
- { label: 'Governors', to: '/site/governors', feature: 'governors' },
- { label: 'Houses', to: '/site/houses', feature: 'houses' },
- { label: 'Rules', to: '/site/rules', feature: 'ruleset' },
- { label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
- { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
- { label: 'Market', to: '/site/market', feature: 'market' },
{ label: 'About', to: '/site/about' },
]
diff --git a/client/src/components/VendorSales.jsx b/client/src/components/VendorSales.jsx
deleted file mode 100644
index 06c4345..0000000
--- a/client/src/components/VendorSales.jsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { useEffect, useState } from 'react'
-import { ago } from '../lib/format.js'
-
-// Owner-private recent player-vendor sales. `fetchSales` is the scope method
-// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
-// sales for accounts linked to the caller.
-export default function VendorSales({ fetchSales }) {
- const [sales, setSales] = useState(null)
- const [error, setError] = useState('')
-
- useEffect(() => {
- let active = true
- fetchSales()
- .then((rows) => active && setSales(rows))
- .catch(() => active && setError('Could not load your vendor sales.'))
- return () => { active = false }
- }, [fetchSales])
-
- if (error) return null
- if (!sales) return null
-
- return (
-
-
- )}
-
- )
-}
diff --git a/client/src/data/cityCrests.js b/client/src/data/cityCrests.js
deleted file mode 100644
index d9bff1b..0000000
--- a/client/src/data/cityCrests.js
+++ /dev/null
@@ -1,31 +0,0 @@
-// 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
deleted file mode 100644
index ffbc1dd..0000000
--- a/client/src/data/regionBuckets.js
+++ /dev/null
@@ -1,72 +0,0 @@
-// 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.
-
-// Named cities/towns, matched as a prefix on the (space/apostrophe-stripped)
-// region name so "skara brae", "serpent's hold", etc. all resolve. Kept as a
-// list rather than one giant alternation regex (simpler to read and retune).
-const TOWN_PREFIXES = [
- 'moonglow', 'minoc', 'trinsic', 'jhelom', 'yew', 'skarabrae', 'magincia',
- 'newmagincia', 'vesper', 'nujelm', 'cove', 'ocllo', 'serpenthold', 'serpentshold',
- 'wind', 'delucia', 'papua',
-]
-const normalizeRegion = (r) => String(r).toLowerCase().replace(/['’\s]/g, '')
-
-// 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) => {
- const norm = normalizeRegion(r)
- return TOWN_PREFIXES.some((t) => norm.startsWith(t))
- },
- },
- {
- 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/lib/adminNav.js b/client/src/lib/adminNav.js
index b6c6fb7..67a397a 100644
--- a/client/src/lib/adminNav.js
+++ b/client/src/lib/adminNav.js
@@ -62,3 +62,39 @@ export function isAllowedPath(pathname, allowed) {
exact ? pathname === to : pathname === to || pathname.startsWith(`${to}/`),
)
}
+
+/**
+ * The first place in this nav a viewer with this role can actually go.
+ *
+ * Added in Phase 3 slice 3, for the player portal, whose index route was
+ * `PlayerCharacters` — a UO page. When it left, `/player` had nothing behind it,
+ * and the three ways out were: redirect somewhere fixed, invent a core landing
+ * page, or resolve the index from the nav the viewer already has. This is the
+ * third, and it is the only one that keeps today's behaviour — with the module
+ * installed the first row is still Characters, so a player still lands on their
+ * characters after signing in, and with nothing installed they land on Account.
+ *
+ * **From the BASE nav, never the override-merged one**, the same rule
+ * `allowedPathsFor` follows and for a sharper version of the same reason: an
+ * override is presentation, and a landing page is behaviour. An admin reordering
+ * the sidebar must not silently change where everybody arrives, and — more to
+ * the point — must not be able to move it somewhere a role cannot follow.
+ *
+ * Deliberately generic, and deliberately in this file rather than in the portal
+ * layout. The admin area has the same shape of question (its index is a
+ * hardcoded Dashboard), and the direction of travel is one logged-in area that
+ * shows the right things for the viewer's permissions rather than two that
+ * duplicate each other. When that happens this is the function it needs, and it
+ * already answers for both nav shapes.
+ *
+ * @param {Array} baseNav flat or grouped, before overrides
+ * @param {string} role
+ * @param {string} fallback where to go when the viewer can see nothing at all
+ */
+export function firstDestinationFor(baseNav, role, fallback) {
+ const items = (Array.isArray(baseNav) ? baseNav : []).flatMap((entry) =>
+ entry && Array.isArray(entry.items) ? entry.items : [entry],
+ )
+ const first = items.find((item) => item && item.to && navItemVisibleTo(item, role))
+ return first ? first.to : fallback
+}
diff --git a/client/src/lib/shardEvents.js b/client/src/lib/shardEvents.js
deleted file mode 100644
index 5ad5786..0000000
--- a/client/src/lib/shardEvents.js
+++ /dev/null
@@ -1,128 +0,0 @@
-// Shared formatting for shard events — used by the public Shard page, the
-// Activity feed, and the admin live feed. One place decides how each kind reads
-// and which category/badge it belongs to.
-
-function nameOf(who) {
- if (!who) return 'Someone'
- if (typeof who === 'string') return who
- return who.name || who.acct || 'Someone'
-}
-
-const n = (v) => Number(v || 0).toLocaleString()
-
-// A one-line human description of each event kind, keyed by kind. Each formatter
-// takes the payload and returns a string. Conditional suffixes are pulled into
-// locals so no template literal is nested inside another.
-const DESCRIBERS = {
- 'vendor.sale': (p) => {
- const qty = p.amount > 1 ? ` ×${p.amount}` : ''
- return `${p.itemType || 'An item'}${qty} sold for ${n(p.price)}gp`
- },
- 'player.death': (p) => {
- const by = p.killer ? ` by ${nameOf(p.killer)}` : ''
- return `${nameOf(p.who)} was slain${by}`
- },
- 'player.murdered': (p) => {
- const by = p.murderer ? ` by ${nameOf(p.murderer)}` : ''
- return `${nameOf(p.victim)} was murdered${by}`
- },
- 'mob.killed': (p) => `${nameOf(p.killer)} killed ${nameOf(p.killed)}`,
- 'skill.gain': (p) => {
- const base = p.base != null ? ` (${p.base})` : ''
- return `${nameOf(p.who)} gained ${p.skill}${base}`
- },
- 'fame.change': (p) => `${nameOf(p.who)}’s fame changed to ${n(p.new)}`,
- 'karma.change': (p) => `${nameOf(p.who)}’s karma changed to ${n(p.new)}`,
- 'quest.complete': (p) => `${nameOf(p.who)} completed “${p.quest}”`,
- 'house.decay': (p) => {
- const region = p.region ? ` — ${p.region}` : ''
- return `${p.name || 'A house'} is now ${p.to || p.stage}${region}`
- },
- 'mob.login': (p) => `${nameOf(p.who)} entered the world`,
- 'mob.logout': (p) => `${nameOf(p.who)} left the world`,
- 'economy.supply': (p) => `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`,
- 'server.hello': (p) => `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`,
- 'server.shutdown': () => 'Shard shut down',
- 'server.crashed': (p) => {
- const err = p.error ? `: ${p.error}` : ''
- return `Shard crashed${err}`
- },
- 'champ.update': (p) => {
- const where = p.name || p.type || 'A champion spawn'
- if (p.status === 'active' && p.bossUp) {
- const boss = p.boss ? ` (${p.boss})` : ''
- return `${where}: boss is up${boss}`
- }
- if (p.status === 'active') {
- const level = p.level != null ? ` — level ${p.level}` : ''
- return `${where} is active${level}`
- }
- if (p.status === 'cooldown') return `${where} is on cooldown`
- return `${where} is ${p.status || 'idle'}`
- },
- 'champ.remove': () => `A champion spawn ended`,
- // Support (help-page) queue + in-game moderation (admin channel only)
- 'page.new': (p) => `New ${p.type || 'help'} page from ${nameOf(p.sender)}`,
- 'page.updated': (p) => {
- const claimed = p.handled ? ' (claimed)' : ''
- return `Help page from ${nameOf(p.sender)} updated${claimed}`
- },
- 'page.closed': (p) => `Help page ${p.pageId || ''} closed`,
- 'admin.audit': (p) => {
- const on = p.target ? ` on ${p.target}` : ''
- const origin = p.origin ? ` [${p.origin}]` : ''
- return `${p.actor || 'Staff'} ${p.action || 'acted'}${on}${origin}`
- },
- // Staff / sensitive (admin channel only)
- 'audit.set': (p) =>
- `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`,
- 'audit.command': (p) => {
- const args = p.args ? ` ${p.args}` : ''
- return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${args}`
- },
- 'cheat.fastwalk': (p) => {
- const ip = p.ip ? ` (${p.ip})` : ''
- return `Fast-walk flagged: ${nameOf(p.who)}${ip}`
- },
- 'account.login.attempt': (p) => {
- const ip = p.ip ? ` from ${p.ip}` : ''
- return `Login attempt: ${p.acct}${ip}`
- },
- 'gold.change': (p) => {
- const sign = p.delta >= 0 ? '+' : ''
- return `${p.acct}: gold ${sign}${n(p.delta)} → ${n(p.new)}`
- },
-}
-
-// A one-line human description of an event. Accepts either a stored event
-// (with .payload) or a raw live frame (fields at top level).
-export function describe(ev) {
- const fmt = DESCRIBERS[ev.kind]
- return fmt ? fmt(ev.payload || ev) : ev.kind
-}
-
-// Category grouping for the filter tabs.
-// Vendor sales are intentionally NOT a public category — they are owner-private
-// (a linked player sees their own under the portal). The admin live feed still
-// describes vendor.sale via describe() below.
-export const CATEGORIES = [
- { id: 'all', label: 'All', kinds: null },
- { id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
- { id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
- { id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
-]
-
-const CATEGORY_OF = (() => {
- const m = {}
- for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
- return m
-})()
-
-export function categoryOf(kind) {
- return CATEGORY_OF[kind] || 'other'
-}
-
-// Short badge label for a kind (the part after the dot, title-cased-ish).
-export function kindLabel(kind) {
- return String(kind || '').replace(/[._]/g, ' ')
-}
diff --git a/client/src/lib/useShardFeatures.js b/client/src/lib/useShardFeatures.js
deleted file mode 100644
index 6e1469a..0000000
--- a/client/src/lib/useShardFeatures.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import { useEffect, useState } from 'react'
-import { api } from '../api/client.js'
-
-// Which shard surfaces the current viewer may reach, from
-// GET /public/shard/features. Admins configure this per feature (Admin → Shard
-// Visibility), so the nav can't be a static list any more.
-//
-// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
-// and an out-of-rung one 403s whether or not the link is rendered. So while the
-// answer is still in flight we return `null` and callers show their default set
-// — better a link that briefly 403s than a nav that flickers in on every load.
-//
-// Cached module-level: the answer is per-viewer but stable for a session, and
-// every consumer would otherwise refetch it on mount.
-let cached = null
-let inFlight = null
-
-export function resetShardFeatures() {
- cached = null
- inFlight = null
-}
-
-export function useShardFeatures() {
- const [features, setFeatures] = useState(cached)
-
- useEffect(() => {
- if (cached) return undefined
- let alive = true
- inFlight =
- inFlight ||
- api.shard
- .features()
- .then((data) => {
- cached = { level: data.level, set: new Set(data.features || []) }
- return cached
- })
- .catch(() => {
- // A failed lookup must not blank the nav — fall back to "show
- // everything" and let the server do the gating.
- cached = null
- inFlight = null
- return null
- })
- inFlight.then((result) => {
- if (alive) setFeatures(result)
- })
- return () => {
- alive = false
- }
- }, [])
-
- return features
-}
-
-// Convenience: true when `name` is visible, or when we don't know yet.
-export function canSee(features, name) {
- return !features || features.set.has(name)
-}
-
-// The same answer in the shape core's generic feature seam takes: a Set-like of
-// the flags this viewer may see, or null while we do not know yet
-// (modules/featureGate.js). Core registers THIS as the provider for the `uo`
-// namespace (main.jsx), so the ten shard-gated rows in the public header are
-// already resolved through the module seam rather than beside it — when Phase 3
-// moves those rows into the module, the registration moves with this file and
-// core is left with nothing to delete.
-export function useShardFlags() {
- const features = useShardFeatures()
- return features ? features.set : null
-}
diff --git a/client/src/lib/useShardFeed.js b/client/src/lib/useShardFeed.js
deleted file mode 100644
index 60e9558..0000000
--- a/client/src/lib/useShardFeed.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import { useEffect, useRef, useState } from 'react'
-import { api } from '../api/client.js'
-
-// Subscribe to the public shard live-event SSE stream and keep a rolling buffer
-// of the most recent events. The browser talks to our own /public/shard/stream
-// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token
-// stays server-side and it works through any reverse proxy.
-//
-// EventSource auto-reconnects on drop, so there is no manual retry loop here; a
-// `connected` flag is exposed for a small live/offline indicator. `filter` (a
-// Set of kinds, optional) limits which events are buffered. `max` caps the
-// buffer length.
-export function useShardFeed({ url, filter, max = 40 } = {}) {
- const [events, setEvents] = useState([])
- const [connected, setConnected] = useState(false)
- // Keep the latest filter in a ref so re-renders don't tear down the stream.
- const filterRef = useRef(filter)
- filterRef.current = filter
- const streamUrl = url || api.shardStreamUrl
-
- useEffect(() => {
- // EventSource isn't available during SSR / very old browsers — degrade to
- // "no live feed" rather than throwing.
- if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
-
- const es = new EventSource(streamUrl, { withCredentials: true })
-
- es.onopen = () => setConnected(true)
- es.onerror = () => setConnected(false) // EventSource will retry on its own
-
- es.onmessage = (msg) => {
- let event
- try {
- event = JSON.parse(msg.data)
- } catch {
- return
- }
- if (!event || !event.kind) return
- const f = filterRef.current
- if (f && !f.has(event.kind)) return
- setEvents((prev) => {
- // Tag with a stable-ish local id for React keys (events carry t but can
- // collide within a ms) and cap the buffer.
- const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev]
- return next.slice(0, max)
- })
- }
-
- return () => es.close()
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [max, streamUrl])
-
- return { events, connected }
-}
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index e8e13f0..9f53c38 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -43,7 +43,6 @@ const IconKey = () =>
const IconPulse = () =>
const IconUser = () =>
-const IconShard = () =>
const IconNav = () =>
const IconPalette = () =>
@@ -76,8 +75,6 @@ export const NAV = [
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
- { to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
- { to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
],
},
{
@@ -91,15 +88,11 @@ export const NAV = [
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
- { to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
- { to: '/admin/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
- { to: '/admin/shard-atlas', label: 'Spawn Atlas', icon: IconShard, roles: ['admin'] },
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
],
},
{
items: [
- { to: '/admin/characters', label: 'My Characters', icon: IconShard },
{ to: '/admin/account', label: 'Account', icon: IconUser },
],
},
@@ -136,18 +129,12 @@ const TITLES = {
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals',
- '/admin/shard-ops': 'In-Game Ops',
- '/admin/houses': 'House Registry',
'/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation',
'/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot',
- '/admin/shard': 'Shard (uo-link)',
- '/admin/shard-visibility': 'Shard Visibility',
- '/admin/shard-atlas': 'Spawn Atlas',
- '/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users',
'/admin/invites': 'Invites',
@@ -170,7 +157,6 @@ function moduleTitle(baseNav, pathname) {
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
- if (pathname.startsWith('/admin/characters')) return 'My Characters'
if (pathname.startsWith('/admin/users/')) return 'User'
return 'Admin'
}
diff --git a/client/src/routes/admin/views/AdminCharacter.jsx b/client/src/routes/admin/views/AdminCharacter.jsx
deleted file mode 100644
index f320f6a..0000000
--- a/client/src/routes/admin/views/AdminCharacter.jsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useParams, Link } from 'react-router-dom'
-import { Loading, ErrorState } from '../../../components/PageState.jsx'
-import CharacterSheet from '../../../components/CharacterSheet.jsx'
-import { useAsync } from '../../../lib/useAsync.js'
-import { api } from '../../../api/client.js'
-
-// A staff member's own character sheet inside the admin shell. Owner-checked —
-// the endpoint only returns a sheet for a character on the caller's linked account.
-export default function AdminCharacter() {
- const { serial } = useParams()
- const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
- const restarting = error && error.status === 503
- const forbidden = error && error.status === 403
-
- return (
-
- )}
-
- )
-}
diff --git a/client/src/routes/admin/views/SettingsAdmin.jsx b/client/src/routes/admin/views/SettingsAdmin.jsx
index 583dd86..711d2b3 100644
--- a/client/src/routes/admin/views/SettingsAdmin.jsx
+++ b/client/src/routes/admin/views/SettingsAdmin.jsx
@@ -35,18 +35,6 @@ const FIELDS = [
],
fallback: 'disabled',
},
- {
- key: 'game_account_signup',
- label: 'Game-account creation',
- help: 'Whether players can create a GAME account (for the game client) from the site. The game server’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
- options: [
- { value: 'disabled', label: 'Disabled — link an existing account only' },
- { value: 'website', label: 'Website — the site creates game accounts' },
- { value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
- { value: 'game', label: 'Game only — created in the game client, not the site' },
- ],
- fallback: 'disabled',
- },
]
export default function SettingsAdmin() {
diff --git a/client/src/routes/admin/views/ShardAdmin.jsx b/client/src/routes/admin/views/ShardAdmin.jsx
deleted file mode 100644
index 5179b42..0000000
--- a/client/src/routes/admin/views/ShardAdmin.jsx
+++ /dev/null
@@ -1,248 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { Loading, ErrorState } from '../../../components/PageState.jsx'
-import { useShardFeed } from '../../../lib/useShardFeed.js'
-import { describe, kindLabel } from '../../../lib/shardEvents.js'
-import { ago } from '../../../lib/format.js'
-import { api } from '../../../api/client.js'
-
-// Full live feed from the admin SSE channel — every kind, incl. staff audit,
-// cheat detection and login attempts that the public channel never carries.
-function AdminLiveFeed() {
- const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
- return (
-
-
- Open help pages from players. A reply reaches them in game (or on their next login).
-
- {err && {err}}
- {queueBody}
-
- )
-}
-
-// ── Audit log ────────────────────────────────────────────────────────────────
-// Seeded from the stored admin.audit history, then kept live from the admin SSE
-// channel (which carries every kind — we filter to admin.audit here).
-function AuditLog() {
- const [seed, setSeed] = useState([])
- const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
-
- useEffect(() => {
- api.admin.shardOps
- .audit(50)
- .then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
- .catch(() => setSeed([]))
- }, [])
-
- // Live events on top; fall back to the seed for anything older than the live tail.
- const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
- const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
-
- return (
-
-
Audit log
- {rows.length === 0 ? (
-
No moderation actions recorded yet.
- ) : (
-
- {rows.map((e) => (
-
- {describe(e)}
- {ago(e.t)}
-
- ))}
-
- )}
-
- )
-}
-
-export default function ShardOps() {
- return (
-
-
-
-
-
-
- )
-}
diff --git a/client/src/routes/admin/views/ShardVisibility.jsx b/client/src/routes/admin/views/ShardVisibility.jsx
deleted file mode 100644
index e2de447..0000000
--- a/client/src/routes/admin/views/ShardVisibility.jsx
+++ /dev/null
@@ -1,325 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import { Loading, ErrorState } from '../../../components/PageState.jsx'
-import { api } from '../../../api/client.js'
-
-// ── Admin · Shard visibility ────────────────────────────────────────────────
-//
-// Who may see which shard surface, and which sensitive fields within it.
-// Admin-only, because this decides what ANONYMOUS visitors get.
-//
-// Two things the UI must communicate honestly, because they are not negotiable
-// server-side (see docs/link/v3.md §3.4):
-// • acct / webId are admin-only always and are not listed as editable fields.
-// • an event kind the server doesn't know about never reaches anyone below
-// admin, whatever is set here.
-//
-// Defaults reproduce the behavior the site had before this panel existed, so a
-// fresh install shows "everything as it was" rather than an empty form.
-
-const RUNG_LABEL = {
- anonymous: 'Everyone',
- logged_in: 'Signed in',
- player: 'Linked players',
- staff: 'Staff',
- admin: 'Admins only',
-}
-
-const RUNG_HINT = {
- anonymous: 'Visible to anyone, signed in or not.',
- logged_in: 'Any signed-in account, linked or not.',
- player: 'Accounts with a linked game account. Staff always qualify.',
- staff: 'Admins and moderators.',
- admin: 'Admins only.',
-}
-
-const FEATURE_LABEL = {
- status: 'Shard status',
- activity: 'Activity feed',
- champs: 'Champion spawns',
- guilds: 'Guilds',
- governors: 'Town governors',
- houses: 'Houses / IDOC',
- presence: 'Players online',
- ruleset: 'Shard rules',
- atlas: 'Spawn atlas',
- leaderboards: 'Leaderboards',
- market: 'Marketplace',
-}
-
-const FEATURE_HINT = {
- status: 'Connection state, online count, gold-supply series.',
- activity: 'Deaths, kills, skill gains, quests, logins.',
- champs: 'The live champion / mini-champ / sea-boss board.',
- guilds: 'Guild rosters, alliances and leaders.',
- governors: 'City Loyalty governors, elections and term history.',
- houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
- presence: 'Population aggregate and the staff-online widget.',
- ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
- atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
- leaderboards: 'Point and loyalty standings across every points system.',
- market: 'The shard-wide player-vendor index.',
-}
-
-const FIELD_LABEL = {
- owner: 'House owner',
- price: 'House price',
- location: 'In-game location (map + coordinates)',
- connect: 'Server connect address',
- // Keyed on the WIRE field, which for a leaderboard entry is `name` — the
- // projection matches literal JSON keys, so the rule cannot be spelled after the
- // field's meaning. The label is what carries the meaning to the admin.
- name: 'Character names on leaderboards',
- ownerName: 'Vendor owner name',
- // One rule, one key — `location` is a nested object on both the wire frame and
- // the stored read model precisely so that hiding it takes the facet, the
- // coordinates, the region and the house together.
- ownerSerial: 'Vendor owner character id',
-}
-
-function RungSelect({ value, onChange, ladder, disabled }) {
- return (
-
- )
-}
-
-function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
- const fields = Object.entries(settings.fields || {})
- const changed =
- defaults &&
- (settings.enabled !== defaults.enabled ||
- settings.audience !== defaults.audience ||
- settings.stream !== defaults.stream ||
- JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
-
- return (
-
- Choose who can see each shard surface on the public site, and how much detail they get.
- Turning a feature off hides it entirely — its pages return “not found” rather than
- revealing that it exists. “Live updates” controls whether the feature streams changes in
- real time; the pages still work without it, they just refresh on load.
-
- {lockedFields.length > 0 && (
-
- Not configurable: {lockedFields.join(', ')} —
- game account names and website user ids are never shown below admin, on any surface. They
- aren’t visible in game either, so publishing them would disclose something the shard
- itself doesn’t.
-
- )
-}
diff --git a/client/src/routes/admin/views/SpawnAtlas.jsx b/client/src/routes/admin/views/SpawnAtlas.jsx
deleted file mode 100644
index 87bc852..0000000
--- a/client/src/routes/admin/views/SpawnAtlas.jsx
+++ /dev/null
@@ -1,285 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import { Loading, ErrorState } from '../../../components/PageState.jsx'
-import { api } from '../../../api/client.js'
-
-// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
-//
-// The atlas re-derives itself from the shard's ServUO tree on every boot, so
-// this panel exists for the three things a restart cannot do:
-//
-// • point it at a different tree,
-// • apply a map change without restarting, and
-// • answer a refresh that was parsed but deliberately NOT applied because it
-// would remove a facet.
-//
-// That last one is the reason the panel is worth building. Losing a facet looks
-// exactly like a half-copied or mid-update tree, and boot cannot tell them
-// apart — so it stages the decision for a human instead of guessing. Until
-// someone decides here, the site keeps serving the atlas it already had.
-
-// A refresh reports its outcome rather than throwing (the boot path must never
-// be stopped by a bad tree), so these are answers, not errors — the panel says
-// what happened in the shard's terms instead of showing a failure box.
-const OUTCOME = {
- imported: (r) =>
- `Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`,
- unchanged: (r) =>
- r.reason === 'refresh previously rejected'
- ? 'Unchanged — this exact tree was already reviewed and declined.'
- : 'Unchanged — the tree matches what is already loaded.',
- needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.',
- unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`,
- skipped: () => 'No ServUO path is configured, so there is nothing to import.',
- failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`,
- rejected: () => 'Declined. It will not be offered again until the tree changes.',
-}
-
-const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result)
-
-function Row({ label, children }) {
- return (
-
- {declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
-
-
- {declined ? (
- <>
- This tree was reviewed and declined, so it is not offered again until the files change.
- Approving now applies it anyway.
- >
- ) : (
- <>
- The tree parses cleanly but would remove {pending.removedFacets?.length || 0} facet
-
- {(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That
- is what a half-copied or mid-update tree looks like as well as a real map change, so it was
- not applied. Approving re-parses the tree as it is right now — if you have since fixed the
- mount, what lands is the corrected import.
- >
- )}
-
-
- )
-}
-
-export default function SpawnAtlas() {
- const [status, setStatus] = useState(null)
- const [path, setPath] = useState('')
- const [force, setForce] = useState(false)
- const [loading, setLoading] = useState(true)
- const [busy, setBusy] = useState(false)
- const [error, setError] = useState('')
- const [msg, setMsg] = useState('')
-
- const load = useCallback(async () => {
- setLoading(true)
- setError('')
- try {
- const data = await api.admin.atlas.status()
- setStatus(data)
- setPath(data.path || '')
- } catch (err) {
- setError(err.message || 'Could not load atlas status.')
- } finally {
- setLoading(false)
- }
- }, [])
-
- useEffect(() => {
- load()
- }, [load])
-
- // Every mutating action shares this: run it, report what it said, then reload
- // status so the panel reflects the world rather than what we assumed happened.
- async function run(action, fn) {
- setBusy(true)
- setMsg('')
- setError('')
- try {
- const result = await fn()
- setMsg(describe(result))
- const fresh = await api.admin.atlas.status()
- setStatus(fresh)
- setPath(fresh.path || '')
- } catch (err) {
- setError(err.message || `Could not ${action}.`)
- } finally {
- setBusy(false)
- }
- }
-
- async function savePath() {
- setBusy(true)
- setMsg('')
- setError('')
- try {
- const fresh = await api.admin.atlas.setPath(path.trim())
- setStatus(fresh)
- setPath(fresh.path || '')
- setMsg(
- fresh.path === ''
- ? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
- : fresh.treeReadable
- ? 'Saved. The tree is readable — import when you are ready.'
- : 'Saved, but the tree could not be read from here. Check the mount and permissions.',
- )
- } catch (err) {
- setError(err.message || 'Could not save the path.')
- } finally {
- setBusy(false)
- }
- }
-
- if (loading) return
- if (error && !status) return
-
- const counts = status?.counts || null
-
- return (
-
-
-
- Spawn atlas
-
-
- The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files.
- It refreshes itself on every server start; everything here is for the times you don’t want
- to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not
- shard state, and stays complete while the shard is down.
-
- Where the website reads the shard’s spawn files from — the same host, a bind mount or a
- shared volume. This setting wins over the SERVUO_PATH deploy default, so the
- mount can move without a redeploy. Leave it blank to turn the atlas off.
-
- Applies a map change without restarting. An unchanged tree costs nothing — the source files
- are hashed first and skipped when they match. A refresh that would remove a facet still
- comes back here for approval rather than being applied.
-
-
-
-
-
-
-
- {(msg || error) && (
-
- {msg && {msg}}
- {error && {error}}
-
- )}
-
- )
-}
diff --git a/client/src/routes/admin/views/UserDetail.jsx b/client/src/routes/admin/views/UserDetail.jsx
index 93f9cca..a0caa3a 100644
--- a/client/src/routes/admin/views/UserDetail.jsx
+++ b/client/src/routes/admin/views/UserDetail.jsx
@@ -171,8 +171,9 @@ export default function UserDetail() {
{/* Whatever the installed module has to say about this user, or nothing
- at all. Core's own UO sections fill it today (UserShardSections.jsx,
- registered in main.jsx) — MODULE_API.md §3.7. */}
+ at all — core filled this with its own UO sections until Phase 3 slice
+ 3, and now nothing does unless a module is installed
+ (MODULE_API.md §3.7). */}
)
diff --git a/client/src/routes/admin/views/UserShardSections.jsx b/client/src/routes/admin/views/UserShardSections.jsx
deleted file mode 100644
index aa0890b..0000000
--- a/client/src/routes/admin/views/UserShardSections.jsx
+++ /dev/null
@@ -1,163 +0,0 @@
-// ── Core's fill for the `admin.users.detail` extension slot ────────────────
-//
-// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1. Every section below
-// is UO, and every one of them leaves core with the client half in slice 3 —
-// this file exists so that when they do, core deletes a registration and a file
-// instead of unpicking a page.
-//
-// Core registers it through the same seam a module uses
-// (`registerExtension('core', …)` in main.jsx), which is the client twin of the
-// server's `registries.registerCore()` and the same trick `useShardFlags`
-// already uses for the feature seam. The mechanism is therefore exercised by
-// core's own content from the day it lands, rather than first proved by the
-// change that depends on it.
-//
-// The slot hands over `userId` and nothing else — deliberately, not `scope`.
-// `api.admin.userShard` is a UO binding that leaves core in slice 3, so a slot
-// that passed it would be handing a module something core is about to delete.
-// An extension builds its own client for the routes it registered at the other
-// end (MODULE_API.md §3.5), and this file does exactly what the module will.
-
-import { useMemo } from 'react'
-import { useAsync } from '../../../lib/useAsync.js'
-import { ago } from '../../../lib/format.js'
-import { api } from '../../../api/client.js'
-import CharacterStats from '../../../components/CharacterStats.jsx'
-import GameAccounts from '../../../components/GameAccounts.jsx'
-import VendorSales from '../../../components/VendorSales.jsx'
-
-// Its own copy, not an export from UserDetail.jsx: six lines of presentational
-// furniture that is not in the §3.4 kit, so a module filling this slot would
-// vendor the same thing. Core's copy stays behind with core's own security
-// panel, which is the other caller.
-function SectionTitle({ children }) {
- return (
-
- {children}
-
- )
-}
-
-// Currently-online characters on the user's accounts, with where they are. The
-// per-character Online/Offline badge lives in the roster; this adds location.
-function OnlineNow({ scope }) {
- const { data } = useAsync(() => scope.online(), [scope])
- if (!data) return null
- return (
-
- Online now
- {data.length === 0 ? (
-
- )
-}
diff --git a/client/src/routes/player/PlayerCharacters.jsx b/client/src/routes/player/PlayerCharacters.jsx
deleted file mode 100644
index e8ddb77..0000000
--- a/client/src/routes/player/PlayerCharacters.jsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import GameAccounts from '../../components/GameAccounts.jsx'
-import VendorSales from '../../components/VendorSales.jsx'
-import { useAsync } from '../../lib/useAsync.js'
-import { api } from '../../api/client.js'
-
-// The logged-in player's characters. Shows the link prompt when no game account
-// is linked, otherwise their characters grouped by account (shared component),
-// plus their own home status and recent vendor sales.
-
-const DECAY_TONE = {
- LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
- Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
-}
-
-// The caller's own houses (home status). Only their own — never anyone else's.
-function MyHouses() {
- const { data } = useAsync(() => api.player.shard.houses(), [])
- if (!data || data.length === 0) return null
- return (
-
-
- )
-}
diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx
index 589b95d..d5134d9 100644
--- a/client/src/routes/player/PlayerPortalLayout.jsx
+++ b/client/src/routes/player/PlayerPortalLayout.jsx
@@ -1,10 +1,11 @@
import { useMemo } from 'react'
-import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
+import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
+import { firstDestinationFor } from '../../lib/adminNav.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
@@ -32,29 +33,37 @@ function Icon({ children, size = 16 }) {
)
}
-const IconUser = () =>
const IconGear = () =>
const IconShield = () =>
// Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
-// carries a gate — every player sees all three — but an installed module's rows
-// join this list before the merge and may carry a `feature`, so the filter after
-// it is not dead code.
+// carries a gate — every player sees both — but an installed module's rows join
+// this list before the merge and may carry a `feature`, so the filter after it
+// is not dead code.
+//
+// "Characters" was the first row and left with the client half in slice 3; the
+// UO module registers it again at `/player/uo/characters`, in this position,
+// with `order: 0`.
export const NAV = [
- { to: '/player', label: 'Characters', end: true, icon: IconUser },
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
]
-// The sticky content header mirrors the active page. Character sheets live under
-// /player/char/:serial and keep their own in-page back link.
+// The sticky content header mirrors the active page. A module's pages are not
+// here and cannot be — core does not know what they are called — so they title
+// from their own nav row, the same rule AdminLayout's `moduleTitle` follows.
const TITLES = {
- '/player': 'Characters',
'/account': 'Account',
'/account/appeals': 'Appeals',
}
+function moduleTitle(baseNav, pathname) {
+ return baseNav
+ .filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
+ .sort((a, b) => b.to.length - a.to.length)[0]?.label
+}
+
const navBtnBase = {
textAlign: 'left',
borderRadius: 8,
@@ -80,9 +89,7 @@ export default function PlayerPortalLayout() {
)
const navigate = useNavigate()
const location = useLocation()
- const title =
- TITLES[location.pathname] ||
- (location.pathname.startsWith('/player/char/') ? 'Character' : 'Player Portal')
+ const title = TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || 'Player Portal'
async function signOut() {
await logout()
@@ -182,3 +189,28 @@ export default function PlayerPortalLayout() {
)
}
+
+/**
+ * What `/player` renders.
+ *
+ * It used to be `PlayerCharacters`, a UO page, which left the portal with no
+ * index at all when the client half was extracted (slice 3). Rather than pick a
+ * fixed destination or invent a core landing page, the index resolves to the
+ * first row of the portal nav this viewer can actually reach — so with the UO
+ * module installed a player still arrives at their characters, exactly as
+ * before, and with nothing installed they arrive at Account.
+ *
+ * Resolved from the BASE nav, before overrides: where everybody lands is
+ * behaviour, and an override is presentation (`firstDestinationFor`). `replace`
+ * so the back button leaves the portal rather than bouncing off this redirect.
+ *
+ * The same question exists one area over — the admin index is a hardcoded
+ * Dashboard — and if the two logged-in areas ever become one, this is the shape
+ * that answers for both. Nothing here assumes a portal separate from admin.
+ */
+export function PlayerIndex() {
+ const { user } = useAuth()
+ const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
+ const to = firstDestinationFor(baseNav, user?.role, '/account')
+ return
+}
diff --git a/client/src/routes/public/Atlas.jsx b/client/src/routes/public/Atlas.jsx
deleted file mode 100644
index 049e5af..0000000
--- a/client/src/routes/public/Atlas.jsx
+++ /dev/null
@@ -1,310 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
-import { Link } from 'react-router-dom'
-import PublicLayout from '../../components/PublicLayout.jsx'
-import PageHeader from '../../components/PageHeader.jsx'
-import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
-import { useAsync } from '../../lib/useAsync.js'
-import { api } from '../../api/client.js'
-
-// ── The spawn atlas ─────────────────────────────────────────────────────────
-//
-// What the shard CONTAINS, as opposed to what it is doing: which creatures
-// spawn, where, and which champion altars are configured. There is no live feed
-// here and no `connected` indicator, deliberately — this is parsed from the
-// shard's own files and stays complete while the shard is down.
-//
-// Facet names come from the shard's data, never from a list in this file. A
-// shard running custom maps gets its own names in the filter with no code
-// change (docs/link/v3.md §6.1 R2).
-
-const PAGE = 50
-
-const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
-
-const TABS = [
- { key: 'creatures', label: 'Creatures' },
- { key: 'champions', label: 'Champion altars' },
- { key: 'places', label: 'Places' },
-]
-
-function Chip({ active, onClick, children }) {
- return (
-
- )
-}
-
-function CreatureCard({ creature }) {
- const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
- return (
-
-
-
- )
-}
-
-// The creature list owns its own paging rather than going through useAsync: a
-// "load more" appends to what is already on screen, which a hook that resets to
-// `{ loading: true, data: null }` on every dependency change cannot express.
-function Creatures({ q, facet }) {
- const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
- const [more, setMore] = useState(false)
-
- const load = useCallback(
- async (offset) => {
- const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
- return page
- },
- [q, facet],
- )
-
- useEffect(() => {
- let alive = true
- setState({ loading: true, error: null, items: [], total: 0 })
- load(0)
- .then((page) => {
- if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
- })
- .catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
- return () => {
- alive = false
- }
- }, [load])
-
- const loadMore = async () => {
- setMore(true)
- try {
- const page = await load(state.items.length)
- setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
- } catch {
- // A failed "load more" leaves what is already on screen alone; the button
- // simply stays available to retry.
- } finally {
- setMore(false)
- }
- }
-
- if (state.loading) return
- if (state.error) return
- if (state.items.length === 0) {
- return Nothing in the atlas matches that.
- }
-
- return (
- <>
-
- Showing {num(state.items.length)} of {num(state.total)}
-
-
- {state.items.map((c) => (
-
- ))}
-
- {state.items.length < state.total && (
-
-
-
- )}
- >
- )
-}
-
-// The CONFIGURED altar roster — where the altars are and what each summons. The
-// live board ("it is on level 3 right now") is a different page, /site/champs,
-// fed by the sidecar. Both exist; they are not the same thing.
-function Champions({ facet }) {
- const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
- if (loading) return
- if (error) return
- if (!data || data.length === 0) return No champion altars are configured.
- return (
-
-
-
- {/* The atlas is only as good as its placement rate, so the page states
- it rather than implying every spawner resolved to a named place. */}
- {counts && (
-
- {num(counts.creatures)} creatures across {num(counts.points)} spawners
- {Number.isFinite(counts.unresolvedPoints) && counts.points
- ? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
- : ''}
- {imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
-
-
- )
-}
diff --git a/client/src/routes/public/AtlasCreature.jsx b/client/src/routes/public/AtlasCreature.jsx
deleted file mode 100644
index d479067..0000000
--- a/client/src/routes/public/AtlasCreature.jsx
+++ /dev/null
@@ -1,201 +0,0 @@
-import { useMemo, useState } from 'react'
-import { Link, useParams } from 'react-router-dom'
-import PublicLayout from '../../components/PublicLayout.jsx'
-import PageHeader from '../../components/PageHeader.jsx'
-import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
-import { useAsync } from '../../lib/useAsync.js'
-import { api } from '../../api/client.js'
-
-// One creature: where it spawns, and what spawns alongside it.
-//
-// `places` is the point of the page — the aggregate that turns 62 raw
-// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
-// available underneath for the reader who actually wants a coordinate, but they
-// are secondary and collapsed by default.
-
-const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
-
-// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
-function delay(min, max) {
- const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
- if (!Number.isFinite(min) || !Number.isFinite(max)) return null
- if (min === max) return fmt(min)
- return `${fmt(min)}–${fmt(max)}`
-}
-
-function Panel({ title, right, children }) {
- return (
-
-
- )}
-
- )
-}
-
-export default function AtlasCreature() {
- const { slug } = useParams()
- const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
-
- // A 404 here means "no such creature in this atlas", which is a real answer
- // and not a failure — a visitor following a stale link deserves to be told
- // that plainly rather than shown a generic error box.
- const missing = error?.status === 404 || error?.message === 'Not Found'
-
- const facets = useMemo(
- () => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
- [data],
- )
-
- return (
-
-
-
-
- ← Spawn atlas
-
-
-
- {loading && }
- {error && !missing && }
- {missing && Nothing by that name spawns on this shard.}
-
- {!loading && !error && data && (
- <>
-
-
-
-
- )
-}
diff --git a/client/src/routes/public/Houses.jsx b/client/src/routes/public/Houses.jsx
deleted file mode 100644
index 246a72f..0000000
--- a/client/src/routes/public/Houses.jsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import { useMemo } 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'
-
-// 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 }) {
- return (
-
-
- )
-}
diff --git a/client/src/routes/public/Leaderboards.jsx b/client/src/routes/public/Leaderboards.jsx
deleted file mode 100644
index fc432b9..0000000
--- a/client/src/routes/public/Leaderboards.jsx
+++ /dev/null
@@ -1,240 +0,0 @@
-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'
-import { useSite } from '../../contexts/SiteContext.jsx'
-
-// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
-// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
-// loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing
-// players build over months, and none of them visible anywhere but an in-game gump
-// until now.
-//
-// Loaded from /public/shard/points, then kept current from the live feed. Unlike
-// the ruleset (one frame = the whole thing), a points.board frame describes ONE
-// system, so live frames are merged over the fetched set by system key rather than
-// replacing it.
-const POINTS_KINDS = new Set(['points.board'])
-
-// A board's display name may arrive as a literal (`nameString`), a cliloc id
-// (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc
-// table on the site, so a cliloc-only board falls back to humanising its own
-// PointsType key, which is already close to a display name ("CleanUpBritannia" →
-// "Clean Up Britannia"). Better than showing a bare number.
-const humanise = (key) =>
- String(key || '')
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
- .replace(/^./, (c) => c.toUpperCase())
-
-const boardTitle = (b) => b.nameString || humanise(b.system)
-
-const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
-
-// Merge live frames over the fetched boards. Newest frame per system wins; a
-// system that has never appeared in either is simply absent.
-function mergeBoards(fetched, events) {
- const bySystem = new Map()
- for (const b of Array.isArray(fetched) ? fetched : []) {
- if (b && b.system) bySystem.set(b.system, b)
- }
- // Events arrive newest-first, so walk backwards and let the newest land last.
- for (let i = events.length - 1; i >= 0; i--) {
- const ev = events[i]
- if (ev && ev.system) bySystem.set(ev.system, ev)
- }
- return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b)))
-}
-
-function Medal({ rank }) {
- // Gold / silver / bronze for the podium, plain for the rest.
- const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)'
- return (
-
- {rank}
-
- )
-}
-
-// One ranked player. `name` is absent rather than empty when an admin has gated
-// the leaderboards `name` field above this viewer's rung — the row still renders,
-// because the standing itself is the point.
-function Entry({ entry, best }) {
- const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0
- return (
-
- )
-}
-
-function Board({ board }) {
- const { siteTitle } = useSite()
- const top = Array.isArray(board.top) ? board.top : []
- // Bars are relative to the board leader, not to maxPoints: most systems have no
- // cap (maxPoints 0), and where there is one the leader is often nowhere near it,
- // which would render every bar as a stub.
- const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0)
-
- return (
-
-
-
- {top.length === 0 ? (
- // A board nobody has scored on still gets a row, so the page reads as a set
- // of standings waiting to be filled rather than a stack of blanks. It is
- // deliberately NOT shaped like an Entry — no medal, no bar, an em dash where
- // a score goes — because a placeholder that looked like a real standing would
- // be a fabricated one. The first real entry replaces it.
-
-
-
- {siteTitle}
-
- —
-
-
- Nobody has earned points here yet.
-
-
- ) : (
-
- {top.map((entry) => (
-
- ))}
-
- )}
-
- {Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
-
- Maximum {num(board.maxPoints)} points
-
- )}
-
- )
-}
-
-export default function Leaderboards() {
- const { loading, error, data } = useAsync(() => api.shard.points())
- // Buffer generously: a single sweep can emit a frame for every system at once,
- // and a board dropped from the buffer would silently revert to its fetched copy.
- const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 })
- const [query, setQuery] = useState('')
-
- const boards = useMemo(() => mergeBoards(data, events), [data, events])
-
- const shown = useMemo(() => {
- const q = query.trim().toLowerCase()
- if (!q) return boards
- // Match the board name, the raw system key, or any ranked player on it — the
- // last is what makes the filter useful ("where do I appear?").
- return boards.filter(
- (b) =>
- boardTitle(b).toLowerCase().includes(q) ||
- String(b.system).toLowerCase().includes(q) ||
- (b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)),
- )
- }, [boards, query])
-
- return (
-
-
-
- )
-}
diff --git a/client/src/routes/public/Market.jsx b/client/src/routes/public/Market.jsx
deleted file mode 100644
index 612cf17..0000000
--- a/client/src/routes/public/Market.jsx
+++ /dev/null
@@ -1,325 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
-import PublicLayout from '../../components/PublicLayout.jsx'
-import PageHeader from '../../components/PageHeader.jsx'
-import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
-import { useAsync } from '../../lib/useAsync.js'
-import { api } from '../../api/client.js'
-
-// ── The player-vendor marketplace ───────────────────────────────────────────
-//
-// What every player vendor on the shard is selling, for how much, and where it
-// is standing — the same index the in-game Vendor Search gump reads, honouring
-// the same per-vendor opt-out, reachable without logging in to the game.
-//
-// Three things this page must be honest about, all of them consequences of how
-// the data is gathered (docs/link/v3.md §8):
-//
-// • **The prices are not live.** The shard sweeps vendors round-robin, so a
-// shop can be a full cycle behind. The banner says how far, from `staleAt`.
-// A page that implied live prices would send people across the world to a
-// vendor whose item sold twenty minutes ago.
-// • **A shop can be truncated.** A commodity reseller with thousands of stacks
-// publishes only the first N, and saying so beats presenting a partial shop
-// as complete.
-// • **An item may have no name.** On a shard whose operator has not converted
-// a cliloc table, `displayName` is null and the honest render is the item id
-// — not an invented name.
-//
-// There is deliberately no live feed here. The market feature's SSE stream ships
-// disabled: a firehose of whole vendor inventories would be the site's single
-// biggest bandwidth consumer, and nothing on this page needs it.
-
-const PAGE = 50
-
-const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
-
-const SORTS = [
- { key: 'price_asc', label: 'Cheapest' },
- { key: 'price_desc', label: 'Priciest' },
- { key: 'recent', label: 'Recently seen' },
-]
-
-// How old the index may be, in words. `staleAt` is the OLDEST vendor row, so
-// this is a worst case rather than an average — which is the number worth
-// showing, because the one stale shop is the one that wastes a trip.
-function staleness(staleAt) {
- if (!staleAt) return null
- const ms = Date.now() - new Date(staleAt).getTime()
- if (!Number.isFinite(ms) || ms < 0) return null
- const mins = Math.round(ms / 60000)
- if (mins < 1) return 'just now'
- if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
- const hours = Math.round(mins / 60)
- if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
- return `${Math.round(hours / 24)} days ago`
-}
-
-// The item's name, or an honest statement that we do not have one. Never a
-// fabricated label — "Item 3922" would be indistinguishable from a real name.
-const itemLabel = (l) => l.displayName || l.name || `id ${l.itemId}`
-
-function Chip({ active, onClick, children }) {
- return (
-
- )
-}
-
-function ListingRow({ listing }) {
- const v = listing.vendor || {}
- // `location` is one field the admin can gate away wholesale, so everything
- // that reads from it has to tolerate its absence rather than assuming a map.
- const loc = v.location || null
- const where = loc ? [loc.region, loc.map].filter(Boolean).join(', ') : null
-
- return (
-
-
-
- {/* Not decoration. The sweep is round-robin, so the index is inherently
- up to one full cycle old and the page has to say so. */}
- {age && (
-
-
- {/* Facet and region names come from the shard's own data, never a list in
- this file — a shard running custom maps gets its own names here with
- no code change (docs/link/v3.md §6.1 R2). */}
- {maps.length > 0 && (
-
- Showing {num(state.listings.length)} of {num(state.total)}
-
-
- {state.listings.map((l) => (
-
- ))}
-
- {state.listings.length < state.total && (
-
-
-
- )}
- >
- )}
-
-
- )
-}
diff --git a/client/src/routes/public/MarketVendor.jsx b/client/src/routes/public/MarketVendor.jsx
deleted file mode 100644
index 7359b9e..0000000
--- a/client/src/routes/public/MarketVendor.jsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { Link, useParams } from 'react-router-dom'
-import PublicLayout from '../../components/PublicLayout.jsx'
-import PageHeader from '../../components/PageHeader.jsx'
-import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
-import { useAsync } from '../../lib/useAsync.js'
-import { api } from '../../api/client.js'
-
-// One player vendor: where to find it and everything it is selling.
-//
-// The page a search result points at. Two states it has to render honestly and
-// which the search list cannot (docs/link/v3.md §8):
-//
-// • `truncated` — the shop holds more than the shard publishes per frame. A
-// commodity reseller with thousands of stacks is a real thing, and showing
-// 250 of 3,104 as if it were the whole shop would be a lie about the shard.
-// • a gated `location` — an admin may put vendor whereabouts behind a rung, in
-// which case there is nothing to render and the page says so rather than
-// showing an empty coordinate.
-
-const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
-
-const itemLabel = (i) => i.displayName || i.name || `id ${i.itemId}`
-
-export default function MarketVendor() {
- const { serial } = useParams()
- const { loading, error, data } = useAsync(() => api.shard.marketVendor(serial), [serial])
-
- if (loading) {
- return (
-
-
- {data.truncated
- ? `Showing ${num(data.count)} of ${num(data.total)} listings — this shop holds more than the shard publishes.`
- : `${num(data.total)} listing${data.total === 1 ? '' : 's'}`}
- {data.updatedAt ? ` · last seen ${new Date(data.updatedAt).toLocaleString()}` : ''}
-
-
- {items.length === 0 ? (
- This shop has nothing priced for sale.
- ) : (
-
- {items.map((i) => (
-
-
- {i.amount > 1 ? `${num(i.amount)} × ` : ''}
- {itemLabel(i)}
- {i.child ? · sold with its container : null}
-
-
- {num(i.price)}
-
-
- ))}
-
- )}
-
-
- ← Back to the marketplace
-
-
-
- )
-}
diff --git a/client/src/routes/public/Rules.jsx b/client/src/routes/public/Rules.jsx
deleted file mode 100644
index 87618f6..0000000
--- a/client/src/routes/public/Rules.jsx
+++ /dev/null
@@ -1,341 +0,0 @@
-import { useMemo } 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 shard ruleset. Loaded from /public/shard/ruleset, replaced wholesale by any
-// world.ruleset frame on the live feed (the shard re-emits the entire ruleset, so
-// there is nothing to merge — latest wins).
-//
-// Everything on this page is published BY THE SHARD from its own Config/*.cfg, so
-// it cannot drift the way a hand-written rules page does. That is the whole point
-// of the feature, and the page says so.
-const RULESET_KINDS = new Set(['world.ruleset'])
-
-// Skill and stat caps arrive in tenths, the way ServUO stores them: 1000 is 100.0
-// skill. Showing the raw number would be actively misleading.
-const tenths = (v) => (Number.isFinite(v) ? (v / 10).toFixed(1) : null)
-
-const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : null)
-
-const pct = (v) => (Number.isFinite(v) ? `${v}%` : null)
-
-// The systems block is a flat bag of booleans; these are their display names, and
-// the order here is the order they render. A key the shard sends that we don't
-// know about still renders, humanised, rather than being silently dropped — a new
-// plugin must not go invisible against an older client.
-const SYSTEM_LABELS = {
- cityLoyalty: 'City Loyalty (governors)',
- vvv: 'Vice vs Virtue',
- factions: 'Factions',
- siege: 'Siege ruleset',
- chat: 'In-game chat',
- store: 'Ultima Store',
- dailyRares: 'Daily rares',
- honesty: 'Honesty virtue',
- shadowguard: 'Shadowguard',
- treasureMaps: 'Treasure maps',
- vetRewards: 'Veteran rewards',
- testCenter: 'Test Center',
-}
-
-const humanise = (key) =>
- key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())
-
-function Panel({ title, children }) {
- return (
-
-
- {title}
-
- {children}
-
- )
-}
-
-// A label/value row. Rows whose value is null are dropped by the caller, so a
-// block never renders a dangling label for something the shard didn't publish.
-function Row({ label, value }) {
- return (
-
-
- )
-}
diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx
deleted file mode 100644
index 9785e89..0000000
--- a/client/src/routes/public/Shard.jsx
+++ /dev/null
@@ -1,254 +0,0 @@
-import { Link } from 'react-router-dom'
-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 { describe } from '../../lib/shardEvents.js'
-import { ago } from '../../lib/format.js'
-import { api } from '../../api/client.js'
-import PlayersOnline from '../../components/PlayersOnline.jsx'
-import { useAuth } from '../../contexts/AuthContext.jsx'
-
-// Flavor line under the online/offline banner: online, configured-but-down, or
-// not configured yet.
-function statusMessage(online, enabled) {
- if (online) return 'The gate to Britannia stands open.'
- if (enabled) return 'The link to the game world is down — checking back automatically.'
- return 'Live shard data is not configured yet.'
-}
-
-// ── Gold-supply sparkline ───────────────────────────────────────────────────
-function Sparkline({ series }) {
- if (!series || series.length < 2) return null
- const w = 320
- const h = 56
- const golds = series.map((s) => Number(s.gold) || 0)
- const min = Math.min(...golds)
- const max = Math.max(...golds)
- const span = max - min || 1
- const pts = series
- .map((s, i) => {
- const x = (i / (series.length - 1)) * w
- const y = h - ((Number(s.gold) || 0) - min) / span * h
- return `${x.toFixed(1)},${y.toFixed(1)}`
- })
- .join(' ')
- return (
-
- )
-}
-
-// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
-function Stat({ value, label }) {
- return (
-
-
{value}
-
- {label}
-
-
- )
-}
-
-export default function Shard() {
- const { loading, error, data } = useAsync(() =>
- Promise.all([
- api.shard.status(),
- api.shard.idoc(),
- api.shard.economy(60),
- api.shard.online(),
- ]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
- )
- const { events, connected } = useShardFeed({ max: 30 })
- const { user } = useAuth()
- // Staff in-game location is privileged: only admins/moderators see it. Players
- // and the public see that staff are online but not where. The server enforces
- // this too (it omits the location fields entirely for non-privileged callers).
- const canSeeLocation = user?.role === 'admin' || user?.role === 'moderator'
-
- const status = data?.status
- const online = status?.pluginConnected
- const gold = status?.economy?.gold
-
- return (
-
-
-
- )
-}
diff --git a/client/test/adminNav.test.js b/client/test/adminNav.test.js
index 89f46bb..2a73906 100644
--- a/client/test/adminNav.test.js
+++ b/client/test/adminNav.test.js
@@ -1,7 +1,7 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
-import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../src/lib/adminNav.js'
+import { navItemVisibleTo, allowedPathsFor, isAllowedPath, firstDestinationFor } from '../src/lib/adminNav.js'
// Moderator confinement, derived from each row's `roles` (Phase 2 PR 8 —
// MODULE_SYSTEM.md §1.4). This replaced two hardcoded path lists that had
@@ -123,3 +123,50 @@ test('a nav that is not there does not throw', () => {
assert.deepEqual(allowedPathsFor(null, 'moderator'), [])
assert.equal(isAllowedPath('/admin', undefined), false)
})
+
+// ── firstDestinationFor: where an area's index goes (Phase 3 slice 3) ────────
+//
+// `/player` used to be `PlayerCharacters`, a UO page. When the client half was
+// extracted the portal had no index at all, and rather than pick a fixed page or
+// invent a core landing screen, the index resolves to the first row this viewer
+// can reach. The interesting properties are that it follows the ROLE and that it
+// reads the base nav, not the merged one.
+
+const PORTAL_NAV = [
+ { to: '/account/appeals', label: 'Appeals' },
+ { to: '/account', label: 'Account', end: true },
+]
+
+test('the index is the first row a viewer can actually reach', () => {
+ assert.equal(firstDestinationFor(PORTAL_NAV, 'player', '/account'), '/account/appeals')
+})
+
+test('a module row registered at the front becomes the index', () => {
+ // The behaviour that makes this a non-regression: with module-uo installed,
+ // Characters is the first row again and a player still lands on it.
+ const withModule = [{ to: '/player/uo/characters', label: 'Characters', moduleId: 'uo' }, ...PORTAL_NAV]
+ assert.equal(firstDestinationFor(withModule, 'player', '/account'), '/player/uo/characters')
+})
+
+test('a row this role cannot see is skipped, not landed on', () => {
+ const gated = [{ to: '/player/uo/staff', label: 'Staff', roles: ['admin'] }, ...PORTAL_NAV]
+ assert.equal(firstDestinationFor(gated, 'player', '/account'), '/account/appeals')
+ assert.equal(firstDestinationFor(gated, 'admin', '/account'), '/player/uo/staff')
+})
+
+test('an empty or all-gated nav falls back rather than resolving to nothing', () => {
+ assert.equal(firstDestinationFor([], 'player', '/account'), '/account')
+ assert.equal(firstDestinationFor(null, 'player', '/account'), '/account')
+ const allGated = [{ to: '/x', label: 'X', roles: ['admin'] }]
+ assert.equal(firstDestinationFor(allGated, 'player', '/account'), '/account')
+})
+
+test('it reads the grouped admin nav too, flattening in order', () => {
+ // Same function for both areas, which is the point: the admin index is a
+ // hardcoded Dashboard today, and if the two logged-in areas ever merge this is
+ // what answers for the result.
+ assert.equal(firstDestinationFor(NAV, 'moderator', '/admin/account'), '/admin')
+ // An editor cannot see Dashboard's neighbours in Moderation, so they land on
+ // the first row they can see wherever it is.
+ assert.equal(firstDestinationFor(NAV, 'editor', '/admin/account'), '/admin')
+})
diff --git a/client/test/regionBuckets.test.js b/client/test/regionBuckets.test.js
deleted file mode 100644
index 7649a2a..0000000
--- a/client/test/regionBuckets.test.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import { test } from 'node:test'
-import assert from 'node:assert/strict'
-import { bucketize, BUCKETS } from '../src/data/regionBuckets.js'
-
-// Unit-test the presence.online region roll-up for the "Players Online" widget.
-// The load-bearing invariant: the bucket counts ALWAYS reconcile to the true
-// total — anything unmatched lands in Wilderness — so the widget can never show
-// a sum that disagrees with the headline online count.
-
-test('bucketize groups named regions into their buckets', () => {
- const { rows, total } = bucketize({
- 'Britain': 4,
- 'Moonglow': 2,
- 'Despise': 3,
- 'Green Acres House 12': 1, // not a town/dungeon name → Housing
- })
- const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
- assert.equal(byId.britain, 4)
- assert.equal(byId.towns, 2)
- assert.equal(byId.dungeons, 3)
- assert.equal(byId.housing, 1)
- assert.equal(total, 10)
-})
-
-test('first match wins by BUCKETS order: a town-named house region counts as Towns, not Housing', () => {
- // The towns regex is ^-anchored and towns is checked BEFORE housing, so a house
- // region whose name starts with a town name is bucketed as Towns. Pinning this
- // documents the ordering dependency for anyone retuning BUCKETS.
- const { rows } = bucketize({ 'Trinsic House 12': 1 })
- const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
- assert.equal(byId.towns, 1)
- assert.equal(byId.housing, undefined) // empty bucket dropped
-})
-
-test('an unmatched region falls through to Wilderness so counts always reconcile', () => {
- const { rows, total } = bucketize({ 'Some Unnamed Field': 5, 'Wilderness': 2 })
- const wilderness = rows.find((r) => r.id === 'wilderness')
- assert.equal(wilderness.count, 7)
- assert.equal(total, 7)
- // The reconciliation guarantee: the buckets sum to the total, exactly.
- assert.equal(rows.reduce((s, r) => s + r.count, 0), total)
-})
-
-test('bucketize returns rows in BUCKETS order and drops empty buckets', () => {
- const { rows } = bucketize({ 'Despise': 1, 'Britain': 1 })
- assert.deepEqual(rows.map((r) => r.id), ['britain', 'dungeons']) // BUCKETS order, no empty towns/housing/wilderness
-})
-
-test('bucketize coerces non-numeric counts and tolerates empty/nullish input', () => {
- assert.deepEqual(bucketize({}), { rows: [], total: 0 })
- assert.deepEqual(bucketize(), { rows: [], total: 0 })
- const { total } = bucketize({ 'Britain': '3', 'Minoc': 'oops' })
- assert.equal(total, 3) // '3' → 3, 'oops' → 0
-})
-
-test('the last bucket is the catch-all (its match accepts anything)', () => {
- const last = BUCKETS[BUCKETS.length - 1]
- assert.equal(last.id, 'wilderness')
- assert.equal(last.match('literally anything'), true)
-})
diff --git a/client/test/shardEvents.test.js b/client/test/shardEvents.test.js
deleted file mode 100644
index c4e5327..0000000
--- a/client/test/shardEvents.test.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import { test } from 'node:test'
-import assert from 'node:assert/strict'
-import { describe, categoryOf, kindLabel, CATEGORIES } from '../src/lib/shardEvents.js'
-
-// Unit-test the shared shard-event formatter — the single place that decides how
-// each event kind reads and which filter category it belongs to. These strings
-// are user-facing on the public Shard page, the Activity feed, and the admin
-// live feed, so a regression here is visible everywhere at once.
-
-// ── describe(): works on both stored (.payload) and live (top-level) frames ──
-test('describe reads fields from .payload when present, else the top level', () => {
- const stored = { kind: 'quest.complete', payload: { who: { name: 'Ada' }, quest: 'The Cavern' } }
- const live = { kind: 'quest.complete', who: { name: 'Ada' }, quest: 'The Cavern' }
- assert.equal(describe(stored), 'Ada completed “The Cavern”')
- assert.equal(describe(live), 'Ada completed “The Cavern”')
-})
-
-test('describe resolves an actor from name → acct → "Someone"', () => {
- assert.equal(describe({ kind: 'mob.login', who: { name: 'Bob' } }), 'Bob entered the world')
- assert.equal(describe({ kind: 'mob.login', who: { acct: 'acct7' } }), 'acct7 entered the world')
- assert.equal(describe({ kind: 'mob.login', who: null }), 'Someone entered the world')
- assert.equal(describe({ kind: 'mob.login', who: 'RawString' }), 'RawString entered the world')
-})
-
-test('describe pluralizes a vendor sale only when amount > 1 and formats the price', () => {
- assert.equal(describe({ kind: 'vendor.sale', itemType: 'Katana', amount: 1, price: 1200 }), 'Katana sold for 1,200gp')
- assert.equal(describe({ kind: 'vendor.sale', itemType: 'Arrow', amount: 40, price: 80 }), 'Arrow ×40 sold for 80gp')
-})
-
-test('describe includes the killer only when present (optional clause)', () => {
- assert.equal(describe({ kind: 'player.death', who: { name: 'Ada' } }), 'Ada was slain')
- assert.equal(
- describe({ kind: 'player.death', who: { name: 'Ada' }, killer: { name: 'Orc' } }),
- 'Ada was slain by Orc',
- )
-})
-
-test('describe champ.update branches on status and boss state', () => {
- assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', bossUp: true }), 'Rikktor: boss is up')
- assert.equal(
- describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', level: 3 }),
- 'Rikktor is active — level 3',
- )
- assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'cooldown' }), 'Rikktor is on cooldown')
-})
-
-test('describe falls back to the raw kind for an unknown event', () => {
- assert.equal(describe({ kind: 'some.future.kind' }), 'some.future.kind')
-})
-
-// ── categoryOf(): membership + catch-all ────────────────────────────────
-test('categoryOf groups kinds per the CATEGORIES table, and unknowns are "other"', () => {
- assert.equal(categoryOf('player.death'), 'pvp')
- assert.equal(categoryOf('skill.gain'), 'progress')
- assert.equal(categoryOf('house.decay'), 'world')
- assert.equal(categoryOf('vendor.sale'), 'other') // deliberately not a public category
- assert.equal(categoryOf('totally.unknown'), 'other')
-})
-
-test('every kind listed in CATEGORIES maps back to that category (table stays consistent)', () => {
- for (const cat of CATEGORIES) {
- if (!cat.kinds) continue
- for (const kind of cat.kinds) {
- assert.equal(categoryOf(kind), cat.id, `${kind} should be in ${cat.id}`)
- }
- }
-})
-
-// ── kindLabel(): badge text ─────────────────────────────────────────────
-test('kindLabel turns dots/underscores into spaces and tolerates empty input', () => {
- assert.equal(kindLabel('player.death'), 'player death')
- assert.equal(kindLabel('account.login.attempt'), 'account login attempt')
- assert.equal(kindLabel(null), '')
-})
diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js
index 1fa816f..53f10a9 100644
--- a/server/src/model/settings/settings.model.js
+++ b/server/src/model/settings/settings.model.js
@@ -55,27 +55,6 @@ function registrationFlags(mode) {
}
}
-// Game-account signup (Protocol 2.0). The admin picks who mints game accounts:
-// disabled — the site never offers game-account creation (link-only).
-// website — the site is the authority (offer creation; pair with the shard in
-// website mode + AutoCreateAccounts=false).
-// hybrid — either side may create (the site offers creation).
-// game — the game server is the authority; the site does NOT offer creation.
-// The site OFFERS creation only for 'website'/'hybrid'; the shard's own SignupMode
-// (Bridge.cfg) still has the final say and may 403 a call regardless.
-const GAME_SIGNUP_KEY = 'game_account_signup'
-const GAME_SIGNUP_MODES = ['disabled', 'website', 'hybrid', 'game']
-const GAME_SIGNUP_OFFER = ['website', 'hybrid']
-
-async function getGameSignupMode() {
- const v = await settingsDb.get(GAME_SIGNUP_KEY)
- return GAME_SIGNUP_MODES.includes(v) ? v : 'disabled'
-}
-
-async function isGameAccountSignupEnabled() {
- return GAME_SIGNUP_OFFER.includes(await getGameSignupMode())
-}
-
// Android App Links opt-in (M9 follow-up). When on, the shard auto-serves
// /.well-known/assetlinks.json and the mobile SSO bridge additionally accepts the
// self-origin https:///mobile/callback redirect. Stored as the string
@@ -146,10 +125,12 @@ async function getPublic() {
// page show/hide the password form and SSO buttons.
const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled'
out.registration = registrationFlags(mode)
- // Whether the site offers game-account creation (the shard's own mode still has
- // the final say when the call is made). Lets the portal show/hide the form.
- const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
- out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
+ // `gameAccountSignup` was derived here until Phase 3 slice 3. It said whether
+ // the site offers GAME-account creation, which is a question about a shard —
+ // its own SignupMode has to agree — so it left with the rest of core's UO
+ // prose. The `game_account_signup` row is unchanged and module-uo reads it
+ // through ctx.settings; the derived flag is on its `/public/shard/features`.
+ //
// The effective CSS custom properties for the admin's theme, or absent when
// no theme_visual row exists (or nothing in it was usable). The SPA writes
// these onto ; absence means it writes nothing and theme.css's :root
@@ -264,10 +245,6 @@ module.exports = {
REGISTRATION_MODES,
getRegistrationMode,
registrationFlags,
- GAME_SIGNUP_KEY,
- GAME_SIGNUP_MODES,
- getGameSignupMode,
- isGameAccountSignupEnabled,
MOBILE_APP_LINKS_KEY,
isMobileAppLinksEnabled,
}
diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js
index 047394a..b1da58d 100644
--- a/server/src/router/v1/admin/admin.controller.js
+++ b/server/src/router/v1/admin/admin.controller.js
@@ -520,12 +520,12 @@ async function updateSettings(req, res) {
) {
return res.status(400).json({ message: 'Invalid player_registration value' })
}
- if (
- settings.GAME_SIGNUP_KEY in updates &&
- !settings.GAME_SIGNUP_MODES.includes(updates[settings.GAME_SIGNUP_KEY])
- ) {
- return res.status(400).json({ message: 'Invalid game_account_signup value' })
- }
+ // `game_account_signup` was validated here until Phase 3 slice 3. The key and
+ // its stored value are unchanged, but the four legal modes are a fact about a
+ // ServUO shard, so module-uo owns them and validates on its own route. This
+ // endpoint takes arbitrary keys either way, and an unrecognised value resolves
+ // to `disabled` on read — the module's gate fails closed, which is the right
+ // direction for "may this player mint a game account".
// App Links toggle is a boolean stored as a 'true'/'false' string; accept a real
// boolean or those two strings and normalize, reject anything else.
if (settings.MOBILE_APP_LINKS_KEY in updates) {
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index d2feea3..86f8189 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -14571,19 +14571,6 @@
}
}
},
- "gameAccountSignup": {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "example": "boolean"
- },
- "example": {
- "type": "boolean",
- "example": false
- }
- }
- },
"brand": {
"$ref": "#/components/schemas/Brand"
},
diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js
index 597b53b..9718f65 100644
--- a/server/swagger/swagger.js
+++ b/server/swagger/swagger.js
@@ -796,7 +796,6 @@ const doc = {
type: 'object',
properties: { password: { type: 'boolean' }, sso: { type: 'boolean' } },
},
- gameAccountSignup: { type: 'boolean', example: false },
brand: { $ref: '#/components/schemas/Brand' },
theme: {
type: 'object',
diff --git a/server/test/settingsGameSignup.test.js b/server/test/settingsGameSignup.test.js
new file mode 100644
index 0000000..5b430c0
--- /dev/null
+++ b/server/test/settingsGameSignup.test.js
@@ -0,0 +1,75 @@
+// ── What core's settings no longer know about ──────────────────────────────
+//
+// Phase 3, slice 3. `game_account_signup` policy left core: the mode list, the
+// derived public flag, the validation and the Site Settings field. The row in
+// the `settings` table is untouched and module-uo reads it through
+// `ctx.settings`, which is the whole point — the DATA stays, the SEMANTICS move.
+//
+// This file exists because the deletion passed 616 tests without one of them
+// noticing. Nothing asserted the shape of `getPublic()`, so a field could leave
+// the public settings payload — which the SPA, the Android app and the Discord
+// bot all read — and no suite would say a word. That is worth a test in the
+// direction of "and it is gone", but it is much more worth one in the direction
+// of "and nothing else went with it".
+
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, after, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const settings = require('../src/model/settings/settings.model')
+const settingsDb = require('../src/model/settings/settings.db')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+const originals = { getAll: settingsDb.getAll, get: settingsDb.get }
+afterEach(() => Object.assign(settingsDb, originals))
+
+// Both readers, because the two halves of this test read different ones:
+// `getPublic` folds `getAll`, and a module's `ctx.settings.get` is `get`.
+const withRows = (rows) => {
+ settingsDb.getAll = async () => Object.entries(rows).map(([key, value]) => ({ key, value }))
+ settingsDb.get = async (key) => (key in rows ? rows[key] : null)
+}
+
+test('the public settings payload no longer carries gameAccountSignup', async () => {
+ // Configured as it would be on a live instance that had the feature on. The
+ // row is still there and still readable — it is simply not core's to interpret.
+ withRows({ site_title: 'Test', game_account_signup: 'hybrid' })
+ const out = await settings.getPublic()
+ assert.equal('gameAccountSignup' in out, false)
+})
+
+test('and the raw row is still there for the module to read', async () => {
+ // The load-bearing half. Renaming or dropping the key would silently reset
+ // every configured instance to `disabled`, with players reporting that signup
+ // stopped working as the only clue.
+ withRows({ game_account_signup: 'hybrid' })
+ assert.equal(await settings.get('game_account_signup'), 'hybrid')
+})
+
+test('the rest of the public payload is unchanged', async () => {
+ // The assertion the deletion actually needed: three clients read this object
+ // and none of them are in this repo. Registration flags are the near
+ // neighbour — same shape, same file, same derived-from-a-mode pattern — and
+ // the one most likely to be taken along by an over-eager edit.
+ withRows({
+ site_title: 'Test',
+ player_registration: 'both',
+ game_account_signup: 'hybrid',
+ })
+ const out = await settings.getPublic()
+ assert.deepEqual(out.registration, { password: true, sso: true })
+ assert.equal(out.site_title, 'Test')
+})
+
+test('core no longer exports the game-signup policy', () => {
+ // A leftover export is a leftover consumer waiting to happen, and the module
+ // has its own copy now. `ctx.settings` is three functions and never included
+ // these, so nothing outside core could have been using them.
+ for (const name of ['GAME_SIGNUP_KEY', 'GAME_SIGNUP_MODES', 'getGameSignupMode', 'isGameAccountSignupEnabled']) {
+ assert.equal(name in settings, false, `settings still exports ${name}`)
+ }
+})