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/api/client.js b/client/src/api/client.js index 068e99b..0051b81 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -51,7 +51,10 @@ function safeParse(text) { // The `api` object below stays core's own binding surface. Its `atlas` and // `shard` namespaces are module bindings that only still live here because // Phase 3 has not moved them yet. -export { req as request } +// `BASE` goes with it: a module that needs an EventSource URL cannot go through +// `req` (fetch-only) and must not hardcode `/api/v1`, which is core's choice of +// mount point and not a promise it has made. +export { req as request, BASE } export const api = { // ----- auth ----- 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 ( -
-
- - {label} - {Number.isFinite(entry.rank) && ( - · #{entry.rank} - )} - - - {(entry.points ?? 0).toLocaleString()} - {max > 0 && / {max.toLocaleString()}} - -
- {/* 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 && ( -
-
-
- )} -
- ) -} - -function TitleChip({ children, tone = 'var(--muted)' }) { - return ( - - {children} - - ) -} - -function StatTile({ value, label }) { - return ( -
-
{value}
-
{label}
-
- ) -} - -function Vital({ label, cur, max }) { - const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0 - return ( -
-
- {label} - {cur ?? '—'} / {max ?? '—'} -
-
-
-
-
- ) -} - -export default function CharacterSheet({ char, moderation = false }) { - if (!char) return null - const stats = char.stats || {} - const resist = stats.resist || {} - // Skills the character actually has, best first. - const skills = (char.skills || []) - .filter((s) => (s.value || s.base || 0) > 0) - .sort((a, b) => (b.value || 0) - (a.value || 0)) - const equipment = char.equipment || [] - // Best standing first, so the character's strongest loyalty leads. Guarded for - // an older shard plugin that sends no `points` block at all. - const points = (Array.isArray(char.points) ? char.points : []) - .filter((p) => p && (p.points || 0) > 0) - .sort((a, b) => (b.points || 0) - (a.points || 0)) - - return ( -
- {/* Identity */} -
-

{char.name || 'Unknown'}

- {char.title && {char.title}} - - - {char.online ? 'Online' : 'Offline'} - - {char.serial} -
- - {/* Titles + standing (guild led / governorship) — all optional */} - {(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && ( -
- {char.governorOf && char.governorOf.map((city) => ( - Governor of {city} - ))} - {char.guild && ( - - Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name} - - )} - {displayTitles(char.titles).map((t) => {t})} -
- )} - - {/* Staff moderation for this character's account (self-gates to staff). */} - {moderation && char.acct && ( -
- Account {char.acct} - -
- )} - - {/* Core stats */} -
-
Attributes
-
- - - -
-
- - - -
-
- - {/* Resistances */} - {Object.keys(resist).length > 0 && ( -
-
Resistances
-
- {['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => ( -
-
{resist[k] ?? 0}
-
{RESIST_LABELS[k]}
-
- ))} -
-
- )} - - {/* Skills */} - {skills.length > 0 && ( -
-
Skills ({skills.length})
-
- {skills.map((s) => { - const cap = s.cap || 100 - const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100)) - return ( -
-
- {s.n} - {s.value} -
-
-
-
-
- ) - })} -
-
- )} - - {/* Loyalty & points — one entry per system this character has scored in */} - {points.length > 0 && ( -
-
- Loyalty & points ({points.length}) -
-
- {points.map((p) => ( - - ))} -
-
- )} - - {/* Equipment */} - {equipment.length > 0 && ( -
-
Equipment
-
- {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 ( -
- -
-
{label}
-
{detail.filter(Boolean).join(' · ')}
-
- {it.mods && Object.keys(it.mods).length > 0 && ( -
- {Object.entries(it.mods).map(([k, v]) => ( - {k} {v} - ))} -
- )} -
- ) - })} -
-
- )} -
- ) -} 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 ( -
- {!compact && ( -

- Choose the username and password you’ll type into the game client. These are your - game credentials — separate from your website login. -

- )} - - - - {error &&

{error}

} - {msg &&

{msg}

} - - -
- ) -} 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 ( -
- - - {msg && {msg}} - {error && {error}} -
- ) -} - -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.

