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 ( + + + ) +} + +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) +})