diff --git a/.env.example b/.env.example index b8f3850..31c49cc 100644 --- a/.env.example +++ b/.env.example @@ -117,7 +117,10 @@ BOT_INTERNAL_KEY=change-me-to-a-long-random-string # token). These URLs are just defaults; the admin can override them at runtime. UOLINK_BASE_URL=http://127.0.0.1:8080 UOLINK_WS_URL=ws://127.0.0.1:8080/ws -UOLINK_PROTOCOL=1 +# Wire protocol this build speaks (3 = Protocol 3.0). Only a fallback for a site +# with nothing saved yet — the admin panel's pinned value wins — but set it lower +# if you deliberately run an older sidecar. +UOLINK_PROTOCOL=3 # ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ─── # The `ntfy` compose service and the backend's push fan-out (opt-in notifications diff --git a/.gitignore b/.gitignore index 987f65b..9d33164 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,24 @@ uploads/ server/logs/ logs/ +# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites +# are extracted from the operator's own UO client .mul/.uop files and are theirs, +# not ours to redistribute. The images live under server/uploads/atlas/, already +# ignored above; this is the slug -> file-name map pointing at them. +# See docs/website/SPAWN_ATLAS.md and db/data/spawnAtlas.art.example.json. +server/db/data/spawnAtlas.art.json + +# Operator-supplied cliloc table. UO's localization strings are EA's, extracted +# from the operator's own client and converted once (docs/website/CLILOCS.md); +# the repo ships no string table, for the same reason it ships no artwork and no +# map snapshot. This covers the conventional in-repo location — the supported +# arrangement is a path OUTSIDE the repo, set from Admin → Shard. +server/db/data/cliloc* +server/db/data/clilocs.* +# The build output of tools/cliloc-export (a throwaway helper, not a package). +server/tools/cliloc-export/bin/ +server/tools/cliloc-export/obj/ + # reference material (extracted from the provided archives) _reference/ diff --git a/client/src/App.jsx b/client/src/App.jsx index 69386a0..3d46f16 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -22,6 +22,12 @@ 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 Atlas from './routes/public/Atlas.jsx' +import AtlasCreature from './routes/public/AtlasCreature.jsx' +import Leaderboards from './routes/public/Leaderboards.jsx' +import Market from './routes/public/Market.jsx' +import MarketVendor from './routes/public/MarketVendor.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' import CmsPage from './routes/public/CmsPage.jsx' @@ -40,6 +46,8 @@ import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx' import ShardAdmin from './routes/admin/views/ShardAdmin.jsx' +import ShardVisibility from './routes/admin/views/ShardVisibility.jsx' +import SpawnAtlasAdmin from './routes/admin/views/SpawnAtlas.jsx' import ShardOps from './routes/admin/views/ShardOps.jsx' import AdminCharacters from './routes/admin/views/AdminCharacters.jsx' import AdminCharacter from './routes/admin/views/AdminCharacter.jsx' @@ -97,6 +105,12 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> {/* CMS pages: top-level /:slug, matched only after the named routes @@ -142,6 +156,8 @@ export default function App() { } /> } /> } /> + } /> + } /> 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'), + // 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)}`), + // Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so + // the page debounces its search box rather than firing per keystroke. + market: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.q) qs.set('q', opts.q) + if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice) + if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice) + if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId) + if (opts.map) qs.set('map', opts.map) + if (opts.region) qs.set('region', opts.region) + if (opts.sort) qs.set('sort', opts.sort) + if (opts.limit) qs.set('limit', opts.limit) + if (opts.offset) qs.set('offset', opts.offset) + return req(`/public/shard/market${withQs(qs.toString())}`) + }, + marketMeta: () => req('/public/shard/market/meta'), + marketVendor: (serial, opts = {}) => { + const qs = new URLSearchParams() + if (opts.limit) qs.set('limit', opts.limit) + if (opts.offset) qs.set('offset', opts.offset) + return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`) + }, + // 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'), + }, + + // ----- spawn atlas (Protocol 3.0 Part C) ----- + // Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately + // not under /shard, because nothing here depends on the sidecar and the pages + // stay populated while the shard is offline. + atlas: { + creatures: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.q) qs.set('q', opts.q) + if (opts.facet) qs.set('facet', opts.facet) + if (opts.limit) qs.set('limit', opts.limit) + if (opts.offset) qs.set('offset', opts.offset) + return req(`/public/atlas/creatures${withQs(qs.toString())}`) + }, + creature: (slug, opts = {}) => { + const qs = new URLSearchParams() + if (opts.facet) qs.set('facet', opts.facet) + if (opts.points) qs.set('points', opts.points) + return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`) + }, + regions: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.facet) qs.set('facet', opts.facet) + if (opts.q) qs.set('q', opts.q) + return req(`/public/atlas/regions${withQs(qs.toString())}`) + }, + landmarks: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.facet) qs.set('facet', opts.facet) + if (opts.q) qs.set('q', opts.q) + return req(`/public/atlas/landmarks${withQs(qs.toString())}`) + }, + // The CONFIGURED altar roster, not the live board — see shard.champs() for + // "which spawn is on level 3 right now". + champions: (facet) => req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`), + meta: () => req('/public/atlas/meta'), }, // Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is // fetch-only, so SSE subscribers build the URL from here. The admin stream @@ -346,6 +415,25 @@ export const api = { saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }), postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }), deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }), + // Per-feature shard visibility: who may see which shard surface, and which + // sensitive fields within it. Admin only — it decides what ANONYMOUS + // visitors get. acct/webId are admin-only always and the API rejects any + // attempt to configure them. + getShardVisibility: () => req('/admin/shard/visibility'), + saveShardVisibility: (features) => + req('/admin/shard/visibility', { method: 'PUT', body: { features } }), + + // ----- spawn atlas operation (admin only) ----- + // The atlas re-derives itself from the ServUO tree on every boot; these are + // for applying a map change without a restart, and for the approve/reject + // decision on a refresh that would remove a facet. + atlas: { + status: () => req('/admin/shard/atlas'), + import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }), + approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }), + reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }), + setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }), + }, // ----- in-game staff operations: write plane + support queue (admin/moderator) ----- // `actor` is stamped server-side from the session — never sent from here. diff --git a/client/src/components/CharacterSheet.jsx b/client/src/components/CharacterSheet.jsx index 967bee0..58c8fb4 100644 --- a/client/src/components/CharacterSheet.jsx +++ b/client/src/components/CharacterSheet.jsx @@ -10,24 +10,89 @@ import ShardAccountActions from './ShardAccountActions.jsx' const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' } +// What to call an equipped item. +// +// Items on the wire carry a `LabelNumber`, not a name, so this used to be able +// to show nothing but the layer and `id 12345`. The server now resolves the +// cliloc against its own table and attaches `clilocName` (see +// docs/website/CLILOCS.md); a shard with no cliloc file configured sends none, +// and the layer fallback below is exactly what the sheet did before. +// +// A player-given `name` outranks the resolved type name — "Bob's lucky axe" +// should not be relabelled "hatchet" — and the server applies the same +// precedence, so this only re-states it for a profile that arrived with both. +const itemName = (it) => it.name || it.clilocName || it.layer || 'Item' + // The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already // computed display strings; reward entries may be a cliloc NUMBER-as-string or a -// literal string. Without a cliloc table on the site we can only show literals, so -// numeric reward entries are skipped rather than shown as a raw number. Returns a -// de-duped list of human-readable title chips. +// literal string. +// +// `rewardResolved` is the server's parallel array with the numeric entries turned +// into words (null where the cliloc table had nothing, or is not configured at +// all). Prefer it, and keep the literal-only path as the fallback for a profile +// served before the cliloc table existed — a numeric entry with no resolution is +// still skipped rather than shown as a raw number. function displayTitles(titles) { if (!titles) return [] const out = [] if (titles.fameKarma) out.push(titles.fameKarma) if (titles.skill) out.push(titles.skill) - const reward = Array.isArray(titles.reward) ? titles.reward : [] + const raw = Array.isArray(titles.reward) ? titles.reward : [] + const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null + const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r))) const sel = typeof titles.selected === 'number' ? titles.selected : -1 - // Prefer the selected reward title; fall back to the first literal one. - const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r))) - if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate)) + // Prefer the selected reward title; fall back to the first one that resolved. + // The `??` matters: a selected title whose cliloc did not resolve must fall + // through to the fallback rather than suppress the chip entirely. + const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean) + if (candidate) out.push(String(candidate)) 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 ( +
+
+ + {label} + {Number.isFinite(entry.rank) && ( + · #{entry.rank} + )} + + + {(entry.points ?? 0).toLocaleString()} + {max > 0 && / {max.toLocaleString()}} + +
+ {/* 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 && ( +
+
+
+ )} +
+ ) +} + function TitleChip({ children, tone = 'var(--muted)' }) { return ( (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 (
@@ -173,17 +243,37 @@ export default function CharacterSheet({ char, moderation = false }) { )} + {/* Loyalty & points — one entry per system this character has scored in */} + {points.length > 0 && ( +
+
+ Loyalty & points ({points.length}) +
+
+ {points.map((p) => ( + + ))} +
+
+ )} + {/* Equipment */} {equipment.length > 0 && (
Equipment
- {equipment.map((it) => ( + {equipment.map((it) => { + const label = itemName(it) + const layer = it.layer || 'Item' + // The layer only earns its own line once the headline is a real + // name; when it IS the headline, repeating it is just noise. + const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null] + return (
-
{it.layer || 'Item'}
-
id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}
+
{label}
+
{detail.filter(Boolean).join(' · ')}
{it.mods && Object.keys(it.mods).length > 0 && (
@@ -193,7 +283,8 @@ export default function CharacterSheet({ char, moderation = false }) {
)}
- ))} + ) + })}
)} diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 4394419..a3c0341 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -2,9 +2,15 @@ import { Link, NavLink } from 'react-router-dom' import MoonDot from './MoonDot.jsx' import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' +import { useShardFeatures, canSee } from '../lib/useShardFeatures.js' // One consistent top nav for the whole public site. Every page gets the same // main links plus an auth-aware entry on the right (Sign in / My Account / Admin). +// +// Entries carrying a `feature` are shard surfaces an admin can disable or gate +// to a higher audience (Admin -> Shard Visibility). They are hidden when this +// viewer can't reach them, so we never render a link that would 403. The gate +// itself is server-side; this is only about not advertising a dead end. const NAV = [ { label: 'Home', to: '/', end: true }, { label: 'News', to: '/site/news' }, @@ -12,11 +18,15 @@ const NAV = [ { label: 'Five on Friday', to: '/site/five-on-friday' }, { label: 'Newsletter', to: '/site/newsletter' }, { label: 'Wiki', to: '/wiki' }, - { label: 'Shard', to: '/site/shard' }, - { label: 'Champions', to: '/site/champs' }, - { label: 'Guilds', to: '/site/guilds' }, - { label: 'Governors', to: '/site/governors' }, - { label: 'Houses', to: '/site/houses' }, + { label: 'Shard', to: '/site/shard', feature: 'status' }, + { label: 'Champions', to: '/site/champs', feature: 'champs' }, + { 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: 'Atlas', to: '/site/atlas', feature: 'atlas' }, + { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' }, + { label: 'Market', to: '/site/market', feature: 'market' }, { label: 'About', to: '/site/about' }, ] @@ -29,6 +39,8 @@ const linkStyle = ({ isActive }) => ({ export default function SiteHeader() { const { user, loading } = useAuth() const { siteTitle } = useSite() + const shardFeatures = useShardFeatures() + const nav = NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)) // Where the auth entry points: staff → admin, player → portal, else sign in. let account @@ -60,7 +72,7 @@ export default function SiteHeader() { {siteTitle}