diff --git a/client/src/App.jsx b/client/src/App.jsx
index c37bb73..2e8b4fa 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -22,6 +22,7 @@ import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
+import Rules from './routes/public/Rules.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
@@ -98,6 +99,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
{/* CMS pages: top-level /:slug, matched only after the named routes
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 33343c8..b9ac6dc 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -147,6 +147,9 @@ export const api = {
},
presence: () => req('/public/shard/presence'),
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
// resolved to. Drives nav so we never render a link that would 403.
features: () => req('/public/shard/features'),
diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx
index 58a1dc6..f3bf8eb 100644
--- a/client/src/components/SiteHeader.jsx
+++ b/client/src/components/SiteHeader.jsx
@@ -23,6 +23,7 @@ const NAV = [
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
+ { label: 'Rules', to: '/site/rules', feature: 'ruleset' },
{ label: 'About', to: '/site/about' },
]
diff --git a/client/src/routes/public/Rules.jsx b/client/src/routes/public/Rules.jsx
new file mode 100644
index 0000000..87618f6
--- /dev/null
+++ b/client/src/routes/public/Rules.jsx
@@ -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 (
+
+
+ {title}
+
+ {children}
+
+ )
+}
+
+// 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 (
+
+ {label}
+ {value}
+
+ )
+}
+
+function Rows({ items }) {
+ const rows = items.filter(([, value]) => value !== null && value !== undefined)
+ if (rows.length === 0) return null
+ return (
+
+ {rows.map(([label, value]) => (
+
+ ))}
+
+ )
+}
+
+function SystemPill({ label, on }) {
+ const color = on ? '#8fdcae' : 'var(--muted)'
+ return (
+
+
+ {label}
+
+ )
+}
+
+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 (
+
+
+ {keys.map((k) => (
+
+ ))}
+
+
+ )
+}
+
+function Caps({ caps }) {
+ return (
+
+
+
+ )
+}
+
+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 (
+
+
+
+ )
+}
+
+function Champions({ champions }) {
+ const t = champions.rankThresholds
+ return (
+
+ 0 ? t.join(' · ') : null,
+ ],
+ ]}
+ />
+
+ )
+}
+
+function Felucca({ loot }) {
+ return (
+
+
+
+ )
+}
+
+function Vendors({ vendors }) {
+ return (
+
+
+
+ )
+}
+
+function Pvp({ vvv }) {
+ return (
+
+
+
+ )
+}
+
+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 (
+
+
+
+ )
+}
+
+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 (
+
+
+
+
+
+
+ {connected ? 'Live' : 'Offline'}
+
+
+
+ {loading &&
}
+ {error &&
}
+
+ {!loading && !error && !ruleset && (
+
+
+ The shard has not published its ruleset yet.
+
+
+ )}
+
+ {!loading && !error && ruleset && (
+
+
+
+
+
+ {ruleset.systems &&
}
+ {ruleset.caps &&
}
+
+ {ruleset.champions &&
}
+ {ruleset.loot &&
}
+ {ruleset.vendors &&
}
+ {ruleset.vvv?.enabled &&
}
+ {ruleset.schedule &&
}
+
+ )}
+
+
+ )
+}
diff --git a/server/db/schema.sql b/server/db/schema.sql
index e01717b..430827a 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -597,6 +597,24 @@ CREATE TABLE IF NOT EXISTS shard_presence (
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
) 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; an absent row means "use the compiled default", and the compiled
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
diff --git a/server/routes.guards.json b/server/routes.guards.json
index fb863e2..3be5846 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -1901,6 +1901,12 @@
"handlers": 2,
"gates": []
},
+ {
+ "method": "GET",
+ "path": "/api/v1/public/shard/ruleset",
+ "handlers": 2,
+ "gates": []
+ },
{
"method": "GET",
"path": "/api/v1/public/shard/status",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index 69fb934..0ce4477 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -781,6 +781,10 @@
"method": "GET",
"path": "/api/v1/public/shard/presence"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/public/shard/ruleset"
+ },
{
"method": "GET",
"path": "/api/v1/public/shard/status"
diff --git a/server/src/model/shardState/shardState.db.js b/server/src/model/shardState/shardState.db.js
index c834bb6..51dc014 100644
--- a/server/src/model/shardState/shardState.db.js
+++ b/server/src/model/shardState/shardState.db.js
@@ -259,6 +259,25 @@ async function latestPresence() {
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 = {
upsertOnline,
removeOnline,
@@ -290,6 +309,8 @@ module.exports = {
listGovernorTerms,
setPresence,
latestPresence,
+ setRuleset,
+ getRuleset,
upsertChamp,
removeChamp,
clearChamps,
diff --git a/server/src/model/shardState/shardState.model.js b/server/src/model/shardState/shardState.model.js
index 1cce781..0c9d1a5 100644
--- a/server/src/model/shardState/shardState.model.js
+++ b/server/src/model/shardState/shardState.model.js
@@ -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) {
try {
return JSON.parse(s)
@@ -557,4 +586,6 @@ module.exports = {
replaceGovernors,
setPresence,
latestPresence,
+ setRuleset,
+ getRuleset,
}
diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js
index 9cc1a7d..c114318 100644
--- a/server/src/router/v1/public/shard.controller.js
+++ b/server/src/router/v1/public/shard.controller.js
@@ -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,
// 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
@@ -270,6 +295,7 @@ module.exports = {
getGovernorHistory,
getPresence,
getHouses,
+ getRuleset,
getFeatures,
stream,
}
diff --git a/server/src/router/v1/public/shard.router.js b/server/src/router/v1/public/shard.router.js
index 49da8ca..37e3806 100644
--- a/server/src/router/v1/public/shard.router.js
+++ b/server/src/router/v1/public/shard.router.js
@@ -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" } } } } } */
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(
'/features',
// #swagger.tags = ['Public · Shard']
diff --git a/server/src/utils/shardIngest.js b/server/src/utils/shardIngest.js
index 08ac7ba..abab232 100644
--- a/server/src/utils/shardIngest.js
+++ b/server/src/utils/shardIngest.js
@@ -173,6 +173,14 @@ async function applyStateChange(event, deps) {
case 'house.remove':
await shardState.removeHouse(event.serial)
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':
// A player ran [unlink in game (or a site-side unlink echoed back) — drop
// our local link mirror so attribution stops immediately.
diff --git a/server/src/utils/uoLinkClient.js b/server/src/utils/uoLinkClient.js
index 0a44099..88a100a 100644
--- a/server/src/utils/uoLinkClient.js
+++ b/server/src/utils/uoLinkClient.js
@@ -128,6 +128,9 @@ const getGuilds = () => call('/guilds')
const getGovernors = () => call('/governors')
const getHouses = () => call('/houses')
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 ──────────────────────────────────────────────────────────────
const confirmLink = (code, websiteUserId) =>
@@ -191,6 +194,7 @@ module.exports = {
getGovernors,
getHouses,
getPresence,
+ getRuleset,
confirmLink,
linkLookup,
createAccount,
diff --git a/server/src/utils/uoLinkSocket.js b/server/src/utils/uoLinkSocket.js
index b4822db..5b92b11 100644
--- a/server/src/utils/uoLinkSocket.js
+++ b/server/src/utils/uoLinkSocket.js
@@ -88,6 +88,17 @@ async function backfill() {
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')
+ // ── 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()
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
await shardState.setPresence(presence.data)
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 16d6c0c..b98da8a 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -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": {
"get": {
"tags": [
diff --git a/server/test/shardControllerPublic.test.js b/server/test/shardControllerPublic.test.js
index f1b39f9..da2dd6d 100644
--- a/server/test/shardControllerPublic.test.js
+++ b/server/test/shardControllerPublic.test.js
@@ -56,6 +56,7 @@ const originals = {
listIdoc: shardState.listIdoc,
onlineCount: shardState.onlineCount,
latestEconomy: shardState.latestEconomy,
+ getRuleset: shardState.getRuleset,
getSafe: uoLinkConfig.getSafe,
}
afterEach(() => {
@@ -63,6 +64,7 @@ afterEach(() => {
shardState.listIdoc = originals.listIdoc
shardState.onlineCount = originals.onlineCount
shardState.latestEconomy = originals.latestEconomy
+ shardState.getRuleset = originals.getRuleset
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())
})
+// ── 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 ─────────────────────────────────────
test('getStatus merges the sidecar config with the online count and latest economy', async () => {
uoLinkConfig.getSafe = async () => ({
diff --git a/server/test/shardIngest.ruleset.test.js b/server/test/shardIngest.ruleset.test.js
new file mode 100644
index 0000000..1db9b62
--- /dev/null
+++ b/server/test/shardIngest.ruleset.test.js
@@ -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)
+})