- -
- ) - } - if (error) return

{error}

- if (!roster) return

Loading…

- - const chars = roster.chars || [] - if (chars.length === 0) return

No characters on this account.

- - return ( -
- {chars.map((c) => ( - - - {(c.name || '?').charAt(0)} - -
-
{c.name}
-
{c.online ? 'Online' : 'Offline'}
-
- - - ))} -
- ) -} - -// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms, -// then calls onUnlink(account) and reloads. Errors surface inline. -function UnlinkButton({ account, onUnlink }) { - const [busy, setBusy] = useState(false) - const [error, setError] = useState('') - async function go() { - if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return - setBusy(true); setError('') - try { - await onUnlink(account) - } catch (err) { - const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' } - setError(byStatus[err.status] || err.message || 'Could not unlink.') - setBusy(false) - } - } - return ( - - - {error && {error}} - - ) -} - -export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) { - const [accounts, setAccounts] = useState(null) - const [error, setError] = useState('') - // Whether the site currently offers game-account creation (public flag). Only - // relevant for the self-service (non-readOnly) view with a createAccount scope. - const [signupOk, setSignupOk] = useState(false) - - const load = useCallback(async () => { - setError('') - try { - setAccounts(await scope.accounts()) - } catch { - setError(readOnly ? 'Could not load this user’s game accounts.' : 'Could not load your game accounts.') - } - }, [scope, readOnly]) - useEffect(() => { load() }, [load]) - - useEffect(() => { - if (readOnly || !scope.createAccount) return - let active = true - api.publicSettings() - .then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup))) - .catch(() => {}) - return () => { active = false } - }, [readOnly, scope]) - - const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk - - if (error) return - if (!accounts) return - - // No linked accounts. In read-only (admin viewing another user) this is just an - // empty state; otherwise it's the link-your-account prompt. - if (accounts.length === 0) { - if (readOnly) { - return ( -
-

- This user has not linked a game account. -

