feat(client): the whole client half (phase 3, slice 3)

The 35 files behind twelve public pages, seven admin views, two player views
and three core-page extensions, ported onto `window.__rg`. Every one of them
imports exactly the seven kit members plus `lib/format.js`, which is the
finding §2.7.1 predicted and this confirms.

`client/src/core.js` is the port mechanism, and unlike the server's it is a
plain read: `window.__rg` is published before any module chunk evaluates, so
there is no gap to defer around and a ported component keeps its ordinary
import shape. `client/src/api.js` rebuilds the UO namespaces over the request
primitive — same URLs, because §1.2 freezes the API surface.

SPA paths changed and API paths did not. `/site/shard` is `/uo/shard`, and the
admin paths lost their now-redundant `shard-` prefixes (`/admin/uo/ops`), a
clean break being the only moment that is free.

`shim/rg.js` becomes the single reader of the global, so the "core did not
publish its dependencies" message is reachable from whichever module the
bundler happens to touch first rather than from whichever one is imported
first — a guarantee that used to last until someone sorted the imports.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:00:25 -05:00
parent 050a02c21d
commit 28f4b9afe2
47 changed files with 6031 additions and 70 deletions

View File

@@ -0,0 +1,338 @@
import { useMemo } from 'react'
import { useShardFeed } from '../../lib/useShardFeed.js'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
// The shard ruleset. Loaded from /public/shard/ruleset, replaced wholesale by any
// world.ruleset frame on the live feed (the shard re-emits the entire ruleset, so
// there is nothing to merge — latest wins).
//
// Everything on this page is published BY THE SHARD from its own Config/*.cfg, so
// it cannot drift the way a hand-written rules page does. That is the whole point
// of the feature, and the page says so.
const RULESET_KINDS = new Set(['world.ruleset'])
// Skill and stat caps arrive in tenths, the way ServUO stores them: 1000 is 100.0
// skill. Showing the raw number would be actively misleading.
const tenths = (v) => (Number.isFinite(v) ? (v / 10).toFixed(1) : null)
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : null)
const pct = (v) => (Number.isFinite(v) ? `${v}%` : null)
// The systems block is a flat bag of booleans; these are their display names, and
// the order here is the order they render. A key the shard sends that we don't
// know about still renders, humanised, rather than being silently dropped — a new
// plugin must not go invisible against an older client.
const SYSTEM_LABELS = {
cityLoyalty: 'City Loyalty (governors)',
vvv: 'Vice vs Virtue',
factions: 'Factions',
siege: 'Siege ruleset',
chat: 'In-game chat',
store: 'Ultima Store',
dailyRares: 'Daily rares',
honesty: 'Honesty virtue',
shadowguard: 'Shadowguard',
treasureMaps: 'Treasure maps',
vetRewards: 'Veteran rewards',
testCenter: 'Test Center',
}
const humanise = (key) =>
key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())
function Panel({ title, children }) {
return (
<section className="panel" style={{ padding: 18 }}>
<h2
className="display"
style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}
>
{title}
</h2>
{children}
</section>
)
}
// A label/value row. Rows whose value is null are dropped by the caller, so a
// block never renders a dangling label for something the shard didn't publish.
function Row({ label, value }) {
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 12,
padding: '5px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span className="dim" style={{ minWidth: 0 }}>{label}</span>
<strong style={{ flex: 'none', color: 'var(--head)' }}>{value}</strong>
</div>
)
}
function Rows({ items }) {
const rows = items.filter(([, value]) => value !== null && value !== undefined)
if (rows.length === 0) return null
return (
<div>
{rows.map(([label, value]) => (
<Row key={label} label={label} value={value} />
))}
</div>
)
}
function SystemPill({ label, on }) {
const color = on ? '#8fdcae' : 'var(--muted)'
return (
<span
className="sans"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 7,
fontSize: '0.8rem',
padding: '5px 11px',
borderRadius: 999,
color,
background: on ? 'rgba(95,185,138,0.12)' : 'rgba(140,150,165,0.1)',
border: `1px solid ${on ? 'rgba(95,185,138,0.4)' : 'var(--line)'}`,
}}
>
<span
aria-hidden="true"
style={{ width: 7, height: 7, borderRadius: '50%', background: color, flex: 'none' }}
/>
{label}
</span>
)
}
function Systems({ systems }) {
// Known keys first in their declared order, then anything the shard added that
// this build doesn't know about.
const known = Object.keys(SYSTEM_LABELS).filter((k) => k in systems)
const extra = Object.keys(systems).filter((k) => !(k in SYSTEM_LABELS))
const keys = [...known, ...extra]
if (keys.length === 0) return null
return (
<Panel title="Systems">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{keys.map((k) => (
<SystemPill key={k} label={SYSTEM_LABELS[k] || humanise(k)} on={!!systems[k]} />
))}
</div>
</Panel>
)
}
function Caps({ caps }) {
return (
<Panel title="Skill & stat caps">
<Rows
items={[
['Individual skill cap', tenths(caps.skill)],
['Total skill cap', tenths(caps.totalSkill)],
['Total stat cap', num(caps.stat)],
['Strength cap', num(caps.str)],
['Dexterity cap', num(caps.dex)],
['Intelligence cap', num(caps.int)],
['Strength max', num(caps.strMax)],
['Dexterity max', num(caps.dexMax)],
['Intelligence max', num(caps.intMax)],
]}
/>
</Panel>
)
}
function AccountsAndHousing({ accounts, housing, vetRewards }) {
const items = []
if (accounts) {
items.push(['Accounts per IP', num(accounts.perIp)])
items.push(['Character slots', num(accounts.charSlots)])
items.push([
'In-game account creation',
accounts.autoCreate === undefined ? null : accounts.autoCreate ? 'Enabled' : 'Website only',
])
}
if (housing) items.push(['Houses per account', num(housing.accountHouseLimit)])
if (vetRewards?.enabled) {
items.push(['Veteran reward interval', vetRewards.rewardIntervalDays
? `${vetRewards.rewardIntervalDays} days`
: null])
}
if (items.length === 0) return null
return (
<Panel title="Accounts & housing">
<Rows items={items} />
</Panel>
)
}
function Champions({ champions }) {
const t = champions.rankThresholds
return (
<Panel title="Champion spawns">
<Rows
items={[
['Power scrolls per spawn', num(champions.powerScrolls)],
['Stat scrolls per spawn', num(champions.statScrolls)],
['Scroll drop chance', pct(champions.scrollChance)],
['Transcendence chance', pct(champions.transcendenceChance)],
[
'Red skulls per rank',
Array.isArray(t) && t.length > 0 ? t.join(' · ') : null,
],
]}
/>
</Panel>
)
}
function Felucca({ loot }) {
return (
<Panel title="Felucca bonuses">
<Rows
items={[
['Luck bonus', num(loot.feluccaLuckBonus)],
['Loot budget bonus', num(loot.feluccaBudgetBonus)],
['Max item properties', num(loot.feluccaMaxProps)],
]}
/>
</Panel>
)
}
function Vendors({ vendors }) {
return (
<Panel title="Vendors">
<Rows
items={[
['Restock delay', vendors.restockDelayMinutes
? `${vendors.restockDelayMinutes} min`
: null],
['Max items sold at once', num(vendors.maxSell)],
['Economy stock amount', num(vendors.economyStockAmount)],
]}
/>
</Panel>
)
}
function Pvp({ vvv }) {
return (
<Panel title="Vice vs Virtue">
<Rows
items={[
['Starting silver', num(vvv.startSilver)],
['Enhanced rules', vvv.enhancedRules === undefined
? null
: vvv.enhancedRules ? 'On' : 'Off'],
]}
/>
</Panel>
)
}
function Schedule({ schedule }) {
const items = []
if (schedule.autoSaveEnabled && schedule.autoSaveFrequencyMinutes) {
items.push(['World save', `every ${schedule.autoSaveFrequencyMinutes} min`])
} else if (schedule.autoSaveEnabled === false) {
items.push(['World save', 'Disabled'])
}
if (schedule.autoRestartEnabled) {
const h = String(schedule.autoRestartHour ?? 0).padStart(2, '0')
const m = String(schedule.autoRestartMinute ?? 0).padStart(2, '0')
items.push(['Automatic restart', `${h}:${m} server time`])
if (schedule.autoRestartFrequencyHours) {
items.push(['Restart interval', `every ${schedule.autoRestartFrequencyHours}h`])
}
}
if (items.length === 0) return null
return (
<Panel title="Save & restart schedule">
<Rows items={items} />
</Panel>
)
}
export default function Rules() {
const { loading, error, data } = useAsync(() => api.shard.ruleset())
const { events, connected } = useShardFeed({ filter: RULESET_KINDS, max: 4 })
// The newest world.ruleset on the feed wins outright over the fetched copy —
// the frame is a complete ruleset, not a delta.
const ruleset = useMemo(() => events[0] || data || null, [data, events])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader
eyebrow="Live"
title="Shard ruleset"
lead="Published by the server itself, straight from its configuration — so it cannot drift from how the shard actually plays."
/>
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the shard ruleset right now." />}
{!loading && !error && !ruleset && (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>
The shard has not published its ruleset yet.
</p>
</section>
)}
{!loading && !error && ruleset && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Panel title="Shard">
<Rows
items={[
['Name', ruleset.shard || null],
['Expansion', ruleset.expansion || null],
['Connect', ruleset.connect || null],
]}
/>
</Panel>
{ruleset.systems && <Systems systems={ruleset.systems} />}
{ruleset.caps && <Caps caps={ruleset.caps} />}
<AccountsAndHousing
accounts={ruleset.accounts}
housing={ruleset.housing}
vetRewards={ruleset.vetRewards}
/>
{ruleset.champions && <Champions champions={ruleset.champions} />}
{ruleset.loot && <Felucca loot={ruleset.loot} />}
{ruleset.vendors && <Vendors vendors={ruleset.vendors} />}
{ruleset.vvv?.enabled && <Pvp vvv={ruleset.vvv} />}
{ruleset.schedule && <Schedule schedule={ruleset.schedule} />}
</div>
)}
</div>
</PublicLayout>
)
}