Protocol 3.0 §5 (docs/link/v3.md). The shard publishes its own ruleset —
expansion, which optional systems are on, skill/stat caps, account and house
limits, champion scroll rules, the save/restart schedule — and the site renders
it, so the rules page cannot drift from how the shard actually plays.
Server
- shard_ruleset: a singleton table (id = 1) holding the whole frame in
`payload`, with `rev` and `expansion` hoisted. Nothing is normalized out:
the frame is a flat description of config read as one page, and splitting it
into columns would mean a schema change every time the shard grows a block.
- shardIngest routes world.ruleset to setRuleset and deliberately does NOT
log it — the shard re-emits the whole ruleset on every sidecar connect, so
logging would append a duplicate row per reconnect, and server.hello already
marks each of those.
- uoLinkSocket backfills GET /ruleset explicitly rather than via snapshot(),
which asserts an array; this covers the order where the sidecar was already
up and holding the ruleset when we reconnected.
- GET /public/shard/ruleset behind requireFeature('ruleset') and projected,
per §3.6.1's rule that a shard read which doesn't project is a bug. `null`
means the shard has never published one — a real answer, distinct from a
published ruleset, and the page says so.
Client
- routes/public/Rules.jsx at /site/rules, live via world.ruleset (a frame is a
complete ruleset, not a delta, so the newest one wins outright). Caps are
rendered from tenths — 7000 is 700.0, and showing the raw number would
mislead. A systems key this build doesn't know still renders, humanised, so
a newer plugin can't go invisible against an older client.
- Nav entry gated on the `ruleset` feature, so it hides rather than 403s.
Verified end to end against the local MariaDB and a sidecar fed by a fake shard:
backfill snapshot, live SSE delivery of a changed ruleset, REST reflecting the
overwrite, an empty /feed (not logged), and the gate — 200 by default, 403 at
audience=staff (and dropped from /features so nav hides it), 404 when disabled.
Page rendered clean at all breakpoints checked, no console errors.
497 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
342 lines
11 KiB
JavaScript
342 lines
11 KiB
JavaScript
import { useMemo } from 'react'
|
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
|
import PageHeader from '../../components/PageHeader.jsx'
|
|
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'
|
|
|
|
// 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>
|
|
)
|
|
}
|