Core no longer fills a slot by name - it offers a contribution and the module that owns the page says where each one goes (MODULE_API 1.6.0, amended). The three slot names are unchanged and stay this module's own vocabulary; what is new is the second argument saying which of core's three contributions belongs in each place. Nothing here worked differently before. The change is for every game that is not this one: core used to fill the literal name uo.guild.detail, so a second module declaring a place under its own id got an empty page and no error. The registration fake gained the same validation core does, including the contribution catalogue - written down rather than imported, since this suite runs against the built chunk with no core in the process, which makes it a claim about core that has to be re-read when core's list changes. 42 client tests, 437 server tests. Co-Authored-By: Claude <noreply@anthropic.com>
221 lines
12 KiB
JavaScript
221 lines
12 KiB
JavaScript
// ── module-uo's client entry point ─────────────────────────────────────────
|
|
//
|
|
// Core injects `dist/entry.js` as a `<script type="module" src>` before
|
|
// `</body>`, this file registers what the module has, and core renders it. The
|
|
// normative contract is MODULE_API.md §3.3.
|
|
//
|
|
// **Registration is synchronous and happens at evaluation time.** Module scripts
|
|
// are deferred, so this runs after core's bundle — which is where `window.__rg`
|
|
// is published — and before DOMContentLoaded, which is what core waits for
|
|
// before its first render. There is no subscription and no late registration: a
|
|
// module that registered asynchronously would register after the routes had been
|
|
// read, and the symptom is a page that redirects home with nothing logged. That
|
|
// bug cost the Phase 2 client PR an afternoon and no unit test in either repo
|
|
// can see it, which is why §7.7's browser smoke exists.
|
|
//
|
|
// So everything below is a plain top-level call, and every page is a static
|
|
// import. Lazy-loading the routes would be the natural instinct for a chunk this
|
|
// size and it is the one thing this seam cannot have.
|
|
|
|
import { registry, coreApiVersion } from './core.js'
|
|
import { IconShard, IconUser } from './icons.jsx'
|
|
import { useShardFlags } from './lib/useShardFeatures.js'
|
|
|
|
// Public pages — the twelve that used to live at /site/*.
|
|
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 Guild from './routes/public/Guild.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'
|
|
|
|
// Admin views.
|
|
import ShardAdmin from './routes/admin/ShardAdmin.jsx'
|
|
import ShardOps from './routes/admin/ShardOps.jsx'
|
|
import ShardVisibility from './routes/admin/ShardVisibility.jsx'
|
|
import SpawnAtlas from './routes/admin/SpawnAtlas.jsx'
|
|
import HousesAdmin from './routes/admin/HousesAdmin.jsx'
|
|
import AdminCharacters from './routes/admin/AdminCharacters.jsx'
|
|
import AdminCharacter from './routes/admin/AdminCharacter.jsx'
|
|
|
|
// Player-portal views.
|
|
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
|
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
|
|
|
// Extension-slot fills (§3.7) — module content inside a core page.
|
|
import ShardStatusLink from './components/ShardStatusLink.jsx'
|
|
import UserShardSections from './routes/admin/UserShardSections.jsx'
|
|
import InviteGameAccountStep from './components/InviteGameAccountStep.jsx'
|
|
|
|
const ID = 'uo'
|
|
|
|
// ── Routes ─────────────────────────────────────────────────────────────────
|
|
//
|
|
// Paths are relative to this module's namespace and core prefixes them:
|
|
// `/uo/…`, `/admin/uo/…`, `/player/uo/…`. A module cannot write the segment its
|
|
// routes hang under however it spells `path`, which is the point.
|
|
//
|
|
// **These SPA paths changed and the API paths did not.** `/site/shard` is now
|
|
// `/uo/shard` and `/admin/shard-ops` is now `/admin/uo/ops` — a clean break with
|
|
// no redirects, settled in MODULE_SYSTEM.md §2.7. Every URL in `api.js` is
|
|
// byte-identical to the one core called, because §1.2 freezes the API surface
|
|
// and the shipped Android app calls seven of these routes.
|
|
//
|
|
// The admin paths lost their `shard-` prefixes on the way through: under a `/uo/`
|
|
// namespace `/admin/uo/shard-visibility` says "shard" twice, and a clean break is
|
|
// the only moment that tidy-up is free.
|
|
//
|
|
// `gate` is core's own RoleGate, applied by core. A module cannot supply an auth
|
|
// wrapper — the sidebar and the route table have to agree about who may see what.
|
|
const STAFF = { roles: ['admin', 'moderator'] }
|
|
|
|
registry.registerRoutes(ID, {
|
|
public: [
|
|
{ path: 'shard', element: <Shard /> },
|
|
{ path: 'shard/activity', element: <ShardActivity /> },
|
|
{ path: 'champs', element: <ChampSpawns /> },
|
|
{ path: 'guilds', element: <Guilds /> },
|
|
{ path: 'guilds/:id', element: <Guild /> },
|
|
{ path: 'governors', element: <Governors /> },
|
|
{ path: 'houses', element: <Houses /> },
|
|
{ path: 'rules', element: <Rules /> },
|
|
{ path: 'atlas', element: <Atlas /> },
|
|
{ path: 'atlas/:slug', element: <AtlasCreature /> },
|
|
{ path: 'leaderboards', element: <Leaderboards /> },
|
|
{ path: 'market', element: <Market /> },
|
|
{ path: 'market/vendors/:serial', element: <MarketVendor /> },
|
|
],
|
|
admin: [
|
|
// Admin-only: the sidecar's configuration, who may see which surface, and
|
|
// the atlas import. No `gate` on the other three because AdminLayout already
|
|
// requires staff and these carry their own role rows below.
|
|
{ path: 'link', element: <ShardAdmin /> },
|
|
{ path: 'visibility', element: <ShardVisibility /> },
|
|
{ path: 'atlas', element: <SpawnAtlas /> },
|
|
{ path: 'ops', element: <ShardOps />, gate: STAFF },
|
|
{ path: 'houses', element: <HousesAdmin />, gate: STAFF },
|
|
// Self-service, and deliberately ungated: a staff member's own characters
|
|
// are theirs to read whatever their role. Staff are a superset of players.
|
|
{ path: 'characters', element: <AdminCharacters /> },
|
|
{ path: 'characters/:serial', element: <AdminCharacter /> },
|
|
],
|
|
player: [
|
|
{ path: 'characters', element: <PlayerCharacters /> },
|
|
{ path: 'characters/:serial', element: <PlayerCharacter /> },
|
|
],
|
|
})
|
|
|
|
// ── Nav ────────────────────────────────────────────────────────────────────
|
|
//
|
|
// Rows interleave into CORE groups rather than appending as a "UO" block, which
|
|
// is what keeps the extraction invisible in the sidebar (MODULE_SYSTEM.md §1.4).
|
|
//
|
|
// `feature` names a flag resolved by the provider registered below — by THIS
|
|
// module, so the strings are the bare names they have always been and nothing
|
|
// parses a namespace out of them.
|
|
registry.registerNav(ID, {
|
|
area: 'public',
|
|
items: [
|
|
{ label: 'Shard', to: '/uo/shard', feature: 'status' },
|
|
{ label: 'Champions', to: '/uo/champs', feature: 'champs' },
|
|
{ label: 'Guilds', to: '/uo/guilds', feature: 'guilds' },
|
|
{ label: 'Governors', to: '/uo/governors', feature: 'governors' },
|
|
{ label: 'Houses', to: '/uo/houses', feature: 'houses' },
|
|
{ label: 'Rules', to: '/uo/rules', feature: 'ruleset' },
|
|
{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas' },
|
|
{ label: 'Leaderboards', to: '/uo/leaderboards', feature: 'leaderboards' },
|
|
{ label: 'Market', to: '/uo/market', feature: 'market' },
|
|
],
|
|
})
|
|
|
|
registry.registerNav(ID, {
|
|
area: 'admin',
|
|
items: [
|
|
// Moderation: no `order`, because these two are last in that group today and
|
|
// "append after core's rows" is exactly that — and stays that way if core
|
|
// adds a moderation row later, which an explicit index would not.
|
|
{ label: 'In-Game Ops', to: '/admin/uo/ops', icon: IconShard, group: 'Moderation', roles: ['admin', 'moderator'] },
|
|
{ label: 'Houses', to: '/admin/uo/houses', icon: IconShard, group: 'Moderation', roles: ['admin', 'moderator'] },
|
|
// System: these three sit MID-list, between Discord Bot and Web Bot Activity.
|
|
// Core's rows are keyed by their index and an explicit `order` beats a
|
|
// coincidental one at a tie, so all three asking for 8 — Web Bot Activity's
|
|
// index once the UO rows are gone — lands them ahead of it, in this order.
|
|
{ label: 'Shard (uo-link)', to: '/admin/uo/link', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
|
{ label: 'Shard Visibility', to: '/admin/uo/visibility', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
|
{ label: 'Spawn Atlas', to: '/admin/uo/atlas', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
|
// No group: a trailing untitled group of its own, below core's Account row
|
|
// rather than beside it (§3.3). One position lower than it sits today, and
|
|
// the alternative — letting a module into core's furniture groups — is worse.
|
|
{ label: 'My Characters', to: '/admin/uo/characters', icon: IconShard },
|
|
],
|
|
})
|
|
|
|
registry.registerNav(ID, {
|
|
area: 'player',
|
|
// Order 0: Characters is the portal's first row today, and with the module
|
|
// installed it is also what core's `/player` index resolves to.
|
|
items: [{ label: 'Characters', to: '/player/uo/characters', icon: IconUser, order: 0 }],
|
|
})
|
|
|
|
// ── Feature provider ───────────────────────────────────────────────────────
|
|
//
|
|
// Core keeps a generic flag context and owns none of the semantics. Until this
|
|
// slice core registered this same hook itself under owner id `core`, so that the
|
|
// seam was exercised by real content from the day it was built; the registration
|
|
// moves here and core's is deleted.
|
|
registry.registerFeatureProvider(ID, ID, useShardFlags)
|
|
|
|
// ── Extension slots ────────────────────────────────────────────────────────
|
|
//
|
|
// Three core pages have a piece of this module in them. Each was core's own fill
|
|
// under owner id `core` until this slice, so all three are a swap rather than an
|
|
// addition — and each throws rather than failing open if the slot is unknown or
|
|
// already filled, which is how a slice that forgot to delete core's half finds
|
|
// out immediately instead of rendering core's content forever (§3.7).
|
|
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
|
|
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
|
|
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
|
|
// ── The inverted slot: this module DECLARES, core fills ────────────────────
|
|
//
|
|
// The other three above are core's slots that this module fills. This one is the
|
|
// reverse (TEAMS.md Part 3): Teams are a core primitive that this module
|
|
// populates, but core does not own the word "guild" and publishes no Team page of
|
|
// its own — so the page is ours and core contributes the activity feed to it.
|
|
//
|
|
// Declared under this module's own namespace, which core enforces. The second
|
|
// argument is what gets core's content into the place: **core offers a
|
|
// CONTRIBUTION and never names a slot**, so this module says where each one goes
|
|
// and keeps its own word for the place. Core's fills are applied after every
|
|
// module chunk has evaluated, so declaring here is early enough; on a core that
|
|
// knows nothing of Teams the slot simply stays empty.
|
|
registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
|
|
|
|
// A SECOND place on the same page, for core's Team forum (TEAMS.md Part 5). Two
|
|
// declarations rather than one, because a slot holds one component and this module
|
|
// wants to decide where each of core's two contributions sits on its own page —
|
|
// the feed reads as part of the guild's story, the forum is a room you go into.
|
|
// Neither knows the other exists, and a core that fills only one leaves the other
|
|
// empty.
|
|
registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
|
|
|
|
// And a THIRD, at the top of the same page, for core's per-Team notification
|
|
// control (TEAMS.md §6.3). Same reasoning as the other two and a different place:
|
|
// muting a guild is an action ON this page, so it sits with the page's heading
|
|
// rather than after its content. Core resolves whether this viewer is in the
|
|
// Team at all — this module neither knows nor asks.
|
|
registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' })
|
|
|
|
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
|
// ever served, so there is nothing to re-check here. It is logged because a
|
|
// mismatch between the core that validated the manifest and the core that
|
|
// published this global would otherwise be invisible from the browser, which is
|
|
// where the client half actually fails.
|
|
console.info(`[module-uo] registered against core API ${coreApiVersion}`)
|