-
- ) - } - return ( -
-
-
Link your game account
-

- Already play? In game, type [link to get a - one-time code, then enter it below to see your characters, stats, skills and vendors here. -

- -
- {canCreate && ( -
-
Create a new game account
- -
- )} -
- ) - } - - // Linked — characters grouped by account. - return ( -
- {accounts.map((a) => ( -
-
-
- {a.account} -
- {onUnlink && { await onUnlink(acct); await load() }} />} -
- {moderation && } - -
- ))} - {!readOnly && ( -
-
Link another account
- - {canCreate && ( -
-
Create another game account
- -
- )} -
- )} -
- ) -} diff --git a/client/src/components/PlayersOnline.jsx b/client/src/components/PlayersOnline.jsx deleted file mode 100644 index 2b2194a..0000000 --- a/client/src/components/PlayersOnline.jsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useMemo } from 'react' -import { useAsync } from '../lib/useAsync.js' -import { useShardFeed } from '../lib/useShardFeed.js' -import { bucketize } from '../data/regionBuckets.js' -import { api } from '../api/client.js' - -// Compact live "Players Online" widget. Loads the presence.online aggregate once, -// then keeps the total + region breakdown current from the presence.online SSE -// kind. The raw byRegion map is rolled up into display buckets (see -// data/regionBuckets.js). NOT a page — drop it into any panel/column. -const PRESENCE_KINDS = new Set(['presence.online']) - -export default function PlayersOnline() { - const { loading, error, data } = useAsync(() => api.shard.presence()) - const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 }) - - // The freshest snapshot wins: the newest buffered presence.online event, else - // the initial fetch. - const snapshot = events[0] || data - - const { total, rows } = useMemo(() => { - const count = Number(snapshot?.count) || 0 - const { rows: bucketRows } = bucketize(snapshot?.byRegion) - return { total: count, rows: bucketRows } - }, [snapshot]) - - return ( -
-
- - Players online - - - {loading ? '—' : total} - -
- - {error && ( -

- Population is unavailable right now. -

- )} - - {!loading && !error && ( -
- {rows.length === 0 ? ( -

- {total > 0 ? 'Locations are settling…' : 'The realm is quiet.'} -

- ) : ( - rows.map((r) => ( -
- {r.label} - {/* tabular figures keep the right-aligned counts in a clean column */} - {r.count} -
- )) - )} -
- )} -
- ) -} diff --git a/client/src/components/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 ( -
-
Recent vendor sales
- {sales.length === 0 ? ( -

No vendor sales recorded yet.

- ) : ( -
    - {sales.map((s) => ( -
  • - - {s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp - - {ago(s.t)} -
  • - ))} -
- )} -
- ) -} 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/main.jsx b/client/src/main.jsx index f1c81fa..c2c3101 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -3,10 +3,7 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { publishSharedDependencies } from './modules/shared.js' -import { declareSlot, registerExtension, registerFeatureProvider } from './modules/registry.js' -import { useShardFlags } from './lib/useShardFeatures.js' -import ShardStatusLink from './components/ShardStatusLink.jsx' -import UserShardSections from './routes/admin/views/UserShardSections.jsx' +import { declareSlot } from './modules/registry.js' import './styles/theme.css' // Publish window.__rg BEFORE rendering and before any module chunk evaluates. @@ -17,18 +14,12 @@ import './styles/theme.css' // (docs/website/MODULE_API.md §3.2). publishSharedDependencies() -// Core registers through the same seam a module uses, and registers FIRST — the -// client twin of the server's `registries.registerCore()` (MODULE_SYSTEM.md -// §1.9). The ten shard-gated rows in the public header are core's only because -// Phase 3 has not moved them yet; routing them through the registry now means -// SiteHeader holds one mechanism instead of two, and the extraction becomes a -// deletion rather than a rewrite made under extraction pressure. -// -// The owner id is `core`, which is what a nav row with no `moduleId` resolves -// against (modules/featureGate.js). The namespace is `uo`, so a module that -// wants to read these flags — the `uo` module itself, once it owns them — asks -// for them by the name they will always have had. -registerFeatureProvider('core', 'uo', useShardFlags) +// Core registered a feature provider here until slice 3, under owner id `core` +// and namespace `uo`, so that the seam was exercised by real content from the +// day it was built. That prediction paid out exactly as written: the extraction +// deleted the registration and the hook it named, and SiteHeader was not touched. +// There is nothing for core to register now — no core nav row carries a +// `feature` — and the filter is a correct no-op until a module supplies one. // ── Extension slots (MODULE_API.md §3.7) ─────────────────────────────────── // @@ -48,15 +39,22 @@ declareSlot('site.footer.status') // resource, one extension point, two halves. The module with routes under // /api/v1/admin/users/:id is the module with something to show on that page. declareSlot('admin.users.detail') +// The invite-acceptance page's optional next step. Core owns invites — staff are +// invited too — and owned the game-account step inside them until slice 3, which +// meant core reading a `gameAccountSignup` flag and posting to a shard route. +// +// Named for the place, like the other two: it is "the point after an invite has +// been accepted and before the invitee is sent on", not "create a game account". +// Whether there is a step at all is the filling module's decision, made from +// data core does not have; core renders the shell and a skip control, and hands +// over `onDone`. With the slot unfilled the invitee goes straight to the portal, +// which is what core's own code did whenever the flag was off. +declareSlot('player.invite.accepted') -// And core fills both itself, under owner id `core`, with the components that -// were inline in SiteFooter.jsx and UserDetail.jsx until this slice. The page -// renders exactly what it rendered before, and the mechanism is exercised by -// core's own content from the day it lands rather than first proved by the -// change that depends on it. Slice 3 deletes these three lines and the two files -// they name, and the module registers the same two slots on its way in. -registerExtension('core', 'site.footer.status', ShardStatusLink) -registerExtension('core', 'admin.users.detail', UserShardSections) +// Core filled the first two itself until slice 3, with the components that were +// inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills +// all three, and core's own fills had to go for it to be able to — the first +// fill wins, and core registered first (§3.7). // Render on DOMContentLoaded rather than immediately, and that is the one line // of core's boot the module system changes. diff --git a/client/src/modules/featureGate.js b/client/src/modules/featureGate.js index dc30912..4c6091c 100644 --- a/client/src/modules/featureGate.js +++ b/client/src/modules/featureGate.js @@ -3,22 +3,22 @@ // Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.5 states the problem); // the contract is docs/website/MODULE_API.md §3.3. // -// Ten of the sixteen rows in the public header carry a `feature`, and every one -// of them is a shard surface an admin can disable or gate to a higher audience. -// The provider that answers those questions — `useShardFeatures` — moves out -// with the module, so core cannot keep calling it directly and still be a core. -// It keeps a generic seam instead, and the module fills it. +// Nine of the sixteen rows in the public header used to carry a `feature`, and +// every one of them was a shard surface an admin can disable or gate to a higher +// audience. The provider that answered those questions moved out with the module +// in Phase 3 slice 3, and core cannot call it directly and still be a core. It +// keeps this generic seam instead, and the module fills it. // // **The namespace comes from the registration, not from the string.** A row's // `feature` is resolved by the provider its OWN module registered, so a module // author writes `feature: 'status'` exactly as it reads today: nothing parses a // prefix, and a typo'd namespace is not a thing that can exist. Core's own rows -// carry no `moduleId` and resolve against the owner id `core`, which is what -// core registers `useShardFeatures` under until Phase 3 moves those rows into -// the module and they arrive stamped `uo` instead. +// carry no `moduleId` and resolve against the owner id `core` — which nothing +// registers now that the shard rows are gone, and that is the correct resting +// state rather than a gap: no core nav row carries a `feature`. // -// Everything here fails OPEN, and that is deliberate and unchanged from -// useShardFeatures' own posture: this is presentation, the gate is server-side +// Everything here fails OPEN, and that is deliberate: this is presentation, the +// gate is server-side // (a disabled feature 404s and an out-of-rung one 403s whether or not a link was // rendered), so an unknown answer shows the link rather than blanking the nav. // The one thing a UI mistake must never do here is hide a page from someone diff --git a/client/src/modules/registry.js b/client/src/modules/registry.js index 87d7a03..d6783f5 100644 --- a/client/src/modules/registry.js +++ b/client/src/modules/registry.js @@ -79,8 +79,18 @@ export function registerRoutes(id, byArea) { * core group, `order` sorts within it, and an unknown group name appends rather * than dropping the item — a mis-typed group must cost a position, never a link. * + * `icon` is a component core renders exactly as it renders its own rows' icons + * (1.3.0). It exists because without it the six UO rows would have extracted as + * the only text-only entries in a sidebar where every other row has a glyph, + * which reads as breakage rather than as a design. Core does not supply a + * fallback: a module that omits it gets no icon, the same as a core row that + * omits it, and inventing one would be core making a presentation choice for + * content it knows nothing about. Note that `icon` is already among the fields + * an override may not touch (lib/navOverrides.js) — the concept predates a + * module being able to supply one. + * * @param {string} id - * @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?}>}} spec + * @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?, icon?}>}} spec */ export function registerNav(id, spec) { const { area, items } = spec || {} @@ -92,10 +102,10 @@ export function registerNav(id, spec) { /** * The hook that answers "which of this module's features may this viewer see". * - * Core keeps a generic flag context and owns none of the semantics — `uo` fills - * its namespace with today's `useShardFeatures` (MODULE_SYSTEM.md §1.5). With no - * module installed the nav filter is a correct no-op, because no core nav item - * carries a `feature` today. + * Core keeps a generic flag context and owns none of the semantics + * (MODULE_SYSTEM.md §1.5). With no module installed the nav filter is a correct + * no-op, because no core nav item carries a `feature` — which has been literally + * true since Phase 3 slice 3 took the nine shard-gated rows out. */ export function registerFeatureProvider(id, namespace, hook) { providers.set(namespace, { id, hook }) diff --git a/client/src/modules/shared.js b/client/src/modules/shared.js index 03a4ad7..9d2a1df 100644 --- a/client/src/modules/shared.js +++ b/client/src/modules/shared.js @@ -37,7 +37,7 @@ import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx' import { useAsync } from '../lib/useAsync.js' import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' -import { request, ApiError } from '../api/client.js' +import { request, ApiError, BASE } from '../api/client.js' // The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These // seven are what the smallest UO page already needs beyond React and the router: @@ -66,11 +66,16 @@ const ui = { useSite, } -// The request PRIMITIVE, not the `api` object (§3.5). `api.atlas` and `api.shard` -// are module bindings that only still live in core's client because Phase 3 has -// not moved them; a module builds its own namespace over `request` and owns the -// paths it calls — which is right, because it owns the routes at the other end. -const api = { request, ApiError } +// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own +// namespace over `request` and owns the paths it calls, which is right, because +// it owns the routes at the other end. +// +// `BASE` was in §3.5 from the start and missing from this object until slice 3, +// which is when something first needed it. `request` is fetch-only, so an +// EventSource — the shard's live feed is two of them — has to build its own URL, +// and the alternative is a module hardcoding `/api/v1`: an assertion about where +// core mounts its API that core has never promised to keep. +const api = { request, ApiError, BASE } /** * Publish `window.__rg`. Called by main.jsx before it renders, and before any diff --git a/client/src/modules/version.js b/client/src/modules/version.js index 466b4c9..9150dc2 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,12 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.3.0 — three additions, all from Phase 3 slice 3 needing them: a nav item may +// carry an `icon` component (§3.3), core declares a third slot +// `player.invite.accepted` (§3.7), and `window.__rg.api` gained `BASE`, which +// §3.5 always documented and shared.js never published. Additive throughout: a +// module written against 1.2.0 is unaffected. The server half is untouched and +// bumps anyway, for the reason below. // 1.2.0 — `registry` gained `registerExtension` and core gained extension slots // (MODULE_API.md §3.7). The first change to window.__rg since 1.0.0, and an // addition: a module that never fills a slot is unaffected. The server half is @@ -20,4 +26,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.2.0' +export const MODULE_API_VERSION = '1.3.0' 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 ( -
-

