Merge remote-tracking branch 'origin/edge' into feat/spawn-atlas-parse
This commit is contained in:
@@ -22,6 +22,7 @@ import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
|||||||
import Guilds from './routes/public/Guilds.jsx'
|
import Guilds from './routes/public/Guilds.jsx'
|
||||||
import Governors from './routes/public/Governors.jsx'
|
import Governors from './routes/public/Governors.jsx'
|
||||||
import Houses from './routes/public/Houses.jsx'
|
import Houses from './routes/public/Houses.jsx'
|
||||||
|
import Rules from './routes/public/Rules.jsx'
|
||||||
import Wiki from './routes/wiki/Wiki.jsx'
|
import Wiki from './routes/wiki/Wiki.jsx'
|
||||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||||
import CmsPage from './routes/public/CmsPage.jsx'
|
import CmsPage from './routes/public/CmsPage.jsx'
|
||||||
@@ -98,6 +99,7 @@ export default function App() {
|
|||||||
<Route path="/site/guilds" element={<Guilds />} />
|
<Route path="/site/guilds" element={<Guilds />} />
|
||||||
<Route path="/site/governors" element={<Governors />} />
|
<Route path="/site/governors" element={<Governors />} />
|
||||||
<Route path="/site/houses" element={<Houses />} />
|
<Route path="/site/houses" element={<Houses />} />
|
||||||
|
<Route path="/site/rules" element={<Rules />} />
|
||||||
<Route path="/wiki" element={<Wiki />} />
|
<Route path="/wiki" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ export const api = {
|
|||||||
},
|
},
|
||||||
presence: () => req('/public/shard/presence'),
|
presence: () => req('/public/shard/presence'),
|
||||||
houses: () => req('/public/shard/houses'),
|
houses: () => req('/public/shard/houses'),
|
||||||
|
// Protocol 3.0: the shard's published ruleset. Resolves to null when the
|
||||||
|
// shard has never published one — a real answer, not an error.
|
||||||
|
ruleset: () => req('/public/shard/ruleset'),
|
||||||
// Which shard surfaces this caller may reach, plus the audience rung they
|
// Which shard surfaces this caller may reach, plus the audience rung they
|
||||||
// resolved to. Drives nav so we never render a link that would 403.
|
// resolved to. Drives nav so we never render a link that would 403.
|
||||||
features: () => req('/public/shard/features'),
|
features: () => req('/public/shard/features'),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const NAV = [
|
|||||||
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
||||||
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
|
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
|
||||||
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
|
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
|
||||||
|
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' },
|
||||||
{ label: 'About', to: '/site/about' },
|
{ label: 'About', to: '/site/about' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
341
client/src/routes/public/Rules.jsx
Normal file
341
client/src/routes/public/Rules.jsx
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -597,6 +597,24 @@ CREATE TABLE IF NOT EXISTS shard_presence (
|
|||||||
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- The shard's published ruleset (Protocol 3.0 world.ruleset). Singleton row
|
||||||
|
-- (id = 1) holding the latest frame: expansion, which optional systems are on,
|
||||||
|
-- skill/stat caps, account and house limits, champion scroll rules, the
|
||||||
|
-- save/restart schedule. The shard re-emits it on every sidecar connect, so this
|
||||||
|
-- row is simply overwritten; `rev` is the shard's own FNV-1a of the body, which
|
||||||
|
-- distinguishes "same ruleset, re-sent on reconnect" from "an operator changed a
|
||||||
|
-- .cfg". No row at all means the shard has never published one — served as null,
|
||||||
|
-- which the rules page renders differently from a published ruleset.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_ruleset (
|
||||||
|
id INT PRIMARY KEY DEFAULT 1,
|
||||||
|
rev VARCHAR(32) NULL,
|
||||||
|
expansion VARCHAR(16) NULL, -- hoisted for cheap display
|
||||||
|
payload JSON NOT NULL, -- the whole world.ruleset frame
|
||||||
|
t BIGINT NULL, -- frame time, epoch ms
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_shard_ruleset_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
|
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
|
||||||
-- per feature; an absent row means "use the compiled default", and the compiled
|
-- per feature; an absent row means "use the compiled default", and the compiled
|
||||||
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
|
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
|
||||||
|
|||||||
@@ -1901,6 +1901,12 @@
|
|||||||
"handlers": 2,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/ruleset",
|
||||||
|
"handlers": 2,
|
||||||
|
"gates": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/status",
|
"path": "/api/v1/public/shard/status",
|
||||||
|
|||||||
@@ -781,6 +781,10 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/presence"
|
"path": "/api/v1/public/shard/presence"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/ruleset"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/status"
|
"path": "/api/v1/public/shard/status"
|
||||||
|
|||||||
@@ -259,6 +259,25 @@ async function latestPresence() {
|
|||||||
return rows[0] || null
|
return rows[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shard ruleset (Protocol 3.0 world.ruleset) ─────────────────────────────
|
||||||
|
// Singleton, same shape as shard_presence: the shard re-emits the whole frame on
|
||||||
|
// every connect, so there is nothing to merge — the latest one wins outright.
|
||||||
|
async function setRuleset({ rev, expansion, payload, t }) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO shard_ruleset (id, rev, expansion, payload, t) VALUES (1, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE rev = VALUES(rev), expansion = VALUES(expansion),
|
||||||
|
payload = VALUES(payload), t = VALUES(t)`,
|
||||||
|
[rev ?? null, expansion ?? null, payload, Number.isFinite(t) ? t : null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRuleset() {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT rev, expansion, payload, t, updated_at FROM shard_ruleset WHERE id = 1',
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
upsertOnline,
|
upsertOnline,
|
||||||
removeOnline,
|
removeOnline,
|
||||||
@@ -290,6 +309,8 @@ module.exports = {
|
|||||||
listGovernorTerms,
|
listGovernorTerms,
|
||||||
setPresence,
|
setPresence,
|
||||||
latestPresence,
|
latestPresence,
|
||||||
|
setRuleset,
|
||||||
|
getRuleset,
|
||||||
upsertChamp,
|
upsertChamp,
|
||||||
removeChamp,
|
removeChamp,
|
||||||
clearChamps,
|
clearChamps,
|
||||||
|
|||||||
@@ -508,6 +508,35 @@ async function latestPresence() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shard ruleset (Protocol 3.0 world.ruleset) ─────────────────────────────
|
||||||
|
//
|
||||||
|
// The whole frame is stored in `payload` and served back whole. Nothing is
|
||||||
|
// normalized out of it: it 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 new block. `rev` and `expansion` are hoisted only because they are cheap to
|
||||||
|
// index/display, following shard_champs' payload-plus-hoisted-columns pattern.
|
||||||
|
async function setRuleset(ev) {
|
||||||
|
if (!ev) return
|
||||||
|
await db.setRuleset({
|
||||||
|
rev: ev.rev ?? null,
|
||||||
|
expansion: ev.expansion ?? null,
|
||||||
|
payload: JSON.stringify(ev),
|
||||||
|
t: ev.t,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored ruleset, or null when the shard has never published one (an old
|
||||||
|
// plugin, or Bridge.RulesetEnabled=false). Null is a real answer here — the page
|
||||||
|
// says "not published yet" rather than rendering an empty ruleset as if the shard
|
||||||
|
// had no rules — so it is deliberately not smoothed into {}.
|
||||||
|
async function getRuleset() {
|
||||||
|
const r = await db.getRuleset()
|
||||||
|
if (!r) return null
|
||||||
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||||
|
if (!payload) return null
|
||||||
|
return { ...payload, updatedAt: r.updated_at }
|
||||||
|
}
|
||||||
|
|
||||||
function safeJson(s) {
|
function safeJson(s) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(s)
|
return JSON.parse(s)
|
||||||
@@ -557,4 +586,6 @@ module.exports = {
|
|||||||
replaceGovernors,
|
replaceGovernors,
|
||||||
setPresence,
|
setPresence,
|
||||||
latestPresence,
|
latestPresence,
|
||||||
|
setRuleset,
|
||||||
|
getRuleset,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,31 @@ async function getHouses(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/ruleset — the shard's published ruleset (Protocol 3.0):
|
||||||
|
// expansion, which optional systems are on, skill/stat caps, account and house
|
||||||
|
// limits, champion scroll rules, the save/restart schedule. Served from our own
|
||||||
|
// store, so it renders while the shard is down; live via world.ruleset on the
|
||||||
|
// public SSE stream.
|
||||||
|
//
|
||||||
|
// `null` means the shard has never published one (an old plugin, or
|
||||||
|
// Bridge.RulesetEnabled=false) — a real answer, distinct from a published
|
||||||
|
// ruleset, and the page says so rather than rendering an empty one.
|
||||||
|
//
|
||||||
|
// Projected like every other shard read (§3.6.1's rule: a read path that returns
|
||||||
|
// shard data and does not call projectFeature is a bug). The `connect` string is
|
||||||
|
// the one configurable field — an operator who published a connect address may
|
||||||
|
// still want it behind a login.
|
||||||
|
async function getRuleset(req, res) {
|
||||||
|
try {
|
||||||
|
const ruleset = await shardState.getRuleset()
|
||||||
|
if (!ruleset) return res.json(null)
|
||||||
|
return res.json(await visibility.project('ruleset', ruleset, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getRuleset', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET /public/shard/features — the shard features THIS caller can actually see,
|
// GET /public/shard/features — the shard features THIS caller can actually see,
|
||||||
// so the SPA (and the Android client) can hide nav entries instead of rendering
|
// so the SPA (and the Android client) can hide nav entries instead of rendering
|
||||||
// links that 403. Deliberately reports only what the viewer may reach: the list
|
// links that 403. Deliberately reports only what the viewer may reach: the list
|
||||||
@@ -270,6 +295,7 @@ module.exports = {
|
|||||||
getGovernorHistory,
|
getGovernorHistory,
|
||||||
getPresence,
|
getPresence,
|
||||||
getHouses,
|
getHouses,
|
||||||
|
getRuleset,
|
||||||
getFeatures,
|
getFeatures,
|
||||||
stream,
|
stream,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,15 @@ shardRouter.get(
|
|||||||
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||||
shard.getHouses,
|
shard.getHouses,
|
||||||
)
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/ruleset',
|
||||||
|
requireFeature('ruleset'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'The shard\'s published ruleset (expansion, systems, caps, limits)'
|
||||||
|
// #swagger.description = 'How this shard is actually configured, published by the shard itself as one world.ruleset frame: expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules and the save/restart schedule. Served from our own store, so it renders while the shard is down; live via world.ruleset on /shard/stream. Returns `null` if the shard has never published one (an older plugin, or Bridge.RulesetEnabled=false) — distinct from a published ruleset, and the page renders it differently.'
|
||||||
|
/* #swagger.responses[200] = { description: 'The ruleset, or null if never published', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */
|
||||||
|
shard.getRuleset,
|
||||||
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/features',
|
'/features',
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
|||||||
@@ -173,6 +173,14 @@ async function applyStateChange(event, deps) {
|
|||||||
case 'house.remove':
|
case 'house.remove':
|
||||||
await shardState.removeHouse(event.serial)
|
await shardState.removeHouse(event.serial)
|
||||||
return
|
return
|
||||||
|
// ── Protocol 3.0 ─────────────────────────────────────────────────────
|
||||||
|
// The shard re-emits its whole ruleset on every sidecar connect, so this is
|
||||||
|
// an overwrite, not an append — and deliberately NOT in LOGGED_KINDS: it
|
||||||
|
// would put a duplicate row in the event log on every reconnect, and
|
||||||
|
// server.hello already marks each of those.
|
||||||
|
case 'world.ruleset':
|
||||||
|
await shardState.setRuleset(event)
|
||||||
|
return
|
||||||
case 'account.unlinked':
|
case 'account.unlinked':
|
||||||
// A player ran [unlink in game (or a site-side unlink echoed back) — drop
|
// A player ran [unlink in game (or a site-side unlink echoed back) — drop
|
||||||
// our local link mirror so attribution stops immediately.
|
// our local link mirror so attribution stops immediately.
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ const getGuilds = () => call('/guilds')
|
|||||||
const getGovernors = () => call('/governors')
|
const getGovernors = () => call('/governors')
|
||||||
const getHouses = () => call('/houses')
|
const getHouses = () => call('/houses')
|
||||||
const getPresence = () => call('/online') // aggregate population (count + byFacet/byRegion)
|
const getPresence = () => call('/online') // aggregate population (count + byFacet/byRegion)
|
||||||
|
// Protocol 3.0: the shard's published ruleset. Object-shaped, not a board — the
|
||||||
|
// sidecar answers `{ ruleset: null }` until the shard has published one.
|
||||||
|
const getRuleset = () => call('/ruleset')
|
||||||
|
|
||||||
// ── Commands ──────────────────────────────────────────────────────────────
|
// ── Commands ──────────────────────────────────────────────────────────────
|
||||||
const confirmLink = (code, websiteUserId) =>
|
const confirmLink = (code, websiteUserId) =>
|
||||||
@@ -191,6 +194,7 @@ module.exports = {
|
|||||||
getGovernors,
|
getGovernors,
|
||||||
getHouses,
|
getHouses,
|
||||||
getPresence,
|
getPresence,
|
||||||
|
getRuleset,
|
||||||
confirmLink,
|
confirmLink,
|
||||||
linkLookup,
|
linkLookup,
|
||||||
createAccount,
|
createAccount,
|
||||||
|
|||||||
@@ -88,6 +88,17 @@ async function backfill() {
|
|||||||
await snapshot(() => uoLinkClient.getGovernors(), 'cities', (c) => shardState.replaceGovernors(c), 'snapshotted governor board from /governors')
|
await snapshot(() => uoLinkClient.getGovernors(), 'cities', (c) => shardState.replaceGovernors(c), 'snapshotted governor board from /governors')
|
||||||
await snapshot(() => uoLinkClient.getHouses(), 'houses', ingestEach, 'snapshotted house registry from /houses')
|
await snapshot(() => uoLinkClient.getHouses(), 'houses', ingestEach, 'snapshotted house registry from /houses')
|
||||||
|
|
||||||
|
// ── Protocol 3.0 ─────────────────────────────────────────────────────
|
||||||
|
// The ruleset is object-shaped, not a board, so it can't go through
|
||||||
|
// snapshot() (which asserts an array under `key`). The shard also re-emits
|
||||||
|
// world.ruleset on its own connect — this covers the other order, where the
|
||||||
|
// sidecar was already up and holding the ruleset when WE reconnected.
|
||||||
|
const ruleset = await uoLinkClient.getRuleset()
|
||||||
|
if (ruleset.ok && ruleset.data && ruleset.data.ruleset) {
|
||||||
|
await shardState.setRuleset(ruleset.data.ruleset)
|
||||||
|
log.info('snapshotted shard ruleset from /ruleset', { rev: ruleset.data.ruleset.rev })
|
||||||
|
}
|
||||||
|
|
||||||
const presence = await uoLinkClient.getPresence()
|
const presence = await uoLinkClient.getPresence()
|
||||||
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
|
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
|
||||||
await shardState.setPresence(presence.data)
|
await shardState.setPresence(presence.data)
|
||||||
|
|||||||
@@ -11335,6 +11335,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/public/shard/ruleset": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Public · Shard"
|
||||||
|
],
|
||||||
|
"summary": "The shard\\'s published ruleset (expansion, systems, caps, limits)",
|
||||||
|
"description": "How this shard is actually configured, published by the shard itself as one world.ruleset frame: expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules and the save/restart schedule. Served from our own store, so it renders while the shard is down; live via world.ruleset on /shard/stream. Returns `null` if the shard has never published one (an older plugin, or Bridge.RulesetEnabled=false) — distinct from a published ruleset, and the page renders it differently.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The ruleset, or null if never published",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"nullable": true,
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/public/shard/status": {
|
"/api/v1/public/shard/status": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const originals = {
|
|||||||
listIdoc: shardState.listIdoc,
|
listIdoc: shardState.listIdoc,
|
||||||
onlineCount: shardState.onlineCount,
|
onlineCount: shardState.onlineCount,
|
||||||
latestEconomy: shardState.latestEconomy,
|
latestEconomy: shardState.latestEconomy,
|
||||||
|
getRuleset: shardState.getRuleset,
|
||||||
getSafe: uoLinkConfig.getSafe,
|
getSafe: uoLinkConfig.getSafe,
|
||||||
}
|
}
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -63,6 +64,7 @@ afterEach(() => {
|
|||||||
shardState.listIdoc = originals.listIdoc
|
shardState.listIdoc = originals.listIdoc
|
||||||
shardState.onlineCount = originals.onlineCount
|
shardState.onlineCount = originals.onlineCount
|
||||||
shardState.latestEconomy = originals.latestEconomy
|
shardState.latestEconomy = originals.latestEconomy
|
||||||
|
shardState.getRuleset = originals.getRuleset
|
||||||
uoLinkConfig.getSafe = originals.getSafe
|
uoLinkConfig.getSafe = originals.getSafe
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -243,6 +245,66 @@ test('getIdoc preserves Date columns rather than flattening them to {}', async (
|
|||||||
assert.equal(res.body[0].updatedAt.toISOString(), when.toISOString())
|
assert.equal(res.body[0].updatedAt.toISOString(), when.toISOString())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── getRuleset: "never published" is a real answer ──────────────────────
|
||||||
|
test('getRuleset serves null when the shard has never published a ruleset', async () => {
|
||||||
|
shardState.getRuleset = async () => null
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
|
||||||
|
// Deliberately null, not {} — the page says "not published yet" rather than
|
||||||
|
// rendering an empty ruleset as though the shard had no rules.
|
||||||
|
assert.equal(res.body, null)
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getRuleset serves the published ruleset whole, nested blocks intact', async () => {
|
||||||
|
shardState.getRuleset = async () => ({
|
||||||
|
kind: 'world.ruleset',
|
||||||
|
rev: '1a2b3c4d',
|
||||||
|
shard: 'UOMysticmoon',
|
||||||
|
expansion: 'EJ',
|
||||||
|
systems: { cityLoyalty: true, vvv: true, factions: false },
|
||||||
|
caps: { skill: 1000, totalSkill: 7000, stat: 225 },
|
||||||
|
champions: { powerScrolls: 6, rankThresholds: [5, 10, 13] },
|
||||||
|
})
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
|
||||||
|
assert.equal(res.body.expansion, 'EJ')
|
||||||
|
assert.equal(res.body.systems.vvv, true)
|
||||||
|
assert.equal(res.body.caps.totalSkill, 7000)
|
||||||
|
// Arrays must survive projection as arrays, not become objects.
|
||||||
|
assert.deepEqual(res.body.champions.rankThresholds, [5, 10, 13])
|
||||||
|
})
|
||||||
|
|
||||||
|
// §3.6.1's rule: a read path that returns shard data and does not project is a
|
||||||
|
// bug. The ruleset frame carries no actor today, but it goes through the same
|
||||||
|
// gate — so a future block that does cannot leak.
|
||||||
|
test('getRuleset projects: acct/webId never survive below admin', async () => {
|
||||||
|
shardState.getRuleset = async () => ({
|
||||||
|
expansion: 'EJ',
|
||||||
|
connect: 'play.example.com,2593',
|
||||||
|
owner: { name: 'Lord British', acct: 'lb_acct', webId: 7 },
|
||||||
|
})
|
||||||
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getRuleset({ viewerLevel: level }, res)
|
||||||
|
assert.equal(res.body.owner.acct, undefined, `${level} saw acct`)
|
||||||
|
assert.equal(res.body.owner.webId, undefined, `${level} saw webId`)
|
||||||
|
// `connect` defaults to the anonymous rung: an operator who published it
|
||||||
|
// meant it to be readable.
|
||||||
|
assert.equal(res.body.connect, 'play.example.com,2593')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getRuleset degrades to a 500 when the model fails, without throwing', async () => {
|
||||||
|
shardState.getRuleset = async () => {
|
||||||
|
throw new Error('pool down')
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
|
||||||
|
assert.equal(res.statusCode, 500)
|
||||||
|
assert.equal(res.body.message, 'Internal Server Error')
|
||||||
|
})
|
||||||
|
|
||||||
// ── getStatus assembles the summary ─────────────────────────────────────
|
// ── getStatus assembles the summary ─────────────────────────────────────
|
||||||
test('getStatus merges the sidecar config with the online count and latest economy', async () => {
|
test('getStatus merges the sidecar config with the online count and latest economy', async () => {
|
||||||
uoLinkConfig.getSafe = async () => ({
|
uoLinkConfig.getSafe = async () => ({
|
||||||
|
|||||||
98
server/test/shardIngest.ruleset.test.js
Normal file
98
server/test/shardIngest.ruleset.test.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const { test, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const shardIngest = require('../src/utils/shardIngest')
|
||||||
|
|
||||||
|
// Protocol 3.0 world.ruleset routing. Same shape as shardIngest.protocol2.test.js:
|
||||||
|
// stubbed deps, asserting where the dispatcher sends the frame and whether it is
|
||||||
|
// appended to the event log.
|
||||||
|
function makeDeps() {
|
||||||
|
const calls = { rulesetSet: [], appended: [], broadcast: [] }
|
||||||
|
const noop = async () => {}
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||||
|
shardState: {
|
||||||
|
setRuleset: async (ev) => { calls.rulesetSet.push(ev) },
|
||||||
|
// Present so any stray routing is a harmless no-op.
|
||||||
|
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
|
||||||
|
addEconomySample: noop,
|
||||||
|
},
|
||||||
|
shardLinks: { removeByAccount: noop },
|
||||||
|
uoLinkConfig: { recordStatus: noop },
|
||||||
|
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||||
|
pushDispatch: async () => {},
|
||||||
|
log: { warn() {}, info() {}, error() {} },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRAME = {
|
||||||
|
kind: 'world.ruleset',
|
||||||
|
t: 1000,
|
||||||
|
rev: '1a2b3c4d',
|
||||||
|
shard: 'UOMysticmoon',
|
||||||
|
expansion: 'EJ',
|
||||||
|
systems: { cityLoyalty: true, vvv: true, factions: false },
|
||||||
|
caps: { skill: 1000, totalSkill: 7000 },
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => shardIngest.reset())
|
||||||
|
|
||||||
|
test('world.ruleset routes to setRuleset with the whole frame', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(FRAME, deps)
|
||||||
|
assert.equal(deps.calls.rulesetSet.length, 1)
|
||||||
|
const stored = deps.calls.rulesetSet[0]
|
||||||
|
assert.equal(stored.rev, '1a2b3c4d')
|
||||||
|
assert.equal(stored.expansion, 'EJ')
|
||||||
|
// The nested blocks must survive intact — the read model serves the frame whole.
|
||||||
|
assert.equal(stored.systems.vvv, true)
|
||||||
|
assert.equal(stored.caps.totalSkill, 7000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The shard re-emits world.ruleset on EVERY sidecar connect. Logging it would put
|
||||||
|
// a duplicate row in shard_events per reconnect, and server.hello already marks
|
||||||
|
// each of those — so this assertion is the guard on that decision.
|
||||||
|
test('world.ruleset is NOT appended to the event log', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
const r = await shardIngest.ingest(FRAME, deps)
|
||||||
|
assert.equal(r.logged, false)
|
||||||
|
assert.equal(deps.calls.appended.length, 0)
|
||||||
|
assert.equal(shardIngest.LOGGED_KINDS.has('world.ruleset'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('world.ruleset is broadcast (the rules page updates live)', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(FRAME, deps)
|
||||||
|
assert.equal(deps.calls.broadcast.length, 1)
|
||||||
|
assert.equal(deps.calls.broadcast[0].kind, 'world.ruleset')
|
||||||
|
})
|
||||||
|
|
||||||
|
// A backfill replay must reach the store but must NOT re-animate the live ticker.
|
||||||
|
test('a backfilled world.ruleset still stores but does not broadcast', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
|
||||||
|
assert.equal(deps.calls.rulesetSet.length, 1)
|
||||||
|
assert.equal(deps.calls.broadcast.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A re-emitted identical ruleset is an overwrite, not an append: two ingests of
|
||||||
|
// the same rev leave one stored frame's worth of state, never a growing log.
|
||||||
|
test('a repeated world.ruleset overwrites rather than accumulating', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(FRAME, deps)
|
||||||
|
await shardIngest.ingest({ ...FRAME, t: 2000 }, deps)
|
||||||
|
assert.equal(deps.calls.appended.length, 0)
|
||||||
|
assert.equal(deps.calls.rulesetSet.length, 2) // two writes...
|
||||||
|
assert.equal(deps.calls.rulesetSet[1].rev, '1a2b3c4d') // ...of the same singleton
|
||||||
|
})
|
||||||
|
|
||||||
|
// A model write that throws must not kill the feed — ingest swallows it and the
|
||||||
|
// frame is still broadcast.
|
||||||
|
test('a setRuleset failure does not throw or stop the broadcast', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
deps.shardState.setRuleset = async () => { throw new Error('db down') }
|
||||||
|
const r = await shardIngest.ingest(FRAME, deps)
|
||||||
|
assert.equal(r.logged, false)
|
||||||
|
assert.equal(deps.calls.broadcast.length, 1)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user