Files
Module-uo/client/src/entry.jsx
wtclaude 675e879b48
Some checks failed
PR Checks / frozen-manifest (pull_request) Successful in 1m3s
PR Checks / server-tests (pull_request) Successful in 8m4s
PR Checks / client-build (pull_request) Failing after 14m21s
feat(assets): the panel that operates the client-file imports (Phase 8)
Admin -> Client Files: one page over the three things that come out of the
operator's UO client -- creature portraits, item and land pictures, and the
cliloc table. One page rather than three because they are one job: same client
install, same bridge, and all of them change at the same moment, when the
operator patches that client. Boot never asks the shard for any of it, so these
buttons are the only thing that imports.

The cliloc pair had had no UI at all since phase 2. On a bridged install, where
boot deliberately stopped calling the shard, that meant `curl` was the only way
to load 67,496 names.

Update and Re-import everything are section 6's two stages as two buttons rather
than one button and a checkbox, because they cost wildly different things. A
vanished key is reviewed in the page and not in a table -- an asset import only
happens because someone pressed a button here, so the review is already in front
of the person who caused it -- and it shows each key's PICTURE, since
`body/820/a23` names nothing a human recognises. `shard_asset_meta` gained a
`last` block (what the import did, who ran it) so the panel can answer "did last
week's import do anything" without scrolling core's whole activity log.

The live walk against a real shard imported 1,095 portraits in 3.5 s, warmed 313
item pictures in 0.6 s and reloaded 67,496 cliloc rows in 1.7 s -- and found two
DELETIONS that predate this phase and that no test could see, because only a
screen showing the numbers together makes them visible:

  * The body import diffed its manifest against every family's rows. Phase 5 put
    item and land art in the same table, and a body manifest never mentions
    them, so all 313 item pictures were staged for deletion with a sentence
    saying the shard had stopped offering them.
  * An approved vanish unlinked the sprite and kept the row. The catalogue went
    on counting a picture that was gone, the atlas could point a creature page at
    a missing file, and the next forced import offered the same key for review
    again -- reporting "nothing was changed" about a file it had deleted.

Both fixed here, with the removals now inside `saveAssets`'s own transaction.
The same whole-table read made the panel announce a 1,408-row creature catalogue
on an install holding 1,095 portraits and 313 item pictures.

Protocol stays 8 and EXTRACTOR_VERSION stays 3: nothing on the wire changed.

Refs: docs/link/v8.md sections 12.2, 14, 16 (phase 8)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 08:10:16 -05:00

225 lines
13 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 ClientFiles from './routes/admin/ClientFiles.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, the
// atlas import and the client-file imports. No `gate` on these four because
// AdminLayout already requires staff and they carry their own role rows
// below.
{ path: 'link', element: <ShardAdmin /> },
{ path: 'visibility', element: <ShardVisibility /> },
{ path: 'atlas', element: <SpawnAtlas /> },
{ path: 'files', element: <ClientFiles /> },
{ 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'] },
{ label: 'Client Files', to: '/admin/uo/files', 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}`)