- - ← Back to my characters - -

- {loading && } - {restarting && } - {forbidden && } - {error && !restarting && !forbidden && } - {!loading && !error && data && } -
- ) -} diff --git a/client/src/routes/admin/views/AdminCharacters.jsx b/client/src/routes/admin/views/AdminCharacters.jsx deleted file mode 100644 index 8a33933..0000000 --- a/client/src/routes/admin/views/AdminCharacters.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import CharacterStats from '../../../components/CharacterStats.jsx' -import GameAccounts from '../../../components/GameAccounts.jsx' -import VendorSales from '../../../components/VendorSales.jsx' -import { api } from '../../../api/client.js' - -// Staff link their OWN in-game account and view their characters — the same -// shared component players use, pointed at the staff self-service endpoints. -// Sits inside the Admin shell, which supplies the "My Characters" page header; -// stat tiles bring it to parity with the Player Portal's Characters page. -export default function AdminCharacters() { - return ( -
- - `/admin/characters/${serial}`} /> - -
- ) -} diff --git a/client/src/routes/admin/views/HousesAdmin.jsx b/client/src/routes/admin/views/HousesAdmin.jsx deleted file mode 100644 index 89eb787..0000000 --- a/client/src/routes/admin/views/HousesAdmin.jsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useMemo, useState } from 'react' -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' - -// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and -// decay — everything the public board hides. Loaded from /admin/shard/houses, kept -// live from the admin SSE channel (house.update / house.remove). -const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay']) - -const DECAY_TONE = { - LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a', - Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5', -} - -function DecayBadge({ decay, isIdoc }) { - const label = isIdoc ? 'IDOC' : decay - if (!label) return null - const tone = DECAY_TONE[label] || 'var(--muted)' - return ( - - {label} - - ) -} - -function ownerLabel(h) { - return h.ownerName || h.ownerAcct || null -} - -function HouseRow({ h }) { - const owner = ownerLabel(h) - return ( -
-
-
- - {h.name || 'An unnamed house'} - - -
-
- {owner ? <>Owned by {owner} : 'No owner'} - {(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''} -
-
- {h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''} -
-
- {h.price != null && ( -
-
{Number(h.price).toLocaleString()}
-
placement value
-
- )} -
- ) -} - -export default function HousesAdmin() { - const { loading, error, data } = useAsync(() => api.admin.shard.houses()) - // Full registry deltas ride the admin SSE channel (never the public one). - const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 }) - const [q, setQ] = useState('') - - const board = useMemo(() => { - const map = new Map() - for (const h of data || []) if (h && h.serial) map.set(h.serial, h) - for (let i = events.length - 1; i >= 0; i -= 1) { - const ev = events[i] - if (!ev.serial) continue - if (ev.kind === 'house.update') { - map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct }) - } else if (ev.kind === 'house.remove') { - map.delete(ev.serial) - } else if (ev.kind === 'house.decay') { - const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y } - map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' }) - } - } - return [...map.values()] - }, [data, events]) - - const filtered = useMemo(() => { - const needle = q.trim().toLowerCase() - const rows = needle - ? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle))) - : board - return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || '')) - }, [board, q]) - - if (loading) return - if (error) return - - return ( -
-
-

- {board.length.toLocaleString()} houses - {connected ? '● live' : '○ offline'} -

- setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} /> -
- {board.length === 0 ? ( -
-

No houses are being tracked right now.

-
- ) : ( -
- {filtered.map((h) => )} -
- )} - {board.length > 0 && filtered.length === 0 && ( -

No houses match “{q}”.

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

Live feed (all events)

- - - {connected ? 'Live' : 'Offline'} - -
- {events.length === 0 ? ( -

Waiting for shard events…

- ) : ( -
    - {events.map((e) => ( -
  • - {kindLabel(e.kind)} - {describe(e)} - {ago(e.t)} -
  • - ))} -
- )} -
- ) -} - -// uo-link sidecar control panel. The auth token is write-only over this API — -// stored encrypted, never returned — same convention as the Discord bot token. -// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take -// effect immediately with no redeploy. - -function Toggle({ checked, onChange, label }) { - return ( - - ) -} - -const STATUS_COLOR = { - connected: '#7fd0a4', - reconnecting: '#e0b070', - error: '#d98b84', - disconnected: 'var(--muted)', -} - -function StatusPanel({ config }) { - const color = STATUS_COLOR[config.status] || 'var(--muted)' - const ingest = config.ingest || {} - const health = config.health || {} - return ( -
-
- - - {config.status || 'disconnected'} - -
- {config.statusDetail && ( -

{config.statusDetail}

- )} -
- Shard link: {config.pluginConnected ? 'up' : 'down'} - WS ingest: {ingest.connected ? 'connected' : 'offline'} - Reconnects: {ingest.reconnects ?? 0} - SSE clients: {(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)} - {config.lastEventAt && Last event: {new Date(config.lastEventAt).toLocaleString()}} - {health.uptime && Sidecar uptime: {health.uptime}} -
-
- ) -} - -// ── Town crier ────────────────────────────────────────────────────────────── -function TownCrier() { - const [id, setId] = useState('') - const [text, setText] = useState('') - const [durationSec, setDurationSec] = useState(3600) - const [busy, setBusy] = useState(false) - const [msg, setMsg] = useState('') - const [error, setError] = useState('') - - async function post() { - setBusy(true); setMsg(''); setError('') - const lines = text.split('\n').map((l) => l.trim()).filter(Boolean) - if (!id.trim() || lines.length === 0) { - setBusy(false) - return setError('An id and at least one line are required.') - } - try { - await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined }) - setMsg(`Posted “${id.trim()}”.`) - } catch (err) { - setError(err.message || 'Could not post.') - } finally { - setBusy(false) - } - } - async function remove() { - if (!id.trim()) return setError('Enter the id to remove.') - setBusy(true); setMsg(''); setError('') - try { - await api.admin.deleteTownCrier(id.trim()) - setMsg(`Removed “${id.trim()}”.`) - } catch (err) { - setError(err.message || 'Could not remove.') - } finally { - setBusy(false) - } - } - - return ( -
-

Town crier

-

- Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it. -

- -