// ── Public: shard live data ──────────────────────────────────────────────── // // Same-origin, token-free read endpoints backed by the data the WS ingest // pipeline persists (shard_online / shard_events / shard_economy / shard_houses) // plus a live character round-trip to the sidecar. The browser never sees the // sidecar URL or token — every sidecar call is server-side (uoLinkClient). // // The stored-data endpoints are cheap DB reads. The live /char endpoint hits the // running shard, so it is briefly cached and degrades gracefully: a 503 (shard // restarting) surfaces as a retry-able banner rather than an error. const shardEvents = require('../../../model/shardEvents/shardEvents.model') const shardState = require('../../../model/shardState/shardState.model') const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model') const broadcast = require('../../../utils/shardBroadcast') const visibility = require('../../../utils/shardVisibility') const log = require('../../../utils/logger')('public-shard') // GET /public/shard/status — connection state + online count + latest economy. async function getStatus(req, res) { try { const config = await uoLinkConfig.getSafe() const [online, economy] = await Promise.all([ shardState.onlineCount(), shardState.latestEconomy(), ]) return res.json({ enabled: config.enabled, status: config.status, pluginConnected: config.pluginConnected, lastEventAt: config.lastEventAt, onlineCount: online, economy, }) } catch (err) { log.error('shard.getStatus', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /public/shard/feed?kind=&limit= — recent notable events from the log. // // This is the stored-history twin of the SSE stream, and it must reach the same // verdict the stream does about the same event. Two things are therefore resolved // against the LIVE config rather than the compiled defaults: // // • which kinds this viewer may read at all — `visibleKinds`, not the static // PUBLIC_KINDS set (which is fixed at module load, so an admin moving // `guilds` to `staff` would gate /guilds while /feed kept serving // guild.join to anonymous callers), and // • the payload itself, projected per event against ITS OWN kind's feature — // the rows are a mix of features, and without this the stored frames were // returned verbatim, `acct`/`webId` and all, on an anonymous endpoint. async function getFeed(req, res) { try { const config = await visibility.getConfig() const level = req.viewerLevel || (await visibility.viewerLevel(req)) const allowed = new Set(visibility.visibleKinds(level, config)) const { kind, limit } = req.query // No readable kinds ⇒ nothing to serve. Returning early also keeps us clear // of `list({ kinds: [] })`, which means "no filter", not "match nothing". if (allowed.size === 0) return res.json([]) let events if (kind) { if (!allowed.has(kind)) return res.json([]) events = await shardEvents.list({ kind, limit }) } else { events = await shardEvents.list({ kinds: [...allowed], limit }) } return res.json( events.map((ev) => ({ ...ev, payload: visibility.projectFeature( visibility.KIND_FEATURE.get(ev.kind), ev.payload, level, config, ), })), ) } catch (err) { log.error('shard.getFeed', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /public/shard/economy — gold-supply series, oldest → newest. async function getEconomy(req, res) { try { return res.json(await shardState.listEconomy(req.query.limit)) } catch (err) { log.error('shard.getEconomy', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /public/shard/online — players online now whose account is linked to a // STAFF website user (admin/editor/moderator). Everyone sees that a staff member // is online (name + serial); their in-game location (map + coordinates) is gated // on the `presence` feature's `location` field rule, which defaults to `staff` // — the same admin/moderator set this used to hardcode. Non-staff players are // never listed. async function canSeeStaffLocation(req) { const config = await visibility.getConfig() const required = config.presence?.fields?.location || 'staff' const level = req.viewerLevel || (await visibility.viewerLevel(req)) return visibility.meets(level, required) } async function getOnline(req, res) { try { const rows = await shardState.listOnlineLinked() const showLocation = await canSeeStaffLocation(req) return res.json( rows.map((r) => { const entry = { serial: r.serial, name: r.name } if (showLocation) { entry.map = r.map entry.x = r.x entry.y = r.y entry.z = r.z } return entry }), ) } catch (err) { log.error('shard.getOnline', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /public/shard/idoc — houses currently in danger (stage IDOC). // // Projected: shapeHouse flattens the owner actor into `ownerSerial`/`ownerAcct`/ // `ownerName`, so this endpoint used to hand an anonymous caller the house // owner's GAME ACCOUNT NAME. The public IDOC board only ever needed name, region // and location — which is all that survives projection below `staff`. async function getIdoc(req, res) { try { return res.json(await visibility.project('houses', await shardState.listIdoc(), req)) } catch (err) { log.error('shard.getIdoc', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /public/shard/champs — the current champion-spawn board (all categories). // Served from our own store; live deltas (champ.update / champ.remove) arrive on // the public SSE stream so the page can update in place. async function getChamps(req, res) { try { return res.json(await visibility.project('champs', await shardState.listChamps(), req)) } catch (err) { log.error('shard.getChamps', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /public/shard/guilds — the current guild board. Served from our store; // live via guild.update / guild.remove / guild.join on the public SSE stream. // // Projected: the stored payload is the raw guild.update frame, whose `leader` // actor carries `acct` and `webId`. Those are admin-only and were previously // returned verbatim to anonymous callers. async function getGuilds(req, res) { try { return res.json(await visibility.project('guilds', await shardState.listGuilds(), req)) } catch (err) { log.error('shard.getGuilds', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /public/shard/governors — the current town-governor board (empty on shards // without City Loyalty). Live via city.update on the public SSE stream. Projected // for the same reason as getGuilds: `governor` / `governorElect` are actors. async function getGovernors(req, res) { try { return res.json(await visibility.project('governors', await shardState.listGovernors(), req)) } catch (err) { log.error('shard.getGovernors', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /public/shard/governors/:city/history — the term ledger for one city // (look-back: "who were all the governors of Britain?"), newest first. async function getGovernorHistory(req, res) { try { const terms = await shardState.listGovernorHistory(req.params.city, req.query.limit) return res.json(await visibility.project('governors', terms, req)) } catch (err) { log.error('shard.getGovernorHistory', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /public/shard/presence — the online-population aggregate (count + per-facet // + per-region). Live via presence.online on the public SSE stream. async function getPresence(req, res) { try { return res.json(await visibility.project('presence', await shardState.latestPresence(), req)) } catch (err) { log.error('shard.getPresence', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /public/shard/houses — PUBLIC view: only houses in danger (IDOC), and only // their location (name + region + map/coords). Owner, price, co-owners and decay // detail are staff-only (see admin GET /admin/shard/houses). Live via house.decay // on the public SSE stream. This is the "where are the falling houses" board. async function getHouses(req, res) { try { const idoc = await shardState.listIdoc() const publicHouses = idoc.map((h) => ({ serial: h.serial, name: h.name, region: h.region, map: h.map, x: h.x, y: h.y, z: h.z, isIdoc: true, })) // Already a hand-picked safe subset; projected anyway so an admin who // tightens a `houses` field rule sees it honoured on every houses surface // rather than on some of them. return res.json(await visibility.project('houses', publicHouses, req)) } catch (err) { log.error('shard.getHouses', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // 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 // itself must not disclose the existence of a feature they're gated out of. async function getFeatures(req, res) { try { const config = await visibility.getConfig() const level = await visibility.viewerLevel(req) return res.json({ level, features: visibility.visibleFeatures(level, config) }) } catch (err) { log.error('shard.getFeatures', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /public/shard/stream — live-event SSE channel. What arrives depends on the // caller's audience rung, resolved once at subscribe time; see shardBroadcast.js. function stream(req, res) { return broadcast.subscribe(req, res, 'public') } module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChamps, getGuilds, getGovernors, getGovernorHistory, getPresence, getHouses, getRuleset, getFeatures, stream, }