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>
339 lines
11 KiB
JavaScript
339 lines
11 KiB
JavaScript
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>
|
|
)
|
|
}
|