feat(shard): ingest points.board and publish the leaderboards
Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up
Britannia — and the site renders them, plus each character's own standings on
their sheet.
Server
- shard_points_boards: one row per system, keyed by the shard's PointsType
name. The top-N list stays inside `payload` — a fixed-size list read whole,
exactly like shard_governors.candidates. Normalizing into an entries table
buys nothing until something needs a per-character reverse lookup, and a
character's own standings already ride inside char.profile.
- shardIngest routes points.board to upsertPointsBoard and deliberately does
NOT log it: this is board state like guild.update, and the shard emits a
frame every time anyone's score moves a top ten.
- uoLinkSocket backfills /points through snapshot() with ingestEach rather
than a replace*: there is no points.remove and the system set is fixed, so
upserting IS the reconciliation, and a system the operator later excludes
keeps its last-known board rather than vanishing.
- GET /public/shard/points and /points/:system behind
requireFeature('leaderboards'), both projected per §3.6.1. :system is
constrained to an identifier before any query runs; 404 for a system never
published, distinct from a published board nobody has scored in (200, empty
top).
The leaderboards field rule now keys on `name`, not `characterName`
Part A pre-wired FEATURES.leaderboards.fields = { characterName: ... }, but
projectValue matches on the LITERAL JSON key and the wire key is `name`. As
written the rule was inert: an admin tightening character names would have got
no enforcement and no error — precisely the failure §3.6.1 records for the
flattened `ownerAcct` spelling. Fixed, with a test that fails if it is renamed
back, and the admin panel's FIELD_LABEL carries the meaning instead.
Client
- routes/public/Leaderboards.jsx at /site/leaderboards. A points.board frame
describes ONE system, so live frames merge over the fetched set by system
key rather than replacing it wholesale the way the ruleset does. Filter
matches board name, system key, or any ranked player — the last is what
makes it useful ("where do I appear?").
- A "Loyalty & Points" section in CharacterSheet.jsx, one edit serving both
PlayerCharacter and AdminCharacter.
- Both treat maxPoints: 0 as UNCAPPED and both fall back to humanising the
system key when nameString is null. Neither is defensive padding: on a real
shard uncapped and cliloc-only names are the majority case.
Verified end to end against the local MariaDB, the Rust sidecar, and the real
ServUO shard: backfill from /points, live SSE delivery (a board absent from the
initial fetch appearing without a reload, and an existing one updating in
place), REST reflecting the overwrite, and the gate at every rung — 200 by
default with names, names stripped but points kept at fieldRules name=staff, 403
plus dropped from /features at audience=staff, 404 when disabled. Page rendered
clean, no console errors beyond the pre-existing React Router v7 warnings.
605 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import Houses from './routes/public/Houses.jsx'
|
||||
import Rules from './routes/public/Rules.jsx'
|
||||
import Atlas from './routes/public/Atlas.jsx'
|
||||
import AtlasCreature from './routes/public/AtlasCreature.jsx'
|
||||
import Leaderboards from './routes/public/Leaderboards.jsx'
|
||||
import Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
import CmsPage from './routes/public/CmsPage.jsx'
|
||||
@@ -105,6 +106,7 @@ export default function App() {
|
||||
<Route path="/site/rules" element={<Rules />} />
|
||||
<Route path="/site/atlas" element={<Atlas />} />
|
||||
<Route path="/site/atlas/:slug" element={<AtlasCreature />} />
|
||||
<Route path="/site/leaderboards" element={<Leaderboards />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||
|
||||
@@ -150,6 +150,10 @@ export const api = {
|
||||
// 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'),
|
||||
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
|
||||
// `board` 404s for a system the shard has never published.
|
||||
points: () => req('/public/shard/points'),
|
||||
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
|
||||
// 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'),
|
||||
|
||||
@@ -28,6 +28,50 @@ function displayTitles(titles) {
|
||||
return [...new Set(out.filter(Boolean))]
|
||||
}
|
||||
|
||||
// The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system
|
||||
// the character actually holds a score in. Systems at zero are omitted by the
|
||||
// shard, so an empty list means "this character has earned nothing anywhere",
|
||||
// which is a normal state for a new character and renders as nothing at all.
|
||||
//
|
||||
// `nameString` may be null when the system's name is a cliloc; fall back to
|
||||
// humanising the PointsType key, exactly as the leaderboards page does. `rank` is
|
||||
// absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and
|
||||
// "unranked" are different, so the chip only appears when it was actually sent.
|
||||
const humanisePoints = (key) =>
|
||||
String(key || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
function PointsRow({ entry }) {
|
||||
const label = entry.nameString || humanisePoints(entry.system)
|
||||
const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0
|
||||
const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, gap: 10 }}>
|
||||
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>
|
||||
{label}
|
||||
{Number.isFinite(entry.rank) && (
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}> · #{entry.rank}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||
{(entry.points ?? 0).toLocaleString()}
|
||||
{max > 0 && <span className="dim"> / {max.toLocaleString()}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{/* Only systems with a real cap get a bar; an uncapped score has nothing to
|
||||
be a fraction of, and a full-width bar would imply completion. */}
|
||||
{max > 0 && (
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||
return (
|
||||
<span
|
||||
@@ -75,6 +119,11 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
.filter((s) => (s.value || s.base || 0) > 0)
|
||||
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||
const equipment = char.equipment || []
|
||||
// Best standing first, so the character's strongest loyalty leads. Guarded for
|
||||
// an older shard plugin that sends no `points` block at all.
|
||||
const points = (Array.isArray(char.points) ? char.points : [])
|
||||
.filter((p) => p && (p.points || 0) > 0)
|
||||
.sort((a, b) => (b.points || 0) - (a.points || 0))
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
@@ -173,6 +222,20 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Loyalty & points — one entry per system this character has scored in */}
|
||||
{points.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>
|
||||
Loyalty & points <span className="dim">({points.length})</span>
|
||||
</div>
|
||||
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||
{points.map((p) => (
|
||||
<PointsRow key={p.system} entry={p} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{equipment.length > 0 && (
|
||||
<section>
|
||||
|
||||
@@ -25,6 +25,7 @@ const NAV = [
|
||||
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
|
||||
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' },
|
||||
{ label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
|
||||
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
]
|
||||
|
||||
|
||||
@@ -65,7 +65,10 @@ const FIELD_LABEL = {
|
||||
price: 'House price',
|
||||
location: 'In-game location (map + coordinates)',
|
||||
connect: 'Server connect address',
|
||||
characterName: 'Character names',
|
||||
// Keyed on the WIRE field, which for a leaderboard entry is `name` — the
|
||||
// projection matches literal JSON keys, so the rule cannot be spelled after the
|
||||
// field's meaning. The label is what carries the meaning to the admin.
|
||||
name: 'Character names on leaderboards',
|
||||
ownerName: 'Vendor owner name',
|
||||
}
|
||||
|
||||
|
||||
219
client/src/routes/public/Leaderboards.jsx
Normal file
219
client/src/routes/public/Leaderboards.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useMemo, useState } 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'
|
||||
|
||||
// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
|
||||
// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
|
||||
// loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing
|
||||
// players build over months, and none of them visible anywhere but an in-game gump
|
||||
// until now.
|
||||
//
|
||||
// Loaded from /public/shard/points, then kept current from the live feed. Unlike
|
||||
// the ruleset (one frame = the whole thing), a points.board frame describes ONE
|
||||
// system, so live frames are merged over the fetched set by system key rather than
|
||||
// replacing it.
|
||||
const POINTS_KINDS = new Set(['points.board'])
|
||||
|
||||
// A board's display name may arrive as a literal (`nameString`), a cliloc id
|
||||
// (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc
|
||||
// table on the site, so a cliloc-only board falls back to humanising its own
|
||||
// PointsType key, which is already close to a display name ("CleanUpBritannia" →
|
||||
// "Clean Up Britannia"). Better than showing a bare number.
|
||||
const humanise = (key) =>
|
||||
String(key || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
const boardTitle = (b) => b.nameString || humanise(b.system)
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
// Merge live frames over the fetched boards. Newest frame per system wins; a
|
||||
// system that has never appeared in either is simply absent.
|
||||
function mergeBoards(fetched, events) {
|
||||
const bySystem = new Map()
|
||||
for (const b of Array.isArray(fetched) ? fetched : []) {
|
||||
if (b && b.system) bySystem.set(b.system, b)
|
||||
}
|
||||
// Events arrive newest-first, so walk backwards and let the newest land last.
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const ev = events[i]
|
||||
if (ev && ev.system) bySystem.set(ev.system, ev)
|
||||
}
|
||||
return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b)))
|
||||
}
|
||||
|
||||
function Medal({ rank }) {
|
||||
// Gold / silver / bronze for the podium, plain for the rest.
|
||||
const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)'
|
||||
return (
|
||||
<span
|
||||
className="display"
|
||||
style={{
|
||||
flex: 'none', width: 26, textAlign: 'right', color: tone,
|
||||
fontSize: rank <= 3 ? '1rem' : '0.86rem',
|
||||
}}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// One ranked player. `name` is absent rather than empty when an admin has gated
|
||||
// the leaderboards `name` field above this viewer's rung — the row still renders,
|
||||
// because the standing itself is the point.
|
||||
function Entry({ entry, best }) {
|
||||
const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
<Medal rank={entry.rank} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
color: entry.name ? 'var(--ink)' : 'var(--muted)',
|
||||
fontSize: '0.86rem', fontStyle: entry.name ? 'normal' : 'italic',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{entry.name || 'Name hidden'}
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||
{num(entry.points)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden', marginTop: 3 }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Board({ board }) {
|
||||
const top = Array.isArray(board.top) ? board.top : []
|
||||
// Bars are relative to the board leader, not to maxPoints: most systems have no
|
||||
// cap (maxPoints 0), and where there is one the leader is often nowhere near it,
|
||||
// which would render every bar as a stub.
|
||||
const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0)
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||
{boardTitle(board)}
|
||||
</h2>
|
||||
{Number.isFinite(board.players) && (
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem', flex: 'none' }}>
|
||||
{num(board.players)} ranked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{top.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
|
||||
Nobody has earned points here yet.
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{top.map((entry) => (
|
||||
<Entry key={`${board.system}-${entry.rank}-${entry.serial}`} entry={entry} best={best} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
Maximum {num(board.maxPoints)} points
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Leaderboards() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.points())
|
||||
// Buffer generously: a single sweep can emit a frame for every system at once,
|
||||
// and a board dropped from the buffer would silently revert to its fetched copy.
|
||||
const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 })
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const boards = useMemo(() => mergeBoards(data, events), [data, events])
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return boards
|
||||
// Match the board name, the raw system key, or any ranked player on it — the
|
||||
// last is what makes the filter useful ("where do I appear?").
|
||||
return boards.filter(
|
||||
(b) =>
|
||||
boardTitle(b).toLowerCase().includes(q) ||
|
||||
String(b.system).toLowerCase().includes(q) ||
|
||||
(b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)),
|
||||
)
|
||||
}, [boards, query])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader
|
||||
eyebrow="Live"
|
||||
title="Leaderboards"
|
||||
lead="Loyalty and points standings, straight from the shard — every currency the server tracks, updated as players climb."
|
||||
/>
|
||||
<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 leaderboards right now." />}
|
||||
|
||||
{!loading && !error && boards.length === 0 && (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
The shard has not published any leaderboards yet.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && !error && boards.length > 0 && (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter by board or player name…"
|
||||
aria-label="Filter leaderboards"
|
||||
style={{ maxWidth: 340, marginBottom: 14 }}
|
||||
/>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<p className="sans dim">No board or ranked player matches “{query}”.</p>
|
||||
) : (
|
||||
<div className="grid-2" style={{ gap: 12, alignItems: 'start' }}>
|
||||
{shown.map((board) => (
|
||||
<Board key={board.system} board={board} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -615,6 +615,31 @@ CREATE TABLE IF NOT EXISTS shard_ruleset (
|
||||
CONSTRAINT chk_shard_ruleset_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Points/loyalty leaderboards (Protocol 3.0 points.board). One row per point
|
||||
-- system, keyed by the shard's own PointsType name. The shard publishes ~25 of
|
||||
-- these (Queen's Loyalty, Void Pool, the nine city loyalties, …), each a standing
|
||||
-- players accumulate over months.
|
||||
--
|
||||
-- The top-N list stays inside `payload` rather than being normalized into a
|
||||
-- shard_points_entries table. It is a fixed-size list (10 by default) that is only
|
||||
-- ever read whole, exactly like shard_governors.candidates — normalizing it would
|
||||
-- buy nothing until something needs a per-character reverse lookup, and a
|
||||
-- character's own standings already ride inside char.profile instead.
|
||||
--
|
||||
-- No delete path: the shard's set of systems is fixed at startup, so there is no
|
||||
-- points.remove to mirror.
|
||||
CREATE TABLE IF NOT EXISTS shard_points_boards (
|
||||
system VARCHAR(48) PRIMARY KEY, -- PointsType name, e.g. QueensLoyalty
|
||||
name VARCHAR(128) NULL, -- resolved display name, if the shard sent a literal
|
||||
name_cliloc INT NULL, -- cliloc id when the name is a TextDefinition number
|
||||
max_points BIGINT NULL,
|
||||
players INT NULL, -- players actually holding points in this system
|
||||
show_on_gump TINYINT(1) NOT NULL DEFAULT 1, -- the shard's own "is this player-facing?" flag
|
||||
payload JSON NOT NULL, -- the whole points.board frame, incl. `top`
|
||||
t BIGINT NULL, -- frame time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) 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
|
||||
|
||||
@@ -2002,6 +2002,18 @@
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points",
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points/:system",
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/presence",
|
||||
|
||||
@@ -821,6 +821,14 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/online"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points/:system"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/presence"
|
||||
|
||||
@@ -278,6 +278,50 @@ async function getRuleset() {
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
|
||||
// One row per point system. The shard only emits a system whose top N actually
|
||||
// moved, so this is a sparse stream of overwrites; there is no delete, because
|
||||
// the shard's set of systems is fixed at startup.
|
||||
async function upsertPointsBoard({ system, name, nameCliloc, maxPoints, players, showOnGump, payload, t }) {
|
||||
await query(
|
||||
`INSERT INTO shard_points_boards
|
||||
(system, name, name_cliloc, max_points, players, show_on_gump, payload, t)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), name_cliloc = VALUES(name_cliloc),
|
||||
max_points = VALUES(max_points), players = VALUES(players),
|
||||
show_on_gump = VALUES(show_on_gump), payload = VALUES(payload), t = VALUES(t)`,
|
||||
[
|
||||
system,
|
||||
name ?? null,
|
||||
Number.isFinite(nameCliloc) ? nameCliloc : null,
|
||||
Number.isFinite(maxPoints) ? maxPoints : null,
|
||||
Number.isFinite(players) ? players : null,
|
||||
showOnGump ? 1 : 0,
|
||||
payload,
|
||||
Number.isFinite(t) ? t : null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
// Ordered by display name, falling back to the system key for a board whose name
|
||||
// arrived as a bare cliloc — otherwise every unresolved board would sort together
|
||||
// under NULL.
|
||||
async function listPointsBoards() {
|
||||
return query(
|
||||
`SELECT system, name, name_cliloc, max_points, players, show_on_gump, payload, t, updated_at
|
||||
FROM shard_points_boards ORDER BY COALESCE(name, system), system`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getPointsBoard(system) {
|
||||
const rows = await query(
|
||||
`SELECT system, name, name_cliloc, max_points, players, show_on_gump, payload, t, updated_at
|
||||
FROM shard_points_boards WHERE system = ?`,
|
||||
[system],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
removeOnline,
|
||||
@@ -311,6 +355,9 @@ module.exports = {
|
||||
latestPresence,
|
||||
setRuleset,
|
||||
getRuleset,
|
||||
upsertPointsBoard,
|
||||
listPointsBoards,
|
||||
getPointsBoard,
|
||||
upsertChamp,
|
||||
removeChamp,
|
||||
clearChamps,
|
||||
|
||||
@@ -537,6 +537,50 @@ async function getRuleset() {
|
||||
return { ...payload, updatedAt: r.updated_at }
|
||||
}
|
||||
|
||||
// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
|
||||
//
|
||||
// The whole frame is stored in `payload`; the columns beside it are hoisted for
|
||||
// listing and ordering only. The top-N list deliberately stays inside the payload
|
||||
// (see schema.sql) — it is a fixed-size list read whole, like the governor board's
|
||||
// candidates.
|
||||
async function upsertPointsBoard(ev) {
|
||||
if (!ev || !ev.system) return
|
||||
await db.upsertPointsBoard({
|
||||
system: String(ev.system).slice(0, 48),
|
||||
name: ev.nameString ?? null,
|
||||
nameCliloc: ev.nameNumber,
|
||||
maxPoints: ev.maxPoints,
|
||||
players: ev.players,
|
||||
showOnGump: ev.showOnGump !== false,
|
||||
payload: JSON.stringify(ev),
|
||||
t: ev.t,
|
||||
})
|
||||
}
|
||||
|
||||
// A stored frame plus the freshness stamp. `top` is normalized to an array so a
|
||||
// caller never has to guard it — a board with nobody on it is a real state (a
|
||||
// system nobody has scored in yet), distinct from a system that was never
|
||||
// published at all, which is absent from the table entirely.
|
||||
function shapePointsBoard(r) {
|
||||
const payload = (typeof r.payload === 'string' ? safeJson(r.payload) : r.payload) || {}
|
||||
return {
|
||||
...payload,
|
||||
system: r.system,
|
||||
top: Array.isArray(payload.top) ? payload.top : [],
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function listPointsBoards() {
|
||||
const rows = await db.listPointsBoards()
|
||||
return rows.map(shapePointsBoard)
|
||||
}
|
||||
|
||||
async function getPointsBoard(system) {
|
||||
const r = await db.getPointsBoard(system)
|
||||
return r ? shapePointsBoard(r) : null
|
||||
}
|
||||
|
||||
function safeJson(s) {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
@@ -588,4 +632,7 @@ module.exports = {
|
||||
latestPresence,
|
||||
setRuleset,
|
||||
getRuleset,
|
||||
upsertPointsBoard,
|
||||
listPointsBoards,
|
||||
getPointsBoard,
|
||||
}
|
||||
|
||||
@@ -262,6 +262,43 @@ async function getRuleset(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// The shard keys boards by its own PointsType enum name (QueensLoyalty,
|
||||
// CleanUpBritannia, …). Constrain the path param to that shape before it reaches
|
||||
// the model: the column is VARCHAR(48), and an unbounded string here is a needless
|
||||
// query on a value that can only ever be an identifier.
|
||||
const SYSTEM_RE = /^[A-Za-z][A-Za-z0-9_]{0,47}$/
|
||||
|
||||
// GET /public/shard/points — every points/loyalty leaderboard the shard publishes.
|
||||
// Served from our own store, so the page renders while the shard is down — which
|
||||
// matters more here than for live state: these are standings accumulated over
|
||||
// months, and blanking them during a restart would look like a data loss.
|
||||
async function getPointsBoards(req, res) {
|
||||
try {
|
||||
const boards = await shardState.listPointsBoards()
|
||||
return res.json(await visibility.project('leaderboards', boards, req))
|
||||
} catch (err) {
|
||||
log.error('shard.getPointsBoards', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/points/:system — one system's board.
|
||||
//
|
||||
// 404 for a system the shard has never published, matching the sidecar: "no such
|
||||
// board" and "a board nobody is on yet" are different answers.
|
||||
async function getPointsBoard(req, res) {
|
||||
const { system } = req.params
|
||||
if (!SYSTEM_RE.test(system)) return res.status(400).json({ message: 'Invalid points system.' })
|
||||
try {
|
||||
const board = await shardState.getPointsBoard(system)
|
||||
if (!board) return res.status(404).json({ message: 'Unknown points system.' })
|
||||
return res.json(await visibility.project('leaderboards', board, req))
|
||||
} catch (err) {
|
||||
log.error('shard.getPointsBoard', 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
|
||||
@@ -296,6 +333,8 @@ module.exports = {
|
||||
getPresence,
|
||||
getHouses,
|
||||
getRuleset,
|
||||
getPointsBoards,
|
||||
getPointsBoard,
|
||||
getFeatures,
|
||||
stream,
|
||||
}
|
||||
|
||||
@@ -146,6 +146,27 @@ shardRouter.get(
|
||||
/* #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(
|
||||
'/points',
|
||||
requireFeature('leaderboards'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Points / loyalty leaderboards, one board per point system'
|
||||
// #swagger.description = 'Every points/loyalty leaderboard the shard publishes (Queen\'s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board\'s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.'
|
||||
/* #swagger.responses[200] = { description: 'Boards, ordered by display name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardPointsBoard" } } } } } */
|
||||
shard.getPointsBoards,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/points/:system',
|
||||
requireFeature('leaderboards'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'One points system\'s leaderboard'
|
||||
// #swagger.description = 'A single board by the shard\'s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.'
|
||||
/* #swagger.parameters['system'] = { in: 'path', required: true, description: 'PointsType name, e.g. QueensLoyalty', schema: { type: 'string' } } */
|
||||
/* #swagger.responses[200] = { description: 'The board', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardPointsBoard" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Malformed system name' } */
|
||||
/* #swagger.responses[404] = { description: 'The shard has never published that system' } */
|
||||
shard.getPointsBoard,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/features',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
|
||||
@@ -181,6 +181,12 @@ async function applyStateChange(event, deps) {
|
||||
case 'world.ruleset':
|
||||
await shardState.setRuleset(event)
|
||||
return
|
||||
// Board state, like guild.update — the newest frame for a system replaces the
|
||||
// previous one, so it is NOT in LOGGED_KINDS. Logging would append a row every
|
||||
// time anyone's score moved the top ten, which is a board, not an event.
|
||||
case 'points.board':
|
||||
await shardState.upsertPointsBoard(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.
|
||||
|
||||
@@ -102,7 +102,14 @@ const FEATURES = {
|
||||
// ── New in v3. ──
|
||||
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
|
||||
atlas: { audience: 'anonymous', fields: {} },
|
||||
leaderboards: { audience: 'anonymous', fields: { characterName: 'anonymous' } },
|
||||
// `name` is the ranked character's name inside points.board's `top` entries, and
|
||||
// it is spelled the way the WIRE spells it, not the way v3.md §7.4 describes it
|
||||
// ("characterName"). projectValue matches on the literal JSON key, so a rule
|
||||
// named for the field's meaning rather than its key silently does nothing — the
|
||||
// same failure §3.6.1 records for the flattened `ownerAcct` spelling. Within a
|
||||
// leaderboards payload `name` can only be a character name: the board's own
|
||||
// display name arrives as `nameString`/`nameNumber`.
|
||||
leaderboards: { audience: 'anonymous', fields: { name: 'anonymous' } },
|
||||
// Shop name, owner character name and vendor location are already globally
|
||||
// visible in-game via the stock Vendor Search gump, so publishing them is not
|
||||
// a new disclosure — but they stay configurable so an admin can tighten them.
|
||||
|
||||
@@ -131,6 +131,10 @@ const getPresence = () => call('/online') // aggregate population (count + byFac
|
||||
// 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')
|
||||
// Protocol 3.0: points/loyalty leaderboards. `/points` is board-shaped (an array
|
||||
// under `boards`); the per-system read 404s for a system the shard never published.
|
||||
const getPoints = () => call('/points')
|
||||
const getPointsBoard = (system) => call(`/points/${encodeURIComponent(system)}`)
|
||||
|
||||
// ── Commands ──────────────────────────────────────────────────────────────
|
||||
const confirmLink = (code, websiteUserId) =>
|
||||
@@ -195,6 +199,8 @@ module.exports = {
|
||||
getHouses,
|
||||
getPresence,
|
||||
getRuleset,
|
||||
getPoints,
|
||||
getPointsBoard,
|
||||
confirmLink,
|
||||
linkLookup,
|
||||
createAccount,
|
||||
|
||||
@@ -99,6 +99,13 @@ async function backfill() {
|
||||
log.info('snapshotted shard ruleset from /ruleset', { rev: ruleset.data.ruleset.rev })
|
||||
}
|
||||
|
||||
// Points boards ARE array-shaped, so they go through snapshot() — but with
|
||||
// ingestEach rather than a replace*: there is no points.remove and the shard's
|
||||
// system set is fixed, so upserting is the whole reconciliation. A system the
|
||||
// operator has since excluded keeps its last-known board rather than vanishing,
|
||||
// which is the right answer for a month-scale standing.
|
||||
await snapshot(() => uoLinkClient.getPoints(), 'boards', ingestEach, 'snapshotted points boards from /points')
|
||||
|
||||
const presence = await uoLinkClient.getPresence()
|
||||
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
|
||||
await shardState.setPresence(presence.data)
|
||||
|
||||
@@ -11890,6 +11890,83 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/points": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Shard"
|
||||
],
|
||||
"summary": "Points / loyalty leaderboards, one board per point system",
|
||||
"description": "Every points/loyalty leaderboard the shard publishes (Queen\\'s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board\\'s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Boards, ordered by display name",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ShardPointsBoard"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/points/{system}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Shard"
|
||||
],
|
||||
"summary": "One points system\\'s leaderboard",
|
||||
"description": "A single board by the shard\\'s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "system",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "PointsType name, e.g. QueensLoyalty"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The board",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ShardPointsBoard"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Malformed system name"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "The shard has never published that system"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/presence": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -18216,6 +18293,247 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ShardPointsBoard": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "One point system's leaderboard (Protocol 3.0 points.board). The shard carries ~25 separate point currencies; each publishes its own board. The display name may arrive as a literal string, a cliloc id, or both — resolve clilocs client-side."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"system": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "QueensLoyalty"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The shard's PointsType name; the board's stable key."
|
||||
}
|
||||
}
|
||||
},
|
||||
"nameString": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Queen's Loyalty"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nameNumber": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 1114938
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Cliloc id, 0 when the name is a literal."
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxPoints": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 30000
|
||||
}
|
||||
}
|
||||
},
|
||||
"players": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 842
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Players actually holding points in this system."
|
||||
}
|
||||
}
|
||||
},
|
||||
"showOnGump": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The shard's own 'is this player-facing?' flag."
|
||||
}
|
||||
}
|
||||
},
|
||||
"top": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The ranked players, best first. Capped by the shard (10 by default). Empty when nobody has scored yet."
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rank": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"serial": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "0x1A2B"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Darrow"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Omitted when the leaderboards `name` field is gated above the caller."
|
||||
}
|
||||
}
|
||||
},
|
||||
"points": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 29500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"t": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Frame time, epoch ms."
|
||||
}
|
||||
}
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ShardFeatures": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -882,6 +882,34 @@ const doc = {
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
ShardPointsBoard: {
|
||||
type: 'object',
|
||||
description:
|
||||
"One point system's leaderboard (Protocol 3.0 points.board). The shard carries ~25 separate point currencies; each publishes its own board. The display name may arrive as a literal string, a cliloc id, or both — resolve clilocs client-side.",
|
||||
properties: {
|
||||
system: { type: 'string', example: 'QueensLoyalty', description: "The shard's PointsType name; the board's stable key." },
|
||||
nameString: { type: 'string', nullable: true, example: "Queen's Loyalty" },
|
||||
nameNumber: { type: 'integer', nullable: true, example: 1114938, description: 'Cliloc id, 0 when the name is a literal.' },
|
||||
maxPoints: { type: 'integer', nullable: true, example: 30000 },
|
||||
players: { type: 'integer', nullable: true, example: 842, description: 'Players actually holding points in this system.' },
|
||||
showOnGump: { type: 'boolean', example: true, description: "The shard's own 'is this player-facing?' flag." },
|
||||
top: {
|
||||
type: 'array',
|
||||
description: 'The ranked players, best first. Capped by the shard (10 by default). Empty when nobody has scored yet.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rank: { type: 'integer', example: 1 },
|
||||
serial: { type: 'string', example: '0x1A2B' },
|
||||
name: { type: 'string', example: 'Darrow', description: 'Omitted when the leaderboards `name` field is gated above the caller.' },
|
||||
points: { type: 'integer', example: 29500 },
|
||||
},
|
||||
},
|
||||
},
|
||||
t: { type: 'integer', nullable: true, description: 'Frame time, epoch ms.' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
ShardFeatures: {
|
||||
type: 'object',
|
||||
description:
|
||||
|
||||
@@ -295,6 +295,85 @@ test('getRuleset projects: acct/webId never survive below admin', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── points boards ──────────────────────────────────────────────────────
|
||||
const BOARD = {
|
||||
system: 'QueensLoyalty',
|
||||
nameString: "Queen's Loyalty",
|
||||
nameNumber: 1114938,
|
||||
maxPoints: 30000,
|
||||
players: 842,
|
||||
top: [
|
||||
{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 },
|
||||
{ rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 },
|
||||
],
|
||||
}
|
||||
|
||||
test('getPointsBoards serves every board with its ranked list intact', async () => {
|
||||
shardState.listPointsBoards = async () => [BOARD]
|
||||
const res = mockRes()
|
||||
await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
|
||||
assert.equal(res.body.length, 1)
|
||||
assert.equal(res.body[0].system, 'QueensLoyalty')
|
||||
// The ranked list is an ARRAY through projection, not an object keyed 0/1 —
|
||||
// the same trap the ruleset's rankThresholds assertion guards.
|
||||
assert.ok(Array.isArray(res.body[0].top))
|
||||
assert.equal(res.body[0].top[1].name, 'Mireille')
|
||||
})
|
||||
|
||||
test('getPointsBoards serves an empty list before the shard has published any', async () => {
|
||||
shardState.listPointsBoards = async () => []
|
||||
const res = mockRes()
|
||||
await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
|
||||
assert.deepEqual(res.body, [])
|
||||
assert.equal(res.statusCode, 200)
|
||||
})
|
||||
|
||||
// §3.6.1's rule again: a shard read that does not project is a bug. Boards carry
|
||||
// no actor today — they write entries inline as {serial, name} precisely so they
|
||||
// never carry acct/webId — but the gate is what keeps that true if the shape grows.
|
||||
test('getPointsBoard projects: acct/webId never survive below admin', async () => {
|
||||
shardState.getPointsBoard = async () => ({
|
||||
system: 'QueensLoyalty',
|
||||
top: [{ rank: 1, name: 'Darrow', acct: 'darrow_acct', webId: 9, points: 1 }],
|
||||
})
|
||||
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||
const res = mockRes()
|
||||
await ctrl.getPointsBoard({ params: { system: 'QueensLoyalty' }, viewerLevel: level }, res)
|
||||
assert.equal(res.body.top[0].acct, undefined, `${level} saw acct`)
|
||||
assert.equal(res.body.top[0].webId, undefined, `${level} saw webId`)
|
||||
assert.equal(res.body.top[0].name, 'Darrow', 'the ranked name is public by default')
|
||||
}
|
||||
})
|
||||
|
||||
// "No such system" and "a board nobody has scored in" are different answers.
|
||||
test('getPointsBoard 404s for a system the shard has never published', async () => {
|
||||
shardState.getPointsBoard = async () => null
|
||||
const res = mockRes()
|
||||
await ctrl.getPointsBoard({ params: { system: 'NoSuchSystem' }, viewerLevel: 'anonymous' }, res)
|
||||
assert.equal(res.statusCode, 404)
|
||||
})
|
||||
|
||||
test('getPointsBoard rejects a malformed system name before touching the model', async () => {
|
||||
let queried = false
|
||||
shardState.getPointsBoard = async () => { queried = true; return null }
|
||||
for (const system of ['../etc', 'a'.repeat(64), '', 'has space', '1leading']) {
|
||||
const res = mockRes()
|
||||
await ctrl.getPointsBoard({ params: { system }, viewerLevel: 'anonymous' }, res)
|
||||
assert.equal(res.statusCode, 400, `${JSON.stringify(system)} should be rejected`)
|
||||
}
|
||||
assert.equal(queried, false, 'a malformed name must never reach the query')
|
||||
})
|
||||
|
||||
test('getPointsBoards degrades to a 500 when the model fails, without throwing', async () => {
|
||||
shardState.listPointsBoards = async () => {
|
||||
throw new Error('pool down')
|
||||
}
|
||||
const res = mockRes()
|
||||
await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
|
||||
assert.equal(res.statusCode, 500)
|
||||
assert.equal(res.body.message, 'Internal Server Error')
|
||||
})
|
||||
|
||||
test('getRuleset degrades to a 500 when the model fails, without throwing', async () => {
|
||||
shardState.getRuleset = async () => {
|
||||
throw new Error('pool down')
|
||||
|
||||
111
server/test/shardIngest.points.test.js
Normal file
111
server/test/shardIngest.points.test.js
Normal file
@@ -0,0 +1,111 @@
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../src/utils/shardIngest')
|
||||
|
||||
// Protocol 3.0 points.board routing. Same shape as shardIngest.ruleset.test.js:
|
||||
// stubbed deps, asserting where the dispatcher sends the frame and whether it is
|
||||
// appended to the event log.
|
||||
function makeDeps() {
|
||||
const calls = { boards: [], appended: [], broadcast: [] }
|
||||
const noop = async () => {}
|
||||
return {
|
||||
calls,
|
||||
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||
shardState: {
|
||||
upsertPointsBoard: async (ev) => { calls.boards.push(ev) },
|
||||
// Present so any stray routing is a harmless no-op.
|
||||
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
|
||||
addEconomySample: noop, setRuleset: noop,
|
||||
},
|
||||
shardLinks: { removeByAccount: noop },
|
||||
uoLinkConfig: { recordStatus: noop },
|
||||
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||
pushDispatch: async () => {},
|
||||
log: { warn() {}, info() {}, error() {} },
|
||||
}
|
||||
}
|
||||
|
||||
const FRAME = {
|
||||
kind: 'points.board',
|
||||
t: 1000,
|
||||
system: 'QueensLoyalty',
|
||||
nameString: "Queen's Loyalty",
|
||||
nameNumber: 1114938,
|
||||
maxPoints: 30000,
|
||||
showOnGump: true,
|
||||
players: 842,
|
||||
top: [
|
||||
{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 },
|
||||
{ rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 },
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => shardIngest.reset())
|
||||
|
||||
test('points.board routes to upsertPointsBoard with the whole frame', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(deps.calls.boards.length, 1)
|
||||
const stored = deps.calls.boards[0]
|
||||
assert.equal(stored.system, 'QueensLoyalty')
|
||||
assert.equal(stored.nameNumber, 1114938)
|
||||
assert.equal(stored.players, 842)
|
||||
// The ranked list must survive intact — the read model serves it from the payload.
|
||||
assert.equal(stored.top.length, 2)
|
||||
assert.equal(stored.top[0].name, 'Darrow')
|
||||
})
|
||||
|
||||
// A board is state, not an event. The shard emits a frame every time anyone's
|
||||
// score moves the top ten, so logging would grow shard_events without bound for
|
||||
// something whose only interesting value is its latest version — the same call
|
||||
// guild.update already makes.
|
||||
test('points.board 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('points.board'), false)
|
||||
})
|
||||
|
||||
test('points.board is broadcast (the leaderboards 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, 'points.board')
|
||||
})
|
||||
|
||||
test('a backfilled points.board still stores but does not broadcast', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
|
||||
assert.equal(deps.calls.boards.length, 1)
|
||||
assert.equal(deps.calls.broadcast.length, 0)
|
||||
})
|
||||
|
||||
// Each system is its own row, so two systems must not collide — this is the whole
|
||||
// reason the frame is per-system rather than one board of everything.
|
||||
test('two systems are stored independently', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
await shardIngest.ingest({ ...FRAME, system: 'CleanUpBritannia', nameString: null }, deps)
|
||||
assert.deepEqual(deps.calls.boards.map((b) => b.system), ['QueensLoyalty', 'CleanUpBritannia'])
|
||||
})
|
||||
|
||||
// A re-emitted board is an overwrite of one row, never an append.
|
||||
test('a repeated points.board overwrites rather than accumulating', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
await shardIngest.ingest({ ...FRAME, t: 2000, players: 843 }, deps)
|
||||
assert.equal(deps.calls.appended.length, 0)
|
||||
assert.equal(deps.calls.boards.length, 2) // two writes...
|
||||
assert.equal(deps.calls.boards[1].system, 'QueensLoyalty') // ...of the same row
|
||||
})
|
||||
|
||||
// A model write that throws must not kill the feed.
|
||||
test('an upsertPointsBoard failure does not throw or stop the broadcast', async () => {
|
||||
const deps = makeDeps()
|
||||
deps.shardState.upsertPointsBoard = 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)
|
||||
})
|
||||
@@ -150,6 +150,49 @@ test('rule 1 matches FLATTENED spellings, not just the two canonical keys', () =
|
||||
assert.equal(asAdmin.ownerAcct, 'cadmus_acct')
|
||||
})
|
||||
|
||||
// ── Protocol 3.0 leaderboards ──────────────────────────────────────────────
|
||||
//
|
||||
// The leaderboards field rule is spelled `name` because that is the key
|
||||
// points.board actually puts a ranked character's name under. v3.md §7.4 calls it
|
||||
// "characterName", which describes the meaning — and projectValue matches on the
|
||||
// literal key, so a rule under that spelling would have been silently inert. This
|
||||
// is the same failure mode §3.6.1 records for the flattened `ownerAcct`, and this
|
||||
// test is the guard on it: if someone renames the rule back, an admin who tightens
|
||||
// character names would get no enforcement and no error.
|
||||
test('a tightened leaderboards name rule actually strips ranked character names', async () => {
|
||||
withRows([
|
||||
{ feature: 'leaderboards', enabled: true, audience: 'anonymous', stream: true, fieldRules: { name: 'logged_in' } },
|
||||
])
|
||||
const config = await visibility.getConfig()
|
||||
const board = {
|
||||
system: 'QueensLoyalty',
|
||||
nameString: "Queen's Loyalty",
|
||||
top: [{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 }],
|
||||
}
|
||||
|
||||
const anon = visibility.projectFeature('leaderboards', board, 'anonymous', config)
|
||||
assert.equal('name' in anon.top[0], false, 'anonymous must not see the ranked name')
|
||||
assert.equal(anon.top[0].points, 29500, 'the rest of the entry survives')
|
||||
// The BOARD's own display name is a different key and must not be caught by it.
|
||||
assert.equal(anon.nameString, "Queen's Loyalty")
|
||||
|
||||
const member = visibility.projectFeature('leaderboards', board, 'logged_in', config)
|
||||
assert.equal(member.top[0].name, 'Darrow')
|
||||
})
|
||||
|
||||
// Default config: boards are public, exactly as v3.md §7 specifies.
|
||||
test('leaderboards are anonymous-visible by default, names included', () => {
|
||||
const config = visibility.compileDefaults()
|
||||
const out = visibility.projectFeature(
|
||||
'leaderboards',
|
||||
{ top: [{ rank: 1, name: 'Darrow', points: 1 }] },
|
||||
'anonymous',
|
||||
config,
|
||||
)
|
||||
assert.equal(out.top[0].name, 'Darrow')
|
||||
assert.equal(visibility.kindVisibleTo('points.board', 'anonymous', config), true)
|
||||
})
|
||||
|
||||
test('isLockedField locks acct/webId and their suffixed forms, and nothing else', () => {
|
||||
for (const key of ['acct', 'webId', 'WEBID', 'ownerAcct', 'leaderWebId', 'governorAcct']) {
|
||||
assert.equal(visibility.isLockedField(key), true, `${key} must be locked`)
|
||||
|
||||
Reference in New Issue
Block a user